Compare commits
156 Commits
2c909d35ea
...
v2.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
| cc2d383166 | |||
| f30fdc5ed1 | |||
| 290d3c31a2 | |||
| 27fb83ba04 | |||
| 0e78fe02e4 | |||
| 7914e9e9bb | |||
| 06d34dff5e | |||
| c7e48c16f8 | |||
| 4ee0f524d4 | |||
| 43c7be55c6 | |||
| d9fa00bb38 | |||
| 0a042df8e0 | |||
| a60a2f8e39 | |||
| d27f387d6d | |||
| ed675f90d8 | |||
| 4492aa3425 | |||
| 56d93b6128 | |||
| 398fb274fa | |||
| 0343d48c13 | |||
| a3892c0fd3 | |||
| f7df315695 | |||
| aec996a9ce | |||
| 90e02b1ddd | |||
| ef8ceeaf9a | |||
| ba1d760bb3 | |||
| 0942691eb3 | |||
| a2f84be2d4 | |||
| 7e8b99a82b | |||
| 29274c2f48 | |||
| 870793433b | |||
| 08cfeb0e08 | |||
| 5daf2d83b6 | |||
| 6d26ccd0ed | |||
| a7514becbc | |||
| 558cfcbe56 | |||
| d0bd55b9c1 | |||
| b37d3d97b6 | |||
| 8778398d31 | |||
| d5c30237d2 | |||
| a598217975 | |||
| 9205addf55 | |||
| ce8c7696d4 | |||
| 99d1e73676 | |||
| 755cf22ad0 | |||
| e24ca015d1 | |||
| 0de44569b7 | |||
| 06afbee4f8 | |||
| 6ee81a5abe | |||
| ea26c0cbbb | |||
| 13b225d8f5 | |||
| 08aed34bbe | |||
| 5edbe8a276 | |||
| 001e85ac1a | |||
| 393f3d147e | |||
| ac49f44f84 | |||
| 3216debc7d | |||
| 8ffcd9cc8c | |||
| bc183257b7 | |||
| b2e037bb67 | |||
| 975650f11b | |||
| 384d64ea5d | |||
| 3b7423f3a1 | |||
| d136916e80 | |||
| f88304fed2 | |||
| 5296dd7ae5 | |||
| a1d3964e34 | |||
| 5b6ba5094b | |||
| c3e81a5fa6 | |||
| d603a54618 | |||
| c61c211dfe | |||
| 16244d84a0 | |||
| 32be868dbc | |||
| 8bb3198562 | |||
| 4b3f0fa92b | |||
| c7c3440a7b | |||
| e779ded2e8 | |||
| 940dd21464 | |||
| 207f9074db | |||
| 05b00b158b | |||
| f9ddab059e | |||
| de1ae736de | |||
| 03c1b63e03 | |||
| 8c12b27c0a | |||
| c7d163f44a | |||
| 7e4822c021 | |||
| f9b622cb25 | |||
| 9204fa148a | |||
| da0e9f5915 | |||
| d188a08db7 | |||
| ff5f5ddf23 | |||
| 56f9802fb9 | |||
| efb271cb9a | |||
| eb69e491b9 | |||
| 2675b8ab93 | |||
| b3444e0a89 | |||
| 45b652f514 | |||
| c252e60f7f | |||
| fffee9f0b5 | |||
| 00ffc959e5 | |||
| b561406c23 | |||
| 5a0743a17b | |||
| a7b0770ad0 | |||
| 7d8323a622 | |||
| 320944fd18 | |||
| 6d5e0ac614 | |||
| 02554d523d | |||
| 21da9e75fc | |||
| c53b7f771e | |||
| bcee4bfe72 | |||
| e1870c3b23 | |||
| 7cf0bb8bc7 | |||
| 541a9e1fd5 | |||
| 07b8e0b2cb | |||
| de48414c65 | |||
| f0c681b0b5 | |||
| fdf2502ca8 | |||
| e393b0d847 | |||
| 3b041f20c1 | |||
| 77a0ad64b0 | |||
| d0ca2fdf52 | |||
| 9c07b6d33d | |||
| fedfe3d60c | |||
| 95ff9ce9ae | |||
| 203967411a | |||
| 3d0305a9ca | |||
| e7f38c2a45 | |||
| 3a2be661ea | |||
| 17525003c5 | |||
| 267d839d5b | |||
| 987d9b93c7 | |||
| 4c43f2e082 | |||
| 57fa6470b7 | |||
| c68889d276 | |||
| b0a4333cdf | |||
| 4471f54842 | |||
| e08121af2b | |||
| 31ebfc782e | |||
| f3776bcbe5 | |||
| 38f2aa2f8f | |||
| 40f8425d94 | |||
| 2157a30192 | |||
| bef3c0c47d | |||
| 5c570613c8 | |||
| 567732206e | |||
| 0dcc09fc5f | |||
| 82d3178119 | |||
| 7d47c6e14e | |||
| 973bc0d338 | |||
| e4c9b98ca2 | |||
| 1c38f68781 | |||
| 818c29fca7 | |||
| 0a43742897 | |||
| defe14d913 | |||
| 8b55a90102 | |||
| 0fd3d214a4 | |||
| 996e10ed02 |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -11,8 +11,8 @@ prebuilt-binaries/dragonxd-win/*
|
|||||||
!prebuilt-binaries/dragonxd-win/.gitkeep
|
!prebuilt-binaries/dragonxd-win/.gitkeep
|
||||||
prebuilt-binaries/dragonxd-mac/*
|
prebuilt-binaries/dragonxd-mac/*
|
||||||
!prebuilt-binaries/dragonxd-mac/.gitkeep
|
!prebuilt-binaries/dragonxd-mac/.gitkeep
|
||||||
prebuilt-binaries/xmrig-hac/*
|
prebuilt-binaries/drg-xmrig/*
|
||||||
!prebuilt-binaries/xmrig-hac/.gitkeep
|
!prebuilt-binaries/drg-xmrig/.gitkeep
|
||||||
|
|
||||||
|
|
||||||
# External sources / toolchains (created by scripts/setup.sh)
|
# External sources / toolchains (created by scripts/setup.sh)
|
||||||
@@ -33,7 +33,11 @@ imgui.ini
|
|||||||
*.bak*
|
*.bak*
|
||||||
*.params
|
*.params
|
||||||
asmap.dat
|
asmap.dat
|
||||||
/external/xmrig-hac
|
# Wallet files hold PRIVATE KEYS — never commit them
|
||||||
|
wallet.dat
|
||||||
|
wallet-*.dat
|
||||||
|
wallet.dat.*
|
||||||
|
/external/drg-xmrig
|
||||||
/memory
|
/memory
|
||||||
/todo.md
|
/todo.md
|
||||||
/.github/
|
/.github/
|
||||||
|
|||||||
58
CHANGELOG.md
Normal file
58
CHANGELOG.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
|
||||||
|
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### ⚠️ Breaking changes
|
||||||
|
|
||||||
|
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
|
||||||
|
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
|
||||||
|
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
|
||||||
|
network path) after only a dismissible warning. To reconnect, either:
|
||||||
|
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
|
||||||
|
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
|
||||||
|
|
||||||
|
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
|
||||||
|
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
|
||||||
|
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
|
||||||
|
plaintext-RPC protection.
|
||||||
|
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
|
||||||
|
before use, instead of only checking that the files exist. A truncated or corrupt parameter
|
||||||
|
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
|
||||||
|
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
|
||||||
|
process monitor could consume the daemon's exit status, hiding a crash and defeating the
|
||||||
|
automatic-restart cap. The monitor is now the sole reaper.
|
||||||
|
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
|
||||||
|
binary) now reports a precise error immediately instead of briefly showing "running" and
|
||||||
|
then a generic "exited unexpectedly (exit code 127)".
|
||||||
|
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
|
||||||
|
previous daemon to release the data-directory lock and shows a clear, non-crash message
|
||||||
|
instead of exhausting the crash-restart budget.
|
||||||
|
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
|
||||||
|
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
|
||||||
|
can't start.
|
||||||
|
- Directory-creation failures on startup (read-only home, permission denied) now produce a
|
||||||
|
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
|
||||||
|
"binary not found" error (or, in one path, an uncaught exception).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
|
||||||
|
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
|
||||||
|
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
|
||||||
|
automatically once the daemon connects.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Engineering detail and the finding-by-finding rationale for this batch live in
|
||||||
|
`docs/daemon-startup-hardening.md`.
|
||||||
@@ -15,7 +15,7 @@ if(APPLE)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
project(ObsidianDragon
|
project(ObsidianDragon
|
||||||
VERSION 2.0.0
|
VERSION 2.0.1
|
||||||
LANGUAGES C CXX
|
LANGUAGES C CXX
|
||||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||||
)
|
)
|
||||||
@@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "")
|
|||||||
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
# ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's
|
||||||
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
# version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via
|
||||||
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
# DRAGONX_APP_VERSION* (resolved in the lite/full block below).
|
||||||
set(DRAGONX_LITE_VERSION "1.0.0")
|
set(DRAGONX_LITE_VERSION "1.1.0")
|
||||||
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
set(DRAGONX_LITE_VERSION_SUFFIX "")
|
||||||
|
|
||||||
# C++17 standard
|
# C++17 standard
|
||||||
@@ -53,7 +53,6 @@ set_property(CACHE DRAGONX_LITE_BACKEND_LINK_MODE PROPERTY STRINGS imported)
|
|||||||
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
|
set(DRAGONX_LITE_BACKEND_ABI "sdxl-c-v1" CACHE STRING "Expected lite backend C ABI version")
|
||||||
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
|
set(DRAGONX_LITE_BACKEND_SYMBOLS_FILE "" CACHE FILEPATH "Path to generated lite backend exported-symbol inventory")
|
||||||
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
|
set(DRAGONX_LITE_BACKEND_MANIFEST "" CACHE FILEPATH "Optional path to generated lite backend artifact manifest")
|
||||||
option(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE "Require verified signature metadata in the lite backend artifact manifest" OFF)
|
|
||||||
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
|
set(DRAGONX_LITE_BACKEND_REQUIRED_SYMBOLS
|
||||||
litelib_wallet_exists
|
litelib_wallet_exists
|
||||||
litelib_initialize_new
|
litelib_initialize_new
|
||||||
@@ -126,36 +125,24 @@ if(DRAGONX_ENABLE_LITE_BACKEND)
|
|||||||
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
|
if(DRAGONX_LITE_BACKEND_MANIFEST AND NOT EXISTS "${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST does not exist: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||||
endif()
|
endif()
|
||||||
if(DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE)
|
# Note (F15-1): the former signature-metadata gate was removed. It trusted a
|
||||||
if(NOT DRAGONX_LITE_BACKEND_MANIFEST)
|
# "verification_status: verified" field that scripts/build-lite-backend-artifact.sh
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires DRAGONX_LITE_BACKEND_MANIFEST")
|
# self-attested with no cryptographic check (the "verified" SHA was just the artifact's
|
||||||
endif()
|
# own SHA). The trust root is now build-from-source: that script builds the backend from
|
||||||
file(READ "${DRAGONX_LITE_BACKEND_MANIFEST}" DRAGONX_LITE_BACKEND_MANIFEST_JSON)
|
# the vendored in-tree source and refuses prebuilt artifacts, so the library linked here
|
||||||
string(JSON DRAGONX_LITE_SIGNATURE_STATUS ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_STATUS_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_status)
|
# is the one built from reviewed source. The required-symbol inventory check above stays.
|
||||||
if(DRAGONX_LITE_SIGNATURE_STATUS_ERROR)
|
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing signature verification status")
|
|
||||||
endif()
|
|
||||||
if(NOT DRAGONX_LITE_SIGNATURE_STATUS STREQUAL "verified")
|
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verified signature metadata")
|
|
||||||
endif()
|
|
||||||
string(JSON DRAGONX_LITE_SIGNATURE_VERIFIED_SHA ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verified_artifact_sha256)
|
|
||||||
string(JSON DRAGONX_LITE_ARTIFACT_SHA ERROR_VARIABLE DRAGONX_LITE_ARTIFACT_SHA_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" artifact sha256)
|
|
||||||
if(DRAGONX_LITE_SIGNATURE_VERIFIED_SHA_ERROR OR DRAGONX_LITE_ARTIFACT_SHA_ERROR)
|
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST is missing artifact/signature SHA-256 metadata")
|
|
||||||
endif()
|
|
||||||
if(NOT DRAGONX_LITE_SIGNATURE_VERIFIED_SHA STREQUAL DRAGONX_LITE_ARTIFACT_SHA)
|
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_MANIFEST signature metadata does not verify the artifact SHA-256")
|
|
||||||
endif()
|
|
||||||
string(JSON DRAGONX_LITE_SIGNATURE_PERFORMED ERROR_VARIABLE DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR GET "${DRAGONX_LITE_BACKEND_MANIFEST_JSON}" signature_verification verification_performed)
|
|
||||||
if(DRAGONX_LITE_SIGNATURE_PERFORMED_ERROR OR NOT DRAGONX_LITE_SIGNATURE_PERFORMED)
|
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE requires verification_performed=true")
|
|
||||||
endif()
|
|
||||||
endif()
|
|
||||||
|
|
||||||
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
|
add_library(dragonx_lite_backend UNKNOWN IMPORTED)
|
||||||
set_target_properties(dragonx_lite_backend PROPERTIES
|
set_target_properties(dragonx_lite_backend PROPERTIES
|
||||||
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
IMPORTED_LOCATION "${DRAGONX_LITE_BACKEND_LIBRARY}"
|
||||||
)
|
)
|
||||||
|
if(APPLE)
|
||||||
|
# The Rust backend's TLS stack (security-framework / core-foundation crates)
|
||||||
|
# references Secure Transport (SSL*) + CoreFoundation symbols. Link the frameworks
|
||||||
|
# that provide them, or the static lib leaves ~130 symbols undefined at link time.
|
||||||
|
set_property(TARGET dragonx_lite_backend APPEND PROPERTY
|
||||||
|
INTERFACE_LINK_LIBRARIES "-framework Security" "-framework CoreFoundation")
|
||||||
|
endif()
|
||||||
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
if(DRAGONX_LITE_BACKEND_INCLUDE_DIR)
|
||||||
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
if(NOT IS_DIRECTORY "${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||||
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
message(FATAL_ERROR "DRAGONX_LITE_BACKEND_INCLUDE_DIR does not exist: ${DRAGONX_LITE_BACKEND_INCLUDE_DIR}")
|
||||||
@@ -226,7 +213,7 @@ include(FetchContent)
|
|||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
json
|
json
|
||||||
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||||
GIT_TAG v3.11.3
|
GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
|
||||||
GIT_SHALLOW TRUE
|
GIT_SHALLOW TRUE
|
||||||
)
|
)
|
||||||
FetchContent_MakeAvailable(json)
|
FetchContent_MakeAvailable(json)
|
||||||
@@ -235,7 +222,7 @@ FetchContent_MakeAvailable(json)
|
|||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
tomlplusplus
|
tomlplusplus
|
||||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||||
GIT_TAG v3.4.0
|
GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||||
GIT_SHALLOW TRUE
|
GIT_SHALLOW TRUE
|
||||||
)
|
)
|
||||||
FetchContent_MakeAvailable(tomlplusplus)
|
FetchContent_MakeAvailable(tomlplusplus)
|
||||||
@@ -302,8 +289,17 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
|
|||||||
FetchContent_Declare(
|
FetchContent_Declare(
|
||||||
libwebp
|
libwebp
|
||||||
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
||||||
GIT_TAG v1.4.0
|
GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||||
GIT_SHALLOW TRUE
|
GIT_SHALLOW TRUE
|
||||||
|
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
|
||||||
|
# files when it can't probe SSE support. Under a macOS universal build
|
||||||
|
# (-arch arm64;x86_64) that probe fails, so the flags land on the x86_64 slice,
|
||||||
|
# where -mno-sse2 disables _Float16 and breaks the SDK's <math.h>. Neutralize
|
||||||
|
# those disable flags (SSE2 is x86_64 baseline). Portable + idempotent; a no-op
|
||||||
|
# for single-arch Linux/Windows/x86_64 builds. See cmake/patch-libwebp-simd.cmake.
|
||||||
|
PATCH_COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DCPU_CMAKE=<SOURCE_DIR>/cmake/cpu.cmake
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/patch-libwebp-simd.cmake
|
||||||
)
|
)
|
||||||
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
|
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
|
||||||
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
|
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
|
||||||
@@ -525,6 +521,8 @@ set(APP_SOURCES
|
|||||||
src/ui/windows/settings_window.cpp
|
src/ui/windows/settings_window.cpp
|
||||||
src/ui/pages/settings_page.cpp
|
src/ui/pages/settings_page.cpp
|
||||||
src/ui/windows/about_dialog.cpp
|
src/ui/windows/about_dialog.cpp
|
||||||
|
src/ui/windows/faq_dialog.cpp
|
||||||
|
src/ui/windows/faq_content.cpp
|
||||||
src/ui/windows/key_export_dialog.cpp
|
src/ui/windows/key_export_dialog.cpp
|
||||||
src/ui/windows/transaction_details_dialog.cpp
|
src/ui/windows/transaction_details_dialog.cpp
|
||||||
src/ui/windows/qr_popup_dialog.cpp
|
src/ui/windows/qr_popup_dialog.cpp
|
||||||
@@ -548,6 +546,7 @@ set(APP_SOURCES
|
|||||||
src/util/async_task_manager.cpp
|
src/util/async_task_manager.cpp
|
||||||
src/util/amount_format.cpp
|
src/util/amount_format.cpp
|
||||||
src/util/address_validation.cpp
|
src/util/address_validation.cpp
|
||||||
|
src/util/seed_phrase.cpp
|
||||||
src/util/base64.cpp
|
src/util/base64.cpp
|
||||||
src/util/single_instance.cpp
|
src/util/single_instance.cpp
|
||||||
src/util/i18n.cpp
|
src/util/i18n.cpp
|
||||||
@@ -555,6 +554,7 @@ set(APP_SOURCES
|
|||||||
src/util/platform.cpp
|
src/util/platform.cpp
|
||||||
src/util/payment_uri.cpp
|
src/util/payment_uri.cpp
|
||||||
src/util/texture_loader.cpp
|
src/util/texture_loader.cpp
|
||||||
|
src/util/svg_texture.cpp
|
||||||
src/util/noise_texture.cpp
|
src/util/noise_texture.cpp
|
||||||
src/daemon/embedded_daemon.cpp
|
src/daemon/embedded_daemon.cpp
|
||||||
src/daemon/seed_wallet_creator.cpp
|
src/daemon/seed_wallet_creator.cpp
|
||||||
@@ -659,6 +659,8 @@ set(APP_HEADERS
|
|||||||
src/ui/windows/console_tab_helpers.h
|
src/ui/windows/console_tab_helpers.h
|
||||||
src/ui/windows/settings_window.h
|
src/ui/windows/settings_window.h
|
||||||
src/ui/windows/about_dialog.h
|
src/ui/windows/about_dialog.h
|
||||||
|
src/ui/windows/faq_dialog.h
|
||||||
|
src/ui/windows/faq_content.h
|
||||||
src/ui/windows/key_export_dialog.h
|
src/ui/windows/key_export_dialog.h
|
||||||
src/ui/windows/transaction_details_dialog.h
|
src/ui/windows/transaction_details_dialog.h
|
||||||
src/ui/windows/qr_popup_dialog.h
|
src/ui/windows/qr_popup_dialog.h
|
||||||
@@ -1077,6 +1079,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
|||||||
OPTIONAL
|
OPTIONAL
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||||
|
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
|
||||||
|
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
|
||||||
|
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
|
||||||
|
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
|
||||||
|
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
|
||||||
|
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
|
||||||
|
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
|
||||||
|
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
|
||||||
|
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
|
||||||
|
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
|
||||||
|
else()
|
||||||
|
find_package(Threads REQUIRED)
|
||||||
|
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
|
||||||
|
endif()
|
||||||
|
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||||
|
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
|
||||||
|
else()
|
||||||
|
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||||
|
endif()
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Tests
|
# Tests
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
@@ -1125,6 +1153,7 @@ if(BUILD_TESTING)
|
|||||||
src/util/payment_uri.cpp
|
src/util/payment_uri.cpp
|
||||||
src/util/amount_format.cpp
|
src/util/amount_format.cpp
|
||||||
src/util/address_validation.cpp
|
src/util/address_validation.cpp
|
||||||
|
src/util/seed_phrase.cpp
|
||||||
src/util/i18n.cpp
|
src/util/i18n.cpp
|
||||||
src/util/text_format.cpp
|
src/util/text_format.cpp
|
||||||
src/data/wallet_state.cpp
|
src/data/wallet_state.cpp
|
||||||
@@ -1132,6 +1161,7 @@ if(BUILD_TESTING)
|
|||||||
src/data/address_book.cpp
|
src/data/address_book.cpp
|
||||||
src/data/wallet_index.cpp
|
src/data/wallet_index.cpp
|
||||||
src/daemon/lifecycle_adapters.cpp
|
src/daemon/lifecycle_adapters.cpp
|
||||||
|
src/daemon/embedded_daemon.cpp
|
||||||
src/rpc/connection.cpp
|
src/rpc/connection.cpp
|
||||||
src/config/settings.cpp
|
src/config/settings.cpp
|
||||||
src/resources/embedded_resources.cpp
|
src/resources/embedded_resources.cpp
|
||||||
@@ -1203,5 +1233,5 @@ message(STATUS " Lite backend: ${DRAGONX_LITE_BACKEND_READY}")
|
|||||||
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
|
message(STATUS " Lite lib: ${DRAGONX_LITE_BACKEND_LIBRARY}")
|
||||||
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
message(STATUS " Lite symbols: ${DRAGONX_LITE_BACKEND_SYMBOLS_FILE}")
|
||||||
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
message(STATUS " Lite manifest: ${DRAGONX_LITE_BACKEND_MANIFEST}")
|
||||||
message(STATUS " Lite signature: ${DRAGONX_LITE_BACKEND_REQUIRE_SIGNATURE}")
|
message(STATUS " Lite trust: built-from-source (vendored third_party/silentdragonxlite)")
|
||||||
message(STATUS "")
|
message(STATUS "")
|
||||||
|
|||||||
@@ -81,8 +81,8 @@ Download linux and windows binaries of latest releases and place in binary direc
|
|||||||
- prebuilt-binaries/dragonxd-win/
|
- prebuilt-binaries/dragonxd-win/
|
||||||
- prebuilt-binaries/dragonxd-mac/
|
- prebuilt-binaries/dragonxd-mac/
|
||||||
|
|
||||||
**xmrig HAC fork** (https://git.dragonx.is/dragonx/xmrig-hac):
|
**DRG-XMRig fork** (https://git.dragonx.is/DragonX/drg-xmrig):
|
||||||
- prebuilt-binaries/xmrig-hac/
|
- prebuilt-binaries/drg-xmrig/
|
||||||
|
|
||||||
|
|
||||||
## Build Steps
|
## Build Steps
|
||||||
|
|||||||
217
build.sh
217
build.sh
@@ -131,7 +131,7 @@ fi
|
|||||||
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
# truth): the full-node app uses project() VERSION + DRAGONX_VERSION_SUFFIX; ObsidianDragonLite uses
|
||||||
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
# DRAGONX_LITE_VERSION + DRAGONX_LITE_VERSION_SUFFIX.
|
||||||
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
_cml="$SCRIPT_DIR/CMakeLists.txt"
|
||||||
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]]\+\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
_full_ver=$(sed -n 's/^[[:space:]]*VERSION[[:space:]][[:space:]]*\([0-9][0-9.]*\).*/\1/p' "$_cml" | head -1)
|
||||||
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_full_suffix=$(sed -n 's/^set(DRAGONX_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_lite_ver=$(sed -n 's/^set(DRAGONX_LITE_VERSION[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
_lite_suffix=$(sed -n 's/^set(DRAGONX_LITE_VERSION_SUFFIX[[:space:]]*"\([^"]*\)").*/\1/p' "$_cml" | head -1)
|
||||||
@@ -195,6 +195,39 @@ should_bundle_full_node_assets() {
|
|||||||
! $DO_LITE
|
! $DO_LITE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
|
||||||
|
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
|
||||||
|
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
|
||||||
|
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
|
||||||
|
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
|
||||||
|
require_wallet_rebuild_helper() {
|
||||||
|
local helper="$1" depends="$2"
|
||||||
|
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
|
||||||
|
if [[ ! -f "$helper" ]]; then
|
||||||
|
err "wallet-rebuild helper was NOT built: $helper"
|
||||||
|
err " → the recovery 'Repair automatically' option would be MISSING from this release."
|
||||||
|
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
|
||||||
|
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
info " wallet-rebuild helper present: $helper"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Vendored Berkeley DB depends directory for the macOS wallet-rebuild helper, by TARGET arch. The daemon
|
||||||
|
# links a static libdb-6.2; the helper must match the build arch, so derive the triple from it rather than
|
||||||
|
# hardcoding one. Single-arch: x86_64 -> x86_64-apple-darwin, arm64 -> aarch64-apple-darwin. "universal"
|
||||||
|
# (or anything unexpected) falls back to the host arch — a universal .app needs a universal libdb, so if
|
||||||
|
# only a single-arch libdb is vendored, build single-arch via DRAGONX_MAC_ARCHS.
|
||||||
|
mac_bdb_dir() {
|
||||||
|
local arch="$1" triple
|
||||||
|
case "$arch" in
|
||||||
|
x86_64) triple="x86_64-apple-darwin" ;;
|
||||||
|
arm64) triple="aarch64-apple-darwin" ;;
|
||||||
|
*) [[ "$(uname -m)" == "arm64" ]] && triple="aarch64-apple-darwin" || triple="x86_64-apple-darwin" ;;
|
||||||
|
esac
|
||||||
|
printf '%s/external/dragonx/depends/%s\n' "$SCRIPT_DIR" "$triple"
|
||||||
|
}
|
||||||
|
|
||||||
# ── Helper: find resource files ──────────────────────────────────────────────
|
# ── Helper: find resource files ──────────────────────────────────────────────
|
||||||
find_sapling_params() {
|
find_sapling_params() {
|
||||||
local dirs=(
|
local dirs=(
|
||||||
@@ -286,6 +319,9 @@ bundle_linux_daemon() {
|
|||||||
# asmap.dat
|
# asmap.dat
|
||||||
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
||||||
|
|
||||||
|
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
|
||||||
|
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
|
||||||
|
|
||||||
return $found
|
return $found
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,11 +372,24 @@ build_release_linux() {
|
|||||||
mkdir -p "$bd" && cd "$bd"
|
mkdir -p "$bd" && cd "$bd"
|
||||||
|
|
||||||
# ── Compile ──────────────────────────────────────────────────────────────
|
# ── Compile ──────────────────────────────────────────────────────────────
|
||||||
|
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
|
||||||
|
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
|
||||||
|
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
|
||||||
|
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
|
||||||
|
local BDB_ARGS=()
|
||||||
|
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
|
||||||
|
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
|
||||||
|
elif should_bundle_full_node_assets; then
|
||||||
|
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||||
|
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
info "Configuring ..."
|
info "Configuring ..."
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
|
|
||||||
info "Building with $JOBS jobs ..."
|
info "Building with $JOBS jobs ..."
|
||||||
@@ -348,8 +397,12 @@ build_release_linux() {
|
|||||||
|
|
||||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||||
|
|
||||||
|
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||||
|
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
|
||||||
|
|
||||||
info "Stripping ..."
|
info "Stripping ..."
|
||||||
strip "bin/${APP_BASENAME}"
|
strip "bin/${APP_BASENAME}"
|
||||||
|
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
|
||||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||||
|
|
||||||
if should_bundle_full_node_assets; then
|
if should_bundle_full_node_assets; then
|
||||||
@@ -384,9 +437,12 @@ build_release_linux() {
|
|||||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
||||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
||||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||||
|
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||||
|
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
|
||||||
|
info " Bundled dragonx-wallet-rebuild"
|
||||||
fi
|
fi
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
|
[[ -f "$XMRIG_LINUX" ]] && { cp "$XMRIG_LINUX" "$dist_dir/"; chmod +x "$dist_dir/xmrig"; info "Bundled xmrig"; } || warn "xmrig not found — mining unavailable in zip"
|
||||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||||
|
|
||||||
@@ -417,9 +473,11 @@ build_release_linux() {
|
|||||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
||||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
||||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||||
|
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||||
|
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
|
||||||
fi
|
fi
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
[[ -f "$XMRIG_LINUX_AI" ]] && { cp "$XMRIG_LINUX_AI" "$APPDIR/usr/bin/"; chmod +x "$APPDIR/usr/bin/xmrig"; }
|
||||||
|
|
||||||
# Desktop entry
|
# Desktop entry
|
||||||
@@ -478,18 +536,28 @@ APPRUN
|
|||||||
done
|
done
|
||||||
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
|
[[ -f "$bd/_deps/sdl3-build/libSDL3.so" ]] && cp "$bd/_deps/sdl3-build/libSDL3.so"* "$APPDIR/usr/lib/" 2>/dev/null || true
|
||||||
|
|
||||||
# appimagetool
|
# appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
|
||||||
|
# The old "continuous" tag is a MOVING build fetched over the network and run on the release
|
||||||
|
# builder; a compromised/MITM'd artifact would execute here. Verify, or refuse to package.
|
||||||
|
local APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
|
||||||
|
local APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
|
||||||
local APPIMAGETOOL=""
|
local APPIMAGETOOL=""
|
||||||
if command -v appimagetool &>/dev/null; then
|
if command -v appimagetool &>/dev/null; then
|
||||||
APPIMAGETOOL="appimagetool"
|
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
|
||||||
elif [[ -f "$bd/appimagetool-x86_64.AppImage" ]]; then
|
|
||||||
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
|
|
||||||
else
|
else
|
||||||
info "Downloading appimagetool ..."
|
local at="$bd/appimagetool-x86_64.AppImage"
|
||||||
wget -q -O "$bd/appimagetool-x86_64.AppImage" \
|
# Re-verify any cached copy too; a stale unverified download must not be trusted.
|
||||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
if [[ ! -f "$at" ]] || ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
|
||||||
chmod +x "$bd/appimagetool-x86_64.AppImage"
|
info "Downloading appimagetool 1.9.0 (pinned) ..."
|
||||||
APPIMAGETOOL="$bd/appimagetool-x86_64.AppImage"
|
wget -q -O "$at" "$APPIMAGETOOL_URL"
|
||||||
|
if ! echo "${APPIMAGETOOL_SHA256} ${at}" | sha256sum -c --status; then
|
||||||
|
err "appimagetool SHA-256 verification failed — refusing to use it"
|
||||||
|
rm -f "$at"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
chmod +x "$at"
|
||||||
|
fi
|
||||||
|
APPIMAGETOOL="$at"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local ARCH
|
local ARCH
|
||||||
@@ -628,8 +696,32 @@ HDR
|
|||||||
info "Lite mode: skipping embedded daemon binaries"
|
info "Lite mode: skipping embedded daemon binaries"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
# ── Wallet-rebuild recovery helper ───────────────────────────────
|
||||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
|
||||||
|
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
|
||||||
|
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
|
||||||
|
if should_bundle_full_node_assets; then
|
||||||
|
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||||
|
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
|
||||||
|
info "Compiling + embedding wallet-rebuild helper ..."
|
||||||
|
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
|
||||||
|
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
|
||||||
|
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
|
||||||
|
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
|
||||||
|
-o "$RES/dragonx-wallet-rebuild.exe" \
|
||||||
|
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
|
||||||
|
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
|
||||||
|
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
|
||||||
|
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
|
||||||
|
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
|
||||||
|
else
|
||||||
|
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||||
|
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||||
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
||||||
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
# xmrig.exe. Extract it from the matching win-x64 zip if it isn't already staged — otherwise
|
||||||
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
# the embed below never fires (HAS_EMBEDDED_XMRIG stays undefined) and the wallet ships with
|
||||||
@@ -740,11 +832,24 @@ HDR
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── CMake + build ────────────────────────────────────────────────────────
|
# ── CMake + build ────────────────────────────────────────────────────────
|
||||||
|
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
|
||||||
|
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
|
||||||
|
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
|
||||||
|
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||||
|
local BDB_ARGS=()
|
||||||
|
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
|
||||||
|
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
|
||||||
|
elif should_bundle_full_node_assets; then
|
||||||
|
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||||
|
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
info "Configuring (cross-compile) ..."
|
info "Configuring (cross-compile) ..."
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${FT_CMAKE_ARG[@]}" \
|
"${FT_CMAKE_ARG[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
|
|
||||||
@@ -752,8 +857,16 @@ HDR
|
|||||||
cmake --build . -j "$JOBS"
|
cmake --build . -j "$JOBS"
|
||||||
|
|
||||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||||
|
# Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows
|
||||||
|
# helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped
|
||||||
|
# (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort.
|
||||||
|
x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \
|
||||||
|
|| warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe"
|
||||||
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
||||||
|
|
||||||
|
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||||
|
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
|
||||||
|
|
||||||
# ── Package: release/windows/ ────────────────────────────────────────────
|
# ── Package: release/windows/ ────────────────────────────────────────────
|
||||||
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
@@ -769,6 +882,9 @@ HDR
|
|||||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||||
done
|
done
|
||||||
|
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
|
||||||
|
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
|
||||||
|
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
|
||||||
|
|
||||||
# Bundle Sapling params + asmap for the zip distribution
|
# Bundle Sapling params + asmap for the zip distribution
|
||||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||||
@@ -781,7 +897,7 @@ HDR
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Bundle xmrig for mining support
|
# Bundle xmrig for mining support
|
||||||
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig.exe"
|
local XMRIG_WIN="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig.exe"
|
||||||
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
[[ -f "$XMRIG_WIN" ]] && { cp "$XMRIG_WIN" "$dist_dir/"; info "Bundled xmrig.exe"; } || warn "xmrig.exe not found — mining unavailable in zip"
|
||||||
|
|
||||||
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
cp -r bin/res "$dist_dir/" 2>/dev/null || true
|
||||||
@@ -891,8 +1007,26 @@ build_release_mac() {
|
|||||||
fi
|
fi
|
||||||
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
|
info "macOS cross-compiler: $OSXCROSS_CXX (arch: $MAC_ARCH)"
|
||||||
else
|
else
|
||||||
# Native macOS: build universal binary (arm64 + x86_64)
|
# Native macOS: build universal (arm64 + x86_64) by default. Override with
|
||||||
MAC_ARCH="universal"
|
# DRAGONX_MAC_ARCHS (e.g. "x86_64").
|
||||||
|
MAC_ARCHS="${DRAGONX_MAC_ARCHS:-arm64;x86_64}"
|
||||||
|
# When linking the real lite backend, the app can only include architectures
|
||||||
|
# the backend static library actually provides. Its pinned ring 0.16.11 has no
|
||||||
|
# Apple-Silicon assembly, so that artifact is x86_64-only — constrain the app
|
||||||
|
# arch to the backend's (unless the user explicitly forced DRAGONX_MAC_ARCHS),
|
||||||
|
# otherwise the arm64 slice fails to link.
|
||||||
|
if $DO_LITE_BACKEND && [[ -z "${DRAGONX_MAC_ARCHS:-}" && -n "${lb_lib:-}" ]] && command -v lipo &>/dev/null; then
|
||||||
|
local _backend_archs; _backend_archs=$(lipo -archs "$lb_lib" 2>/dev/null | tr ' ' ';')
|
||||||
|
if [[ -n "$_backend_archs" && "$_backend_archs" != "$MAC_ARCHS" ]]; then
|
||||||
|
warn "Lite backend provides only [$_backend_archs] — building the app for that instead of universal."
|
||||||
|
MAC_ARCHS="$_backend_archs"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ "$MAC_ARCHS" == *";"* || "$MAC_ARCHS" == *","* ]]; then
|
||||||
|
MAC_ARCH="universal"
|
||||||
|
else
|
||||||
|
MAC_ARCH="$MAC_ARCHS"
|
||||||
|
fi
|
||||||
export MACOSX_DEPLOYMENT_TARGET="11.0"
|
export MACOSX_DEPLOYMENT_TARGET="11.0"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -964,6 +1098,12 @@ TOOLCHAIN
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
info "Configuring (cross-compile via osxcross) ..."
|
info "Configuring (cross-compile via osxcross) ..."
|
||||||
|
# Vendored static libdb for the recovery helper (full-node only), by target arch; see mac_bdb_dir.
|
||||||
|
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
|
||||||
|
local BDB_ARGS=()
|
||||||
|
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
|
||||||
|
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
|
||||||
|
fi
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_TOOLCHAIN_FILE="$bd/osxcross-toolchain.cmake" \
|
-DCMAKE_TOOLCHAIN_FILE="$bd/osxcross-toolchain.cmake" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
@@ -971,6 +1111,7 @@ TOOLCHAIN
|
|||||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||||
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
${COMPILER_RT:+-DOSXCROSS_COMPILER_RT="$COMPILER_RT"} \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
else
|
else
|
||||||
# Build libsodium as universal if needed
|
# Build libsodium as universal if needed
|
||||||
@@ -980,7 +1121,7 @@ TOOLCHAIN
|
|||||||
need_sodium=true
|
need_sodium=true
|
||||||
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
|
elif [[ -f "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" ]]; then
|
||||||
# Rebuild if existing lib is not universal (single-arch won't link)
|
# Rebuild if existing lib is not universal (single-arch won't link)
|
||||||
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -q "arm64.*x86_64\|x86_64.*arm64"; then
|
if ! lipo -info "$SCRIPT_DIR/libs/libsodium/lib/libsodium.a" 2>/dev/null | grep -Eq "arm64.*x86_64|x86_64.*arm64"; then
|
||||||
info "Existing libsodium is not universal — rebuilding ..."
|
info "Existing libsodium is not universal — rebuilding ..."
|
||||||
rm -rf "$SCRIPT_DIR/libs/libsodium"
|
rm -rf "$SCRIPT_DIR/libs/libsodium"
|
||||||
need_sodium=true
|
need_sodium=true
|
||||||
@@ -991,13 +1132,20 @@ TOOLCHAIN
|
|||||||
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
|
"$SCRIPT_DIR/scripts/fetch-libsodium.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "Configuring (native universal arm64+x86_64) ..."
|
info "Configuring (native macOS, arch: $MAC_ARCHS) ..."
|
||||||
|
# Point CMake at the vendored static libdb for the recovery helper (full-node only); see mac_bdb_dir.
|
||||||
|
local mac_bdb; mac_bdb="$(mac_bdb_dir "$MAC_ARCH")"
|
||||||
|
local BDB_ARGS=()
|
||||||
|
if should_bundle_full_node_assets && [[ -f "$mac_bdb/lib/libdb-6.2.a" && -f "$mac_bdb/include/db.h" ]]; then
|
||||||
|
BDB_ARGS=( -DBDB_INCLUDE_DIR="$mac_bdb/include" -DBDB_LIBRARY="$mac_bdb/lib/libdb-6.2.a" )
|
||||||
|
fi
|
||||||
cmake "$SCRIPT_DIR" \
|
cmake "$SCRIPT_DIR" \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=Release \
|
||||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
-DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 \
|
||||||
-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
|
-DCMAKE_OSX_ARCHITECTURES="$MAC_ARCHS" \
|
||||||
|
"${BDB_ARGS[@]}" \
|
||||||
"${CMAKE_LITE_ARGS[@]}"
|
"${CMAKE_LITE_ARGS[@]}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -1006,6 +1154,11 @@ TOOLCHAIN
|
|||||||
|
|
||||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||||
|
|
||||||
|
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
|
||||||
|
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
|
||||||
|
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
|
||||||
|
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$(mac_bdb_dir "$MAC_ARCH")"
|
||||||
|
|
||||||
# Strip — use osxcross strip for cross-builds
|
# Strip — use osxcross strip for cross-builds
|
||||||
if $IS_CROSS; then
|
if $IS_CROSS; then
|
||||||
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
||||||
@@ -1027,8 +1180,12 @@ TOOLCHAIN
|
|||||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||||
|
|
||||||
# ── Create .app bundle ───────────────────────────────────────────────────
|
# ── Create .app bundle ───────────────────────────────────────────────────
|
||||||
rm -rf "$out"
|
|
||||||
mkdir -p "$out"
|
mkdir -p "$out"
|
||||||
|
# Clean only THIS variant's prior artifacts so full-node and lite releases can
|
||||||
|
# coexist in release/mac/ (Linux/Windows scope their cleanup the same way). The
|
||||||
|
# "ObsidianDragon-" glob never matches "ObsidianDragonLite-" (and vice versa),
|
||||||
|
# and the ".app" names are exact.
|
||||||
|
rm -rf "$out/${APP_BASENAME}.app" "$out/${APP_BASENAME}-"*.app.zip "$out/${APP_BASENAME}-"*.dmg
|
||||||
|
|
||||||
local APP="$out/${APP_BASENAME}.app"
|
local APP="$out/${APP_BASENAME}.app"
|
||||||
local CONTENTS="$APP/Contents"
|
local CONTENTS="$APP/Contents"
|
||||||
@@ -1074,12 +1231,14 @@ TOOLCHAIN
|
|||||||
else
|
else
|
||||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||||
fi
|
fi
|
||||||
|
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
|
||||||
|
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
|
||||||
else
|
else
|
||||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# xmrig binary (from prebuilt-binaries/xmrig-hac/)
|
# xmrig binary (from prebuilt-binaries/drg-xmrig/)
|
||||||
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac/xmrig"
|
local XMRIG_MAC="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||||
if [[ -f "$XMRIG_MAC" ]]; then
|
if [[ -f "$XMRIG_MAC" ]]; then
|
||||||
cp "$XMRIG_MAC" "$MACOS/xmrig"
|
cp "$XMRIG_MAC" "$MACOS/xmrig"
|
||||||
chmod +x "$MACOS/xmrig"
|
chmod +x "$MACOS/xmrig"
|
||||||
@@ -1228,8 +1387,10 @@ PLIST
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Create DMG ───────────────────────────────────────────────────────────
|
# ── Create DMG ───────────────────────────────────────────────────────────
|
||||||
local DMG_BASENAME="DragonX_Wallet"
|
# DMG filename matches the app bundle name (ObsidianDragon / ObsidianDragonLite).
|
||||||
$DO_LITE && DMG_BASENAME="DragonX_Wallet_Lite"
|
# The mounted volume + CFBundleName keep the "DragonX Wallet" display branding
|
||||||
|
# (APP_DISPLAY_NAME above).
|
||||||
|
local DMG_BASENAME="${APP_BASENAME}"
|
||||||
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
local DMG_NAME="${DMG_BASENAME}-${VERSION}-macOS-${MAC_ARCH}.dmg"
|
||||||
|
|
||||||
if command -v create-dmg &>/dev/null; then
|
if command -v create-dmg &>/dev/null; then
|
||||||
@@ -1311,3 +1472,9 @@ if $DO_LINUX || $DO_WIN || $DO_MAC; then
|
|||||||
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
|
[[ -d "$SCRIPT_DIR/release/windows" ]] && echo -e " ${CYAN}windows/${NC} — .exe + .zip"
|
||||||
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
|
[[ -d "$SCRIPT_DIR/release/mac" ]] && echo -e " ${CYAN}mac/${NC} — .app + .dmg"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Reaching here means the build completed (real failures exit 1 at their point of failure).
|
||||||
|
# Exit 0 explicitly: the final `[[ -d release/mac ]] && echo` above returns non-zero on a
|
||||||
|
# non-mac build — and since set -e exempts the left side of an &&, that status would otherwise
|
||||||
|
# become the script's exit code and make a successful build report failure (e.g. to CI).
|
||||||
|
exit 0
|
||||||
|
|||||||
31
cmake/patch-libwebp-simd.cmake
Normal file
31
cmake/patch-libwebp-simd.cmake
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# patch-libwebp-simd.cmake — portable, idempotent FetchContent patch for libwebp.
|
||||||
|
#
|
||||||
|
# libwebp's cmake/cpu.cmake compiles its scalar *reference* DSP files with the
|
||||||
|
# SSE-disable flags "-mno-sse4.1;-mno-sse2" whenever it can't positively detect
|
||||||
|
# SSE support. Under a macOS *universal* build (-arch arm64;x86_64) the per-arch
|
||||||
|
# SSE flag probe fails (a flag valid for x86_64 is invalid for arm64), so those
|
||||||
|
# disable flags get applied to the x86_64 slice. clang gates the _Float16 type on
|
||||||
|
# SSE2 for x86_64, and the macOS 15+/26 SDK's <math.h> declares _Float16 math
|
||||||
|
# functions unconditionally — so any TU including <math.h> fails to compile with
|
||||||
|
# "_Float16 is not supported on this target".
|
||||||
|
#
|
||||||
|
# SSE2 is part of the x86_64 baseline ABI, so disabling it on the reference files
|
||||||
|
# is unnecessary on every platform we target. Blanking the SSE entries (indices
|
||||||
|
# must stay aligned with WEBP_SIMD_FLAGS) fixes the universal build and is a no-op
|
||||||
|
# for single-arch Linux/Windows/x86_64 builds. Idempotent: re-running is a no-op.
|
||||||
|
if(NOT DEFINED CPU_CMAKE OR NOT EXISTS "${CPU_CMAKE}")
|
||||||
|
message(FATAL_ERROR "patch-libwebp-simd: cpu.cmake not found at '${CPU_CMAKE}'")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
file(READ "${CPU_CMAKE}" _contents)
|
||||||
|
string(REPLACE
|
||||||
|
"set(SIMD_DISABLE_FLAGS \"-mno-sse4.1;-mno-sse2;;-mno-dspr2;;-mno-msa\")"
|
||||||
|
"set(SIMD_DISABLE_FLAGS \";;;-mno-dspr2;;-mno-msa\")"
|
||||||
|
_patched "${_contents}")
|
||||||
|
|
||||||
|
if(_patched STREQUAL _contents)
|
||||||
|
message(STATUS "patch-libwebp-simd: no change (already patched or pattern absent)")
|
||||||
|
else()
|
||||||
|
file(WRITE "${CPU_CMAKE}" "${_patched}")
|
||||||
|
message(STATUS "patch-libwebp-simd: neutralized x86 SSE-disable flags in cpu.cmake")
|
||||||
|
endif()
|
||||||
549
docs/daemon-startup-hardening.md
Normal file
549
docs/daemon-startup-hardening.md
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
# Daemon Startup Hardening — Implementation Plan
|
||||||
|
|
||||||
|
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
|
||||||
|
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
|
||||||
|
line references), the chosen approach, the call sites, a representative change, and how
|
||||||
|
to verify it.
|
||||||
|
|
||||||
|
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
|
||||||
|
- **Source:** line references are exact against branch `dev` @ `45b652f`.
|
||||||
|
- **Provenance:** findings verified by direct source read; each fix designed by an
|
||||||
|
independent agent grounded in the cited files, with a sequencing pass for ordering,
|
||||||
|
shared helpers, and merge conflicts.
|
||||||
|
|
||||||
|
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.**
|
||||||
|
|
||||||
|
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||||
|
|
||||||
|
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
|
||||||
|
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
|
||||||
|
pure-helper unit tests added.
|
||||||
|
|
||||||
|
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
|
||||||
|
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
|
||||||
|
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
|
||||||
|
rather than render as tofu).
|
||||||
|
|
||||||
|
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
|
||||||
|
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
|
||||||
|
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended rollout sequence
|
||||||
|
|
||||||
|
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
|
||||||
|
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
|
||||||
|
connect-stall panel and the lock gate both build on. The filesystem cluster lands
|
||||||
|
around a single shared helper. The connectivity-breaking security flip lands last.
|
||||||
|
|
||||||
|
| Step | Finding(s) | Site | Why here | Status |
|
||||||
|
|------|-----------|------|----------|--------|
|
||||||
|
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
|
||||||
|
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
|
||||||
|
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
|
||||||
|
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
|
||||||
|
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
|
||||||
|
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
|
||||||
|
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F1 — Double-`waitpid` race can swallow a daemon crash
|
||||||
|
|
||||||
|
**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
|
||||||
|
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
|
||||||
|
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
|
||||||
|
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
|
||||||
|
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
|
||||||
|
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
|
||||||
|
with an atomic read.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
|
||||||
|
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
|
||||||
|
sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because
|
||||||
|
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136)
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
bool EmbeddedDaemon::isRunning() const // POSIX branch
|
||||||
|
{
|
||||||
|
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
|
||||||
|
// sole reaper. Previously both threads reaped; if the UI thread won, the
|
||||||
|
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
|
||||||
|
if (process_pid_ <= 0) return false;
|
||||||
|
|
||||||
|
State s = state_.load(std::memory_order_relaxed);
|
||||||
|
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
|
||||||
|
// state_ == Stopping, before the process has actually terminated.
|
||||||
|
return (s == State::Running || s == State::Stopping);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
|
||||||
|
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
|
||||||
|
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
|
||||||
|
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
|
||||||
|
iteration — file as its own ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
|
||||||
|
|
||||||
|
**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs
|
||||||
|
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
|
||||||
|
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
|
||||||
|
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
|
||||||
|
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
|
||||||
|
as "exited unexpectedly (exit code 127)".
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
|
||||||
|
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
|
||||||
|
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
|
||||||
|
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
|
||||||
|
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
|
||||||
|
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path
|
||||||
|
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
|
||||||
|
- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053)
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
|
||||||
|
int execpipe[2]; pipe(execpipe);
|
||||||
|
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||||
|
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||||
|
|
||||||
|
pid_t pid = fork();
|
||||||
|
if (pid == 0) { // child
|
||||||
|
close(execpipe[0]);
|
||||||
|
/* setpgid / chdir / dup2 / argv … */
|
||||||
|
execv(binary_path.c_str(), argv.data());
|
||||||
|
int e = errno; // execv failed
|
||||||
|
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
|
||||||
|
_exit(127);
|
||||||
|
}
|
||||||
|
|
||||||
|
close(execpipe[1]); // parent: must close or read() never EOFs
|
||||||
|
int child_errno = 0, total = 0;
|
||||||
|
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
|
||||||
|
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
|
||||||
|
if (n == 0) break;
|
||||||
|
if (n < 0) { if (errno == EINTR) continue; break; }
|
||||||
|
if ((total += n) >= (int)sizeof(int)) break;
|
||||||
|
}
|
||||||
|
close(execpipe[0]);
|
||||||
|
if (total >= (int)sizeof(int)) { // exec never happened
|
||||||
|
waitpid(pid, nullptr, 0); // reap the zombie
|
||||||
|
last_error_ = "dragonxd could not be executed: " +
|
||||||
|
std::string(strerror(child_errno)) +
|
||||||
|
" — not executable or wrong architecture";
|
||||||
|
return false; // start() no longer reports Running
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
|
||||||
|
- Success path: real binary still starts with no perceptible added latency.
|
||||||
|
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
|
||||||
|
`FD_CLOEXEC` makes every successful start hang the parent read forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F4 — Stale datadir-lock start → restart storm that wedges the UI
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
|
||||||
|
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
|
||||||
|
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
|
||||||
|
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
|
||||||
|
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
|
||||||
|
before the lock actually clears.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
|
||||||
|
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
|
||||||
|
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
|
||||||
|
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
|
||||||
|
`crash_count_`. The connect loop's own retry then absorbs the transient.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
|
||||||
|
- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()`
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
static StartLockGateDecision evaluateDatadirLockGate(
|
||||||
|
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
|
||||||
|
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
|
||||||
|
if (!stillRunningAfterWait) return {true, ""};
|
||||||
|
return {false, "A previous dragonxd is still shutting down and holding the "
|
||||||
|
"data directory lock. Retrying shortly…"};
|
||||||
|
}
|
||||||
|
|
||||||
|
// start() — after the isPortInUse() bail, before setState(Starting):
|
||||||
|
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||||
|
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
|
||||||
|
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
|
||||||
|
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
|
||||||
|
stillLocked = true;
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||||
|
}
|
||||||
|
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
|
||||||
|
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
|
||||||
|
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
|
||||||
|
- Migrate-to-seed second daemon still starts (isolated exemption).
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
|
||||||
|
"exited unexpectedly"). Same TU, different function.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F5 — Extraction / copy write-failures never surfaced up front
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
|
||||||
|
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
|
||||||
|
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
|
||||||
|
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
|
||||||
|
spawn and fails opaquely. The innermost write already returns `false`
|
||||||
|
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
|
||||||
|
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
|
||||||
|
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
|
||||||
|
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
|
||||||
|
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
|
||||||
|
without re-touching this control flow.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152)
|
||||||
|
- `src/app.cpp` — second copy-fallback loop (~4210–4242)
|
||||||
|
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
// stop discarding the extraction result (~4152)
|
||||||
|
if (!resources::extractEmbeddedResources()) {
|
||||||
|
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
|
||||||
|
return false; // abort before spawning
|
||||||
|
}
|
||||||
|
|
||||||
|
// second copy-fallback loop — was dropping ec entirely (~4236)
|
||||||
|
bool copyFailed = false;
|
||||||
|
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
|
||||||
|
fs::path dst = fs::path(daemon_dir) / name;
|
||||||
|
if (fs::exists(dst)) continue; // already present — skip
|
||||||
|
for (const auto& dir : searchDirs) {
|
||||||
|
fs::path src = fs::path(dir) / name;
|
||||||
|
if (!fs::exists(src)) continue; // absent source is OK, not a failure
|
||||||
|
fs::copy_file(src, dst, ec);
|
||||||
|
if (ec) { copyFailed = true; ec.clear(); }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (copyFailed) {
|
||||||
|
char buf[512];
|
||||||
|
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||||
|
daemon_status_ = buf;
|
||||||
|
return false; // don't fall through to spawn
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
|
||||||
|
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
|
||||||
|
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
|
||||||
|
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F6 — Sapling params validated by existence/size only, never hashed
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
|
||||||
|
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
|
||||||
|
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
|
||||||
|
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
|
||||||
|
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
|
||||||
|
> per the cross-cutting note. Non-English locales fall back to English until then.
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
|
||||||
|
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
|
||||||
|
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
|
||||||
|
which then fails to build shielded proofs mid-operation — far from the real cause.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
|
||||||
|
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
|
||||||
|
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
|
||||||
|
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
|
||||||
|
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/rpc/connection.h` — `verifySaplingParams` decl
|
||||||
|
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
// connection.cpp — pinned known-good digests
|
||||||
|
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
|
||||||
|
constexpr SaplingParamDigest kSaplingParamDigests[] = {
|
||||||
|
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
|
||||||
|
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
|
||||||
|
};
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParams() {
|
||||||
|
// existence check (unchanged) …
|
||||||
|
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
|
||||||
|
if (readMarkerMatches(marker, statLines)) return true;
|
||||||
|
for (auto& d : kSaplingParamDigests)
|
||||||
|
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
|
||||||
|
writeMarker(marker, statLines);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
|
||||||
|
Third caller of the existing `util::sha256Hex`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F7 — Directory-create errors universally ignored on the daemon-env path
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
|
||||||
|
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
|
||||||
|
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
Five startup directory-create sites either drop the `error_code` or use the throwing
|
||||||
|
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
|
||||||
|
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
|
||||||
|
home or permission-denied yields a confusing "conf missing" / "binary not found"
|
||||||
|
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
|
||||||
|
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
|
||||||
|
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
|
||||||
|
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
|
||||||
|
**structural owner** of the fs-error idiom that F5 and F6 reuse.
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/util/platform.h` / `.cpp` — `ensureDirectory()`
|
||||||
|
- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig`
|
||||||
|
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
|
||||||
|
- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory`
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
// util/platform.cpp — one shared, non-throwing helper
|
||||||
|
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
|
||||||
|
std::error_code ec;
|
||||||
|
if (std::filesystem::is_directory(dir, ec)) return true;
|
||||||
|
ec.clear();
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
if (ec) {
|
||||||
|
if (outError)
|
||||||
|
*outError = "Cannot create " + dir + ": " + ec.message() +
|
||||||
|
". Check permissions / free space.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
|
||||||
|
// and its 4 callers bail on it.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
|
||||||
|
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
|
||||||
|
extension (coordinated with F8). Land before F5/F6/F8.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F8 — Plaintext-remote RPC credential transmission is warn-only
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
|
||||||
|
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
|
||||||
|
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
|
||||||
|
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
|
||||||
|
> `::1`) are unaffected. Call this out prominently in the release notes.
|
||||||
|
>
|
||||||
|
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
|
||||||
|
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
|
||||||
|
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
|
||||||
|
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
|
||||||
|
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
|
||||||
|
> status/notification tells the user exactly what to add. The toggle can be added later
|
||||||
|
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
|
||||||
|
|
||||||
|
### The defect
|
||||||
|
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
|
||||||
|
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
|
||||||
|
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
|
||||||
|
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
|
||||||
|
suppressing even the warning.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
|
||||||
|
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
|
||||||
|
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
|
||||||
|
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
|
||||||
|
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
|
||||||
|
users hit a hard stop until they opt in — **ship with prominent release notes.**
|
||||||
|
|
||||||
|
### Files touched
|
||||||
|
- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
|
||||||
|
- `src/config/settings.h` / `.cpp` — persisted opt-in
|
||||||
|
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
|
||||||
|
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
|
||||||
|
- `src/ui/pages/settings_page.cpp` — toggle UI
|
||||||
|
|
||||||
|
### Core change
|
||||||
|
```cpp
|
||||||
|
// Tightened loopback test — "127.evil.com" is NOT local
|
||||||
|
bool Connection::isLocalHost(const std::string& host) {
|
||||||
|
std::string h = stripBrackets(lowercase(host));
|
||||||
|
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refuse-by-default with an explicit, persisted opt-in
|
||||||
|
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
|
||||||
|
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|
||||||
|
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
|
||||||
|
if (plaintextRemote && !plaintextAllowed) {
|
||||||
|
connection_status_ = TR("sb_plaintext_remote_blocked");
|
||||||
|
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
|
||||||
|
return; // no creds sent
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
|
||||||
|
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
|
||||||
|
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared helpers & coordination points
|
||||||
|
|
||||||
|
| Helper | Purpose | Used by |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
|
||||||
|
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
|
||||||
|
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
|
||||||
|
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
|
||||||
|
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
|
||||||
|
|
||||||
|
## F3 — Unbounded connect spinner (deferred to step 6)
|
||||||
|
|
||||||
|
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||||
|
|
||||||
|
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
|
||||||
|
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
|
||||||
|
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
|
||||||
|
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
|
||||||
|
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
|
||||||
|
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
|
||||||
|
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
|
||||||
|
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
|
||||||
|
|
||||||
|
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
|
||||||
|
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
|
||||||
|
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
|
||||||
|
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
|
||||||
|
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
|
||||||
|
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
|
||||||
|
firing — recovery clears the panel automatically. Guarded off while the daemon is in
|
||||||
|
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
|
||||||
|
design record; see the shared-helper table above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-cutting notes
|
||||||
|
|
||||||
|
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
|
||||||
|
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
|
||||||
|
keep "monitorProcess is the sole reaper" coherent.
|
||||||
|
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
|
||||||
|
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
|
||||||
|
Sequence F7→F6→F8.
|
||||||
|
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
|
||||||
|
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
|
||||||
|
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
|
||||||
|
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
|
||||||
|
`scripts/add_missing_translations.py` **once at the very end**
|
||||||
|
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
|
||||||
|
`res/lang/*.json`.
|
||||||
|
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
|
||||||
|
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
|
||||||
|
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
|
||||||
|
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
|
||||||
|
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
|
||||||
|
mid-sleep monitor iteration — file it as its own ticket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
|
||||||
|
|
||||||
|
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
|
||||||
|
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
|
||||||
|
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
|
||||||
|
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
|
||||||
|
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
|
||||||
|
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
|
||||||
|
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
|
||||||
|
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.
|
||||||
61
docs/release-notes/lite-v1.0.0.md
Normal file
61
docs/release-notes/lite-v1.0.0.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
**DragonX (DRGX) Lite Desktop Wallet — v1.0.0**
|
||||||
|
|
||||||
|
ObsidianDragonLite is a lightweight companion to the ObsidianDragon full-node wallet. It skips the embedded full node entirely — **no multi-gigabyte blockchain download and near-instant startup** — by connecting to a DragonX lite-wallet (lightwalletd-style) server, while keeping the same native ImGui interface and shielded-first workflow.
|
||||||
|
|
||||||
|
This is the **first release** of the Lite variant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why Lite?
|
||||||
|
|
||||||
|
- **No blockchain to download.** Sync in seconds instead of hours; a few MB of state instead of many GB.
|
||||||
|
- **Same wallet, lighter footprint.** The familiar ObsidianDragon UI, shielded and transparent addresses, and address book — without running a node or miner.
|
||||||
|
- **Portable.** A single self-contained binary that stores its data in its own `ObsidianDragonLite` folder, so it coexists cleanly alongside the full-node wallet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Fast, node-free operation** — connect to a DragonX lite-wallet server and sync in seconds; minimal disk and RAM.
|
||||||
|
- **Wallet lifecycle** — create a new wallet, restore from a seed phrase, or open an existing one; wallets auto-open on startup, with a first-run welcome prompt and **guided seed backup** on creation.
|
||||||
|
- **Shielded + transparent** — send, shield, and receive DRGX; per-address balances computed from unspent notes and UTXOs.
|
||||||
|
- **Keys & seed** — export your seed phrase and keys, and import keys, from Settings.
|
||||||
|
- **Passphrase encryption** — encrypt, unlock, lock, and decrypt the wallet; you're prompted to unlock at send time and on startup when the wallet is locked.
|
||||||
|
- **Encrypted messaging (HushChat)** — built-in **Contacts** and **Chat** with a seed-derived, **SilentDragonXLite-compatible** identity and a seed-encrypted local message store, so you can message other DragonX users end-to-end.
|
||||||
|
- **Server management** — a built-in **server browser with automatic failover**; switch servers with live reconnect/recovery, and a **"Redownload blocks"** action to rescan from the server.
|
||||||
|
- **Status & diagnostics** — a **Network tab** showing connection and sync status, plus an **interactive Console** for running backend commands and copyable error output.
|
||||||
|
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||||
|
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (DMG + .app), x86-64.
|
||||||
|
- QR code generation for receiving addresses
|
||||||
|
- Extensive theme and appearance options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How it differs from the full-node wallet
|
||||||
|
|
||||||
|
- **No embedded `dragonxd`, no mining, no local blockchain explorer.** Chain data comes from the lite-wallet server rather than being verified locally by a full node — a lighter footprint in exchange for trusting the server for chain state.
|
||||||
|
- Everything key-related stays **on your device**: seed, keys, and (when enabled) the encryption passphrase never leave the machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
|------|---------|
|
||||||
|
| ObsidianDragonLite-1.0.0.AppImage | `a1459d6081124a6b8df47aa898b60c0237875e8cbb132e4fd8ae637673055ed4` |
|
||||||
|
| ObsidianDragonLite-1.0.0-Linux-x64.zip | `573af502a6372334572e4dc88bd26e0cf36ed543b9df1e835fd8e4abd295108d` |
|
||||||
|
| ObsidianDragonLite-1.0.0.exe | `28659efb770fa573bd1b1d0fcab1c1a3f9c7e46574185c3916cc9f669684852c` |
|
||||||
|
| ObsidianDragonLite-1.0.0-Windows-x64.zip | `f6434a931e873e5936ebe249b044d6c7a413694767c667374c91ee438259b50c` |
|
||||||
|
| ObsidianDragonLite-1.0.0-macOS-x86_64.dmg | `10f976f1453e6b1978cb7a85026fd033c85a10c1e66f436475aa628379a298c7` |
|
||||||
|
| ObsidianDragonLite-1.0.0-macOS-x86_64.app.zip | `3e7259c363f7be2e9027c5282c94b742f4be280c998203752dc0a0551f6ab68b` |
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||||
|
- **Windows:** x86-64, Windows 10 or later
|
||||||
|
- **macOS:** x86-64 (Intel; runs on Apple Silicon via Rosetta 2), macOS 10.15 Catalina or later
|
||||||
|
- Network access to a DragonX lite-wallet server.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||||
86
docs/release-notes/v2.0.0.md
Normal file
86
docs/release-notes/v2.0.0.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
**DragonX (DRGX) Full-Node Desktop Wallet — v2.0.0**
|
||||||
|
|
||||||
|
This is ObsidianDragon, a native ImGui-based wallet for the DragonX network. It ships with an embedded full-node daemon (`dragonxd`) and an integrated CPU miner (DRG-XMRig), giving users a complete, self-contained experience on Linux, Windows, and macOS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What's New in v2.0.0
|
||||||
|
|
||||||
|
v2.0.0 is a major release. It adds **end-to-end encrypted HushChat messaging** and **BIP39 seed-phrase wallets** (with migrate-to-seed for legacy wallets), lets the wallet **update its own node and miner** with each install cryptographically verified, and ships a **security-hardening pass** across the updater, RPC, and secret-storage surfaces — plus full support for the **new multi-threaded `dragonxd`** and a large stability and UX overhaul.
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
- **Encrypted messaging (HushChat)** — new **Contacts** and **Chat** tabs bring DRGX-native, end-to-end encrypted messaging. Your chat identity is derived from your wallet seed, messages are encrypted with libsodium (XChaCha20-Poly1305 / secretstream) and stored in a **seed-encrypted local database**, and the wire format is interoperable with SilentDragonX / SilentDragonXLite.
|
||||||
|
- **BIP39 seed-phrase wallets & migrate-to-seed** — new wallets are backed by a mnemonic **seed phrase** you can back up from Settings. Existing **legacy wallets can be migrated into a seed wallet** — the wallet mints a new mnemonic wallet, sweeps your funds to it, and adopts it only once the sweep is mined, keeping a timestamped `wallet.dat` backup throughout. (The bundled `dragonxd` supports the required mnemonic RPCs; older nodes degrade gracefully.)
|
||||||
|
- **In-app daemon updater** — Settings → **Node & Security → Daemon binary → "Check for updates…"** downloads the latest `dragonxd` from the project Gitea, verifies its **SHA-256 and a detached ed25519 signature** before installing, and replaces the binary **atomically while the node keeps running** (the new build takes effect on the next daemon start). A two-pane version picker lets you pin, downgrade, or install any published/pre-release build.
|
||||||
|
- **In-app miner updater** — an **"Update miner…"** action in the Mining tab fetches, verifies (SHA-256 + ed25519), and installs the latest DRG-XMRig, with the same version picker and a display of current vs. latest.
|
||||||
|
- **Daemon binary management** — the wallet no longer auto-overwrites a node you've dropped in; a dedicated Settings panel shows the managed binary and gathers all node actions (restart, rescan, repair) onto one toolbar.
|
||||||
|
- **Repair Wallet** — a one-click `-zapwallettxes=2` recovery in Settings, plus automatic wallet reconciliation after a bootstrap and runtime rescan support for pruned nodes.
|
||||||
|
- **Explorer search** — fuzzy, live (debounced) filtering of the block list by partial hash or height.
|
||||||
|
- **History sorting & fast load** — sort transactions by date or amount, a "Loading older history (N%)" indicator during the initial bulk load, and persisted history that surfaces pending sends immediately.
|
||||||
|
- **Smarter mining** — the thread benchmark now measures **sustained (thermally-throttled) hashrate** instead of an inflated initial burst, with GPU-aware idle mining and corrected idle thread scaling.
|
||||||
|
|
||||||
|
### Security & Integrity
|
||||||
|
|
||||||
|
- **Mandatory signature verification** — both the daemon and miner updaters **require** a valid ed25519 signature (checked against a key pinned in the wallet) in addition to the SHA-256; an install is refused if the signature is missing or invalid.
|
||||||
|
- **Hardened secret handling** — RPC credentials are wiped from memory after use, RPC responses are size-capped and scrubbed of secrets in logs, generated node config is written **owner-only (0600)**, and secrets copied to the clipboard **auto-clear**.
|
||||||
|
- **Safe on-disk writes** — settings, address book, and secret files are written **atomically with owner-only permissions**; the PIN vault fsyncs its secure-delete overwrite; SQLite cache growth is bounded.
|
||||||
|
- **Recipient validation** — sends validate recipient address checksums (Base58Check + Bech32) before building a transaction.
|
||||||
|
- **Path-traversal protection** — archive extraction (chain bootstrap and the updaters) rejects any entry that would escape the target directory (zip-slip), and the bootstrap fails closed on a missing checksum rather than trusting an unverified archive.
|
||||||
|
- **Robustness** — malformed RPC error JSON is guarded and sends are single-flight, preventing duplicate/ill-formed submissions.
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
|
||||||
|
- **New multi-threaded daemon support** — live Sapling note-witness rebuild progress, accurate sync-speed display, reliable rescan-completion detection, and RPC polling throttled during sync so block download isn't slowed.
|
||||||
|
- **Networking** — DragonX DNS seed nodes, `-maxconnections` passed to the daemon, and a peer count that stays current on every tab.
|
||||||
|
- **Node resilience** — non-blocking warmup so you can connect while the daemon is still initializing, a live daemon-console tail on the startup overlay, fast-failing connect probes, and mid-session disconnect detection that keeps the UI responsive (in-flight RPC calls abort cleanly on disconnect/shutdown).
|
||||||
|
- **Address list** — modernized with drag-to-transfer, labels, and view-only handling.
|
||||||
|
- **UI / DPI** — overlay dialogs scale correctly with the font/DPI setting, improved CJK font rendering, native language names in the language picker, and format-incompatible translations are rejected.
|
||||||
|
- **Settings** — an "Open data folder" button and confirmation modals for rescan and restart-daemon.
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
Extensive fixes across history (shielded-tx ordering, stuck "refreshing history" banners, unconfirmed-badge stickiness), sends (correct fee passed to `z_sendmany`, fast-lane worker restart on reconnect, note-selection fee-gap workaround), rescan (accurate completion detection, no false "complete", no per-second error flood), RPC (mid-session disconnect handling, stale-refresh invalidation), and UI layout/i18n. See the commit history for the full list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Full-node wallet** — send, receive, and verify DRGX transactions with a bundled `dragonxd` daemon; no external setup required.
|
||||||
|
- **Self-updating** — verify-and-install the latest node and miner from within the app (SHA-256 + ed25519 signature enforced).
|
||||||
|
- **Encrypted messaging** — built-in HushChat with a seed-derived identity, end-to-end encryption, and a seed-encrypted local message store, interoperable with SilentDragonX / SilentDragonXLite.
|
||||||
|
- **Seed-phrase wallets** — BIP39 mnemonic backup, and migrate-to-seed to upgrade a legacy wallet into a seed wallet.
|
||||||
|
- **Built-in CPU mining** — start/stop DRG-XMRig from the Mining tab with real-time hashrate and pool statistics, mine-when-idle support with configurable delay, and sustained-hashrate benchmarking.
|
||||||
|
- **Multi-language UI** — full internationalization covering 8 languages: German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese.
|
||||||
|
- **Cross-platform** — native builds for Linux (AppImage + zip), Windows (portable exe + zip), and macOS (**universal** DMG + .app); Linux/Windows x86-64, macOS Intel + Apple Silicon.
|
||||||
|
- Shielded (z-address) and transparent (t-address) send/receive
|
||||||
|
- QR code generation for receiving addresses
|
||||||
|
- Transaction history with memo support, date/amount sorting, and pending-send tracking
|
||||||
|
- Blockchain explorer with live block/transaction search
|
||||||
|
- Blockchain rescan and Sapling witness-rebuild with status-bar progress
|
||||||
|
- Repair Wallet and daemon-binary management in Settings
|
||||||
|
- Built-in console
|
||||||
|
- Extensive theme and appearance options
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Downloads
|
||||||
|
|
||||||
|
| File | SHA-256 |
|
||||||
|
|------|---------|
|
||||||
|
| ObsidianDragon-2.0.0.AppImage | `34c9ea57ec27bf415e59d2890d995ed9069bfce04d979d7d8d58aa18f4570aa1` |
|
||||||
|
| ObsidianDragon-2.0.0-Linux-x64.zip | `563004b4e45650dbfa9411d62fa1c59a3ca2199bd43a647b1614f2683bbdb5fa` |
|
||||||
|
| ObsidianDragon-2.0.0.exe | `56100ae59dc3c67445d015cac5412ca40465bf459ba182c5ad2477a3b95ff548` |
|
||||||
|
| ObsidianDragon-2.0.0-Windows-x64.zip | `ce9d067f36c5296288a473d3c2cceca7fcf807672a93a2084afc53c3b6e780d9` |
|
||||||
|
| ObsidianDragon-2.0.0-macOS-universal.dmg | `8a01b6c0c2b5bebd64a222ba4a5ad04a1b8851138f28458b5c7f767fbb65db66` |
|
||||||
|
| ObsidianDragon-2.0.0-macOS-universal.app.zip | `1bce1e5be15d9a2f3c6f42c95b23a0e89df142a73d65895252e4d8f50d19f541` |
|
||||||
|
|
||||||
|
## System Requirements
|
||||||
|
|
||||||
|
- **Linux:** x86-64, glibc 2.31+ (Ubuntu 20.04+, Fedora 33+, etc.)
|
||||||
|
- **Windows:** x86-64, Windows 10 or later
|
||||||
|
- **macOS:** Intel & Apple Silicon (universal binary), macOS 10.15 Catalina or later
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Released under the **GPLv3**. See [LICENSE](https://git.dragonx.is/DragonX/ObsidianDragon/src/branch/master/LICENSE) for details.
|
||||||
167
docs/wallet-hardening.md
Normal file
167
docs/wallet-hardening.md
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
# Wallet Loading & Management — Hardening Plan
|
||||||
|
|
||||||
|
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
|
||||||
|
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
|
||||||
|
|
||||||
|
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
|
||||||
|
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
|
||||||
|
(W1-5), 1 raised (W5-3 Low→Med).
|
||||||
|
- **Severity:** 8 High · 12 Medium · 13 Low.
|
||||||
|
|
||||||
|
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap (ordered by risk; shared fixes grouped)
|
||||||
|
|
||||||
|
| Phase | Findings | Theme | Status |
|
||||||
|
|-------|----------|-------|--------|
|
||||||
|
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
|
||||||
|
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
|
||||||
|
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
|
||||||
|
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
|
||||||
|
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
|
||||||
|
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## P0-A — Secret hardening
|
||||||
|
|
||||||
|
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
|
||||||
|
key/passphrase paths, plus console redaction and deleting the plaintext export.
|
||||||
|
|
||||||
|
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
|
||||||
|
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
|
||||||
|
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
|
||||||
|
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
|
||||||
|
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
|
||||||
|
unit testing. **← implementing first (self-contained + testable).**
|
||||||
|
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
|
||||||
|
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
|
||||||
|
- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in
|
||||||
|
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
|
||||||
|
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
|
||||||
|
(local + worker-lambda copies).
|
||||||
|
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
|
||||||
|
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
|
||||||
|
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
|
||||||
|
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
|
||||||
|
lite create/open/restore requests (unused; a secret copied for nothing).
|
||||||
|
|
||||||
|
## P0-B — Encryption integrity
|
||||||
|
|
||||||
|
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
|
||||||
|
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
|
||||||
|
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
|
||||||
|
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
|
||||||
|
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
|
||||||
|
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
|
||||||
|
complete it.
|
||||||
|
- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the
|
||||||
|
failure and notify (currently a silent no-op that can leave the wallet unlocked).
|
||||||
|
|
||||||
|
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
|
||||||
|
|
||||||
|
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
|
||||||
|
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
|
||||||
|
- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(<config>/seed-migrate)` unconditionally
|
||||||
|
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
|
||||||
|
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
|
||||||
|
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
|
||||||
|
(`getSeedMigrationPending()`), not only while the dialog is open.
|
||||||
|
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
|
||||||
|
resume/re-poll it instead of silently dropping the txid.
|
||||||
|
|
||||||
|
## P1-B — Missing/wrong wallet-file safety
|
||||||
|
|
||||||
|
- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in
|
||||||
|
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
|
||||||
|
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
|
||||||
|
daemon fabricate an empty wallet.
|
||||||
|
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
|
||||||
|
address/balance readback (idHash non-empty), not bare `onConnected()`.
|
||||||
|
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
|
||||||
|
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
|
||||||
|
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
|
||||||
|
the out-of-datadir path).
|
||||||
|
|
||||||
|
## P2 — State & lite persistence
|
||||||
|
|
||||||
|
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
|
||||||
|
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
|
||||||
|
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a
|
||||||
|
one-shot UI warning (both call sites currently discard the bool).
|
||||||
|
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
|
||||||
|
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
|
||||||
|
of discarding the whole list.
|
||||||
|
|
||||||
|
## F — Diagnostics foundation + QoL
|
||||||
|
|
||||||
|
Land W7-2 first — it unblocks the rest.
|
||||||
|
|
||||||
|
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
|
||||||
|
`main()` on all platforms; add an "Open log folder" action.
|
||||||
|
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
|
||||||
|
POSIX (mirror the Windows SEH path).
|
||||||
|
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
|
||||||
|
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
|
||||||
|
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
|
||||||
|
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress log
|
||||||
|
|
||||||
|
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
|
||||||
|
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
|
||||||
|
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
|
||||||
|
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
|
||||||
|
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
|
||||||
|
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
|
||||||
|
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
|
||||||
|
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
|
||||||
|
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||||
|
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
|
||||||
|
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
|
||||||
|
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
|
||||||
|
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
|
||||||
|
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||||
|
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
|
||||||
|
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
|
||||||
|
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
|
||||||
|
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
|
||||||
|
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
|
||||||
|
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
|
||||||
|
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
|
||||||
|
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
|
||||||
|
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
|
||||||
|
Build-clean; `ctest` 1/1.
|
||||||
|
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
|
||||||
|
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
|
||||||
|
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
|
||||||
|
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
|
||||||
|
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
|
||||||
|
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
|
||||||
|
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
|
||||||
|
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
|
||||||
|
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
|
||||||
|
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
|
||||||
|
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
|
||||||
|
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
|
||||||
|
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
|
||||||
|
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
|
||||||
|
|
||||||
|
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
|
||||||
|
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
|
||||||
|
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
|
||||||
|
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
|
||||||
|
|
||||||
|
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
|
||||||
|
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
|
||||||
|
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
|
||||||
|
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
|
||||||
|
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
|
||||||
|
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
|
||||||
|
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
|
||||||
|
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
|
||||||
|
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)
|
||||||
3278
libs/nanosvg/nanosvg.h
Normal file
3278
libs/nanosvg/nanosvg.h
Normal file
File diff suppressed because it is too large
Load Diff
1472
libs/nanosvg/nanosvgrast.h
Normal file
1472
libs/nanosvg/nanosvgrast.h
Normal file
File diff suppressed because it is too large
Load Diff
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
0
prebuilt-binaries/drg-xmrig/.gitkeep
Normal file
Binary file not shown.
BIN
res/img/backgrounds/texture/jade_bg.png
Normal file
BIN
res/img/backgrounds/texture/jade_bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
23
res/img/logos/logo_dragonx.svg
Normal file
23
res/img/logos/logo_dragonx.svg
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #d82652;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
|
||||||
|
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
|
||||||
|
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
686
res/lang/de.json
686
res/lang/de.json
File diff suppressed because it is too large
Load Diff
686
res/lang/es.json
686
res/lang/es.json
File diff suppressed because it is too large
Load Diff
686
res/lang/fr.json
686
res/lang/fr.json
File diff suppressed because it is too large
Load Diff
682
res/lang/ja.json
682
res/lang/ja.json
File diff suppressed because it is too large
Load Diff
686
res/lang/ko.json
686
res/lang/ko.json
File diff suppressed because it is too large
Load Diff
686
res/lang/pt.json
686
res/lang/pt.json
File diff suppressed because it is too large
Load Diff
686
res/lang/ru.json
686
res/lang/ru.json
File diff suppressed because it is too large
Load Diff
682
res/lang/zh.json
682
res/lang/zh.json
File diff suppressed because it is too large
Load Diff
190
res/themes/jade.toml
Normal file
190
res/themes/jade.toml
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
[theme]
|
||||||
|
name = "Jade"
|
||||||
|
author = "The Hush Developers"
|
||||||
|
dark = true
|
||||||
|
elevation = { --elevation-0 = "#071210", --elevation-1 = "#0C1A16", --elevation-2 = "#16261F", --elevation-3 = "#1D3128", --elevation-4 = "#243B30" }
|
||||||
|
images = { background_image = "backgrounds/texture/jade_bg.png", logo = "logos/logo_ObsidianDragon_dark.png" }
|
||||||
|
|
||||||
|
[theme.palette]
|
||||||
|
--primary = "#2FA07A"
|
||||||
|
--primary-variant = "#1E7357"
|
||||||
|
--primary-light = "#7FD1B5"
|
||||||
|
--secondary = "#C9A24E"
|
||||||
|
--secondary-variant = "#A8842F"
|
||||||
|
--secondary-light = "#E0C583"
|
||||||
|
--background = "#071210"
|
||||||
|
--surface = "#0C1A16"
|
||||||
|
--surface-variant = "#16261F"
|
||||||
|
--on-primary = "#FFFFFF"
|
||||||
|
--on-secondary = "#000000"
|
||||||
|
--on-background = "#DCEDE4"
|
||||||
|
--on-surface = "#DCEDE4"
|
||||||
|
--on-surface-medium = "rgba(220,237,228,0.85)"
|
||||||
|
--on-surface-disabled = "rgba(220,237,228,0.58)"
|
||||||
|
--error = "#CF6679"
|
||||||
|
--on-error = "#000000"
|
||||||
|
--success = "#81C784"
|
||||||
|
--on-success = "#000000"
|
||||||
|
--warning = "#FFB74D"
|
||||||
|
--on-warning = "#000000"
|
||||||
|
--divider = "rgba(130,205,170,0.14)"
|
||||||
|
--outline = "rgba(130,205,170,0.16)"
|
||||||
|
--scrim = "rgba(0,0,0,0.6)"
|
||||||
|
--surface-hover = "rgba(130,205,170,0.07)"
|
||||||
|
--surface-alt = "rgba(130,205,170,0.05)"
|
||||||
|
--surface-active = "rgba(130,205,170,0.10)"
|
||||||
|
--glass-button = "rgba(130,205,170,0.06)"
|
||||||
|
--glass-button-hover = "rgba(130,205,170,0.12)"
|
||||||
|
--card-border = "rgba(130,205,170,0.26)"
|
||||||
|
--text-shadow = "rgba(0,0,0,0.50)"
|
||||||
|
--input-overlay-text = "rgba(220,237,228,0.30)"
|
||||||
|
--slider-text = "rgba(220,237,228,0.85)"
|
||||||
|
--thumb-fill = "rgba(130,205,170,0.15)"
|
||||||
|
--thumb-border = "rgba(130,205,170,0.50)"
|
||||||
|
--disabled-label = "rgba(130,205,170,0.18)"
|
||||||
|
--chart-grid = "rgba(130,205,170,0.05)"
|
||||||
|
--chart-crosshair = "rgba(130,205,170,0.15)"
|
||||||
|
--chart-hover-ring = "rgba(130,205,170,0.30)"
|
||||||
|
--tooltip-bg = "rgba(9,20,16,0.92)"
|
||||||
|
--tooltip-border = "rgba(130,205,170,0.12)"
|
||||||
|
--glass-fill = "rgba(130,205,170,0.08)"
|
||||||
|
--glass-border = "rgba(47,160,122,0.30)"
|
||||||
|
--glass-noise-tint = "rgba(130,205,170,0.03)"
|
||||||
|
--tactile-top = "rgba(130,205,170,0.06)"
|
||||||
|
--tactile-bottom = "rgba(130,205,170,0.0)"
|
||||||
|
--hover-overlay = "rgba(130,205,170,0.05)"
|
||||||
|
--active-overlay = "rgba(130,205,170,0.10)"
|
||||||
|
--rim-light = "rgba(130,205,170,0.14)"
|
||||||
|
--status-divider = "rgba(130,205,170,0.08)"
|
||||||
|
--sidebar-hover = "rgba(130,205,170,0.10)"
|
||||||
|
--sidebar-icon = "rgba(130,205,170,0.42)"
|
||||||
|
--sidebar-badge = "rgba(220,237,228,1.0)"
|
||||||
|
--sidebar-divider = "rgba(130,205,170,0.06)"
|
||||||
|
--chart-line = "rgba(130,205,170,0.10)"
|
||||||
|
--window-control = "rgba(220,237,228,0.78)"
|
||||||
|
--window-control-hover = "rgba(130,205,170,0.12)"
|
||||||
|
--window-close-hover = "rgba(232,17,35,0.78)"
|
||||||
|
--spinner-track = "rgba(130,205,170,0.10)"
|
||||||
|
--spinner-active = "rgba(79,184,154,0.85)"
|
||||||
|
--shutdown-panel-bg = "rgba(7,18,14,0.90)"
|
||||||
|
--shutdown-panel-border = "rgba(130,205,170,0.07)"
|
||||||
|
--ram-bar-app = "#2FA07A"
|
||||||
|
--ram-bar-system = "rgba(255,255,255,0.18)"
|
||||||
|
--accent-total = "#7FD1B5"
|
||||||
|
--accent-shielded = "#4FB89A"
|
||||||
|
--accent-transparent = "#C9A24E"
|
||||||
|
--accent-action = "#2FA07A"
|
||||||
|
--accent-market = "#4FB89A"
|
||||||
|
--accent-portfolio = "#7FD1B5"
|
||||||
|
--toast-info-accent = "#2FA07A"
|
||||||
|
--toast-info-text = "#7FD1B5"
|
||||||
|
--toast-success-accent = "rgba(50,180,80,1.0)"
|
||||||
|
--toast-success-text = "rgba(180,255,180,1.0)"
|
||||||
|
--toast-warning-accent = "rgba(204,166,50,1.0)"
|
||||||
|
--toast-warning-text = "rgba(255,230,130,1.0)"
|
||||||
|
--toast-error-accent = "rgba(204,64,64,1.0)"
|
||||||
|
--toast-error-text = "rgba(255,153,153,1.0)"
|
||||||
|
--snackbar-bg = "rgba(24,40,34,0.95)"
|
||||||
|
--snackbar-text = "rgba(220,237,228,0.87)"
|
||||||
|
--snackbar-action = "rgba(79,184,154,1.0)"
|
||||||
|
--snackbar-action-hover = "rgba(127,209,181,1.0)"
|
||||||
|
--switch-track-off = "rgba(130,205,170,0.12)"
|
||||||
|
--switch-track-on = "rgba(47,160,122,0.50)"
|
||||||
|
--switch-thumb-off = "#A0C0B4"
|
||||||
|
--switch-thumb-on = "#DCEDE4"
|
||||||
|
--control-shadow = "rgba(0,0,0,0.24)"
|
||||||
|
--checkbox-check = "#000000"
|
||||||
|
--app-bar-shadow = "rgba(0,0,0,0.25)"
|
||||||
|
|
||||||
|
[backdrop]
|
||||||
|
base-color-top = "rgba(14,32,26,210)"
|
||||||
|
base-color-bottom = "rgba(6,18,14,210)"
|
||||||
|
texture-tint-alpha = 120
|
||||||
|
gradient-top-r = 10
|
||||||
|
gradient-top-g = 30
|
||||||
|
gradient-top-b = 22
|
||||||
|
gradient-top-a = 90
|
||||||
|
gradient-bottom-r = 5
|
||||||
|
gradient-bottom-g = 16
|
||||||
|
gradient-bottom-b = 12
|
||||||
|
gradient-bottom-a = 70
|
||||||
|
background-alpha = 0.42
|
||||||
|
surface-alpha = 0.56
|
||||||
|
frame-alpha = 0.78
|
||||||
|
surface-inline-alpha = 0.58
|
||||||
|
background-inline-alpha = 0.40
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Theme Visual Effects — Jade (veins of gold shifting through the stone)
|
||||||
|
# Jade's signature is a slow jade→gold color-shifting border on every glass
|
||||||
|
# panel + the active nav button — a vein of gold surfacing through nephrite.
|
||||||
|
# It's drawn via AddRect so it hugs the real rounded corners (no polygonal
|
||||||
|
# edge-trace). Sparse jade motes drift up the viewport. No other theme turns
|
||||||
|
# gradient-border-panels on, so the panel-wide vein is Jade's own —
|
||||||
|
# deliberately NOT Obsidian's specular glare.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[effects]
|
||||||
|
hue-cycle-enabled = { size = 0.0 }
|
||||||
|
rainbow-border-enabled = { size = 0.0 }
|
||||||
|
|
||||||
|
# No shimmer sweep — replaced by specular glare
|
||||||
|
shimmer-enabled = { size = 0.0 }
|
||||||
|
|
||||||
|
positional-hue-enabled = { size = 0.0 }
|
||||||
|
|
||||||
|
glow-pulse-enabled = { size = 0.0 }
|
||||||
|
|
||||||
|
# Edge-trace OFF — its hand-walked perimeter chamfers rounded corners.
|
||||||
|
# Jade's vein is the gradient-border below (corner-clean via AddRect).
|
||||||
|
edge-trace-enabled = { size = 0.0 }
|
||||||
|
edge-trace-speed = { size = 0.16 }
|
||||||
|
edge-trace-length = { size = 0.34 }
|
||||||
|
edge-trace-thickness = { size = 1.6 }
|
||||||
|
edge-trace-alpha = { size = 0.55 }
|
||||||
|
edge-trace-color = { color = "#C9A24E" }
|
||||||
|
|
||||||
|
# Specular glare OFF — that's Obsidian's signature; Jade shouldn't echo it.
|
||||||
|
specular-glare-enabled = { size = 0.0 }
|
||||||
|
specular-glare-speed = { size = 0.018 }
|
||||||
|
specular-glare-intensity = { size = 0.008 }
|
||||||
|
specular-glare-radius = { size = 0.65 }
|
||||||
|
specular-glare-count = { size = 1.0 }
|
||||||
|
specular-glare-color = { color = "rgba(150,220,180,1.0)" }
|
||||||
|
|
||||||
|
# HERO — vein of gold: a slow jade→gold color-shifting border on the active
|
||||||
|
# nav button AND (via gradient-border-panels) every glass panel. Drawn with
|
||||||
|
# AddRect so it follows the rounded corners exactly. Panels drift at a softer
|
||||||
|
# alpha and position-phased offset, so a screenful reads like veins at
|
||||||
|
# different depths rather than one synchronized pulse.
|
||||||
|
gradient-border-enabled = { size = 1.0 }
|
||||||
|
gradient-border-panels = { size = 1.0 }
|
||||||
|
gradient-border-speed = { size = 0.10 }
|
||||||
|
gradient-border-thickness = { size = 1.5 }
|
||||||
|
gradient-border-alpha = { size = 0.55 }
|
||||||
|
gradient-border-color-a = { color = "#7FD1B5" }
|
||||||
|
gradient-border-color-b = { color = "#C9A24E" }
|
||||||
|
|
||||||
|
# Ambient jade motes — sparse, slow, cool green particles drifting up the
|
||||||
|
# viewport (recolored ember-rise; a different mood from dragonx's fire embers).
|
||||||
|
ember-rise-enabled = { size = 1.0 }
|
||||||
|
ember-rise-count = { size = 5.0 }
|
||||||
|
ember-rise-speed = { size = 0.18 }
|
||||||
|
ember-rise-particle-size = { size = 1.4 }
|
||||||
|
ember-rise-alpha = { size = 0.26 }
|
||||||
|
ember-rise-color = { color = "#7FD1B5" }
|
||||||
|
|
||||||
|
# Shader-like viewport overlay — deep green stone atmosphere
|
||||||
|
viewport-wash-enabled = { size = 1.0 }
|
||||||
|
viewport-wash-alpha = { size = 0.05 }
|
||||||
|
viewport-wash-tl = { color = "#12402E" }
|
||||||
|
viewport-wash-tr = { color = "#0E3828" }
|
||||||
|
viewport-wash-bl = { color = "#16442E" }
|
||||||
|
viewport-wash-br = { color = "#1A4A34" }
|
||||||
|
viewport-wash-rotate = { size = 0.015 }
|
||||||
|
viewport-wash-pulse = { size = 0.0 }
|
||||||
|
viewport-wash-pulse-depth = { size = 0.0 }
|
||||||
|
|
||||||
|
viewport-vignette-enabled = { size = 1.0 }
|
||||||
|
viewport-vignette-color = { color = "#04140D" }
|
||||||
|
viewport-vignette-radius = { size = 0.22 }
|
||||||
|
viewport-vignette-alpha = { size = 0.15 }
|
||||||
@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
|
|||||||
status-pill-y-offset = { size = 1 }
|
status-pill-y-offset = { size = 1 }
|
||||||
confirmed-threshold = { size = 10 }
|
confirmed-threshold = { size = 10 }
|
||||||
|
|
||||||
|
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
|
||||||
|
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
|
||||||
|
[banners.node-status]
|
||||||
|
min-height = { size = 26.0 }
|
||||||
|
height = { size = 30.0 }
|
||||||
|
|
||||||
[tabs.transactions]
|
[tabs.transactions]
|
||||||
search-max-width = 300.0
|
search-max-width = 300.0
|
||||||
search-width-ratio = 0.3
|
search-width-ratio = 0.3
|
||||||
@@ -959,6 +965,11 @@ edition-label = { position = 120 }
|
|||||||
link-button = { width = 100, font = "button-sm" }
|
link-button = { width = 100, font = "button-sm" }
|
||||||
close-button = { width = 120, font = "button", align = "center" }
|
close-button = { width = 120, font = "button", align = "center" }
|
||||||
|
|
||||||
|
[dialogs.faq]
|
||||||
|
width = 860.0
|
||||||
|
height = 660.0
|
||||||
|
window = { width = 860, height = 660 }
|
||||||
|
|
||||||
[dialogs.settings]
|
[dialogs.settings]
|
||||||
width = 600.0
|
width = 600.0
|
||||||
height = 550.0
|
height = 550.0
|
||||||
@@ -1503,6 +1514,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
|
|||||||
progress-width = { size = 260.0 }
|
progress-width = { size = 260.0 }
|
||||||
backdrop-alpha = { opacity = 0.80 }
|
backdrop-alpha = { opacity = 0.80 }
|
||||||
vertical-gap = { size = 8.0 }
|
vertical-gap = { size = 8.0 }
|
||||||
|
stall-timeout-sec = { size = 45.0 }
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# First-Run Wizard Screens
|
# First-Run Wizard Screens
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# This script uses bash 4+ features (mapfile, safe empty-array expansion under
|
||||||
|
# `set -u`). macOS ships bash 3.2, so re-exec under a newer bash when one is
|
||||||
|
# present (Homebrew), and fail with a clear message otherwise.
|
||||||
|
if [ "${BASH_VERSINFO:-0}" -lt 4 ]; then
|
||||||
|
for _newer_bash in /opt/homebrew/bin/bash /usr/local/bin/bash; do
|
||||||
|
[ -x "$_newer_bash" ] && exec "$_newer_bash" "$0" "$@"
|
||||||
|
done
|
||||||
|
echo "ERROR: build-lite-backend-artifact.sh requires bash 4+ (found ${BASH_VERSION:-unknown})." >&2
|
||||||
|
echo " On macOS: brew install bash" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
@@ -67,25 +79,9 @@ Options:
|
|||||||
--backend-dir PATH SilentDragonXLite/lib source directory.
|
--backend-dir PATH SilentDragonXLite/lib source directory.
|
||||||
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
|
--silentdragonxlitelib-dir PATH Override the wrapper's silentdragonxlitelib dependency path.
|
||||||
--out-dir PATH Output directory for copied artifact and metadata.
|
--out-dir PATH Output directory for copied artifact and metadata.
|
||||||
--artifact PATH Inventory an existing artifact instead of building.
|
|
||||||
--no-build Do not run cargo; requires --artifact.
|
|
||||||
--reproducible Add deterministic Rust path remaps for clean builds.
|
--reproducible Add deterministic Rust path remaps for clean builds.
|
||||||
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
|
--remap-path-prefix FROM=TO Extra rustc path remap used with --reproducible.
|
||||||
--builder NAME Redacted builder/provenance label. Default: local.
|
--builder NAME Redacted builder/provenance label. Default: local.
|
||||||
--signature-required Fail if verified signature metadata is not supplied.
|
|
||||||
--signature-file PATH Existing sidecar signature file to record.
|
|
||||||
--signature-format FORMAT Signature format: minisign, gpg, sigstore, external, or other.
|
|
||||||
--signature-verification-tool T Verification tool and version used by the release builder.
|
|
||||||
--signature-verification-command C
|
|
||||||
Verification command already run by the release builder.
|
|
||||||
--signature-key-fingerprint F Reviewed public-key fingerprint, when applicable.
|
|
||||||
--signature-certificate-identity ID
|
|
||||||
Reviewed certificate identity, when applicable.
|
|
||||||
--signature-certificate-issuer I
|
|
||||||
Reviewed certificate issuer, when applicable.
|
|
||||||
--signature-transparency-log-url URL
|
|
||||||
Transparency log entry, when applicable.
|
|
||||||
--signature-verified-sha256 SHA Artifact SHA-256 verified by the signature check.
|
|
||||||
-j, --jobs N Cargo parallel jobs.
|
-j, --jobs N Cargo parallel jobs.
|
||||||
--cargo-arg ARG Extra argument forwarded to cargo build.
|
--cargo-arg ARG Extra argument forwarded to cargo build.
|
||||||
-h, --help Show this help.
|
-h, --help Show this help.
|
||||||
@@ -95,9 +91,13 @@ Outputs:
|
|||||||
<out>/<platform>/lite-backend-symbols.txt
|
<out>/<platform>/lite-backend-symbols.txt
|
||||||
<out>/<platform>/lite-backend-artifact-manifest.json
|
<out>/<platform>/lite-backend-artifact-manifest.json
|
||||||
|
|
||||||
The script captures symbols, checksums, and optional read-only signature
|
The lite backend is always built from the vendored in-tree source
|
||||||
verification metadata only. It does not load the library, resolve function
|
(third_party/silentdragonxlite), which is the trust root. Prebuilt artifacts
|
||||||
pointers, call SDXL, sign, upload, or publish artifacts.
|
and self-attested signature metadata are NOT accepted (F15-1) — the previous
|
||||||
|
scheme only recorded an unverified "verified" claim. The script captures the
|
||||||
|
freshly-built artifact's symbols and checksum, and records build provenance.
|
||||||
|
It does not load the library, resolve function pointers, call SDXL, sign,
|
||||||
|
upload, or publish artifacts.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,15 +166,8 @@ while [[ $# -gt 0 ]]; do
|
|||||||
OUT_DIR="$(absolute_path "$2")"
|
OUT_DIR="$(absolute_path "$2")"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--artifact)
|
--artifact|--no-build)
|
||||||
[[ $# -ge 2 ]] || die "--artifact requires a value"
|
die "$1 was removed (F15-1): the lite backend must be built from the vendored in-tree source (third_party/silentdragonxlite); prebuilt artifacts are no longer accepted."
|
||||||
ARTIFACT_PATH="$(absolute_path "$2")"
|
|
||||||
BUILD_ARTIFACT=false
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--no-build)
|
|
||||||
BUILD_ARTIFACT=false
|
|
||||||
shift
|
|
||||||
;;
|
;;
|
||||||
--reproducible)
|
--reproducible)
|
||||||
REPRODUCIBLE=true
|
REPRODUCIBLE=true
|
||||||
@@ -191,54 +184,11 @@ while [[ $# -gt 0 ]]; do
|
|||||||
BUILDER="$2"
|
BUILDER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--signature-required)
|
--signature-required|--signature-file|--signature-path|--signature-format|\
|
||||||
SIGNATURE_REQUIRED=true
|
--signature-verification-tool|--signature-tool|--signature-verification-command|\
|
||||||
shift
|
--signature-key-fingerprint|--signature-certificate-identity|--signature-certificate-issuer|\
|
||||||
;;
|
--signature-transparency-log-url|--signature-verified-sha256)
|
||||||
--signature-file|--signature-path)
|
die "signature-attestation flags were removed (F15-1): they recorded a self-attested \"verified\" claim without running any cryptographic verifier. The lite backend is built from the vendored in-tree source, which is the trust root."
|
||||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
|
||||||
SIGNATURE_FILE="$(absolute_path "$2")"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-format)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-format requires a value"
|
|
||||||
SIGNATURE_FORMAT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-verification-tool|--signature-tool)
|
|
||||||
[[ $# -ge 2 ]] || die "$1 requires a value"
|
|
||||||
SIGNATURE_VERIFICATION_TOOL="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-verification-command)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-verification-command requires a value"
|
|
||||||
SIGNATURE_VERIFICATION_COMMAND="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-key-fingerprint)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-key-fingerprint requires a value"
|
|
||||||
SIGNATURE_KEY_FINGERPRINT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-certificate-identity)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-certificate-identity requires a value"
|
|
||||||
SIGNATURE_CERTIFICATE_IDENTITY="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-certificate-issuer)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-certificate-issuer requires a value"
|
|
||||||
SIGNATURE_CERTIFICATE_ISSUER="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-transparency-log-url)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-transparency-log-url requires a value"
|
|
||||||
SIGNATURE_TRANSPARENCY_LOG_URL="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--signature-verified-sha256)
|
|
||||||
[[ $# -ge 2 ]] || die "--signature-verified-sha256 requires a value"
|
|
||||||
SIGNATURE_VERIFIED_SHA256="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
;;
|
||||||
-j|--jobs)
|
-j|--jobs)
|
||||||
[[ $# -ge 2 ]] || die "--jobs requires a value"
|
[[ $# -ge 2 ]] || die "--jobs requires a value"
|
||||||
@@ -374,6 +324,9 @@ prepare_backend_source() {
|
|||||||
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
ln -s "$BACKEND_SOURCE_DIR/src" "$prepared_root/src"
|
||||||
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
[[ -f "$BACKEND_SOURCE_DIR/Cargo.lock" ]] && ln -s "$BACKEND_SOURCE_DIR/Cargo.lock" "$prepared_root/Cargo.lock"
|
||||||
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
[[ -d "$BACKEND_SOURCE_DIR/.cargo" ]] && ln -s "$BACKEND_SOURCE_DIR/.cargo" "$prepared_root/.cargo"
|
||||||
|
# Honor the pinned Rust toolchain (rust-toolchain.toml) inside the prepared root too,
|
||||||
|
# so builds using --silentdragonxlitelib-dir still select rustc 1.63.
|
||||||
|
[[ -f "$BACKEND_SOURCE_DIR/rust-toolchain.toml" ]] && ln -s "$BACKEND_SOURCE_DIR/rust-toolchain.toml" "$prepared_root/rust-toolchain.toml"
|
||||||
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
[[ -d "$BACKEND_SOURCE_DIR/libsodium-mingw" ]] && ln -s "$BACKEND_SOURCE_DIR/libsodium-mingw" "$prepared_root/libsodium-mingw"
|
||||||
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
# Vendored crate deps (offline builds): the .cargo/config.toml's vendored-sources directory is
|
||||||
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
# "vendor" relative to the build root, so expose it inside the prepared root too.
|
||||||
@@ -766,6 +719,7 @@ MANIFEST_FILE="$PLATFORM_OUT_DIR/lite-backend-artifact-manifest.json"
|
|||||||
printf ' },\n'
|
printf ' },\n'
|
||||||
printf ' "provenance": {\n'
|
printf ' "provenance": {\n'
|
||||||
printf ' "owner_ready": true,\n'
|
printf ' "owner_ready": true,\n'
|
||||||
|
printf ' "built_from_source": true,\n'
|
||||||
printf ' "metadata_provided": true,\n'
|
printf ' "metadata_provided": true,\n'
|
||||||
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
|
printf ' "source": '; json_escape "$BACKEND_SOURCE_DIR"; printf ',\n'
|
||||||
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'
|
printf ' "cargo_build_source": '; json_escape "$BUILD_BACKEND_DIR"; printf ',\n'
|
||||||
|
|||||||
@@ -24,18 +24,27 @@ if [ ! -f "${BUILD_DIR}/bin/ObsidianDragon" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check for appimagetool
|
# Check for appimagetool — pinned to a tagged release and SHA-256 verified before we exec it.
|
||||||
|
# The old "continuous" tag is a moving, unverified network download that runs on the release
|
||||||
|
# builder; verify it or refuse to package.
|
||||||
|
APPIMAGETOOL_URL="https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage"
|
||||||
|
APPIMAGETOOL_SHA256="46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1"
|
||||||
APPIMAGETOOL=""
|
APPIMAGETOOL=""
|
||||||
if command -v appimagetool &> /dev/null; then
|
if command -v appimagetool &> /dev/null; then
|
||||||
APPIMAGETOOL="appimagetool"
|
APPIMAGETOOL="appimagetool" # maintainer's own trusted system install
|
||||||
elif [ -f "${BUILD_DIR}/appimagetool-x86_64.AppImage" ]; then
|
|
||||||
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
|
||||||
else
|
else
|
||||||
print_status "Downloading appimagetool..."
|
AT="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
||||||
wget -q -O "${BUILD_DIR}/appimagetool-x86_64.AppImage" \
|
if [ ! -f "$AT" ] || ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
|
||||||
"https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
|
print_status "Downloading appimagetool 1.9.0 (pinned)..."
|
||||||
chmod +x "${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
wget -q -O "$AT" "$APPIMAGETOOL_URL"
|
||||||
APPIMAGETOOL="${BUILD_DIR}/appimagetool-x86_64.AppImage"
|
if ! echo "${APPIMAGETOOL_SHA256} ${AT}" | sha256sum -c --status; then
|
||||||
|
print_error "appimagetool SHA-256 verification failed — refusing to use it"
|
||||||
|
rm -f "$AT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
chmod +x "$AT"
|
||||||
|
fi
|
||||||
|
APPIMAGETOOL="$AT"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
print_status "Creating AppDir structure..."
|
print_status "Creating AppDir structure..."
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ HEADER_START
|
|||||||
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
|
echo -e "${YELLOW}Note: Daemon binaries not found in prebuilt-binaries/dragonxd-win/ — wallet only${NC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── xmrig binary (from prebuilt-binaries/xmrig-hac/) ────────────────
|
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||||
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/xmrig-hac"
|
XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||||
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
|
if [ -f "$XMRIG_DIR/xmrig.exe" ]; then
|
||||||
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
|
cp -f "$XMRIG_DIR/xmrig.exe" "$EMBED_RES_DIR/xmrig.exe"
|
||||||
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
echo " Staged xmrig.exe ($(du -h "$XMRIG_DIR/xmrig.exe" | cut -f1))"
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Sign dragonx full-node release archives for the wallet's in-app daemon updater (ed25519).
|
# Package the prebuilt dragonx full-node binaries into per-platform release archives and sign them
|
||||||
|
# for the wallet's in-app daemon updater (ed25519 over the EXACT archive bytes).
|
||||||
#
|
#
|
||||||
# The wallet verifies a detached ed25519 signature over the EXACT archive bytes against a public
|
# The wallet verifies a detached ed25519 signature over the archive bytes against a public key
|
||||||
# key pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is
|
# pinned in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64). Verification is MANDATORY
|
||||||
# MANDATORY (kDaemonRequireSignature = true): an in-app update is refused unless a valid signature
|
# (kDaemonRequireSignature = true): an in-app update is refused unless a valid "<archive>.sig" is
|
||||||
# is published. For each archive <name>.zip this produces <name>.zip.sig holding the base64 of the
|
# published next to the archive. The wallet also checks each archive's SHA-256 against a markdown
|
||||||
# raw 64-byte ed25519 signature — upload that .sig next to the .zip as a release asset.
|
# checksum table in the release body, so `release` prints that table for you to paste in.
|
||||||
#
|
#
|
||||||
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl needed. OpenSSL's ed25519 is PureEdDSA (RFC 8032),
|
# Uses OpenSSL (>= 1.1.1) only — no Python/PyNaCl. OpenSSL's ed25519 is PureEdDSA (RFC 8032), the
|
||||||
# the same primitive libsodium's crypto_sign_verify_detached checks, so signatures are compatible
|
# same primitive libsodium's crypto_sign_verify_detached checks, so the signatures are compatible.
|
||||||
# (the same flow the wallet's unit tests verify for the miner updater).
|
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
# scripts/sign-daemon-release.sh keygen [out-prefix] # -> <prefix>.ed25519.{key,pub.b64}
|
||||||
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
# scripts/sign-daemon-release.sh pubkey <secret.key> # print the base64 public key to pin
|
||||||
# scripts/sign-daemon-release.sh sign <secret.key> <file>...# -> <file>.sig per file
|
# scripts/sign-daemon-release.sh sign <secret.key> <file>... # sign existing files -> <file>.sig
|
||||||
|
# scripts/sign-daemon-release.sh release <secret.key> <version> [--src DIR] [--out DIR]
|
||||||
|
# # zip prebuilt-binaries/dragonxd-{linux,mac,win}/ into dragonx-<version>-{linux-amd64,macos,
|
||||||
|
# # win64}.zip, sign each, and print the SHA-256 checksum table. Platforms with no dragonxd
|
||||||
|
# # binary staged are skipped.
|
||||||
#
|
#
|
||||||
# Keep the secret key (.ed25519.key) OFFLINE. Paste the base64 public key into
|
# Keep the secret key (.ed25519.key) OFFLINE (mode 600). Paste the base64 public key into
|
||||||
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
# kDaemonSignaturePublicKeyBase64 in src/util/daemon_updater.h.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||||
die() { echo "error: $*" >&2; exit 1; }
|
die() { echo "error: $*" >&2; exit 1; }
|
||||||
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25519)"
|
||||||
|
|
||||||
@@ -27,6 +33,26 @@ command -v openssl >/dev/null || die "openssl not found (need >= 1.1.1 with ed25
|
|||||||
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
|
# ed25519 is a fixed 12-byte prefix + the 32-byte key, so the trailing 32 bytes are the raw key.
|
||||||
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
|
pubkey_b64() { openssl pkey -in "$1" -pubout -outform DER | tail -c 32 | openssl base64 -A; }
|
||||||
|
|
||||||
|
sha256_of() {
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}';
|
||||||
|
else shasum -a 256 "$1" | awk '{print $1}'; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Detached ed25519 signature over the raw file bytes -> <file>.sig (base64 of the 64-byte sig).
|
||||||
|
sign_file() {
|
||||||
|
local key="$1" f="$2" raw
|
||||||
|
raw="$(mktemp)"
|
||||||
|
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
||||||
|
openssl base64 -A -in "$raw" > "$f.sig"
|
||||||
|
printf '\n' >> "$f.sig"
|
||||||
|
rm -f "$raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
# platform -> (staging dir under prebuilt-binaries, release token, expected daemon binary name)
|
||||||
|
plat_dir() { case "$1" in linux) echo dragonxd-linux;; mac) echo dragonxd-mac;; win) echo dragonxd-win;; esac; }
|
||||||
|
plat_token() { case "$1" in linux) echo linux-amd64;; mac) echo macos;; win) echo win64;; esac; }
|
||||||
|
plat_daemon() { case "$1" in win) echo dragonxd.exe;; *) echo dragonxd;; esac; }
|
||||||
|
|
||||||
cmd="${1:-}"; shift || true
|
cmd="${1:-}"; shift || true
|
||||||
case "$cmd" in
|
case "$cmd" in
|
||||||
keygen)
|
keygen)
|
||||||
@@ -42,26 +68,90 @@ case "$cmd" in
|
|||||||
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
echo "Pin this in src/util/daemon_updater.h (kDaemonSignaturePublicKeyBase64):"
|
||||||
echo " $pub"
|
echo " $pub"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
pubkey)
|
pubkey)
|
||||||
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
[ $# -ge 1 ] || die "usage: pubkey <secret.key>"
|
||||||
pubkey_b64 "$1"
|
pubkey_b64 "$1"
|
||||||
;;
|
;;
|
||||||
|
|
||||||
sign)
|
sign)
|
||||||
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
[ $# -ge 2 ] || die "usage: sign <secret.key> <file>..."
|
||||||
key="$1"; shift
|
key="$1"; shift
|
||||||
[ -f "$key" ] || die "no such key: $key"
|
[ -f "$key" ] || die "no such key: $key"
|
||||||
for f in "$@"; do
|
for f in "$@"; do
|
||||||
[ -f "$f" ] || die "no such file: $f"
|
[ -f "$f" ] || die "no such file: $f"
|
||||||
raw="$(mktemp)"
|
sign_file "$key" "$f"
|
||||||
openssl pkeyutl -sign -inkey "$key" -rawin -in "$f" -out "$raw"
|
|
||||||
openssl base64 -A -in "$raw" > "$f.sig"
|
|
||||||
printf '\n' >> "$f.sig"
|
|
||||||
rm -f "$raw"
|
|
||||||
echo "signed: $f -> $f.sig"
|
echo "signed: $f -> $f.sig"
|
||||||
done
|
done
|
||||||
echo "Upload each .sig as a release asset next to its archive."
|
echo "Upload each .sig as a release asset next to its archive."
|
||||||
;;
|
;;
|
||||||
|
|
||||||
|
release)
|
||||||
|
[ $# -ge 2 ] || die "usage: release <secret.key> <version> [--src DIR] [--out DIR]"
|
||||||
|
key="$1"; version="$2"; shift 2
|
||||||
|
src="$PROJECT_ROOT/prebuilt-binaries"
|
||||||
|
out="$PROJECT_ROOT/release/daemon"
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--src) [ $# -ge 2 ] || die "--src needs a value"; src="$2"; shift 2 ;;
|
||||||
|
--out) [ $# -ge 2 ] || die "--out needs a value"; out="$2"; shift 2 ;;
|
||||||
|
*) die "unknown option: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
[ -f "$key" ] || die "no such key: $key"
|
||||||
|
[ -d "$src" ] || die "no such source dir: $src"
|
||||||
|
command -v zip >/dev/null 2>&1 || die "zip not found (install 'zip')"
|
||||||
|
mkdir -p "$out"
|
||||||
|
|
||||||
|
# Sanity: warn if this key does not match the public key pinned in the wallet (the wallet would
|
||||||
|
# then reject every signature made with it — only expected when deliberately rotating the key).
|
||||||
|
pinned="$(grep -oE '"[A-Za-z0-9+/]{43}="' "$PROJECT_ROOT/src/util/daemon_updater.h" 2>/dev/null | head -1 | tr -d '"')"
|
||||||
|
mine="$(pubkey_b64 "$key")"
|
||||||
|
if [ -n "$pinned" ] && [ "$pinned" != "$mine" ]; then
|
||||||
|
echo "WARNING: this key's public key does not match the one pinned in daemon_updater.h:" >&2
|
||||||
|
echo " signing key -> $mine" >&2
|
||||||
|
echo " pinned key -> $pinned" >&2
|
||||||
|
echo " The wallet will REJECT these signatures unless you are rotating the pinned key." >&2
|
||||||
|
echo >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
made=0
|
||||||
|
table=""
|
||||||
|
for plat in linux mac win; do
|
||||||
|
d="$src/$(plat_dir "$plat")"
|
||||||
|
daemon="$d/$(plat_daemon "$plat")"
|
||||||
|
if [ ! -f "$daemon" ]; then
|
||||||
|
echo "skip $plat: no $(plat_daemon "$plat") staged in $d" >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
archive="dragonx-$version-$(plat_token "$plat").zip"
|
||||||
|
apath="$out/$archive"
|
||||||
|
rm -f "$apath"
|
||||||
|
# Zip the staged files at the archive root (binaries + sapling params + asmap), excluding
|
||||||
|
# the .gitkeep placeholder. The updater flattens paths via baseName(), so a flat zip is fine.
|
||||||
|
files=()
|
||||||
|
while IFS= read -r fn; do files+=("$fn"); done < <(cd "$d" && ls -A | grep -vx '.gitkeep')
|
||||||
|
[ "${#files[@]}" -gt 0 ] || { echo "skip $plat: nothing to package in $d" >&2; continue; }
|
||||||
|
( cd "$d" && zip -q -X "$apath" "${files[@]}" )
|
||||||
|
sign_file "$key" "$apath"
|
||||||
|
sum="$(sha256_of "$apath")"
|
||||||
|
table+="| $archive | \`$sum\` |"$'\n'
|
||||||
|
echo "packaged + signed: $apath (+ .sig) sha256=$sum"
|
||||||
|
made=$((made + 1))
|
||||||
|
done
|
||||||
|
[ "$made" -gt 0 ] || die "no platform had a staged daemon binary under $src/dragonxd-{linux,mac,win}/"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Checksum table (paste into the release body so the wallet can verify SHA-256):"
|
||||||
|
echo "| Archive | SHA-256 |"
|
||||||
|
echo "|---|---|"
|
||||||
|
printf '%s' "$table"
|
||||||
|
echo
|
||||||
|
echo "Upload each .zip AND its .zip.sig as release assets. Wallet enforces the ed25519 signature"
|
||||||
|
echo "(kDaemonRequireSignature=true) and the SHA-256 from the table above."
|
||||||
|
;;
|
||||||
|
|
||||||
*)
|
*)
|
||||||
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>...}"
|
die "usage: $0 {keygen [prefix] | pubkey <secret.key> | sign <secret.key> <file>... | release <secret.key> <version> [--src DIR] [--out DIR]}"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
90
setup.sh
90
setup.sh
@@ -133,7 +133,7 @@ pkgs_core_arch="base-devel cmake git pkg-config
|
|||||||
libxkbcommon wayland libsodium curl
|
libxkbcommon wayland libsodium curl
|
||||||
autoconf automake libtool wget python xxd"
|
autoconf automake libtool wget python xxd"
|
||||||
|
|
||||||
pkgs_core_macos="cmake python xxd"
|
pkgs_core_macos="bash cmake python xxd"
|
||||||
|
|
||||||
# Windows cross-compile (from Linux)
|
# Windows cross-compile (from Linux)
|
||||||
pkgs_win_debian="mingw-w64 zip"
|
pkgs_win_debian="mingw-w64 zip"
|
||||||
@@ -284,18 +284,27 @@ fi
|
|||||||
header "Windows Cross-Compile"
|
header "Windows Cross-Compile"
|
||||||
|
|
||||||
if $SETUP_WIN; then
|
if $SETUP_WIN; then
|
||||||
win_pkgs="$(get_pkgs win)"
|
# Only touch apt / update-alternatives (which need sudo) when the toolchain is missing. If it is
|
||||||
if [[ -n "$win_pkgs" ]]; then
|
# already installed, skip them so `./setup.sh --win` can run WITHOUT sudo — important because the
|
||||||
install_pkgs "$win_pkgs" "Windows cross-compile"
|
# daemon cross-compile that follows should run as the invoking user. Running the whole setup under
|
||||||
fi
|
# sudo leaves root-owned build artifacts under external/dragonx, which then break `make clean` on
|
||||||
|
# a later non-sudo build (stale objects get relinked -> the mingw link failure recurs).
|
||||||
|
if has_cmd x86_64-w64-mingw32-g++-posix || has_cmd x86_64-w64-mingw32-g++; then
|
||||||
|
ok "Windows cross-compile toolchain already present — skipping apt install"
|
||||||
|
else
|
||||||
|
win_pkgs="$(get_pkgs win)"
|
||||||
|
if [[ -n "$win_pkgs" ]]; then
|
||||||
|
install_pkgs "$win_pkgs" "Windows cross-compile"
|
||||||
|
fi
|
||||||
|
|
||||||
# Set posix thread model if available
|
# Set posix thread model if available
|
||||||
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
if has_cmd update-alternatives && [[ "$PKG" == "apt" ]]; then
|
||||||
if ! $CHECK_ONLY; then
|
if ! $CHECK_ONLY; then
|
||||||
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
sudo update-alternatives --set x86_64-w64-mingw32-gcc \
|
||||||
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
/usr/bin/x86_64-w64-mingw32-gcc-posix 2>/dev/null || true
|
||||||
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
sudo update-alternatives --set x86_64-w64-mingw32-g++ \
|
||||||
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
/usr/bin/x86_64-w64-mingw32-g++-posix 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -391,11 +400,26 @@ elif $SETUP_SAPLING; then
|
|||||||
|
|
||||||
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
|
SPEND_URL="https://z.cash/downloads/sapling-spend.params"
|
||||||
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
|
OUTPUT_URL="https://z.cash/downloads/sapling-output.params"
|
||||||
|
# Consensus-critical MPC parameters with fixed, well-known SHA-256 (identical across every
|
||||||
|
# Zcash-family node; also pinned in scripts/build-lite-backend-artifact.sh). z.cash is
|
||||||
|
# plain HTTPS with no signature, so verify the digest and refuse a tampered/corrupt file.
|
||||||
|
SPEND_SHA256="8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13"
|
||||||
|
OUTPUT_SHA256="2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4"
|
||||||
|
|
||||||
curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" && \
|
if curl -fSL -o "$PARAMS_DIR/sapling-spend.params" "$SPEND_URL" \
|
||||||
ok "Downloaded sapling-spend.params"
|
&& echo "${SPEND_SHA256} $PARAMS_DIR/sapling-spend.params" | sha256sum -c --status; then
|
||||||
curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" && \
|
ok "Downloaded + verified sapling-spend.params"
|
||||||
ok "Downloaded sapling-output.params"
|
else
|
||||||
|
rm -f "$PARAMS_DIR/sapling-spend.params"
|
||||||
|
err "sapling-spend.params download or SHA-256 verification failed — not installed"
|
||||||
|
fi
|
||||||
|
if curl -fSL -o "$PARAMS_DIR/sapling-output.params" "$OUTPUT_URL" \
|
||||||
|
&& echo "${OUTPUT_SHA256} $PARAMS_DIR/sapling-output.params" | sha256sum -c --status; then
|
||||||
|
ok "Downloaded + verified sapling-output.params"
|
||||||
|
else
|
||||||
|
rm -f "$PARAMS_DIR/sapling-output.params"
|
||||||
|
err "sapling-output.params download or SHA-256 verification failed — not installed"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"
|
skip "Sapling params not found (use --sapling to download, or they'll be extracted at runtime from embedded builds)"
|
||||||
@@ -684,11 +708,13 @@ if [[ "$STALE_DAEMON" -eq 1 ]]; then
|
|||||||
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
warn " Linux: ./setup.sh · Windows: ./setup.sh --win · macOS: ./setup.sh --mac"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── 7. xmrig-hac (mining binary) ────────────────────────────────────────────
|
# ── 7. drg-xmrig (mining binary) ────────────────────────────────────────────
|
||||||
header "xmrig-hac Mining Binary"
|
header "drg-xmrig Mining Binary"
|
||||||
|
|
||||||
XMRIG_SRC="$PROJECT_DIR/external/xmrig-hac"
|
XMRIG_SRC="$PROJECT_DIR/external/drg-xmrig"
|
||||||
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/xmrig-hac"
|
# Output dir bundled by build.sh (Linux zip, AppImage, Windows embed, mac .app)
|
||||||
|
# and scripts/legacy/build-windows.sh — keep this path in sync with those.
|
||||||
|
XMRIG_PREBUILT="$PROJECT_DIR/prebuilt-binaries/drg-xmrig"
|
||||||
|
|
||||||
# Clean previous prebuilt xmrig binaries so we always rebuild
|
# Clean previous prebuilt xmrig binaries so we always rebuild
|
||||||
# Only clean the binary for the platform(s) we are actually building,
|
# Only clean the binary for the platform(s) we are actually building,
|
||||||
@@ -700,14 +726,14 @@ if ! $CHECK_ONLY; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Helper: clone xmrig-hac if not present
|
# Helper: clone drg-xmrig if not present
|
||||||
clone_xmrig_if_needed() {
|
clone_xmrig_if_needed() {
|
||||||
if [[ ! -d "$XMRIG_SRC" ]]; then
|
if [[ ! -d "$XMRIG_SRC" ]]; then
|
||||||
info "Cloning xmrig-hac..."
|
info "Cloning drg-xmrig..."
|
||||||
git clone https://git.dragonx.is/dragonx/xmrig-hac.git "$XMRIG_SRC"
|
git clone https://git.dragonx.is/DragonX/drg-xmrig.git "$XMRIG_SRC"
|
||||||
else
|
else
|
||||||
ok "xmrig-hac source already present"
|
ok "drg-xmrig source already present"
|
||||||
info "Pulling latest xmrig-hac..."
|
info "Pulling latest drg-xmrig..."
|
||||||
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
|
(cd "$XMRIG_SRC" && git pull --ff-only 2>/dev/null || true)
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -728,15 +754,15 @@ else
|
|||||||
rm -rf "$XMRIG_SRC/build"
|
rm -rf "$XMRIG_SRC/build"
|
||||||
|
|
||||||
# Build dependencies (libuv, hwloc, openssl)
|
# Build dependencies (libuv, hwloc, openssl)
|
||||||
info "Building xmrig-hac dependencies (libuv, hwloc, openssl)..."
|
info "Building drg-xmrig dependencies (libuv, hwloc, openssl)..."
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/scripts"
|
cd "$XMRIG_SRC/scripts"
|
||||||
sh build_deps.sh
|
sh build_deps.sh
|
||||||
)
|
)
|
||||||
ok "xmrig-hac dependencies built"
|
ok "drg-xmrig dependencies built"
|
||||||
|
|
||||||
# Build xmrig
|
# Build xmrig
|
||||||
info "Building xmrig-hac (Linux)..."
|
info "Building drg-xmrig (Linux)..."
|
||||||
mkdir -p "$XMRIG_SRC/build"
|
mkdir -p "$XMRIG_SRC/build"
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/build"
|
cd "$XMRIG_SRC/build"
|
||||||
@@ -753,7 +779,7 @@ else
|
|||||||
mkdir -p "$XMRIG_PREBUILT"
|
mkdir -p "$XMRIG_PREBUILT"
|
||||||
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
|
if [[ -f "$XMRIG_SRC/build/xmrig" ]]; then
|
||||||
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
|
cp "$XMRIG_SRC/build/xmrig" "$XMRIG_LINUX"
|
||||||
ok "xmrig (Linux) built and installed to prebuilt-binaries/xmrig-hac/"
|
ok "xmrig (Linux) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||||
else
|
else
|
||||||
err "xmrig (Linux) build failed — binary not found"
|
err "xmrig (Linux) build failed — binary not found"
|
||||||
MISSING=$((MISSING + 1))
|
MISSING=$((MISSING + 1))
|
||||||
@@ -777,7 +803,7 @@ else
|
|||||||
# Clean previous Windows build
|
# Clean previous Windows build
|
||||||
rm -rf "$XMRIG_SRC/build-windows"
|
rm -rf "$XMRIG_SRC/build-windows"
|
||||||
|
|
||||||
info "Building xmrig-hac (Windows cross-compile)..."
|
info "Building drg-xmrig (Windows cross-compile)..."
|
||||||
(
|
(
|
||||||
cd "$XMRIG_SRC/scripts"
|
cd "$XMRIG_SRC/scripts"
|
||||||
bash build_windows.sh
|
bash build_windows.sh
|
||||||
@@ -787,7 +813,7 @@ else
|
|||||||
mkdir -p "$XMRIG_PREBUILT"
|
mkdir -p "$XMRIG_PREBUILT"
|
||||||
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
|
if [[ -f "$XMRIG_SRC/build-windows/xmrig.exe" ]]; then
|
||||||
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
|
cp "$XMRIG_SRC/build-windows/xmrig.exe" "$XMRIG_WIN"
|
||||||
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/xmrig-hac/"
|
ok "xmrig.exe (Windows) built and installed to prebuilt-binaries/drg-xmrig/"
|
||||||
else
|
else
|
||||||
err "xmrig.exe (Windows) build failed — binary not found"
|
err "xmrig.exe (Windows) build failed — binary not found"
|
||||||
MISSING=$((MISSING + 1))
|
MISSING=$((MISSING + 1))
|
||||||
@@ -797,7 +823,7 @@ fi
|
|||||||
# ── 8. Binary directories ───────────────────────────────────────────────────
|
# ── 8. Binary directories ───────────────────────────────────────────────────
|
||||||
header "Binary Directories"
|
header "Binary Directories"
|
||||||
|
|
||||||
for platform in dragonxd-linux dragonxd-win dragonxd-mac xmrig; do
|
for platform in dragonxd-linux dragonxd-win dragonxd-mac drg-xmrig; do
|
||||||
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
|
dir="$PROJECT_DIR/prebuilt-binaries/$platform"
|
||||||
if [[ -d "$dir" ]]; then
|
if [[ -d "$dir" ]]; then
|
||||||
# Count actual files (not .gitkeep)
|
# Count actual files (not .gitkeep)
|
||||||
|
|||||||
2122
src/app.cpp
2122
src/app.cpp
File diff suppressed because it is too large
Load Diff
298
src/app.h
298
src/app.h
@@ -13,6 +13,8 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
|
#include <deque>
|
||||||
|
#include <condition_variable>
|
||||||
#include <nlohmann/json_fwd.hpp>
|
#include <nlohmann/json_fwd.hpp>
|
||||||
#include "data/transaction_history_cache.h"
|
#include "data/transaction_history_cache.h"
|
||||||
#include "data/address_book.h"
|
#include "data/address_book.h"
|
||||||
@@ -70,14 +72,28 @@ enum class EncryptDialogPhase {
|
|||||||
Done // Finished — close dialog
|
Done // Finished — close dialog
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A status string written by a background/worker thread and read every frame by the UI thread. Its
|
||||||
|
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
|
||||||
|
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
|
||||||
|
class GuardedStatus {
|
||||||
|
public:
|
||||||
|
GuardedStatus() = default;
|
||||||
|
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
|
||||||
|
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
|
||||||
|
private:
|
||||||
|
mutable std::mutex m_;
|
||||||
|
std::string v_;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Main application class
|
* @brief Main application class
|
||||||
*
|
*
|
||||||
* Manages application state, RPC connection, and coordinates UI rendering.
|
* Manages application state, RPC connection, and coordinates UI rendering.
|
||||||
*/
|
*/
|
||||||
class App {
|
class App {
|
||||||
public:
|
public:
|
||||||
App();
|
App();
|
||||||
|
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||||
~App();
|
~App();
|
||||||
|
|
||||||
// Non-copyable
|
// Non-copyable
|
||||||
@@ -146,6 +162,18 @@ public:
|
|||||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||||
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
||||||
|
|
||||||
|
// Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
|
||||||
|
// pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
|
||||||
|
bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
|
||||||
|
bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
|
||||||
|
const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
|
||||||
|
const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
|
||||||
|
bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; }
|
||||||
|
|
||||||
|
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
|
||||||
|
// for the "Copy diagnostics" action. Contains no secrets.
|
||||||
|
std::string buildDiagnosticsReport();
|
||||||
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
||||||
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
||||||
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
||||||
@@ -155,6 +183,21 @@ public:
|
|||||||
*/
|
*/
|
||||||
void renderShutdownScreen();
|
void renderShutdownScreen();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
|
||||||
|
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
|
||||||
|
* attached to rather than spawned — so the user can still see the node flushing/exiting.
|
||||||
|
*/
|
||||||
|
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
|
||||||
|
|
||||||
|
// True when the daemon's debug.log shows an in-progress Sapling witness-cache rebuild (best-effort
|
||||||
|
// heuristic). Stopping the daemon during one discards it and forces a multi-minute redo next launch.
|
||||||
|
bool daemonWitnessRebuildActive() const;
|
||||||
|
// Whether beginShutdown() should pause and confirm before stopping the daemon (rebuild in progress).
|
||||||
|
bool shouldConfirmDaemonStop() const;
|
||||||
|
// The "node is rebuilding — stop anyway / keep running / cancel" modal, rendered from render().
|
||||||
|
void renderDaemonStopConfirm();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Render loading overlay in content area while daemon is starting/syncing
|
* @brief Render loading overlay in content area while daemon is starting/syncing
|
||||||
* @param contentH Height of the content area child window
|
* @param contentH Height of the content area child window
|
||||||
@@ -351,6 +394,10 @@ public:
|
|||||||
|
|
||||||
// Force refresh
|
// Force refresh
|
||||||
void refreshNow();
|
void refreshNow();
|
||||||
|
// Called on window restore: drop the daemon-output backlog that accumulated while minimized (the
|
||||||
|
// per-frame update loop was paused), so a background witness rebuild that started+finished during
|
||||||
|
// the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast).
|
||||||
|
void skipDaemonOutputBacklog();
|
||||||
void refreshMiningInfo();
|
void refreshMiningInfo();
|
||||||
void refreshPeerInfo();
|
void refreshPeerInfo();
|
||||||
void refreshMarketData();
|
void refreshMarketData();
|
||||||
@@ -388,6 +435,9 @@ public:
|
|||||||
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
|
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
|
||||||
void startFullUiSweep();
|
void startFullUiSweep();
|
||||||
std::string screenshotFullDir() const;
|
std::string screenshotFullDir() const;
|
||||||
|
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
|
||||||
|
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
|
||||||
|
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
|
||||||
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
|
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
|
||||||
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
|
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
|
||||||
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
|
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
|
||||||
@@ -413,6 +463,7 @@ public:
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
void showAboutDialog() { show_about_ = true; }
|
void showAboutDialog() { show_about_ = true; }
|
||||||
|
void showFaqDialog() { show_faq_ = true; }
|
||||||
|
|
||||||
// Legacy tab compat — maps int to NavPage
|
// Legacy tab compat — maps int to NavPage
|
||||||
void setCurrentTab(int tab);
|
void setCurrentTab(int tab);
|
||||||
@@ -478,6 +529,10 @@ public:
|
|||||||
// Coin logo texture accessor (DragonX currency icon for balance tab)
|
// Coin logo texture accessor (DragonX currency icon for balance tab)
|
||||||
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
|
ImTextureID getCoinLogoTexture() const { return coin_logo_tex_; }
|
||||||
|
|
||||||
|
// DragonX custom chat emoji (the ":drgx:" shortcode) — the mark recolored to the theme accent (like
|
||||||
|
// the logo), re-rasterized on theme change. Used by the emoji picker tile + inline in chat bubbles.
|
||||||
|
ImTextureID getDrgxEmojiTexture() const { return drgx_emoji_tex_; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Reload theme images (background gradient + logo) from new paths
|
* @brief Reload theme images (background gradient + logo) from new paths
|
||||||
* @param bgPath Path to background image override (empty = use default)
|
* @param bgPath Path to background image override (empty = use default)
|
||||||
@@ -485,6 +540,10 @@ public:
|
|||||||
*/
|
*/
|
||||||
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
|
void reloadThemeImages(const std::string& bgPath, const std::string& logoPath);
|
||||||
|
|
||||||
|
// Load / recolor-per-theme the DragonX header logo (SVG rasterized to the theme accent). Called at
|
||||||
|
// the top of render() so the wizard, lock screen, and main header all show it.
|
||||||
|
void ensureLogoTexture();
|
||||||
|
|
||||||
// Wizard / first-run
|
// Wizard / first-run
|
||||||
WizardPhase getWizardPhase() const { return wizard_phase_; }
|
WizardPhase getWizardPhase() const { return wizard_phase_; }
|
||||||
bool isFirstRun() const;
|
bool isFirstRun() const;
|
||||||
@@ -564,6 +623,9 @@ public:
|
|||||||
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
|
// plaintext. Call pumpSecretClipboardClear() each frame to action the clear.
|
||||||
void copySecretToClipboard(const std::string& secret);
|
void copySecretToClipboard(const std::string& secret);
|
||||||
void pumpSecretClipboardClear();
|
void pumpSecretClipboardClear();
|
||||||
|
// Immediately clear the clipboard if it still holds the armed secret (ignores the 45s timer).
|
||||||
|
// Called on app shutdown so a copied key/seed does not outlive the process in the OS clipboard.
|
||||||
|
void clearSecretClipboardIfArmed();
|
||||||
bool isTransactionRefreshInProgress() const {
|
bool isTransactionRefreshInProgress() const {
|
||||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Transactions);
|
||||||
}
|
}
|
||||||
@@ -605,10 +667,13 @@ private:
|
|||||||
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
|
// the recipient `to`/`amount`/`memo`/`fee` used to record the optimistic pending-send row).
|
||||||
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
|
// When markFeeGapRetry is set, the returned opid is recorded in send_feegap_retried_opids_ so a
|
||||||
// retry of a retry is reported as a real error.
|
// retry of a retry is reported as a real error.
|
||||||
|
// background=true (autonomous chat sends / note-buffer splits): keep the single-flight + opid
|
||||||
|
// accounting but DON'T raise the global "transaction in progress" UI (status is on the chat message).
|
||||||
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
void submitZSendMany(const std::string& from, const std::string& to, double amount, double fee,
|
||||||
const std::string& memo, const nlohmann::json& recipients,
|
const std::string& memo, const nlohmann::json& recipients,
|
||||||
const char* traceLabel, bool markFeeGapRetry,
|
const char* traceLabel, bool markFeeGapRetry,
|
||||||
std::function<void(bool, const std::string&)> callback);
|
std::function<void(bool, const std::string&)> callback,
|
||||||
|
bool background = false);
|
||||||
void markPendingSendTransactionSucceeded(const std::string& opid,
|
void markPendingSendTransactionSucceeded(const std::string& opid,
|
||||||
const std::string& txid);
|
const std::string& txid);
|
||||||
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
void removePendingSendTransactions(const std::vector<std::string>& opids,
|
||||||
@@ -695,12 +760,101 @@ private:
|
|||||||
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
// thread is viewed (markChatConversationSeen); wiped in resetChatSession so unread doesn't leak across
|
||||||
// wallets. In-memory only (resets on app restart).
|
// wallets. In-memory only (resets on app restart).
|
||||||
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
std::map<std::string, std::int64_t> chat_seen_watermark_;
|
||||||
|
|
||||||
|
// ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ──
|
||||||
|
// Chat nav-badge unread count — recomputed only when the store revision changes or after a short
|
||||||
|
// interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount().
|
||||||
|
mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute
|
||||||
|
mutable int chat_unread_cached_ = 0;
|
||||||
|
mutable double chat_unread_computed_at_ = 0.0;
|
||||||
|
// Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update +
|
||||||
|
// size), not every frame. See App::render().
|
||||||
|
std::int64_t sb_unconf_key_ts_ = -1;
|
||||||
|
std::size_t sb_unconf_key_n_ = 0;
|
||||||
|
int sb_unconf_count_ = 0;
|
||||||
|
// Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the
|
||||||
|
// Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB().
|
||||||
|
mutable double daemon_mem_cached_mb_ = 0.0;
|
||||||
|
mutable double daemon_mem_probe_at_ = 0.0;
|
||||||
|
|
||||||
|
// ── Chat note buffer (BOTH variants) ────────────────────────────────────────────────────────
|
||||||
|
// Each chat message is a shielded tx that spends a note; its change needs a few confirmations before
|
||||||
|
// it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so
|
||||||
|
// rapid sends run out of verified funds. We keep a buffer of ~kChatBufferTarget small self-notes so a
|
||||||
|
// burst of messages each spends a separate verified note, refilled from change + background self-
|
||||||
|
// splits. Chat sends, self-splits and user sends share the wallet's single send channel; inflight_op_
|
||||||
|
// + (lite) lite_send_callback_ / (full node) send_submissions_in_flight_+pending_opids_ serialize them
|
||||||
|
// so exactly one send is ever outstanding. Implemented in app_network.cpp; pumped from update().
|
||||||
|
enum class LiteOpKind { None, ChatSend, ContactRequest, Split, UserSend };
|
||||||
|
struct LiteInflightOp {
|
||||||
|
LiteOpKind kind = LiteOpKind::None;
|
||||||
|
std::string echoLocalId; // ChatSend/ContactRequest: the echo to resolve when it completes
|
||||||
|
int sessionGen = 0; // chat_session_generation_ snapshot at submit (stale-guard)
|
||||||
|
double submittedAt = 0.0;
|
||||||
|
};
|
||||||
|
struct QueuedChatOp {
|
||||||
|
LiteOpKind kind = LiteOpKind::ChatSend;
|
||||||
|
chat::OutgoingChatMemos memos; // kept so a transient-funds retry re-broadcasts, never recomposes
|
||||||
|
std::string echoLocalId;
|
||||||
|
int sessionGen = 0;
|
||||||
|
int retries = 0;
|
||||||
|
};
|
||||||
|
LiteInflightOp inflight_op_; // the single chat/split op currently on the send channel
|
||||||
|
std::deque<QueuedChatOp> chat_send_queue_; // chat/contact sends awaiting a free channel + verified note
|
||||||
|
// Note-availability estimate between refreshes/scans: reset from a fresh count, decremented on each chat
|
||||||
|
// submit (the count lags a spend by a cycle, but the wallet still picks a fresh note per send). Zeroed on
|
||||||
|
// a transient-funds failure so we stop draining until the next refresh/scan restores the truth.
|
||||||
|
int chat_verified_note_budget_ = 0;
|
||||||
|
int chat_pipeline_note_count_ = 0; // verified + maturing self-notes (drives shouldSplit)
|
||||||
|
std::uint64_t chat_verified_shielded_zat_ = 0; // verified shielded balance (split affordability)
|
||||||
|
bool chat_note_model_seen_ = false; // saw a refresh/scan carrying per-note visibility
|
||||||
|
// Single-split-in-flight guard: a self-split's OUTPUT notes are invisible until mined (~1 block), far
|
||||||
|
// longer than any wall-clock cooldown — so we permit only ONE outstanding split and clear the flag when
|
||||||
|
// the pipeline recovers (outputs mined) or a watchdog expires (a split that never mines mustn't wedge
|
||||||
|
// refill forever). Prevents runaway splitting that would drain balance into fees.
|
||||||
|
bool chat_split_outstanding_ = false;
|
||||||
|
double chat_split_submitted_at_ = 0.0; // ImGui time the outstanding split was submitted (watchdog)
|
||||||
|
// Full-node only: a coordinator-owned z_listunspent worker scan feeds the per-note counts (the shared
|
||||||
|
// balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_.
|
||||||
|
bool chat_note_scan_in_flight_ = false;
|
||||||
|
double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit)
|
||||||
|
double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off)
|
||||||
|
// Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs
|
||||||
|
// may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads
|
||||||
|
// this cache rather than requiring the current model to have both — else the budget flickers to 0.
|
||||||
|
std::int64_t chat_last_chain_height_ = 0;
|
||||||
|
int chat_fast_scan_last_seen_ = -1; // dedup: last memo-note count logged by the 0-conf scan
|
||||||
|
|
||||||
|
// Coordinator helpers (both variants unless noted; see app_network.cpp).
|
||||||
|
void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model
|
||||||
|
void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan
|
||||||
|
// Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh),
|
||||||
|
// so the dedicated scan above is skipped while the address refresh is active (dedup — see #3).
|
||||||
|
void updateChatNoteBudgetFromUnspent(const std::vector<services::NetworkRefreshService::UnspentNoteLite>& unspentNotes);
|
||||||
|
void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer
|
||||||
|
int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||||
|
int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite
|
||||||
|
bool shouldSplitChatBuffer();
|
||||||
|
bool broadcastSelfSplitLite(int noteCount); // lite: self-send minting noteCount reply-address notes
|
||||||
|
bool broadcastSelfSplitNode(int noteCount); // full node: z_sendmany self-send minting noteCount notes
|
||||||
|
void enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId);
|
||||||
|
void onChatBroadcastResult(const LiteInflightOp& op, bool ok, const std::string& error);
|
||||||
|
static bool isTransientVerifiedFundsError(const std::string& error);
|
||||||
|
int chatConfsRequired() const; // verified-note confs threshold: 5 (lite) / 1 (full node)
|
||||||
|
public:
|
||||||
|
// Status-bar summary of the chat note buffer (empty when not applicable). Shown while the Chat tab is
|
||||||
|
// active so the buffer's state (ready / building / sending) is visible.
|
||||||
|
std::string chatBufferStatusText();
|
||||||
|
private:
|
||||||
public:
|
public:
|
||||||
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
// Total unread incoming chat messages across all conversations (for the sidebar badge). 0 when the
|
||||||
// feature is off / no identity.
|
// feature is off / no identity.
|
||||||
int chatUnreadCount() const;
|
int chatUnreadCount() const;
|
||||||
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
|
// Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed).
|
||||||
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
|
void markChatConversationSeen(const std::string& cid, std::int64_t latestTs);
|
||||||
|
// Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one
|
||||||
|
// whose stamped time predates the deleted thread's last message — still badges as unread).
|
||||||
|
void forgetChatConversationSeen(const std::string& cid);
|
||||||
private:
|
private:
|
||||||
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
// Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both
|
||||||
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
// variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the
|
||||||
@@ -713,12 +867,21 @@ private:
|
|||||||
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
||||||
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
||||||
void maybeRemindSeedBackup();
|
void maybeRemindSeedBackup();
|
||||||
|
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
|
||||||
|
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
|
||||||
|
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
|
||||||
|
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
|
||||||
|
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
|
||||||
|
|
||||||
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
|
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
|
||||||
void beginCreateSeedWallet(); // starts the isolated create on a background thread
|
void beginCreateSeedWallet(); // starts the isolated create on a background thread
|
||||||
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
||||||
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
|
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
|
||||||
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
|
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
|
||||||
|
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
|
||||||
|
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
|
||||||
|
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
|
||||||
|
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||||
@@ -739,6 +902,8 @@ private:
|
|||||||
void fastScanChatMemos();
|
void fastScanChatMemos();
|
||||||
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
|
bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs
|
||||||
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
|
float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll)
|
||||||
|
double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off)
|
||||||
|
double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress)
|
||||||
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
|
bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame()
|
||||||
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
// Lite first-run welcome prompt: dismissed for the session once the user picks an action.
|
||||||
bool lite_firstrun_dismissed_ = false;
|
bool lite_firstrun_dismissed_ = false;
|
||||||
@@ -748,6 +913,16 @@ private:
|
|||||||
bool lite_startup_lock_checked_ = false;
|
bool lite_startup_lock_checked_ = false;
|
||||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||||
|
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
|
||||||
|
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
|
||||||
|
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
|
||||||
|
std::thread mining_ctl_thread_;
|
||||||
|
std::mutex mining_ctl_mutex_;
|
||||||
|
std::condition_variable mining_ctl_cv_;
|
||||||
|
std::deque<std::function<void()>> mining_ctl_queue_;
|
||||||
|
bool mining_ctl_stop_ = false;
|
||||||
|
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
|
||||||
|
void stopMiningControlThread(); // signal + join the control thread (shutdown)
|
||||||
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
||||||
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
||||||
util::PoolStatsService pool_stats_service_;
|
util::PoolStatsService pool_stats_service_;
|
||||||
@@ -780,8 +955,13 @@ private:
|
|||||||
std::atomic<bool> shutting_down_{false};
|
std::atomic<bool> shutting_down_{false};
|
||||||
std::atomic<bool> shutdown_complete_{false};
|
std::atomic<bool> shutdown_complete_{false};
|
||||||
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
|
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
|
||||||
std::string shutdown_status_;
|
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
|
||||||
std::thread shutdown_thread_;
|
std::thread shutdown_thread_;
|
||||||
|
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
|
||||||
|
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
|
||||||
|
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
|
||||||
|
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
|
||||||
|
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
|
||||||
float shutdown_timer_ = 0.0f;
|
float shutdown_timer_ = 0.0f;
|
||||||
bool force_quit_confirm_ = false;
|
bool force_quit_confirm_ = false;
|
||||||
std::chrono::steady_clock::time_point shutdown_start_time_;
|
std::chrono::steady_clock::time_point shutdown_start_time_;
|
||||||
@@ -806,6 +986,49 @@ private:
|
|||||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||||
bool show_switch_stop_daemon_confirm_ = false;
|
bool show_switch_stop_daemon_confirm_ = false;
|
||||||
std::string pending_switch_wallet_file_;
|
std::string pending_switch_wallet_file_;
|
||||||
|
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
|
||||||
|
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
|
||||||
|
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
|
||||||
|
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
|
||||||
|
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
|
||||||
|
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
|
||||||
|
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
|
||||||
|
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||||
|
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||||
|
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||||
|
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||||
|
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||||
|
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||||
|
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
|
||||||
|
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
|
||||||
|
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
|
||||||
|
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
|
||||||
|
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
|
||||||
|
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
|
||||||
|
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
|
||||||
|
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
|
||||||
|
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
|
||||||
|
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
|
||||||
|
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
|
||||||
|
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
|
||||||
|
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
|
||||||
|
enum class RecoveryPhase { Offer, Working, Done, Failed };
|
||||||
|
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
|
||||||
|
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
|
||||||
|
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
|
||||||
|
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
|
||||||
|
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
|
||||||
|
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
|
||||||
|
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
|
||||||
|
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
|
||||||
|
bool post_recovery_rescan_ = false;
|
||||||
|
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
|
||||||
|
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
|
||||||
|
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
|
||||||
|
std::mutex wallet_restore_mutex_;
|
||||||
|
bool wallet_restore_done_ = false;
|
||||||
|
int wallet_restore_severity_ = 0;
|
||||||
|
std::string wallet_restore_msg_;
|
||||||
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
||||||
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
||||||
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||||
@@ -832,6 +1055,7 @@ private:
|
|||||||
bool show_demo_window_ = false;
|
bool show_demo_window_ = false;
|
||||||
bool show_settings_ = false;
|
bool show_settings_ = false;
|
||||||
bool show_about_ = false;
|
bool show_about_ = false;
|
||||||
|
bool show_faq_ = false;
|
||||||
bool show_import_key_ = false;
|
bool show_import_key_ = false;
|
||||||
bool show_export_key_ = false;
|
bool show_export_key_ = false;
|
||||||
bool show_backup_ = false;
|
bool show_backup_ = false;
|
||||||
@@ -845,6 +1069,7 @@ private:
|
|||||||
bool seed_backup_loading_ = false;
|
bool seed_backup_loading_ = false;
|
||||||
bool seed_backup_no_mnemonic_ = false;
|
bool seed_backup_no_mnemonic_ = false;
|
||||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||||
|
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
|
||||||
|
|
||||||
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
||||||
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
|
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
|
||||||
@@ -938,10 +1163,30 @@ private:
|
|||||||
bool daemon_start_error_shown_ = false;
|
bool daemon_start_error_shown_ = false;
|
||||||
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
||||||
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
||||||
|
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
|
||||||
|
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
|
||||||
|
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
|
||||||
|
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
|
||||||
|
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
|
||||||
|
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
|
||||||
|
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
|
||||||
|
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
|
||||||
|
// Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main:
|
||||||
|
// the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a
|
||||||
|
// large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and
|
||||||
|
// starving block connection near the tip (where effectivelySyncing() reads false). See
|
||||||
|
// addressRefreshDue() / txRefreshDue(); only the routine periodic poll is throttled — explicit
|
||||||
|
// refreshes (tab switch, dirty set, in-flight send) call the refresh directly and bypass this.
|
||||||
|
double last_address_scan_ms_ = 0.0; // measured cost of the last address scan
|
||||||
|
double last_tx_scan_ms_ = 0.0; // measured cost of the last history scan
|
||||||
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
||||||
std::uint64_t clipboard_secret_hash_ = 0;
|
std::uint64_t clipboard_secret_hash_ = 0;
|
||||||
double clipboard_clear_deadline_ = 0.0;
|
double clipboard_clear_deadline_ = 0.0;
|
||||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||||
|
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
||||||
|
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
|
||||||
|
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
|
||||||
|
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
|
||||||
|
|
||||||
// Current page (sidebar navigation)
|
// Current page (sidebar navigation)
|
||||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||||
@@ -951,6 +1196,7 @@ private:
|
|||||||
|
|
||||||
// Debug screenshot sweep state.
|
// Debug screenshot sweep state.
|
||||||
bool screenshot_sweep_active_ = false;
|
bool screenshot_sweep_active_ = false;
|
||||||
|
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
|
||||||
bool sweep_capture_this_frame_ = false;
|
bool sweep_capture_this_frame_ = false;
|
||||||
int sweep_skin_idx_ = 0;
|
int sweep_skin_idx_ = 0;
|
||||||
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
|
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
|
||||||
@@ -1010,6 +1256,9 @@ private:
|
|||||||
int logo_h_ = 0;
|
int logo_h_ = 0;
|
||||||
bool logo_loaded_ = false;
|
bool logo_loaded_ = false;
|
||||||
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
|
bool logo_is_dark_variant_ = true; // tracks which variant is currently loaded
|
||||||
|
ImU32 logo_accent_ = 0; // theme accent the SVG logo was last rasterized with (re-render on change)
|
||||||
|
ImTextureID drgx_emoji_tex_ = 0; // ":drgx:" custom chat emoji (themed to the accent, like the logo)
|
||||||
|
int drgx_emoji_w_ = 0, drgx_emoji_h_ = 0;
|
||||||
|
|
||||||
// Coin logo texture (DragonX currency icon, separate from wallet branding)
|
// Coin logo texture (DragonX currency icon, separate from wallet branding)
|
||||||
ImTextureID coin_logo_tex_ = 0;
|
ImTextureID coin_logo_tex_ = 0;
|
||||||
@@ -1041,6 +1290,16 @@ private:
|
|||||||
|
|
||||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||||
std::atomic<bool> auto_shield_pending_{false};
|
std::atomic<bool> auto_shield_pending_{false};
|
||||||
|
// v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
|
||||||
|
// defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
|
||||||
|
// the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
|
||||||
|
// daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
|
||||||
|
bool daemon_autoshield_probed_ = false;
|
||||||
|
bool daemon_autoshield_active_ = false;
|
||||||
|
std::atomic<bool> daemon_autoshield_probe_inflight_{false};
|
||||||
|
std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
|
||||||
|
std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
|
||||||
|
bool daemon_autoshield_seed_recoverable_ = false;
|
||||||
|
|
||||||
// P4: Incremental transaction cache
|
// P4: Incremental transaction cache
|
||||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||||
@@ -1079,6 +1338,11 @@ private:
|
|||||||
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
|
// the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for
|
||||||
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
|
// the whole scan and would block them); completion is signalled by the rescan RPC callback.
|
||||||
bool runtime_rescan_active_ = false;
|
bool runtime_rescan_active_ = false;
|
||||||
|
// True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex
|
||||||
|
// restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does
|
||||||
|
// on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it;
|
||||||
|
// cleared when the toast is shown.
|
||||||
|
std::atomic<bool> user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads
|
||||||
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
|
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
|
||||||
// that reconciles the preserved wallet.dat against the freshly-imported chain.
|
// that reconciles the preserved wallet.dat against the freshly-imported chain.
|
||||||
bool post_bootstrap_rescan_pending_ = false;
|
bool post_bootstrap_rescan_pending_ = false;
|
||||||
@@ -1137,8 +1401,8 @@ private:
|
|||||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||||
|
|
||||||
// Wizard: stopping an external daemon before bootstrap
|
// Wizard: stopping an external daemon before bootstrap
|
||||||
bool wizard_stopping_external_ = false;
|
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
|
||||||
std::string wizard_stop_status_;
|
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
|
||||||
|
|
||||||
// PIN vault
|
// PIN vault
|
||||||
std::unique_ptr<util::SecureVault> vault_;
|
std::unique_ptr<util::SecureVault> vault_;
|
||||||
@@ -1204,6 +1468,13 @@ private:
|
|||||||
|
|
||||||
// Private methods - rendering
|
// Private methods - rendering
|
||||||
void renderStatusBar();
|
void renderStatusBar();
|
||||||
|
// Persistent node/RPC error strip at the top of the content column when the wallet can't
|
||||||
|
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
||||||
|
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
||||||
|
void renderNodeStatusBanner();
|
||||||
|
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
|
||||||
|
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
|
||||||
|
void renderAlertHistoryPanel();
|
||||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||||
void renderImportKeyDialog();
|
void renderImportKeyDialog();
|
||||||
@@ -1221,6 +1492,16 @@ private:
|
|||||||
void renderPinDialogs();
|
void renderPinDialogs();
|
||||||
void renderAntivirusHelpDialog();
|
void renderAntivirusHelpDialog();
|
||||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
||||||
|
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
|
||||||
|
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
|
||||||
|
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
|
||||||
|
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
|
||||||
|
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
|
||||||
|
void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
|
||||||
|
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
|
||||||
|
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
|
||||||
|
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
|
||||||
|
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
|
||||||
void processDeferredEncryption();
|
void processDeferredEncryption();
|
||||||
|
|
||||||
// Private methods - connection
|
// Private methods - connection
|
||||||
@@ -1251,6 +1532,13 @@ private:
|
|||||||
void refreshPrice();
|
void refreshPrice();
|
||||||
void refreshWalletEncryptionState();
|
void refreshWalletEncryptionState();
|
||||||
void applyRefreshPolicy(ui::NavPage page);
|
void applyRefreshPolicy(ui::NavPage page);
|
||||||
|
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
|
||||||
|
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
|
||||||
|
bool addressRefreshDue() const; // same adaptive back-off for the address scan (z_listunspent)
|
||||||
|
bool txRefreshDue() const; // same adaptive back-off for the history scan (z_listreceivedbyaddress)
|
||||||
|
// Shared duty-cycle rule: a scan may resume only once (lastScanMs / kScanDutyCycle) has elapsed since
|
||||||
|
// lastUpdate, so any single O(mapWallet) scan can occupy at most ~kScanDutyCycle of wall-clock.
|
||||||
|
bool scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const;
|
||||||
bool currentPageNeedsWalletDataRefresh() const;
|
bool currentPageNeedsWalletDataRefresh() const;
|
||||||
bool shouldRunWalletTransactionRefresh() const;
|
bool shouldRunWalletTransactionRefresh() const;
|
||||||
bool shouldRefreshTransactions() const;
|
bool shouldRefreshTransactions() const;
|
||||||
|
|||||||
1684
src/app_network.cpp
1684
src/app_network.cpp
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,10 @@
|
|||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
#include <sodium.h>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
@@ -234,6 +238,41 @@ private:
|
|||||||
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
||||||
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
||||||
// processDeferredEncryption(); must be called on the main thread.
|
// processDeferredEncryption(); must be called on the main thread.
|
||||||
|
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
|
||||||
|
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
|
||||||
|
void App::scrubAndRemoveExport(const std::string& path)
|
||||||
|
{
|
||||||
|
if (path.empty()) return;
|
||||||
|
std::error_code ec;
|
||||||
|
const auto sz = std::filesystem::file_size(path, ec);
|
||||||
|
if (!ec && sz > 0) {
|
||||||
|
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
|
||||||
|
if (scrub) {
|
||||||
|
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
|
||||||
|
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
|
||||||
|
scrub.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::filesystem::remove(path, ec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||||
|
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
|
||||||
|
void App::sweepStaleDecryptExports()
|
||||||
|
{
|
||||||
|
std::error_code ec;
|
||||||
|
const std::string dir = util::Platform::getDragonXDataDir();
|
||||||
|
std::filesystem::directory_iterator it(dir, ec), end;
|
||||||
|
for (; it != end; it.increment(ec)) {
|
||||||
|
if (ec) break;
|
||||||
|
const std::string name = it->path().filename().string();
|
||||||
|
if (name.rfind("obsidiandecryptexport", 0) == 0) {
|
||||||
|
scrubAndRemoveExport(it->path().string());
|
||||||
|
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
||||||
if (isUsingEmbeddedDaemon()) {
|
if (isUsingEmbeddedDaemon()) {
|
||||||
if (announceRestartStatus) {
|
if (announceRestartStatus) {
|
||||||
@@ -261,14 +300,14 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
ui::Notifications::instance().warning(
|
ui::Notifications::instance().warning(
|
||||||
"Please restart your daemon for encryption to take effect.");
|
TR("sec_restart_daemon_for_encryption"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||||
if (!rpc_ || !rpc_->isConnected()) return;
|
if (!rpc_ || !rpc_->isConnected()) return;
|
||||||
encrypt_in_progress_ = true;
|
encrypt_in_progress_ = true;
|
||||||
encrypt_status_ = "Encrypting wallet...";
|
encrypt_status_ = TR("sec_encrypting_wallet");
|
||||||
|
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
@@ -278,7 +317,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
|||||||
if (result.encrypted) {
|
if (result.encrypted) {
|
||||||
return [this]() {
|
return [this]() {
|
||||||
encrypt_in_progress_ = false;
|
encrypt_in_progress_ = false;
|
||||||
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
|
encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon");
|
||||||
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
|
DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n");
|
||||||
|
|
||||||
// Immediately update local encryption state so the
|
// Immediately update local encryption state so the
|
||||||
@@ -297,7 +336,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ui::Notifications::instance().info(
|
ui::Notifications::instance().info(
|
||||||
"Wallet encrypted successfully", 5.0f);
|
TR("sec_wallet_encrypted_successfully"), 5.0f);
|
||||||
|
|
||||||
// The daemon shuts itself down after encryptwallet.
|
// The daemon shuts itself down after encryptwallet.
|
||||||
// Update connection_status_ so the loading overlay
|
// Update connection_status_ so the loading overlay
|
||||||
@@ -309,11 +348,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
|||||||
std::string err = result.error;
|
std::string err = result.error;
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
encrypt_in_progress_ = false;
|
encrypt_in_progress_ = false;
|
||||||
encrypt_status_ = "Encryption failed: " + err;
|
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
|
||||||
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
|
DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str());
|
||||||
|
|
||||||
ui::Notifications::instance().error(
|
ui::Notifications::instance().error(
|
||||||
"Encryption failed: " + err);
|
std::string(TR("sec_encryption_failed_prefix")) + err);
|
||||||
|
|
||||||
// Return to passphrase entry on failure
|
// Return to passphrase entry on failure
|
||||||
if (show_encrypt_dialog_ &&
|
if (show_encrypt_dialog_ &&
|
||||||
@@ -354,7 +393,7 @@ void App::processDeferredEncryption() {
|
|||||||
std::string pin = std::move(deferredEncryption.pin);
|
std::string pin = std::move(deferredEncryption.pin);
|
||||||
|
|
||||||
encrypt_in_progress_ = true;
|
encrypt_in_progress_ = true;
|
||||||
encrypt_status_ = "Encrypting wallet...";
|
encrypt_status_ = TR("sec_encrypting_wallet");
|
||||||
|
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
@@ -374,13 +413,13 @@ void App::processDeferredEncryption() {
|
|||||||
if (result.pinStored) {
|
if (result.pinStored) {
|
||||||
settings_->setPinEnabled(true);
|
settings_->setPinEnabled(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
|
ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f);
|
||||||
} else {
|
} else {
|
||||||
ui::Notifications::instance().warning(
|
ui::Notifications::instance().warning(
|
||||||
"Wallet encrypted but PIN vault failed");
|
TR("sec_wallet_encrypted_but_pin_vault_failed"));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
|
ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f);
|
||||||
}
|
}
|
||||||
|
|
||||||
wallet_security_.clearDeferredEncryption();
|
wallet_security_.clearDeferredEncryption();
|
||||||
@@ -393,9 +432,9 @@ void App::processDeferredEncryption() {
|
|||||||
std::string err = result.error;
|
std::string err = result.error;
|
||||||
return [this, err]() {
|
return [this, err]() {
|
||||||
encrypt_in_progress_ = false;
|
encrypt_in_progress_ = false;
|
||||||
encrypt_status_ = "Encryption failed: " + err;
|
encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err;
|
||||||
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
||||||
ui::Notifications::instance().error("Encryption failed: " + err);
|
ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err);
|
||||||
wallet_security_.clearDeferredEncryption();
|
wallet_security_.clearDeferredEncryption();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -483,7 +522,17 @@ void App::lockWallet() {
|
|||||||
state_.locked = true;
|
state_.locked = true;
|
||||||
state_.unlocked_until = 0;
|
state_.unlocked_until = 0;
|
||||||
resetTransactionHistoryCacheSession();
|
resetTransactionHistoryCacheSession();
|
||||||
|
lock_failure_warned_ = false;
|
||||||
DEBUG_LOGF("[App] Wallet locked\n");
|
DEBUG_LOGF("[App] Wallet locked\n");
|
||||||
|
} else {
|
||||||
|
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
|
||||||
|
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
|
||||||
|
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
|
||||||
|
if (!lock_failure_warned_) {
|
||||||
|
lock_failure_warned_ = true;
|
||||||
|
ui::Notifications::instance().warning(
|
||||||
|
TR("sec_couldnt_lock_wallet"), 12.0f);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -492,7 +541,7 @@ void App::lockWallet() {
|
|||||||
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
|
void App::changePassphrase(const std::string& oldPass, const std::string& newPass) {
|
||||||
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
if (!rpc_ || !rpc_->isConnected() || !worker_) return;
|
||||||
encrypt_in_progress_ = true;
|
encrypt_in_progress_ = true;
|
||||||
encrypt_status_ = "Changing passphrase...";
|
encrypt_status_ = TR("sec_changing_passphrase");
|
||||||
|
|
||||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||||
@@ -525,9 +574,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas
|
|||||||
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_));
|
||||||
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
unlockTransactionHistoryCacheWithPassphrase(newPass);
|
||||||
storeTransactionHistoryCacheIfAvailable();
|
storeTransactionHistoryCacheIfAvailable();
|
||||||
ui::Notifications::instance().info("Passphrase changed successfully");
|
ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully"));
|
||||||
} else {
|
} else {
|
||||||
encrypt_status_ = "Failed: " + err_msg;
|
encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg;
|
||||||
}
|
}
|
||||||
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
util::SecureVault::secureZero(newPass.data(), newPass.size());
|
||||||
};
|
};
|
||||||
@@ -560,6 +609,12 @@ void App::refreshWalletEncryptionState() {
|
|||||||
state_.unlocked_until = until;
|
state_.unlocked_until = until;
|
||||||
state_.locked = (until == 0);
|
state_.locked = (until == 0);
|
||||||
state_.encryption_state_known = true;
|
state_.encryption_state_known = true;
|
||||||
|
// Wallet is encrypted — any pending deferred-encryption request has now been
|
||||||
|
// satisfied (however it completed). Clear the persisted flag (W2-2).
|
||||||
|
if (settings_ && settings_->getEncryptionPending()) {
|
||||||
|
settings_->setEncryptionPending(false);
|
||||||
|
settings_->save();
|
||||||
|
}
|
||||||
if (state_.locked) {
|
if (state_.locked) {
|
||||||
resetTransactionHistoryCacheSession();
|
resetTransactionHistoryCacheSession();
|
||||||
} else if (state_.transactions.empty()) {
|
} else if (state_.transactions.empty()) {
|
||||||
@@ -572,6 +627,18 @@ void App::refreshWalletEncryptionState() {
|
|||||||
state_.locked = false;
|
state_.locked = false;
|
||||||
state_.unlocked_until = 0;
|
state_.unlocked_until = 0;
|
||||||
state_.encryption_state_known = true;
|
state_.encryption_state_known = true;
|
||||||
|
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
|
||||||
|
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
|
||||||
|
// failed connect before it applied. Warn (once/session) instead of silently leaving
|
||||||
|
// an unencrypted wallet the user believes is protected. The flag stays set until the
|
||||||
|
// wallet is actually encrypted, so the warning recurs each launch until resolved.
|
||||||
|
if (settings_ && settings_->getEncryptionPending() &&
|
||||||
|
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
|
||||||
|
!encryption_incomplete_warned_) {
|
||||||
|
encryption_incomplete_warned_ = true;
|
||||||
|
ui::Notifications::instance().warning(
|
||||||
|
TR("sec_encryption_did_not_complete"), 30.0f);
|
||||||
|
}
|
||||||
if (state_.transactions.empty()) {
|
if (state_.transactions.empty()) {
|
||||||
loadTransactionHistoryCacheIfAvailable();
|
loadTransactionHistoryCacheIfAvailable();
|
||||||
} else {
|
} else {
|
||||||
@@ -691,6 +758,10 @@ void App::checkIdleMining() {
|
|||||||
// Resolve auto values: active defaults to half, idle defaults to all
|
// Resolve auto values: active defaults to half, idle defaults to all
|
||||||
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
|
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
|
||||||
if (idleThreads <= 0) idleThreads = maxThreads;
|
if (idleThreads <= 0) idleThreads = maxThreads;
|
||||||
|
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
|
||||||
|
// could otherwise carry an arbitrary count straight past every bound. (M-06)
|
||||||
|
activeThreads = std::clamp(activeThreads, 1, maxThreads);
|
||||||
|
idleThreads = std::clamp(idleThreads, 1, maxThreads);
|
||||||
|
|
||||||
if (systemIdle) {
|
if (systemIdle) {
|
||||||
// System is idle — scale up to idle thread count
|
// System is idle — scale up to idle thread count
|
||||||
@@ -879,7 +950,7 @@ void App::renderLockScreen() {
|
|||||||
ImU32 textCol = ui::material::OnSurface();
|
ImU32 textCol = ui::material::OnSurface();
|
||||||
|
|
||||||
{
|
{
|
||||||
const char* title = "Wallet Locked";
|
const char* title = TR("sec_wallet_locked_title");
|
||||||
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
|
ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title);
|
||||||
dl->AddText(titleFont, titleFont->LegacySize,
|
dl->AddText(titleFont, titleFont->LegacySize,
|
||||||
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
|
ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title);
|
||||||
@@ -892,7 +963,7 @@ void App::renderLockScreen() {
|
|||||||
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
|
if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0;
|
||||||
|
|
||||||
char msg[128];
|
char msg[128];
|
||||||
snprintf(msg, sizeof(msg), "Too many attempts. Wait %.0f seconds...", lock_lockout_timer_);
|
snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_);
|
||||||
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
|
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg);
|
||||||
@@ -905,10 +976,10 @@ void App::renderLockScreen() {
|
|||||||
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
|
// Mode toggle (PIN / Passphrase) — only show if PIN vault exists
|
||||||
if (hasPinVault) {
|
if (hasPinVault) {
|
||||||
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
|
const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD;
|
||||||
const char* modeText = lock_use_pin_ ? " PIN" : " Passphrase";
|
const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase");
|
||||||
const char* switchLabel = lock_use_pin_
|
const char* switchLabel = lock_use_pin_
|
||||||
? "Use passphrase instead"
|
? TR("sec_use_passphrase_instead")
|
||||||
: "Use PIN instead";
|
: TR("sec_use_pin_instead");
|
||||||
|
|
||||||
// Current mode indicator — icon with icon font, text with caption font
|
// Current mode indicator — icon with icon font, text with caption font
|
||||||
ImFont* iconFont = ui::material::Type().iconSmall();
|
ImFont* iconFont = ui::material::Type().iconSmall();
|
||||||
@@ -1010,7 +1081,7 @@ void App::renderLockScreen() {
|
|||||||
if (lock_unlock_in_progress_) {
|
if (lock_unlock_in_progress_) {
|
||||||
// Animated spinner dots
|
// Animated spinner dots
|
||||||
char msg[64];
|
char msg[64];
|
||||||
snprintf(msg, sizeof(msg), "Unlocking%s", ui::material::LoadingDots());
|
snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots());
|
||||||
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg);
|
||||||
dl->AddText(captionFont, captionFont->LegacySize,
|
dl->AddText(captionFont, captionFont->LegacySize,
|
||||||
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
|
ImVec2(cardX + (cardW - ms.x) * 0.5f, cy),
|
||||||
@@ -1034,7 +1105,7 @@ void App::renderLockScreen() {
|
|||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary()));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||||
ImGui::BeginDisabled(!canSubmit);
|
ImGui::BeginDisabled(!canSubmit);
|
||||||
bool btnClicked = ui::material::TactileButton("Unlock", ImVec2(unlockW, unlockH));
|
bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH));
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
ImGui::PopStyleVar();
|
ImGui::PopStyleVar();
|
||||||
ImGui::PopStyleColor(3);
|
ImGui::PopStyleColor(3);
|
||||||
@@ -1088,7 +1159,7 @@ void App::renderLockScreen() {
|
|||||||
r->call("walletpassphrase", {passphrase, timeout});
|
r->call("walletpassphrase", {passphrase, timeout});
|
||||||
rpcOk = true;
|
rpcOk = true;
|
||||||
} else {
|
} else {
|
||||||
rpcErr = "Not connected to daemon";
|
rpcErr = TR("sec_not_connected_to_daemon");
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
rpcErr = e.what();
|
rpcErr = e.what();
|
||||||
@@ -1104,7 +1175,7 @@ void App::renderLockScreen() {
|
|||||||
// so route through applyUnlockFailure so the lockout curve applies
|
// so route through applyUnlockFailure so the lockout curve applies
|
||||||
// (this path previously bumped the counter but skipped the lockout math).
|
// (this path previously bumped the counter but skipped the lockout math).
|
||||||
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
return [this, rpcErr, passphrase = std::move(passphrase)]() mutable {
|
||||||
applyUnlockFailure("Unlock failed: " + rpcErr);
|
applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr);
|
||||||
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
util::SecureVault::secureZero(passphrase.data(), passphrase.size());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1193,7 +1264,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
|
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
|
||||||
|
|
||||||
float barW = ImGui::GetContentRegionAvail().x;
|
float barW = ImGui::GetContentRegionAvail().x;
|
||||||
float barH = 4.0f;
|
float barH = 4.0f * ui::Layout::dpiScale();
|
||||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||||
@@ -1243,7 +1314,7 @@ void App::renderEncryptWalletDialog() {
|
|||||||
// Indeterminate progress bar
|
// Indeterminate progress bar
|
||||||
{
|
{
|
||||||
float barW = ImGui::GetContentRegionAvail().x;
|
float barW = ImGui::GetContentRegionAvail().x;
|
||||||
float barH = 6.0f;
|
float barH = 6.0f * ui::Layout::dpiScale();
|
||||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||||
@@ -1311,9 +1382,13 @@ void App::renderEncryptWalletDialog() {
|
|||||||
enc_dlg_pin_status_.clear();
|
enc_dlg_pin_status_.clear();
|
||||||
std::string savedPass = enc_dlg_saved_passphrase_;
|
std::string savedPass = enc_dlg_saved_passphrase_;
|
||||||
if (worker_ && vault_) {
|
if (worker_ && vault_) {
|
||||||
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
// Argon2id runs here (worker thread)
|
// Argon2id runs here (worker thread)
|
||||||
bool ok = vault_->store(pinStr, savedPass);
|
bool ok = vault_->store(pinStr, savedPass);
|
||||||
|
// Scrub the captured PIN + passphrase copies (they live in the worker's task
|
||||||
|
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
|
||||||
|
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
|
||||||
|
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
|
||||||
return [this, ok]() {
|
return [this, ok]() {
|
||||||
if (ok) {
|
if (ok) {
|
||||||
settings_->setPinEnabled(true);
|
settings_->setPinEnabled(true);
|
||||||
@@ -1374,6 +1449,11 @@ void App::renderEncryptWalletDialog() {
|
|||||||
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
|
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
|
||||||
if (BeginOverlayDialog(ov)) {
|
if (BeginOverlayDialog(ov)) {
|
||||||
|
|
||||||
|
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
|
||||||
|
// passphrase is lost — reuse their warning string/header for consistency.
|
||||||
|
DialogWarningHeader(TR("wiz_encrypt_warning"));
|
||||||
|
ImGui::Spacing();
|
||||||
|
|
||||||
ImGui::TextUnformatted(TR("change_pass_current"));
|
ImGui::TextUnformatted(TR("change_pass_current"));
|
||||||
ImGui::PushItemWidth(-1);
|
ImGui::PushItemWidth(-1);
|
||||||
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
||||||
@@ -1400,12 +1480,22 @@ void App::renderEncryptWalletDialog() {
|
|||||||
bool valid = strlen(change_old_pass_buf_) > 0 &&
|
bool valid = strlen(change_old_pass_buf_) > 0 &&
|
||||||
strlen(change_new_pass_buf_) >= 8 &&
|
strlen(change_new_pass_buf_) >= 8 &&
|
||||||
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
|
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
|
||||||
|
|
||||||
|
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||||
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
||||||
changePassphrase(std::string(change_old_pass_buf_),
|
changePassphrase(std::string(change_old_pass_buf_),
|
||||||
std::string(change_new_pass_buf_));
|
std::string(change_new_pass_buf_));
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||||
|
// the !show_change_passphrase_ cleanup block below.
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||||
|
show_change_passphrase_ = false;
|
||||||
|
}
|
||||||
EndOverlayDialog();
|
EndOverlayDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1478,15 +1568,17 @@ void App::renderDecryptWalletDialog() {
|
|||||||
|
|
||||||
// Run entire decrypt flow on worker thread
|
// Run entire decrypt flow on worker thread
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||||
[this](rpc::RPCClient& client, const char* context) {
|
[this](rpc::RPCClient& client, const char* context) {
|
||||||
return sendStopCommandSafely(client, context);
|
return sendStopCommandSafely(client, context);
|
||||||
});
|
});
|
||||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||||
|
// Scrub the passphrase — unlock is its only use in this flow.
|
||||||
|
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
|
||||||
if (!unlock.ok) {
|
if (!unlock.ok) {
|
||||||
return [this]() {
|
return [this]() {
|
||||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt"));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1553,6 +1645,11 @@ void App::renderDecryptWalletDialog() {
|
|||||||
std::chrono::steady_clock::now());
|
std::chrono::steady_clock::now());
|
||||||
|
|
||||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||||
|
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
|
||||||
|
// success, a restart-failure early return, or an exception. A full cleartext dump of all
|
||||||
|
// private keys must never outlive this step. The startup sweep is a further net for a
|
||||||
|
// crash/kill mid-flight. (H-02)
|
||||||
|
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
|
||||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||||
[this](rpc::RPCClient& client, const char* context) {
|
[this](rpc::RPCClient& client, const char* context) {
|
||||||
@@ -1597,7 +1694,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui::Notifications::instance().info(
|
ui::Notifications::instance().info(
|
||||||
"Importing keys & rescanning blockchain — wallet is usable while this runs",
|
TR("sec_importing_keys_rescanning"),
|
||||||
8.0f);
|
8.0f);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1606,6 +1703,8 @@ void App::renderDecryptWalletDialog() {
|
|||||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||||
importAdapter, exportPath);
|
importAdapter, exportPath);
|
||||||
|
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
|
||||||
|
|
||||||
if (!importResult.ok) {
|
if (!importResult.ok) {
|
||||||
std::string err = importResult.error;
|
std::string err = importResult.error;
|
||||||
if (worker_) {
|
if (worker_) {
|
||||||
@@ -1614,7 +1713,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
wallet_security_workflow_.finishImport();
|
wallet_security_workflow_.finishImport();
|
||||||
ui::Notifications::instance().error(
|
ui::Notifications::instance().error(
|
||||||
err +
|
err +
|
||||||
"\nEncrypted backup: wallet.dat.encrypted.bak",
|
TR("sec_encrypted_backup_suffix"),
|
||||||
12.0f);
|
12.0f);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -1640,7 +1739,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
refreshPeerInfo();
|
refreshPeerInfo();
|
||||||
|
|
||||||
ui::Notifications::instance().success(
|
ui::Notifications::instance().success(
|
||||||
"Wallet decrypted successfully! All keys imported.",
|
TR("sec_wallet_decrypted_all_keys_imported"),
|
||||||
8.0f);
|
8.0f);
|
||||||
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
|
DEBUG_LOGF("[App] Wallet decrypted successfully\n");
|
||||||
};
|
};
|
||||||
@@ -1730,7 +1829,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
// Indeterminate progress bar
|
// Indeterminate progress bar
|
||||||
{
|
{
|
||||||
float barW = ImGui::GetContentRegionAvail().x;
|
float barW = ImGui::GetContentRegionAvail().x;
|
||||||
float barH = 6.0f;
|
float barH = 6.0f * ui::Layout::dpiScale();
|
||||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||||
@@ -1762,7 +1861,7 @@ void App::renderDecryptWalletDialog() {
|
|||||||
int tMins = (int)(totalElapsed / 60);
|
int tMins = (int)(totalElapsed / 60);
|
||||||
int tSecs = (int)(totalElapsed % 60);
|
int tSecs = (int)(totalElapsed % 60);
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs);
|
ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Phase 2: Success ----
|
// ---- Phase 2: Success ----
|
||||||
@@ -1860,10 +1959,12 @@ void App::renderPinDialogs() {
|
|||||||
util::SecureVault::isValidPin(pinStr) &&
|
util::SecureVault::isValidPin(pinStr) &&
|
||||||
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
||||||
|
|
||||||
|
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||||
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = "Verifying passphrase...";
|
pin_status_ = TR("sec_verifying_passphrase");
|
||||||
|
|
||||||
// Verify passphrase + store vault on worker thread to avoid
|
// Verify passphrase + store vault on worker thread to avoid
|
||||||
// blocking the UI with Argon2id key derivation.
|
// blocking the UI with Argon2id key derivation.
|
||||||
@@ -1874,20 +1975,25 @@ void App::renderPinDialogs() {
|
|||||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||||
|
|
||||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
||||||
// Verify passphrase via RPC (worker thread)
|
// Verify passphrase via RPC (worker thread)
|
||||||
try {
|
try {
|
||||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
|
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||||
|
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||||
return [this]() {
|
return [this]() {
|
||||||
pin_status_ = "Incorrect passphrase";
|
pin_status_ = TR("sec_incorrect_passphrase_pin_setup");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Passphrase correct — store in vault (Argon2id, worker thread)
|
// Passphrase correct — store in vault (Argon2id, worker thread)
|
||||||
bool storeOk = vault_ && vault_->store(pin, passphrase);
|
bool storeOk = vault_ && vault_->store(pin, passphrase);
|
||||||
|
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
|
||||||
|
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||||
|
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||||
|
|
||||||
// Lock wallet back
|
// Lock wallet back
|
||||||
try {
|
try {
|
||||||
@@ -1902,19 +2008,26 @@ void App::renderPinDialogs() {
|
|||||||
pin_status_.clear();
|
pin_status_.clear();
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
show_pin_setup_ = false;
|
show_pin_setup_ = false;
|
||||||
ui::Notifications::instance().info("PIN set successfully");
|
ui::Notifications::instance().info(TR("sec_pin_set_successfully"));
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Failed to create vault";
|
pin_status_ = TR("sec_failed_to_create_vault");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Not connected to daemon";
|
pin_status_ = TR("sec_not_connected_to_daemon_pin");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
|
ImGui::SameLine();
|
||||||
|
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||||
|
// the !show_pin_setup_ cleanup block below.
|
||||||
|
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||||
|
show_pin_setup_ = false;
|
||||||
|
}
|
||||||
EndOverlayDialog();
|
EndOverlayDialog();
|
||||||
}
|
}
|
||||||
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
|
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
|
||||||
@@ -1968,7 +2081,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = "Changing PIN...";
|
pin_status_ = TR("sec_changing_pin");
|
||||||
std::string oldPin(pin_old_buf_);
|
std::string oldPin(pin_old_buf_);
|
||||||
std::string newPinCopy = newPin;
|
std::string newPinCopy = newPin;
|
||||||
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
||||||
@@ -1984,15 +2097,15 @@ void App::renderPinDialogs() {
|
|||||||
pin_status_.clear();
|
pin_status_.clear();
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
show_pin_change_ = false;
|
show_pin_change_ = false;
|
||||||
ui::Notifications::instance().info("PIN changed successfully");
|
ui::Notifications::instance().info(TR("sec_pin_changed_successfully"));
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Incorrect current PIN";
|
pin_status_ = TR("sec_incorrect_current_pin");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Internal error";
|
pin_status_ = TR("sec_internal_error_change_pin");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2033,7 +2146,7 @@ void App::renderPinDialogs() {
|
|||||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||||
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
|
if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) {
|
||||||
pin_in_progress_ = true;
|
pin_in_progress_ = true;
|
||||||
pin_status_ = "Verifying PIN...";
|
pin_status_ = TR("sec_verifying_pin");
|
||||||
std::string oldPin(pin_old_buf_);
|
std::string oldPin(pin_old_buf_);
|
||||||
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
memset(pin_old_buf_, 0, sizeof(pin_old_buf_));
|
||||||
|
|
||||||
@@ -2053,15 +2166,15 @@ void App::renderPinDialogs() {
|
|||||||
pin_status_.clear();
|
pin_status_.clear();
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
show_pin_remove_ = false;
|
show_pin_remove_ = false;
|
||||||
ui::Notifications::instance().info("PIN removed");
|
ui::Notifications::instance().info(TR("sec_pin_removed"));
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Incorrect PIN";
|
pin_status_ = TR("sec_incorrect_pin_remove");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pin_status_ = "Internal error";
|
pin_status_ = TR("sec_internal_error_remove_pin");
|
||||||
pin_in_progress_ = false;
|
pin_in_progress_ = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,10 +170,10 @@ void App::installDemoWalletData()
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto zaddr = [](const char* a, double bal, const char* label) {
|
auto zaddr = [](const char* a, double bal, const char* label) {
|
||||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
|
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
|
||||||
};
|
};
|
||||||
auto taddr = [](const char* a, double bal, const char* label) {
|
auto taddr = [](const char* a, double bal, const char* label) {
|
||||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
|
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
|
||||||
};
|
};
|
||||||
state_.z_addresses = {
|
state_.z_addresses = {
|
||||||
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
|
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
|
||||||
@@ -310,6 +310,8 @@ void App::buildSweepCatalog()
|
|||||||
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
|
[](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); });
|
||||||
add("modal-backup", ui::NavPage::Overview,
|
add("modal-backup", ui::NavPage::Overview,
|
||||||
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
|
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
|
||||||
|
add("modal-faq", ui::NavPage::Overview,
|
||||||
|
[](App& a) { a.show_faq_ = true; }, [](App& a) { a.show_faq_ = false; });
|
||||||
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
|
// Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt).
|
||||||
add("modal-encrypt", ui::NavPage::Settings,
|
add("modal-encrypt", ui::NavPage::Settings,
|
||||||
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
|
[](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; },
|
||||||
@@ -704,9 +706,26 @@ void App::startSweepImpl(bool full)
|
|||||||
if (sk.valid) sweep_skins_.push_back(sk.id);
|
if (sk.valid) sweep_skins_.push_back(sk.id);
|
||||||
if (sweep_skins_.empty()) return;
|
if (sweep_skins_.empty()) return;
|
||||||
|
|
||||||
|
// Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
|
||||||
|
if (sweep_current_theme_only_)
|
||||||
|
sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
|
||||||
|
|
||||||
|
// DEV/TEST hook (dormant unless the env is set): DRAGONX_SWEEP_ONLY="send,receive" restricts the
|
||||||
|
// sweep to the named surfaces and the dark skin, so a slow large-window run captures just the tab
|
||||||
|
// under review instead of all surfaces x every skin.
|
||||||
|
const char* sweepOnly = std::getenv("DRAGONX_SWEEP_ONLY");
|
||||||
|
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
|
||||||
|
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
|
||||||
|
|
||||||
sweep_full_ = full;
|
sweep_full_ = full;
|
||||||
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
||||||
buildSweepCatalog();
|
buildSweepCatalog();
|
||||||
|
if (!sweepOnlyStr.empty()) {
|
||||||
|
std::vector<SweepTarget> keep;
|
||||||
|
for (const auto& t : sweep_targets_)
|
||||||
|
if (sweepOnlyStr.find(t.name) != std::string::npos) keep.push_back(t);
|
||||||
|
sweep_targets_.swap(keep);
|
||||||
|
}
|
||||||
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
|
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
|
||||||
|
|
||||||
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();
|
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();
|
||||||
|
|||||||
@@ -177,8 +177,36 @@ void App::renderFirstRunWizard() {
|
|||||||
// DPI scale factor — multiply all pixel constants by dp
|
// DPI scale factor — multiply all pixel constants by dp
|
||||||
const float dp = ui::Layout::dpiScale();
|
const float dp = ui::Layout::dpiScale();
|
||||||
|
|
||||||
|
// Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
|
||||||
|
// font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
|
||||||
|
// / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
|
||||||
|
// last frame's measured content height, so every control stays reachable. The window keeps
|
||||||
|
// NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
|
||||||
|
static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
|
||||||
|
if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
|
||||||
|
const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
|
||||||
|
// Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
|
||||||
|
// scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
|
||||||
|
// IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
|
||||||
|
const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
|
||||||
|
if (wizMaxScroll > 0.0f && !wizPopupOpen &&
|
||||||
|
ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
|
||||||
|
float wheel = ImGui::GetIO().MouseWheel;
|
||||||
|
if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
|
||||||
|
}
|
||||||
|
s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
|
||||||
|
const float scrollY = s_wizScroll;
|
||||||
|
|
||||||
// --- Header: Logo + Welcome ---
|
// --- Header: Logo + Welcome ---
|
||||||
float headerCy = winPos.y + 20.0f * dp;
|
// Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
|
||||||
|
// monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
|
||||||
|
// height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
|
||||||
|
// fight the scroll), push everything down by half the leftover space. No-op once content
|
||||||
|
// fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
|
||||||
|
float vCenter = 0.0f;
|
||||||
|
if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
|
||||||
|
vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
|
||||||
|
float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
|
||||||
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
|
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
|
||||||
if (logo_tex_ != 0) {
|
if (logo_tex_ != 0) {
|
||||||
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
|
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
|
||||||
@@ -266,29 +294,43 @@ void App::renderFirstRunWizard() {
|
|||||||
{
|
{
|
||||||
int state = cardState(0);
|
int state = cardState(0);
|
||||||
bool isFocused = (state == 1);
|
bool isFocused = (state == 1);
|
||||||
|
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
|
||||||
float cx = leftX + cardPad;
|
float cx = leftX + cardPad;
|
||||||
float cy = card0Top + cardPad;
|
float cy = card0Top + cardPad;
|
||||||
float contentW = colW - 2 * cardPad;
|
float contentW = colW - 2 * cardPad;
|
||||||
|
|
||||||
// Step indicator
|
// Step indicator + title (inline when collapsed)
|
||||||
{
|
if (isCollapsed) {
|
||||||
|
// Compact single-line: check icon + "Step 1" + "Appearance"
|
||||||
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
float labelX = cx + iconW + 4.0f * dp;
|
||||||
cy += captionFont->LegacySize + 6.0f * dp;
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
|
||||||
}
|
float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
|
||||||
|
float titleX = labelX + step1W + 12.0f * dp;
|
||||||
|
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
|
||||||
|
cy += captionFont->LegacySize + 4.0f * dp;
|
||||||
|
} else {
|
||||||
|
// Step indicator
|
||||||
|
{
|
||||||
|
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||||
|
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||||
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
||||||
|
cy += captionFont->LegacySize + 6.0f * dp;
|
||||||
|
}
|
||||||
|
|
||||||
// Title
|
// Title
|
||||||
{
|
{
|
||||||
const char* t = TR("wiz_appearance");
|
const char* t = TR("wiz_appearance");
|
||||||
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
||||||
cy += titleFont->LegacySize + 10.0f * dp;
|
cy += titleFont->LegacySize + 10.0f * dp;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Separator
|
// Separator
|
||||||
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
||||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||||
cy += 14.0f * dp;
|
cy += 14.0f * dp;
|
||||||
|
}
|
||||||
|
|
||||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||||
@@ -324,6 +366,9 @@ void App::renderFirstRunWizard() {
|
|||||||
wiz_appearance_init = true;
|
wiz_appearance_init = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Controls: rendered for the focused and upcoming states so content is visible under
|
||||||
|
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
|
||||||
|
if (!isCollapsed) {
|
||||||
// Render controls always so content is visible under the dim
|
// Render controls always so content is visible under the dim
|
||||||
// overlay when not focused; disable interaction when not active.
|
// overlay when not focused; disable interaction when not active.
|
||||||
ImGui::BeginDisabled(!isFocused);
|
ImGui::BeginDisabled(!isFocused);
|
||||||
@@ -640,13 +685,21 @@ void App::renderFirstRunWizard() {
|
|||||||
cy += btnH;
|
cy += btnH;
|
||||||
}
|
}
|
||||||
|
|
||||||
cy += cardPad;
|
} // if (!isCollapsed)
|
||||||
// Lock card height to the tallest content ever seen
|
|
||||||
float& card0MaxH = wizardUi.card0_max_h;
|
|
||||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
|
||||||
card0Bot = card0Top + card0MaxH;
|
|
||||||
|
|
||||||
// Card 0 finalization deferred until after cards 1+2 are sized
|
cy += cardPad;
|
||||||
|
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||||
|
float& card0MaxH = wizardUi.card0_max_h;
|
||||||
|
if (isCollapsed) {
|
||||||
|
// Completed: finalize immediately as a compact pill (do not stretch to the
|
||||||
|
// right column height, and skip the deferred stretch below).
|
||||||
|
card0Bot = card0Top + (cy - card0Top);
|
||||||
|
finalizeCard(leftX, colW, card0Top, card0Bot, state);
|
||||||
|
} else {
|
||||||
|
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||||
|
card0Bot = card0Top + card0MaxH;
|
||||||
|
// Card 0 finalization deferred until after cards 1+2 are sized
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -889,8 +942,9 @@ void App::renderFirstRunWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (wizard_stopping_external_) {
|
if (wizard_stopping_external_) {
|
||||||
|
const std::string ws = wizard_stop_status_.get();
|
||||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
|
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
|
||||||
wizard_stop_status_.c_str());
|
ws.c_str());
|
||||||
cy += captionFont->LegacySize + 8.0f * dp;
|
cy += captionFont->LegacySize + 8.0f * dp;
|
||||||
} else {
|
} else {
|
||||||
float stopW = 150.0f * dp;
|
float stopW = 150.0f * dp;
|
||||||
@@ -1338,6 +1392,10 @@ void App::renderFirstRunWizard() {
|
|||||||
wallet_security_.beginDeferredEncryption(
|
wallet_security_.beginDeferredEncryption(
|
||||||
std::string(encrypt_pass_buf_),
|
std::string(encrypt_pass_buf_),
|
||||||
(pinEntered && pinOk) ? pinStr : std::string());
|
(pinEntered && pinOk) ? pinStr : std::string());
|
||||||
|
// Persist that encryption was requested (never the passphrase) so a quit/crash or
|
||||||
|
// failed daemon connect before it applies isn't silent — reconciled on the next
|
||||||
|
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
|
||||||
|
settings_->setEncryptionPending(true);
|
||||||
|
|
||||||
// Clear sensitive buffers
|
// Clear sensitive buffers
|
||||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||||
@@ -1372,6 +1430,13 @@ void App::renderFirstRunWizard() {
|
|||||||
encrypt_status_ = TR("wiz_skip_confirm");
|
encrypt_status_ = TR("wiz_skip_confirm");
|
||||||
} else {
|
} else {
|
||||||
s_skipEncConfirm = false;
|
s_skipEncConfirm = false;
|
||||||
|
// Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
|
||||||
|
// typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
|
||||||
|
// path cleared them before). (L-07)
|
||||||
|
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||||
|
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
||||||
|
memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
|
||||||
|
memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
|
||||||
wizard_phase_ = WizardPhase::Done;
|
wizard_phase_ = WizardPhase::Done;
|
||||||
settings_->setWizardCompleted(true);
|
settings_->setWizardCompleted(true);
|
||||||
settings_->save();
|
settings_->save();
|
||||||
@@ -1407,7 +1472,9 @@ void App::renderFirstRunWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Deferred Card 0 finalization: match right column total height ---
|
// --- Deferred Card 0 finalization: match right column total height ---
|
||||||
{
|
// Only for the focused/upcoming Appearance card; a completed one was already finalized
|
||||||
|
// above as a compact pill and must not be re-stretched.
|
||||||
|
if (cardState(0) != 2) {
|
||||||
float rightColBot = card2Bot;
|
float rightColBot = card2Bot;
|
||||||
if (rightColBot > card0Bot) card0Bot = rightColBot;
|
if (rightColBot > card0Bot) card0Bot = rightColBot;
|
||||||
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
|
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
|
||||||
@@ -1416,6 +1483,24 @@ void App::renderFirstRunWizard() {
|
|||||||
// Merge channels: backgrounds → content → overlays
|
// Merge channels: backgrounds → content → overlays
|
||||||
dl->ChannelsMerge();
|
dl->ChannelsMerge();
|
||||||
|
|
||||||
|
// Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
|
||||||
|
// window, draw a slim scroll indicator so the off-screen content is discoverable.
|
||||||
|
{
|
||||||
|
float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
|
||||||
|
// Subtract vCenter back out: everything below the header was shifted down by it, so the raw
|
||||||
|
// span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
|
||||||
|
// vertical-centering above would feed on itself and oscillate frame-to-frame.
|
||||||
|
s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
|
||||||
|
if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
|
||||||
|
float trackH = winSize.y - 8.0f * dp;
|
||||||
|
float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
|
||||||
|
float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
|
||||||
|
float barX = winPos.x + winSize.x - 6.0f * dp;
|
||||||
|
dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
|
||||||
|
ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret)
|
|||||||
lock();
|
lock();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
loadTombstones();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,11 +98,13 @@ void ChatDatabase::lock()
|
|||||||
sodium_memzero(key_.data(), key_.size());
|
sodium_memzero(key_.data(), key_.size());
|
||||||
key_ready_ = false;
|
key_ready_ = false;
|
||||||
wallet_tag_.clear();
|
wallet_tag_.clear();
|
||||||
|
tombstones_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChatDatabase::append(const ChatMessage& message)
|
bool ChatDatabase::append(const ChatMessage& message)
|
||||||
{
|
{
|
||||||
if (!key_ready_ || !ensureOpen()) return false;
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
|
if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan
|
||||||
|
|
||||||
std::vector<unsigned char> nonce;
|
std::vector<unsigned char> nonce;
|
||||||
std::vector<unsigned char> cipher;
|
std::vector<unsigned char> cipher;
|
||||||
@@ -193,13 +196,79 @@ std::vector<ChatMessage> ChatDatabase::load()
|
|||||||
void ChatDatabase::clearWallet()
|
void ChatDatabase::clearWallet()
|
||||||
{
|
{
|
||||||
if (wallet_tag_.empty() || !ensureOpen()) return;
|
if (wallet_tag_.empty() || !ensureOpen()) return;
|
||||||
|
for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?",
|
||||||
|
"DELETE FROM chat_deleted WHERE wallet_tag = ?"}) {
|
||||||
|
sqlite3_stmt* stmt = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue;
|
||||||
|
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_step(stmt);
|
||||||
|
sqlite3_finalize(stmt);
|
||||||
|
}
|
||||||
|
tombstones_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDatabase::deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone)
|
||||||
|
{
|
||||||
|
if (!key_ready_ || !ensureOpen()) return false;
|
||||||
|
if (messages.empty()) return true;
|
||||||
|
|
||||||
|
if (!exec("BEGIN")) return false;
|
||||||
|
bool ok = true;
|
||||||
|
for (const auto& m : messages) {
|
||||||
|
const std::string dedup = dedupHash(m.txid, m.payload_position);
|
||||||
|
|
||||||
|
sqlite3_stmt* del = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?",
|
||||||
|
-1, &del, nullptr) == SQLITE_OK) {
|
||||||
|
sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(del) != SQLITE_DONE) ok = false;
|
||||||
|
sqlite3_finalize(del);
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tombstone) {
|
||||||
|
sqlite3_stmt* ins = nullptr;
|
||||||
|
if (sqlite3_prepare_v2(db_,
|
||||||
|
"INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)",
|
||||||
|
-1, &ins, nullptr) == SQLITE_OK) {
|
||||||
|
sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
|
if (sqlite3_step(ins) != SQLITE_DONE) ok = false;
|
||||||
|
sqlite3_finalize(ins);
|
||||||
|
} else {
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false;
|
||||||
|
// Only reflect the tombstones in the in-memory cache once they are durably committed.
|
||||||
|
if (ok && tombstone)
|
||||||
|
for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position));
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ChatDatabase::isTombstoned(const ChatMessage& message) const
|
||||||
|
{
|
||||||
|
if (!key_ready_ || tombstones_.empty()) return false;
|
||||||
|
return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChatDatabase::loadTombstones()
|
||||||
|
{
|
||||||
|
tombstones_.clear();
|
||||||
|
if (!key_ready_ || !ensureOpen()) return;
|
||||||
sqlite3_stmt* stmt = nullptr;
|
sqlite3_stmt* stmt = nullptr;
|
||||||
if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr)
|
if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?",
|
||||||
!= SQLITE_OK) {
|
-1, &stmt, nullptr) != SQLITE_OK) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT);
|
||||||
sqlite3_step(stmt);
|
while (sqlite3_step(stmt) == SQLITE_ROW) {
|
||||||
|
const auto* h = reinterpret_cast<const char*>(sqlite3_column_text(stmt, 0));
|
||||||
|
if (h) tombstones_.insert(h);
|
||||||
|
}
|
||||||
sqlite3_finalize(stmt);
|
sqlite3_finalize(stmt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +297,17 @@ bool ChatDatabase::ensureOpen()
|
|||||||
exec("PRAGMA journal_mode=WAL");
|
exec("PRAGMA journal_mode=WAL");
|
||||||
exec("PRAGMA synchronous=NORMAL");
|
exec("PRAGMA synchronous=NORMAL");
|
||||||
|
|
||||||
|
// C3-1: restrict the chat DB and its WAL/SHM sidecars to owner-only. sqlite creates them with
|
||||||
|
// umask-derived permissions (often world/group-readable); they hold per-row nonces + AEAD
|
||||||
|
// ciphertext of the user's messages. Best-effort (errors swallowed; a no-op-ish on Windows).
|
||||||
|
{
|
||||||
|
std::error_code perr;
|
||||||
|
const auto ownerOnly = std::filesystem::perms::owner_read | std::filesystem::perms::owner_write;
|
||||||
|
std::filesystem::permissions(database_path_, ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||||
|
std::filesystem::permissions(database_path_ + "-wal", ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||||
|
std::filesystem::permissions(database_path_ + "-shm", ownerOnly, std::filesystem::perm_options::replace, perr);
|
||||||
|
}
|
||||||
|
|
||||||
if (!createSchema()) {
|
if (!createSchema()) {
|
||||||
close();
|
close();
|
||||||
return false;
|
return false;
|
||||||
@@ -249,11 +329,18 @@ bool ChatDatabase::exec(const char* sql)
|
|||||||
|
|
||||||
bool ChatDatabase::createSchema()
|
bool ChatDatabase::createSchema()
|
||||||
{
|
{
|
||||||
return exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
if (!exec("CREATE TABLE IF NOT EXISTS chat_messages ("
|
||||||
|
"wallet_tag TEXT NOT NULL, "
|
||||||
|
"dedup_hash TEXT NOT NULL, "
|
||||||
|
"nonce BLOB NOT NULL, "
|
||||||
|
"payload BLOB NOT NULL, "
|
||||||
|
"PRIMARY KEY (wallet_tag, dedup_hash))"))
|
||||||
|
return false;
|
||||||
|
// Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the
|
||||||
|
// message rows use). A chain re-scan checks this so a deleted message never re-imports.
|
||||||
|
return exec("CREATE TABLE IF NOT EXISTS chat_deleted ("
|
||||||
"wallet_tag TEXT NOT NULL, "
|
"wallet_tag TEXT NOT NULL, "
|
||||||
"dedup_hash TEXT NOT NULL, "
|
"dedup_hash TEXT NOT NULL, "
|
||||||
"nonce BLOB NOT NULL, "
|
|
||||||
"payload BLOB NOT NULL, "
|
|
||||||
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
"PRIMARY KEY (wallet_tag, dedup_hash))");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
struct sqlite3;
|
struct sqlite3;
|
||||||
@@ -54,8 +55,20 @@ public:
|
|||||||
|
|
||||||
void clearWallet(); // delete the unlocked wallet's rows
|
void clearWallet(); // delete the unlocked wallet's rows
|
||||||
|
|
||||||
|
// Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also
|
||||||
|
// records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the
|
||||||
|
// "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply
|
||||||
|
// removed (used by "delete & block", where a settings-level cid block suppresses re-import until the
|
||||||
|
// user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked.
|
||||||
|
bool deleteMessages(const std::vector<ChatMessage>& messages, bool tombstone);
|
||||||
|
|
||||||
|
// True if this message's (txid,position) was locally deleted with a tombstone. Checked against an
|
||||||
|
// in-memory cache loaded on unlock — O(1), no SQL. False while locked.
|
||||||
|
bool isTombstoned(const ChatMessage& message) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
bool ensureOpen();
|
bool ensureOpen();
|
||||||
|
void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet
|
||||||
bool exec(const char* sql);
|
bool exec(const char* sql);
|
||||||
bool createSchema();
|
bool createSchema();
|
||||||
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
std::string dedupHash(const std::string& txid, std::size_t position) const;
|
||||||
@@ -74,6 +87,7 @@ private:
|
|||||||
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
std::array<unsigned char, 32> key_{}; // AEAD storage key (seed-derived)
|
||||||
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex)
|
||||||
bool key_ready_ = false;
|
bool key_ready_ = false;
|
||||||
|
std::unordered_set<std::string> tombstones_; // dedup_hash cache of locally-deleted messages
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
|||||||
const std::string& conversationId,
|
const std::string& conversationId,
|
||||||
const char* type,
|
const char* type,
|
||||||
const std::string& streamHeaderHex,
|
const std::string& streamHeaderHex,
|
||||||
const std::string& publicKeyHex)
|
const std::string& publicKeyHex,
|
||||||
|
std::int64_t sentAt)
|
||||||
{
|
{
|
||||||
nlohmann::json header;
|
nlohmann::json header;
|
||||||
header["h"] = 1; // header number (>= 1)
|
header["h"] = 1; // header number (>= 1)
|
||||||
@@ -26,6 +27,7 @@ std::string buildHeaderMemo(const std::string& replyZaddr,
|
|||||||
header["t"] = type; // "Memo" or "Cont"
|
header["t"] = type; // "Memo" or "Cont"
|
||||||
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont)
|
||||||
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
header["p"] = publicKeyHex; // my 64-hex crypto_kx public key
|
||||||
|
if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this
|
||||||
return header.dump();
|
return header.dump();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,8 @@ ChatComposeStatus buildOutgoingMessage(const ChatKeyPair& mine,
|
|||||||
|
|
||||||
OutgoingChatMemos memos;
|
OutgoingChatMemos memos;
|
||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex);
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Memo", streamHeaderHex, myPublicKeyHex,
|
||||||
|
static_cast<std::int64_t>(std::time(nullptr)));
|
||||||
memos.payloadMemo = ciphertextHex;
|
memos.payloadMemo = ciphertextHex;
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||||
@@ -95,7 +98,8 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex,
|
|||||||
|
|
||||||
OutgoingChatMemos memos;
|
OutgoingChatMemos memos;
|
||||||
memos.recipientZaddr = peerZaddr;
|
memos.recipientZaddr = peerZaddr;
|
||||||
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex);
|
memos.headerMemo = buildHeaderMemo(myReplyZaddr, conversationId, "Cont", "", myPublicKeyHex,
|
||||||
|
static_cast<std::int64_t>(std::time(nullptr)));
|
||||||
memos.payloadMemo = requestText;
|
memos.payloadMemo = requestText;
|
||||||
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
if (memos.headerMemo.size() > kHushChatMemoByteLimit ||
|
||||||
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
memos.payloadMemo.size() > kHushChatMemoByteLimit) {
|
||||||
|
|||||||
@@ -124,6 +124,10 @@ HushChatHeaderParseResult parseHushChatHeaderMemo(const std::string& memo)
|
|||||||
if (!readRequiredString(object, "t", type, error)) return fail(error);
|
if (!readRequiredString(object, "t", type, error)) return fail(error);
|
||||||
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
|
if (!readRequiredString(object, "e", header.secretstream_header_hex, error)) return fail(error);
|
||||||
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
|
if (!readRequiredString(object, "p", header.public_key_hex, error)) return fail(error);
|
||||||
|
// Optional sender compose time (Unix seconds). Absent on older senders — leave sent_at = 0 so the
|
||||||
|
// receiver falls back to the tx/receive time. Read leniently; never fail the header on a bad value.
|
||||||
|
if (auto it = object.find("ts"); it != object.end() && it->is_number_integer())
|
||||||
|
header.sent_at = it->get<std::int64_t>();
|
||||||
|
|
||||||
if (header.header_number < 1) return fail("header number must be positive");
|
if (header.header_number < 1) return fail("header number must be positive");
|
||||||
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
|
if (header.version != kHushChatSupportedVersion) return fail("unsupported HushChat version");
|
||||||
@@ -303,6 +307,7 @@ HushChatTransactionExtractionResult extractHushChatTransactionMetadata(
|
|||||||
metadata.sender_public_key_hex = pair.header.public_key_hex;
|
metadata.sender_public_key_hex = pair.header.public_key_hex;
|
||||||
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
metadata.secretstream_header_hex = pair.header.secretstream_header_hex;
|
||||||
metadata.payload_memo = pair.payload_memo;
|
metadata.payload_memo = pair.payload_memo;
|
||||||
|
metadata.sent_at = pair.header.sent_at; // carry the sender's compose time (0 if absent)
|
||||||
result.metadata.push_back(std::move(metadata));
|
result.metadata.push_back(std::move(metadata));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ struct HushChatHeader {
|
|||||||
HushChatHeaderType type = HushChatHeaderType::Message;
|
HushChatHeaderType type = HushChatHeaderType::Message;
|
||||||
std::string secretstream_header_hex;
|
std::string secretstream_header_hex;
|
||||||
std::string public_key_hex;
|
std::string public_key_hex;
|
||||||
|
// Optional sender-stamped compose time (header "ts", Unix seconds). 0 = absent (older sender) → the
|
||||||
|
// receiver falls back to the tx/receive time. Lets both sides show the SAME (send) time.
|
||||||
|
std::int64_t sent_at = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HushChatHeaderParseResult {
|
struct HushChatHeaderParseResult {
|
||||||
@@ -80,6 +83,7 @@ struct HushChatTransactionMetadata {
|
|||||||
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
|
std::string sender_public_key_hex; // header "p": peer crypto_kx public key (hex)
|
||||||
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
std::string secretstream_header_hex; // header "e": secretstream header (hex; empty for ContactRequest)
|
||||||
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
std::string payload_memo; // ciphertext hex (Message) or plaintext request text (ContactRequest)
|
||||||
|
std::int64_t sent_at = 0; // header "ts": sender compose time (Unix s); 0 = absent → use tx time
|
||||||
};
|
};
|
||||||
|
|
||||||
struct HushChatTransactionExtractionResult {
|
struct HushChatTransactionExtractionResult {
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
std::int64_t fallbackTimestamp,
|
std::int64_t fallbackTimestamp,
|
||||||
std::vector<std::string>* newIncomingCids) {
|
std::vector<std::string>* newIncomingCids) {
|
||||||
if (!has_identity_) return 0;
|
if (!has_identity_) return 0;
|
||||||
|
// Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't
|
||||||
|
// consult tombstones, so ingesting now would resurface locally-deleted messages into the store.
|
||||||
|
// Skip until the DB is usable — chat is degraded anyway without its store.
|
||||||
|
if (db_ && !db_->hasKey()) return 0;
|
||||||
|
|
||||||
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
const std::string myPubKey = chatIdentityPublicKeyHex(identity_);
|
||||||
|
|
||||||
@@ -45,10 +49,30 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
message.conversation_id = meta.conversation_id;
|
message.conversation_id = meta.conversation_id;
|
||||||
message.peer_zaddr = meta.reply_zaddr;
|
message.peer_zaddr = meta.reply_zaddr;
|
||||||
message.peer_public_key_hex = meta.sender_public_key_hex;
|
message.peer_public_key_hex = meta.sender_public_key_hex;
|
||||||
|
// Reference time: the tx/receive time (block time if confirmed, else the receiver's wall clock for
|
||||||
|
// a mempool receive).
|
||||||
const auto timeIt = txTimestamps.find(meta.txid);
|
const auto timeIt = txTimestamps.find(meta.txid);
|
||||||
message.timestamp = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
const std::int64_t refTime = timeIt != txTimestamps.end() ? timeIt->second : fallbackTimestamp;
|
||||||
|
// Prefer the sender's stamped compose time (header "ts") — the true send time, shown identically on
|
||||||
|
// both ends. But REJECT a value implausibly in the FUTURE vs the reference: a wrong/ahead peer clock
|
||||||
|
// would otherwise pin their messages to the bottom of the thread forever. A compose time in the
|
||||||
|
// PAST is fine — the note buffer can broadcast a queued message long after it was composed, and a
|
||||||
|
// confirmed tx's block time is always >= the compose time.
|
||||||
|
constexpr std::int64_t kSenderTsFutureToleranceSec = 3600; // 1 hour of clock skew tolerated
|
||||||
|
if (meta.sent_at > 0 && (refTime <= 0 || meta.sent_at <= refTime + kSenderTsFutureToleranceSec)) {
|
||||||
|
message.timestamp = meta.sent_at;
|
||||||
|
} else {
|
||||||
|
message.timestamp = refTime;
|
||||||
|
}
|
||||||
message.payload_position = meta.payload_position;
|
message.payload_position = meta.payload_position;
|
||||||
|
|
||||||
|
// Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid
|
||||||
|
// is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was
|
||||||
|
// deleted with "revive on new message", so only that exact message is skipped — a new message
|
||||||
|
// in the same conversation has a different txid and flows through normally.
|
||||||
|
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||||
|
if (db_ && db_->isTombstoned(message)) continue;
|
||||||
|
|
||||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||||
message.kind = ChatMessageKind::ContactRequest;
|
message.kind = ChatMessageKind::ContactRequest;
|
||||||
message.body = meta.payload_memo; // plaintext request text
|
message.body = meta.payload_memo; // plaintext request text
|
||||||
@@ -79,10 +103,28 @@ int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata
|
|||||||
void ChatService::loadFromDatabase() {
|
void ChatService::loadFromDatabase() {
|
||||||
if (!db_) return;
|
if (!db_) return;
|
||||||
for (const auto& message : db_->load()) {
|
for (const auto& message : db_->load()) {
|
||||||
|
// Never surface a blocked conversation, even if a prior "delete & block" failed to remove its
|
||||||
|
// rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path.
|
||||||
|
if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue;
|
||||||
store_.append(message);
|
store_.append(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool ChatService::deleteConversation(const std::string& conversationId, bool block) {
|
||||||
|
// Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the
|
||||||
|
// other way round means a failed DB write (disk full / locked) would empty the store while the rows
|
||||||
|
// survive — and on the next reload the conversation silently reappears with no tombstone.
|
||||||
|
// Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them.
|
||||||
|
// Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import
|
||||||
|
// until unblocked, at which point the conversation re-imports from chain.
|
||||||
|
if (db_) {
|
||||||
|
std::vector<ChatMessage> msgs = store_.conversation(conversationId); // snapshot (copy)
|
||||||
|
if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false;
|
||||||
|
}
|
||||||
|
store_.eraseConversation(conversationId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
std::string ChatService::identityPublicKeyHex() const {
|
std::string ChatService::identityPublicKeyHex() const {
|
||||||
if (!has_identity_) return {};
|
if (!has_identity_) return {};
|
||||||
return chatIdentityPublicKeyHex(identity_);
|
return chatIdentityPublicKeyHex(identity_);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
#include "chat_store.h"
|
#include "chat_store.h"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -55,6 +56,19 @@ public:
|
|||||||
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
// the seed-derived key) into the in-memory store. No-op without an unlocked database.
|
||||||
void loadFromDatabase();
|
void loadFromDatabase();
|
||||||
|
|
||||||
|
// Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages
|
||||||
|
// entirely (they are never stored or persisted) until the predicate stops returning true — this is
|
||||||
|
// how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked.
|
||||||
|
void setBlockedPredicate(std::function<bool(const std::string&)> pred) { blocked_pred_ = std::move(pred); }
|
||||||
|
|
||||||
|
// Locally delete a conversation: remove its messages from the store and the database. When `block`
|
||||||
|
// is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a
|
||||||
|
// genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed
|
||||||
|
// WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking
|
||||||
|
// later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the
|
||||||
|
// persisted rows couldn't be removed, so the caller can avoid a store/DB divergence.
|
||||||
|
bool deleteConversation(const std::string& conversationId, bool block);
|
||||||
|
|
||||||
// --- Outgoing (compose) ---
|
// --- Outgoing (compose) ---
|
||||||
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
// My chat public key (hex), or "" without an identity — goes in an outgoing header's "p".
|
||||||
std::string identityPublicKeyHex() const;
|
std::string identityPublicKeyHex() const;
|
||||||
@@ -90,6 +104,7 @@ private:
|
|||||||
bool has_identity_ = false;
|
bool has_identity_ = false;
|
||||||
ChatStore store_;
|
ChatStore store_;
|
||||||
ChatDatabase* db_ = nullptr; // optional; not owned
|
ChatDatabase* db_ = nullptr; // optional; not owned
|
||||||
|
std::function<bool(const std::string&)> blocked_pred_; // true => cid is blocked (drop its messages)
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) {
|
|||||||
bool ChatStore::append(const ChatMessage& message) {
|
bool ChatStore::append(const ChatMessage& message) {
|
||||||
if (!seen_.insert(dedupKey(message)).second) return false;
|
if (!seen_.insert(dedupKey(message)).second) return false;
|
||||||
messages_.push_back(message);
|
messages_.push_back(message);
|
||||||
|
++revision_;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive
|
|||||||
for (auto& message : messages_) {
|
for (auto& message : messages_) {
|
||||||
if (message.txid == txid) {
|
if (message.txid == txid) {
|
||||||
message.delivery = delivery;
|
message.delivery = delivery;
|
||||||
|
++revision_;
|
||||||
return &message;
|
return &message;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int ChatStore::countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||||
|
const std::function<std::int64_t(const std::string&)>& seenFor) const {
|
||||||
|
int unread = 0;
|
||||||
|
for (const auto& m : messages_) {
|
||||||
|
if (m.direction != ChatDirection::Incoming) continue;
|
||||||
|
if (excluded && excluded(m.conversation_id)) continue;
|
||||||
|
if (m.timestamp > seenFor(m.conversation_id)) ++unread;
|
||||||
|
}
|
||||||
|
return unread;
|
||||||
|
}
|
||||||
|
|
||||||
std::vector<std::string> ChatStore::conversationIds() const {
|
std::vector<std::string> ChatStore::conversationIds() const {
|
||||||
std::vector<std::string> ids;
|
std::vector<std::string> ids;
|
||||||
std::unordered_set<std::string> seenIds;
|
std::unordered_set<std::string> seenIds;
|
||||||
@@ -53,9 +66,27 @@ std::vector<std::string> ChatStore::conversationIds() const {
|
|||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::vector<ChatMessage> ChatStore::eraseConversation(const std::string& conversationId) {
|
||||||
|
std::vector<ChatMessage> removed;
|
||||||
|
std::vector<ChatMessage> kept;
|
||||||
|
kept.reserve(messages_.size());
|
||||||
|
for (auto& m : messages_) {
|
||||||
|
if (m.conversation_id == conversationId) {
|
||||||
|
seen_.erase(dedupKey(m));
|
||||||
|
removed.push_back(std::move(m));
|
||||||
|
} else {
|
||||||
|
kept.push_back(std::move(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
messages_.swap(kept);
|
||||||
|
if (!removed.empty()) ++revision_;
|
||||||
|
return removed;
|
||||||
|
}
|
||||||
|
|
||||||
void ChatStore::clear() {
|
void ChatStore::clear() {
|
||||||
messages_.clear();
|
messages_.clear();
|
||||||
seen_.clear();
|
seen_.clear();
|
||||||
|
++revision_;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
#include "chat_message.h"
|
#include "chat_message.h"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <unordered_set>
|
#include <unordered_set>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -28,15 +30,42 @@ public:
|
|||||||
// Distinct conversation ids, in first-seen order.
|
// Distinct conversation ids, in first-seen order.
|
||||||
std::vector<std::string> conversationIds() const;
|
std::vector<std::string> conversationIds() const;
|
||||||
|
|
||||||
|
// The set of on-chain txids that carried a chat message (sent or received, messages + contact
|
||||||
|
// requests) — used by the History tab to badge / filter chat transactions. O(messages), no copies.
|
||||||
|
std::unordered_set<std::string> chatTxids() const {
|
||||||
|
std::unordered_set<std::string> out;
|
||||||
|
out.reserve(messages_.size());
|
||||||
|
for (const auto& m : messages_)
|
||||||
|
if (!m.txid.empty()) out.insert(m.txid);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove every message in a conversation from the in-memory view and return the removed messages
|
||||||
|
// (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest
|
||||||
|
// guard (blocked-cid predicate / DB tombstone), not here.
|
||||||
|
std::vector<ChatMessage> eraseConversation(const std::string& conversationId);
|
||||||
|
|
||||||
std::size_t size() const { return messages_.size(); }
|
std::size_t size() const { return messages_.size(); }
|
||||||
bool empty() const { return messages_.empty(); }
|
bool empty() const { return messages_.empty(); }
|
||||||
void clear();
|
void clear();
|
||||||
|
|
||||||
|
// Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation /
|
||||||
|
// clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it
|
||||||
|
// so they only rebuild when the store actually changed.
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
|
|
||||||
|
// Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its
|
||||||
|
// cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation
|
||||||
|
// copy+sort that conversation() does — the count doesn't need ordering.
|
||||||
|
int countUnread(const std::function<bool(const std::string&)>& excluded,
|
||||||
|
const std::function<std::int64_t(const std::string&)>& seenFor) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static std::string dedupKey(const ChatMessage& message);
|
static std::string dedupKey(const ChatMessage& message);
|
||||||
|
|
||||||
std::vector<ChatMessage> messages_;
|
std::vector<ChatMessage> messages_;
|
||||||
std::unordered_set<std::string> seen_;
|
std::unordered_set<std::string> seen_;
|
||||||
|
std::uint64_t revision_ = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace dragonx::chat
|
} // namespace dragonx::chat
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ void loadClamped(const json& j, const char* key, T& field, T lo, T hi)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::vector<Settings::LiteServerPreference> Settings::defaultLiteServers()
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{"https://lite.dragonx.is", "DragonX Lite", true},
|
||||||
|
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
||||||
|
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
||||||
|
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
||||||
|
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
||||||
|
{"https://lite5.dragonx.is", "DragonX Lite 5", true},
|
||||||
|
{"https://lite6.dragonx.is", "DragonX Lite 6", true},
|
||||||
|
{"https://lite7.dragonx.is", "DragonX Lite 7", true}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
std::string Settings::getDefaultPath()
|
std::string Settings::getDefaultPath()
|
||||||
{
|
{
|
||||||
// Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the
|
// Single per-platform, per-variant config dir (util::Platform::getConfigDir handles the
|
||||||
@@ -157,6 +171,13 @@ bool Settings::load(const std::string& path)
|
|||||||
for (const auto& c : j["hidden_chat_cids"])
|
for (const auto& c : j["hidden_chat_cids"])
|
||||||
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
if (c.is_string()) hidden_chat_cids_.push_back(c.get<std::string>());
|
||||||
}
|
}
|
||||||
|
if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) {
|
||||||
|
blocked_chat_convs_.clear();
|
||||||
|
for (const auto& c : j["blocked_chat_convs"])
|
||||||
|
if (c.is_object() && c.contains("cid") && c["cid"].is_string())
|
||||||
|
blocked_chat_convs_.push_back({c["cid"].get<std::string>(),
|
||||||
|
c.value("name", std::string())});
|
||||||
|
}
|
||||||
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
// Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range).
|
||||||
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
loadScalar(j, "chat_emoji_color", chat_emoji_color_);
|
||||||
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_);
|
||||||
@@ -196,12 +217,15 @@ bool Settings::load(const std::string& path)
|
|||||||
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
|
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
|
||||||
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
|
loadScalar(j, "contacts_view_mode", contacts_view_mode_);
|
||||||
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
|
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
|
||||||
|
loadScalar(j, "contacts_avatar_shape", contacts_avatar_shape_); setContactsAvatarShape(contacts_avatar_shape_);
|
||||||
|
loadScalar(j, "contacts_list_scale", contacts_list_scale_); setContactsListScale(contacts_list_scale_);
|
||||||
loadScalar(j, "animate_avatars", animate_avatars_);
|
loadScalar(j, "animate_avatars", animate_avatars_);
|
||||||
loadScalar(j, "scanline_enabled", scanline_enabled_);
|
loadScalar(j, "scanline_enabled", scanline_enabled_);
|
||||||
loadScalar(j, "console_line_accents", console_line_accents_);
|
loadScalar(j, "console_line_accents", console_line_accents_);
|
||||||
loadScalar(j, "console_text_color", console_text_color_);
|
loadScalar(j, "console_text_color", console_text_color_);
|
||||||
loadScalar(j, "console_zoom", console_zoom_);
|
loadScalar(j, "console_zoom", console_zoom_);
|
||||||
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
|
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
|
||||||
|
loadScalar(j, "console_auto_focus", console_auto_focus_);
|
||||||
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
|
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
|
||||||
hidden_addresses_.clear();
|
hidden_addresses_.clear();
|
||||||
for (const auto& a : j["hidden_addresses"])
|
for (const auto& a : j["hidden_addresses"])
|
||||||
@@ -229,18 +253,28 @@ bool Settings::load(const std::string& path)
|
|||||||
}
|
}
|
||||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||||
|
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||||
|
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||||
|
empty_wallet_warning_acked_.clear();
|
||||||
|
for (const auto& w : j["empty_wallet_warning_acked"])
|
||||||
|
if (w.is_string()) empty_wallet_warning_acked_.insert(w.get<std::string>());
|
||||||
|
}
|
||||||
|
loadScalar(j, "encryption_pending", encryption_pending_);
|
||||||
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
||||||
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
||||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||||
|
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||||
loadScalar(j, "keep_daemon_running", keep_daemon_running_);
|
loadScalar(j, "keep_daemon_running", keep_daemon_running_);
|
||||||
loadScalar(j, "stop_external_daemon", stop_external_daemon_);
|
loadScalar(j, "stop_external_daemon", stop_external_daemon_);
|
||||||
loadScalar(j, "max_connections", max_connections_);
|
loadScalar(j, "max_connections", max_connections_);
|
||||||
|
loadScalar(j, "stratum_host_enabled", stratum_host_enabled_);
|
||||||
|
loadScalar(j, "stratum_allowip", stratum_allowip_);
|
||||||
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
|
if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) {
|
||||||
const auto& lite = j["lite_wallet"];
|
const auto& lite = j["lite_wallet"];
|
||||||
if (lite.contains("server_selection_mode")) {
|
if (lite.contains("server_selection_mode")) {
|
||||||
@@ -287,6 +321,16 @@ bool Settings::load(const std::string& path)
|
|||||||
}
|
}
|
||||||
lite_servers_.push_back(preference);
|
lite_servers_.push_back(preference);
|
||||||
}
|
}
|
||||||
|
// Surface newly-shipped default servers (e.g. lite6/lite7) on an existing
|
||||||
|
// install whose saved list predates them. Servers are hidden (see
|
||||||
|
// lite_hidden_servers_), never deleted, so appending a missing default won't
|
||||||
|
// resurrect one the user intentionally removed.
|
||||||
|
for (const auto& def : defaultLiteServers()) {
|
||||||
|
bool present = false;
|
||||||
|
for (const auto& s : lite_servers_)
|
||||||
|
if (s.url == def.url) { present = true; break; }
|
||||||
|
if (!present) lite_servers_.push_back(def);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
|
if (lite.contains("rollout_override") && lite["rollout_override"].is_string()) {
|
||||||
const auto v = lite["rollout_override"].get<std::string>();
|
const auto v = lite["rollout_override"].get<std::string>();
|
||||||
@@ -448,6 +492,13 @@ bool Settings::save(const std::string& path)
|
|||||||
j["hidden_chat_cids"] = json::array();
|
j["hidden_chat_cids"] = json::array();
|
||||||
for (const auto& c : hidden_chat_cids_)
|
for (const auto& c : hidden_chat_cids_)
|
||||||
j["hidden_chat_cids"].push_back(c);
|
j["hidden_chat_cids"].push_back(c);
|
||||||
|
j["blocked_chat_convs"] = json::array();
|
||||||
|
for (const auto& b : blocked_chat_convs_) {
|
||||||
|
json o;
|
||||||
|
o["cid"] = b.cid;
|
||||||
|
o["name"] = b.name;
|
||||||
|
j["blocked_chat_convs"].push_back(o);
|
||||||
|
}
|
||||||
j["chat_emoji_color"] = chat_emoji_color_;
|
j["chat_emoji_color"] = chat_emoji_color_;
|
||||||
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
j["chat_poll_rate_sec"] = chat_poll_rate_sec_;
|
||||||
j["chat_bubble_style"] = chat_bubble_style_;
|
j["chat_bubble_style"] = chat_bubble_style_;
|
||||||
@@ -467,11 +518,14 @@ bool Settings::save(const std::string& path)
|
|||||||
j["balance_layout"] = balance_layout_; // saved as string ID
|
j["balance_layout"] = balance_layout_; // saved as string ID
|
||||||
j["portfolio_style"] = portfolio_style_;
|
j["portfolio_style"] = portfolio_style_;
|
||||||
j["contacts_view_mode"] = contacts_view_mode_;
|
j["contacts_view_mode"] = contacts_view_mode_;
|
||||||
|
j["contacts_avatar_shape"] = contacts_avatar_shape_;
|
||||||
|
j["contacts_list_scale"] = contacts_list_scale_;
|
||||||
j["animate_avatars"] = animate_avatars_;
|
j["animate_avatars"] = animate_avatars_;
|
||||||
j["scanline_enabled"] = scanline_enabled_;
|
j["scanline_enabled"] = scanline_enabled_;
|
||||||
j["console_line_accents"] = console_line_accents_;
|
j["console_line_accents"] = console_line_accents_;
|
||||||
j["console_text_color"] = console_text_color_;
|
j["console_text_color"] = console_text_color_;
|
||||||
j["console_zoom"] = console_zoom_;
|
j["console_zoom"] = console_zoom_;
|
||||||
|
j["console_auto_focus"] = console_auto_focus_;
|
||||||
j["hidden_addresses"] = json::array();
|
j["hidden_addresses"] = json::array();
|
||||||
for (const auto& addr : hidden_addresses_)
|
for (const auto& addr : hidden_addresses_)
|
||||||
j["hidden_addresses"].push_back(addr);
|
j["hidden_addresses"].push_back(addr);
|
||||||
@@ -493,18 +547,26 @@ bool Settings::save(const std::string& path)
|
|||||||
}
|
}
|
||||||
j["wizard_completed"] = wizard_completed_;
|
j["wizard_completed"] = wizard_completed_;
|
||||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||||
|
j["large_wallet_warned"] = large_wallet_warned_;
|
||||||
|
j["empty_wallet_warning_acked"] = json::array();
|
||||||
|
for (const auto& w : empty_wallet_warning_acked_)
|
||||||
|
j["empty_wallet_warning_acked"].push_back(w);
|
||||||
|
j["encryption_pending"] = encryption_pending_;
|
||||||
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
||||||
j["active_wallet_file"] = active_wallet_file_;
|
j["active_wallet_file"] = active_wallet_file_;
|
||||||
j["seed_migration_pending"] = seed_migration_pending_;
|
j["seed_migration_pending"] = seed_migration_pending_;
|
||||||
j["seed_migration_dest"] = seed_migration_dest_;
|
j["seed_migration_dest"] = seed_migration_dest_;
|
||||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||||
|
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||||
j["unlock_duration"] = unlock_duration_;
|
j["unlock_duration"] = unlock_duration_;
|
||||||
j["pin_enabled"] = pin_enabled_;
|
j["pin_enabled"] = pin_enabled_;
|
||||||
j["keep_daemon_running"] = keep_daemon_running_;
|
j["keep_daemon_running"] = keep_daemon_running_;
|
||||||
j["stop_external_daemon"] = stop_external_daemon_;
|
j["stop_external_daemon"] = stop_external_daemon_;
|
||||||
j["max_connections"] = max_connections_;
|
j["max_connections"] = max_connections_;
|
||||||
|
j["stratum_host_enabled"] = stratum_host_enabled_;
|
||||||
|
j["stratum_allowip"] = stratum_allowip_;
|
||||||
{
|
{
|
||||||
json lite = json::object();
|
json lite = json::object();
|
||||||
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);
|
lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_);
|
||||||
|
|||||||
@@ -91,7 +91,8 @@ public:
|
|||||||
bool showValue = true; // show the converted/fiat value on the card
|
bool showValue = true; // show the converted/fiat value on the card
|
||||||
bool show24h = false; // show the 24h % change (live-market bases only)
|
bool show24h = false; // show the 24h % change (live-market bases only)
|
||||||
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
|
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
|
||||||
int sparklineInterval = 0; // 0=minute 1=hour 2=day 3=week 4=month (resample of price history)
|
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
|
||||||
|
// from the daily series, vs the young in-session minute buffer)
|
||||||
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
|
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
|
||||||
// hash (shown only when that wallet is active). New entries are tagged with the current
|
// hash (shown only when that wallet is active). New entries are tagged with the current
|
||||||
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
|
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
|
||||||
@@ -143,6 +144,26 @@ public:
|
|||||||
hidden_chat_cids_.end());
|
hidden_chat_cids_.end());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the
|
||||||
|
// local history AND suppresses every message for the cid — old and future — until you unblock, at
|
||||||
|
// which point the conversation re-imports from the chain. The last-known peer name is kept so the
|
||||||
|
// "Blocked" list can label the entry (its messages are gone from the local store).
|
||||||
|
struct BlockedChatConv { std::string cid; std::string name; };
|
||||||
|
bool isChatBlocked(const std::string& cid) const {
|
||||||
|
for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) {
|
||||||
|
const bool already = isChatBlocked(cid);
|
||||||
|
if (blocked && !already) blocked_chat_convs_.push_back({cid, name});
|
||||||
|
else if (!blocked && already)
|
||||||
|
blocked_chat_convs_.erase(
|
||||||
|
std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(),
|
||||||
|
[&](const BlockedChatConv& b) { return b.cid == cid; }),
|
||||||
|
blocked_chat_convs_.end());
|
||||||
|
}
|
||||||
|
const std::vector<BlockedChatConv>& blockedChatConversations() const { return blocked_chat_convs_; }
|
||||||
|
|
||||||
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
// ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ──────
|
||||||
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
bool getChatEmojiColor() const { return chat_emoji_color_; }
|
||||||
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
void setChatEmojiColor(bool v) { chat_emoji_color_ = v; }
|
||||||
@@ -226,13 +247,19 @@ public:
|
|||||||
std::string getBalanceLayout() const { return balance_layout_; }
|
std::string getBalanceLayout() const { return balance_layout_; }
|
||||||
void setBalanceLayout(const std::string& v) { balance_layout_ = v; }
|
void setBalanceLayout(const std::string& v) { balance_layout_ = v; }
|
||||||
|
|
||||||
// Market-tab portfolio row style: 0 = single-line, 1 = two-line, 2 = value-hero. Cycled with
|
// Market-tab portfolio row style: 0 = Table (borderless grid), 1 = Cards (glass card + Z/T bar),
|
||||||
// Left/Right arrows on the Market tab (like the Overview layouts).
|
// 2 = Spotlight (hero value). Cycled with Left/Right arrows or set in the Market settings modal.
|
||||||
int getPortfolioStyle() const { return portfolio_style_; }
|
int getPortfolioStyle() const { return portfolio_style_; }
|
||||||
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
|
void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; }
|
||||||
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
|
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
|
||||||
int getContactsViewMode() const { return contacts_view_mode_; }
|
int getContactsViewMode() const { return contacts_view_mode_; }
|
||||||
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
|
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
|
||||||
|
// Contacts customization (gear modal): avatar shape + card/list row scale.
|
||||||
|
// Avatar shape: 0 = circle, 1 = rounded square, 2 = full-row-height left tab (rounded-left, flat right).
|
||||||
|
int getContactsAvatarShape() const { return contacts_avatar_shape_; }
|
||||||
|
void setContactsAvatarShape(int v) { contacts_avatar_shape_ = (v < 0 || v > 2) ? 0 : v; }
|
||||||
|
float getContactsListScale() const { return contacts_list_scale_; }
|
||||||
|
void setContactsListScale(float v) { contacts_list_scale_ = std::max(0.8f, std::min(1.5f, v)); }
|
||||||
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
|
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
|
||||||
bool getAnimateAvatars() const { return animate_avatars_; }
|
bool getAnimateAvatars() const { return animate_avatars_; }
|
||||||
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
|
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
|
||||||
@@ -249,6 +276,9 @@ public:
|
|||||||
void setConsoleTextColor(bool v) { console_text_color_ = v; }
|
void setConsoleTextColor(bool v) { console_text_color_ = v; }
|
||||||
float getConsoleZoom() const { return console_zoom_; }
|
float getConsoleZoom() const { return console_zoom_; }
|
||||||
void setConsoleZoom(float v) { console_zoom_ = v; }
|
void setConsoleZoom(float v) { console_zoom_ = v; }
|
||||||
|
// Auto-place the text cursor in the command box when the Console tab is opened.
|
||||||
|
bool getConsoleAutoFocus() const { return console_auto_focus_; }
|
||||||
|
void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
|
||||||
|
|
||||||
// Hidden addresses (addresses hidden from the UI by the user)
|
// Hidden addresses (addresses hidden from the UI by the user)
|
||||||
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
|
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
|
||||||
@@ -320,6 +350,24 @@ public:
|
|||||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||||
|
|
||||||
|
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||||
|
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||||
|
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
|
||||||
|
|
||||||
|
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
|
||||||
|
// warning has been dismissed. Keyed per active wallet file so switching to a different empty
|
||||||
|
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
|
||||||
|
bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
|
||||||
|
return empty_wallet_warning_acked_.count(walletFile) > 0;
|
||||||
|
}
|
||||||
|
void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
|
||||||
|
|
||||||
|
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
|
||||||
|
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
|
||||||
|
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
|
||||||
|
bool getEncryptionPending() const { return encryption_pending_; }
|
||||||
|
void setEncryptionPending(bool v) { encryption_pending_ = v; }
|
||||||
|
|
||||||
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
|
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
|
||||||
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
|
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
|
||||||
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
||||||
@@ -343,6 +391,11 @@ public:
|
|||||||
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
||||||
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
||||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
||||||
|
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
|
||||||
|
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
|
||||||
|
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||||
|
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||||
|
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||||
|
|
||||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||||
@@ -367,6 +420,11 @@ public:
|
|||||||
// Daemon — maximum peer connections (0 = daemon default)
|
// Daemon — maximum peer connections (0 = daemon default)
|
||||||
int getMaxConnections() const { return max_connections_; }
|
int getMaxConnections() const { return max_connections_; }
|
||||||
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
|
void setMaxConnections(int v) { max_connections_ = std::max(0, v); }
|
||||||
|
// Host a RandomX stratum pool from the node (v1.3.0+ daemons). Empty allow-IP = loopback only (safe).
|
||||||
|
bool getStratumHost() const { return stratum_host_enabled_; }
|
||||||
|
void setStratumHost(bool v) { stratum_host_enabled_ = v; }
|
||||||
|
const std::string& getStratumAllowIp() const { return stratum_allowip_; }
|
||||||
|
void setStratumAllowIp(const std::string& v) { stratum_allowip_ = v; }
|
||||||
|
|
||||||
// Lite wallet server selection
|
// Lite wallet server selection
|
||||||
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
|
LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; }
|
||||||
@@ -381,6 +439,9 @@ public:
|
|||||||
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
|
void setLitePersistSelectedServer(bool persist) { lite_persist_selected_server_ = persist; }
|
||||||
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
|
||||||
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
|
||||||
|
// Servers shipped as defaults. Also merged into an existing install's saved list on load
|
||||||
|
// (see Settings::load), so upgrades surface newly-added servers (e.g. lite6/lite7).
|
||||||
|
static std::vector<LiteServerPreference> defaultLiteServers();
|
||||||
|
|
||||||
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
|
||||||
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
|
||||||
@@ -521,6 +582,7 @@ private:
|
|||||||
std::string chat_reply_zaddr_;
|
std::string chat_reply_zaddr_;
|
||||||
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
std::vector<std::string> muted_chat_cids_; // muted chat conversations by cid (Q10)
|
||||||
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
std::vector<std::string> hidden_chat_cids_; // hidden chat conversations by cid
|
||||||
|
std::vector<BlockedChatConv> blocked_chat_convs_; // blocked chat conversations (cid + last-known name)
|
||||||
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
// Chat-tab customization (chat settings modal + Settings → Chat & Contacts).
|
||||||
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome
|
||||||
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node)
|
||||||
@@ -553,30 +615,39 @@ private:
|
|||||||
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
|
float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque
|
||||||
#endif
|
#endif
|
||||||
std::string balance_layout_ = "classic";
|
std::string balance_layout_ = "classic";
|
||||||
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
|
int portfolio_style_ = 0; // Market portfolio row style (0 Table / 1 Cards / 2 Spotlight)
|
||||||
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
|
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
|
||||||
|
int contacts_avatar_shape_ = 0; // 0 = circle, 1 = rounded square, 2 = full-height left tab
|
||||||
|
float contacts_list_scale_ = 1.0f; // card/list row scale (does not affect the table view)
|
||||||
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
|
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
|
||||||
bool scanline_enabled_ = true;
|
bool scanline_enabled_ = true;
|
||||||
bool console_line_accents_ = true; // left color accent bars in console output
|
bool console_line_accents_ = true; // left color accent bars in console output
|
||||||
bool console_text_color_ = true; // per-channel text coloring in console output
|
bool console_text_color_ = true; // per-channel text coloring in console output
|
||||||
float console_zoom_ = 1.0f; // console output font zoom factor
|
float console_zoom_ = 1.0f; // console output font zoom factor
|
||||||
|
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
|
||||||
std::set<std::string> hidden_addresses_;
|
std::set<std::string> hidden_addresses_;
|
||||||
std::set<std::string> favorite_addresses_;
|
std::set<std::string> favorite_addresses_;
|
||||||
std::map<std::string, AddressMeta> address_meta_;
|
std::map<std::string, AddressMeta> address_meta_;
|
||||||
bool wizard_completed_ = false;
|
bool wizard_completed_ = false;
|
||||||
bool seed_backup_reminded_ = false;
|
bool seed_backup_reminded_ = false;
|
||||||
|
bool large_wallet_warned_ = false;
|
||||||
|
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||||
|
bool encryption_pending_ = false;
|
||||||
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
||||||
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
||||||
bool seed_migration_pending_ = false;
|
bool seed_migration_pending_ = false;
|
||||||
std::string seed_migration_dest_;
|
std::string seed_migration_dest_;
|
||||||
std::string seed_migration_temp_dir_;
|
std::string seed_migration_temp_dir_;
|
||||||
std::string seed_migration_sweep_txid_;
|
std::string seed_migration_sweep_txid_;
|
||||||
|
std::string seed_migration_sweep_opid_;
|
||||||
int auto_lock_timeout_ = 900; // 15 minutes
|
int auto_lock_timeout_ = 900; // 15 minutes
|
||||||
int unlock_duration_ = 600; // 10 minutes
|
int unlock_duration_ = 600; // 10 minutes
|
||||||
bool pin_enabled_ = false;
|
bool pin_enabled_ = false;
|
||||||
bool keep_daemon_running_ = false;
|
bool keep_daemon_running_ = false;
|
||||||
bool stop_external_daemon_ = false;
|
bool stop_external_daemon_ = false;
|
||||||
int max_connections_ = 0; // 0 = daemon default
|
int max_connections_ = 0; // 0 = daemon default
|
||||||
|
bool stratum_host_enabled_ = false; // host a RandomX stratum pool from the node (v1.3.0+ daemons)
|
||||||
|
std::string stratum_allowip_; // -stratumallowip filter (empty = daemon default: loopback only)
|
||||||
|
|
||||||
// Lite wallet server preferences. These are user/server settings only;
|
// Lite wallet server preferences. These are user/server settings only;
|
||||||
// wallet secrets, wallet files, and lifecycle state are never stored here.
|
// wallet secrets, wallet files, and lifecycle state are never stored here.
|
||||||
@@ -587,14 +658,7 @@ private:
|
|||||||
bool lite_persist_selected_server_ = true;
|
bool lite_persist_selected_server_ = true;
|
||||||
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
|
std::string lite_rollout_override_ = "auto"; // auto|force_on|force_off
|
||||||
std::string lite_install_id_; // random local-only id; rollout-bucket source
|
std::string lite_install_id_; // random local-only id; rollout-bucket source
|
||||||
std::vector<LiteServerPreference> lite_servers_ = {
|
std::vector<LiteServerPreference> lite_servers_ = defaultLiteServers();
|
||||||
{"https://lite.dragonx.is", "DragonX Lite", true},
|
|
||||||
{"https://lite1.dragonx.is", "DragonX Lite 1", true},
|
|
||||||
{"https://lite2.dragonx.is", "DragonX Lite 2", true},
|
|
||||||
{"https://lite3.dragonx.is", "DragonX Lite 3", true},
|
|
||||||
{"https://lite4.dragonx.is", "DragonX Lite 4", true},
|
|
||||||
{"https://lite5.dragonx.is", "DragonX Lite 5", true}
|
|
||||||
};
|
|
||||||
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
|
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
|
||||||
|
|
||||||
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
|
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ void DaemonController::syncSettings(const config::Settings* settings)
|
|||||||
if (!settings) return;
|
if (!settings) return;
|
||||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||||
|
daemon_->setStratumHosting(settings->getStratumHost(), settings->getStratumAllowIp());
|
||||||
|
|
||||||
std::string walletFile = settings->getActiveWalletFile();
|
std::string walletFile = settings->getActiveWalletFile();
|
||||||
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
|
// The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a
|
||||||
@@ -71,9 +72,11 @@ DaemonController::State DaemonController::state() const
|
|||||||
return daemon_->getState();
|
return daemon_->getState();
|
||||||
}
|
}
|
||||||
|
|
||||||
const std::string& DaemonController::lastError() const
|
std::string DaemonController::lastError() const
|
||||||
{
|
{
|
||||||
return daemon_->getLastError();
|
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
|
||||||
|
// dangle (bind a reference to that temporary). (M-04 follow-through)
|
||||||
|
return daemon_ ? daemon_->getLastError() : std::string();
|
||||||
}
|
}
|
||||||
|
|
||||||
int DaemonController::crashCount() const
|
int DaemonController::crashCount() const
|
||||||
@@ -126,6 +129,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
|||||||
daemon_->setSalvageOnNextStart(enabled);
|
daemon_->setSalvageOnNextStart(enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||||
|
{
|
||||||
|
daemon_->setReindexOnNextStart(enabled);
|
||||||
|
}
|
||||||
|
|
||||||
bool DaemonController::zapOnNextStart() const
|
bool DaemonController::zapOnNextStart() const
|
||||||
{
|
{
|
||||||
return daemon_->zapOnNextStart();
|
return daemon_->zapOnNextStart();
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public:
|
|||||||
bool externalDaemonDetected() const;
|
bool externalDaemonDetected() const;
|
||||||
void clearExternalDaemonDetected();
|
void clearExternalDaemonDetected();
|
||||||
State state() const;
|
State state() const;
|
||||||
const std::string& lastError() const;
|
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
|
||||||
int crashCount() const;
|
int crashCount() const;
|
||||||
int lastBlockHeight() const;
|
int lastBlockHeight() const;
|
||||||
double memoryUsageMB() const;
|
double memoryUsageMB() const;
|
||||||
@@ -108,6 +108,7 @@ public:
|
|||||||
void setZapOnNextStart(bool enabled);
|
void setZapOnNextStart(bool enabled);
|
||||||
bool zapOnNextStart() const;
|
bool zapOnNextStart() const;
|
||||||
void setSalvageOnNextStart(bool enabled);
|
void setSalvageOnNextStart(bool enabled);
|
||||||
|
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
|
||||||
|
|
||||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||||
bool externalDaemonDetected,
|
bool externalDaemonDetected,
|
||||||
|
|||||||
107
src/daemon/daemon_startup_diagnosis.h
Normal file
107
src/daemon/daemon_startup_diagnosis.h
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// daemon_startup_diagnosis.h — pure classifiers over a crashed daemon's captured console output,
|
||||||
|
// so the app can offer a targeted one-click fix instead of a bare "daemon crashed" / silent no-funds.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace daemon {
|
||||||
|
|
||||||
|
// True when dragonxd aborted because its BLOCK DATABASE could not be loaded — either a
|
||||||
|
// daemon-vs-chaindata serialization-format mismatch after a daemon update (the deterministic
|
||||||
|
// "non-canonical optional discriminant" → "Error loading block database" → "Aborted block database
|
||||||
|
// rebuild. Exiting." sequence) or a genuinely corrupt/incomplete block index. In BOTH cases the fix
|
||||||
|
// is the same: `-reindex` rebuilds the index + chainstate from the intact raw blocks (blk*.dat).
|
||||||
|
// This is what otherwise silently presents as a wallet with zero balance — the node never starts.
|
||||||
|
inline bool blockDbOutputLooksBroken(const std::string& out)
|
||||||
|
{
|
||||||
|
return out.find("Error loading block database") != std::string::npos
|
||||||
|
|| out.find("non-canonical optional discriminant") != std::string::npos
|
||||||
|
|| out.find("Aborted block database rebuild") != std::string::npos
|
||||||
|
|| out.find("LoadBlockIndex()") != std::string::npos; // "... : failed to read value"
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when dragonxd AUTO-RECOVERED the wallet on startup: on any BDB-verify failure it moves the
|
||||||
|
// original wallet.dat to "wallet.{timestamp}.bak", salvages readable keys into a fresh wallet.dat, and
|
||||||
|
// keeps running — no flag required (CWallet::Verify → CDBEnv::Verify(walletFile, CWalletDB::Recover)).
|
||||||
|
// The salvage can be incomplete (or a false positive from stale/cross-platform BDB env state), so the
|
||||||
|
// node silently comes up on a possibly-empty wallet — which reads as fund loss unless we surface it.
|
||||||
|
inline bool walletAutoRecovered(const std::string& out)
|
||||||
|
{
|
||||||
|
// Cover BOTH salvage outcomes. A successful salvage prints the "data salvaged"/"saved as wallet.<ts>.bak"
|
||||||
|
// warning; a FAILED one (e.g. an inconsistent-but-readable file where aggressive salvage finds no
|
||||||
|
// records) prints "salvage failed"/"found no records". In every case CWalletDB::Recover first logs
|
||||||
|
// "Renamed <wallet> to wallet.<ts>.bak" and CDBEnv::Salvage logs its own banner — those two fire the
|
||||||
|
// instant a salvage begins, before the daemon may abort, so they're the earliest reliable signal.
|
||||||
|
return out.find("CDBEnv::Salvage") != std::string::npos // salvage is running
|
||||||
|
|| out.find("wallet.dat corrupt, data salvaged") != std::string::npos // RECOVER_OK
|
||||||
|
|| out.find("Original wallet.dat saved as wallet.") != std::string::npos
|
||||||
|
|| out.find("wallet.dat corrupt, salvage failed") != std::string::npos // RECOVER_FAIL
|
||||||
|
|| out.find("found no records in wallet") != std::string::npos // aggressive salvage empty
|
||||||
|
|| (out.find("Renamed ") != std::string::npos && out.find(" to wallet.") != std::string::npos
|
||||||
|
&& out.find(".bak") != std::string::npos); // Recover moved wallet.dat aside
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when dragonxd (v1.3.0+) opened the wallet in DEGRADED mode: a wallet.dat that lost its hdchain
|
||||||
|
// record (e.g. an old `-salvagewallet` output) now OPENS — existing keys stay intact and spendable —
|
||||||
|
// instead of aborting, but the daemon can no longer derive NEW HD keys, so z_getnewaddress /
|
||||||
|
// z_shieldcoinbase / a t->z z_sendmany fail with "HD seed not found". The only signal is a startup log
|
||||||
|
// line; pre-1.3.0 daemons never emit it, so this classifier is naturally a no-op against them.
|
||||||
|
inline bool walletOpenedDegraded(const std::string& out)
|
||||||
|
{
|
||||||
|
return out.find("Wallet opened in DEGRADED mode") != std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If `name` is a daemon salvage backup "wallet.<unixtime>.bak", return its timestamp; else -1.
|
||||||
|
inline long long parseWalletSalvageBakTs(const std::string& name)
|
||||||
|
{
|
||||||
|
if (name.rfind("wallet.", 0) != 0) return -1; // must start "wallet."
|
||||||
|
if (name.size() < 12 || name.compare(name.size() - 4, 4, ".bak") != 0) return -1; // ...and end ".bak"
|
||||||
|
const std::string mid = name.substr(7, name.size() - 7 - 4); // digits between the dots
|
||||||
|
if (mid.empty() || mid.size() > 18) return -1;
|
||||||
|
for (char c : mid) if (c < '0' || c > '9') return -1;
|
||||||
|
long long ts = 0;
|
||||||
|
for (char c : mid) ts = ts * 10 + (c - '0');
|
||||||
|
return ts;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Most RECENT salvage backup (highest timestamp). Pure, testable.
|
||||||
|
inline std::string newestWalletSalvageBak(const std::vector<std::string>& filenames)
|
||||||
|
{
|
||||||
|
long long best = -1;
|
||||||
|
std::string bestName;
|
||||||
|
for (const auto& f : filenames) {
|
||||||
|
const long long ts = parseWalletSalvageBakTs(f);
|
||||||
|
if (ts > best) { best = ts; bestName = f; }
|
||||||
|
}
|
||||||
|
return bestName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LARGEST salvage backup, from (filename, fileSize) pairs — the least-salvaged one, i.e. the original.
|
||||||
|
// This is what "Restore original wallet" should use: a salvage CASCADE shrinks the wallet each round, so
|
||||||
|
// the newest .bak is the WORST and the largest is the pristine pre-salvage original (an emptied salvage
|
||||||
|
// is tiny; a real wallet is large). Ties break toward the newest timestamp. Returns "" if none present.
|
||||||
|
inline std::string largestWalletSalvageBak(const std::vector<std::pair<std::string, unsigned long long>>& files)
|
||||||
|
{
|
||||||
|
std::string bestName;
|
||||||
|
unsigned long long bestSize = 0;
|
||||||
|
long long bestTs = -1;
|
||||||
|
for (const auto& fp : files) {
|
||||||
|
const long long ts = parseWalletSalvageBakTs(fp.first);
|
||||||
|
if (ts < 0) continue;
|
||||||
|
if (fp.second > bestSize || (fp.second == bestSize && ts > bestTs)) {
|
||||||
|
bestSize = fp.second; bestTs = ts; bestName = fp.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bestName;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace daemon
|
||||||
|
} // namespace dragonx
|
||||||
@@ -205,11 +205,13 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
|||||||
"-ac_reward=300000000",
|
"-ac_reward=300000000",
|
||||||
"-ac_blocktime=36",
|
"-ac_blocktime=36",
|
||||||
"-ac_private=1",
|
"-ac_private=1",
|
||||||
"-addnode=node.dragonx.is",
|
// Seeds: seed.dragonx.is is a round-robin A record over the live seed set (self-updates without
|
||||||
|
// a wallet release), with node1/node5 as static fallbacks — mirrors the daemon's own vSeeds.
|
||||||
|
// Plain -addnode hostname resolution works on EVERY daemon version, and is load-bearing for
|
||||||
|
// pre-1.3.0 daemons whose built-in peer discovery was broken (they rely on these to find peers).
|
||||||
|
"-addnode=seed.dragonx.is",
|
||||||
"-addnode=node1.dragonx.is",
|
"-addnode=node1.dragonx.is",
|
||||||
"-addnode=node2.dragonx.is",
|
"-addnode=node5.dragonx.is",
|
||||||
"-addnode=node3.dragonx.is",
|
|
||||||
"-addnode=node4.dragonx.is",
|
|
||||||
"-experimentalfeatures",
|
"-experimentalfeatures",
|
||||||
"-developerencryptwallet",
|
"-developerencryptwallet",
|
||||||
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
|
// Create fresh wallets from a BIP39 mnemonic so their 24-word phrase can be
|
||||||
@@ -224,12 +226,11 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
|||||||
void EmbeddedDaemon::setState(State s, const std::string& message)
|
void EmbeddedDaemon::setState(State s, const std::string& message)
|
||||||
{
|
{
|
||||||
state_ = s;
|
state_ = s;
|
||||||
if (!message.empty()) {
|
if (!message.empty() && s == State::Error) {
|
||||||
if (s == State::Error) {
|
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
|
||||||
last_error_ = message;
|
last_error_ = message;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state_callback_) {
|
if (state_callback_) {
|
||||||
state_callback_(s, message);
|
state_callback_(s, message);
|
||||||
}
|
}
|
||||||
@@ -488,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
external_daemon_detected_ = false;
|
external_daemon_detected_ = false;
|
||||||
|
|
||||||
|
// A previous dragonxd can release the RPC port well before it releases the datadir
|
||||||
|
// .lock — a graceful shutdown can take up to ~90s (see isDaemonProcessRunning). Starting
|
||||||
|
// into a still-held lock spawns a process that dies instantly with "Cannot obtain a lock
|
||||||
|
// on data directory"; the crash monitor reports that generically and, three times in
|
||||||
|
// ~12s, that is enough to trip the 3-strike restart cap before the lock's ~90s life
|
||||||
|
// elapses. Gate on the process actually still being alive, with a SHORT bounded wait
|
||||||
|
// (not the full ~90s — start() runs on the UI thread). Isolated starts (migrate-to-seed:
|
||||||
|
// skip_port_check_ / -datadir override) are exempt; they run their own datadir+port.
|
||||||
|
{
|
||||||
|
constexpr int kDatadirLockWaitPollMs = 100;
|
||||||
|
constexpr int kDatadirLockWaitMaxPolls = 3; // ~300ms total, breaks early on exit
|
||||||
|
bool stillRunning = false;
|
||||||
|
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||||
|
stillRunning = isDaemonProcessRunning();
|
||||||
|
for (int i = 0; stillRunning && i < kDatadirLockWaitMaxPolls; ++i) {
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||||
|
stillRunning = isDaemonProcessRunning();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const StartLockGateDecision gate =
|
||||||
|
evaluateDatadirLockGate(skip_port_check_, !override_datadir_.empty(), stillRunning);
|
||||||
|
if (!gate.proceed) {
|
||||||
|
VERBOSE_LOGF("[INFO] %s\n", gate.errorMessage);
|
||||||
|
setState(State::Error, gate.errorMessage);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setState(State::Starting, "Looking for dragonxd binary...");
|
setState(State::Starting, "Looking for dragonxd binary...");
|
||||||
|
|
||||||
@@ -516,6 +545,15 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
args.push_back("-maxconnections=" + std::to_string(max_connections_));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older
|
||||||
|
// ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon
|
||||||
|
// version, so this is only enabled against a daemon that supports it. Without -stratumallowip the
|
||||||
|
// daemon serves loopback only (safe default); a subnet opens it to that LAN.
|
||||||
|
if (stratum_enabled_) {
|
||||||
|
args.push_back("-stratum");
|
||||||
|
if (!stratum_allowip_.empty()) args.push_back("-stratumallowip=" + stratum_allowip_);
|
||||||
|
}
|
||||||
|
|
||||||
// Active wallet file (multi-wallet). The daemon loads <datadir>/<name>. Only pass it for a
|
// Active wallet file (multi-wallet). The daemon loads <datadir>/<name>. Only pass it for a
|
||||||
// non-default name so the common case's command line is unchanged; skip during an isolated
|
// non-default name so the common case's command line is unchanged; skip during an isolated
|
||||||
// start (seed migration manages its own throwaway wallet).
|
// start (seed migration manages its own throwaway wallet).
|
||||||
@@ -543,6 +581,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
args.push_back("-rescan");
|
args.push_back("-rescan");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -reindex rebuilds the block index + chainstate from the raw blocks (fixes an unreadable/format-
|
||||||
|
// mismatched block DB). It's about the CHAIN, not the wallet, so it's independent of the wallet-repair
|
||||||
|
// chain above (and implies its own wallet rescan). One-shot, consumed here.
|
||||||
|
if (reindex_on_next_start_.exchange(false)) {
|
||||||
|
DEBUG_LOGF("[INFO] Adding -reindex flag to rebuild the block database from raw blocks\n");
|
||||||
|
args.push_back("-reindex");
|
||||||
|
}
|
||||||
|
|
||||||
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
|
// One-shot isolated-datadir override (migrate-to-seed flow): run this start against a
|
||||||
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
|
// throwaway datadir, plus any extra args (e.g. -connect=0). Consumed here so later starts
|
||||||
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
|
// revert to the normal datadir. The datadir's basename MUST be the assetchain name (DRAGONX)
|
||||||
@@ -557,8 +603,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
override_extra_args_.clear();
|
override_extra_args_.clear();
|
||||||
|
|
||||||
if (!startProcess(daemon_path, args)) {
|
if (!startProcess(daemon_path, args)) {
|
||||||
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
|
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||||
setState(State::Error, "Failed to start dragonxd process");
|
// ... not executable or wrong architecture"). Surface THAT via setState — which also
|
||||||
|
// stores the Error message into last_error_ — instead of clobbering it with a generic
|
||||||
|
// string that would then be all getLastError()/the UI ever sees.
|
||||||
|
std::string detail = last_error_.empty() ? std::string("Failed to start dragonxd process")
|
||||||
|
: last_error_;
|
||||||
|
DEBUG_LOGF("[ERROR] %s\n", detail.c_str());
|
||||||
|
setState(State::Error, detail);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,12 +631,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
|||||||
// Forward declaration — defined after startProcess
|
// Forward declaration — defined after startProcess
|
||||||
static DWORD findProcessByName(const char* name);
|
static DWORD findProcessByName(const char* name);
|
||||||
|
|
||||||
|
// Quote a single argument per the CommandLineToArgvW rules (MSDN) so a value containing a space or a
|
||||||
|
// quote is delivered as ONE argv token to the daemon instead of splitting/corrupting argv (L-02).
|
||||||
|
static std::string quoteWinArg(const std::string& arg) {
|
||||||
|
if (!arg.empty() && arg.find_first_of(" \t\n\v\"") == std::string::npos) return arg;
|
||||||
|
std::string out = "\"";
|
||||||
|
for (size_t i = 0; ; ++i) {
|
||||||
|
size_t nbs = 0;
|
||||||
|
while (i < arg.size() && arg[i] == '\\') { ++nbs; ++i; }
|
||||||
|
if (i == arg.size()) { out.append(nbs * 2, '\\'); break; }
|
||||||
|
if (arg[i] == '"') { out.append(nbs * 2 + 1, '\\'); out.push_back('"'); }
|
||||||
|
else { out.append(nbs, '\\'); out.push_back(arg[i]); }
|
||||||
|
}
|
||||||
|
out.push_back('"');
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
|
bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vector<std::string>& args)
|
||||||
{
|
{
|
||||||
// Build command line
|
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
|
||||||
std::string cmd = "\"" + binary_path + "\"";
|
std::string cmd = "\"" + binary_path + "\"";
|
||||||
for (const auto& arg : args) {
|
for (const auto& arg : args) {
|
||||||
cmd += " " + arg;
|
cmd += " " + quoteWinArg(arg);
|
||||||
}
|
}
|
||||||
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
|
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
|
||||||
|
|
||||||
@@ -632,7 +700,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
debug_log_path_.c_str(), debug_log_offset_);
|
debug_log_path_.c_str(), debug_log_offset_);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Launch daemon with CREATE_NEW_CONSOLE (hidden via SW_HIDE).
|
// Launch daemon windowless. Use CREATE_NO_WINDOW (NOT CREATE_NEW_CONSOLE): CREATE_NEW_CONSOLE
|
||||||
|
// allocates a console window that briefly flashes on screen before SW_HIDE can hide it, which is
|
||||||
|
// visible as a console-window flash on wallet launch. CREATE_NO_WINDOW gives the console child no
|
||||||
|
// window at all (same approach as the xmrig launcher). The daemon logs to debug.log, not a console.
|
||||||
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
|
// The daemon binary must NOT be in the data directory (%APPDATA%\Hush\DRAGONX)
|
||||||
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
|
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
|
||||||
STARTUPINFOA si;
|
STARTUPINFOA si;
|
||||||
@@ -642,7 +713,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
si.dwFlags = STARTF_USESHOWWINDOW;
|
si.dwFlags = STARTF_USESHOWWINDOW;
|
||||||
si.wShowWindow = SW_HIDE;
|
si.wShowWindow = SW_HIDE;
|
||||||
ZeroMemory(&pi, sizeof(pi));
|
ZeroMemory(&pi, sizeof(pi));
|
||||||
|
|
||||||
char* cmd_line = _strdup(cmd.c_str());
|
char* cmd_line = _strdup(cmd.c_str());
|
||||||
BOOL success = CreateProcessA(
|
BOOL success = CreateProcessA(
|
||||||
NULL,
|
NULL,
|
||||||
@@ -650,7 +721,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
FALSE,
|
FALSE,
|
||||||
CREATE_NEW_CONSOLE,
|
CREATE_NO_WINDOW,
|
||||||
NULL,
|
NULL,
|
||||||
work_dir.c_str(),
|
work_dir.c_str(),
|
||||||
&si,
|
&si,
|
||||||
@@ -745,11 +816,21 @@ void EmbeddedDaemon::drainOutput()
|
|||||||
}
|
}
|
||||||
|
|
||||||
size_t currentSize = static_cast<size_t>(fileSize.QuadPart);
|
size_t currentSize = static_cast<size_t>(fileSize.QuadPart);
|
||||||
|
// Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps
|
||||||
|
// only the tail once it has grown large), so the file can become SMALLER than where we left off.
|
||||||
|
// Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead
|
||||||
|
// of the freshly-truncated file and we read NOTHING for the whole session — no block height (status
|
||||||
|
// bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling
|
||||||
|
// Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink,
|
||||||
|
// restart from the new beginning; the parser is monotonic and converges as it reaches current output.
|
||||||
|
if (currentSize < debug_log_offset_) {
|
||||||
|
debug_log_offset_ = 0;
|
||||||
|
}
|
||||||
if (currentSize <= debug_log_offset_) {
|
if (currentSize <= debug_log_offset_) {
|
||||||
CloseHandle(hFile);
|
CloseHandle(hFile);
|
||||||
return; // No new data
|
return; // No new data
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seek to where we left off
|
// Seek to where we left off
|
||||||
LARGE_INTEGER seekPos;
|
LARGE_INTEGER seekPos;
|
||||||
seekPos.QuadPart = static_cast<LONGLONG>(debug_log_offset_);
|
seekPos.QuadPart = static_cast<LONGLONG>(debug_log_offset_);
|
||||||
@@ -962,18 +1043,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-pipe used purely as an exec-success/failure handshake, separate from
|
||||||
|
// the stdout pipe above. Both ends are close-on-exec, so a successful execv()
|
||||||
|
// closes the write end for free (parent reads EOF); on execv() failure the
|
||||||
|
// child writes errno here, so the parent learns synchronously instead of
|
||||||
|
// reporting State::Running for a child that never became dragonxd. We use
|
||||||
|
// pipe()+FD_CLOEXEC (not pipe2) because this POSIX branch is shared with
|
||||||
|
// macOS, which has no pipe2().
|
||||||
|
int execpipe[2];
|
||||||
|
if (pipe(execpipe) == -1) {
|
||||||
|
last_error_ = "Failed to create exec-status pipe: " + std::string(strerror(errno));
|
||||||
|
close(pipefd[0]);
|
||||||
|
close(pipefd[1]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||||
|
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid == -1) {
|
if (pid == -1) {
|
||||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
|
close(execpipe[0]);
|
||||||
|
close(execpipe[1]);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pid == 0) {
|
if (pid == 0) {
|
||||||
// Child process
|
// Child process
|
||||||
close(pipefd[0]); // Close read end
|
close(pipefd[0]); // Close read end of the stdout pipe
|
||||||
|
close(execpipe[0]); // Child only writes the exec-status pipe
|
||||||
|
|
||||||
// Put child in its own process group so we can kill the entire
|
// Put child in its own process group so we can kill the entire
|
||||||
// group later (including dragonxd spawned by a wrapper script).
|
// group later (including dragonxd spawned by a wrapper script).
|
||||||
@@ -1040,22 +1141,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
|||||||
execv(binary_path.c_str(), argv.data());
|
execv(binary_path.c_str(), argv.data());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we get here, exec failed
|
// If we get here, execv() failed — the child never became dragonxd.
|
||||||
fprintf(stderr, "execv failed: %s\n", strerror(errno));
|
// Capture errno before fprintf/strerror can clobber it, report it to
|
||||||
|
// the parent over the exec-status pipe (EINTR-safe), then exit.
|
||||||
|
int exec_errno = errno;
|
||||||
|
fprintf(stderr, "execv failed: %s\n", strerror(exec_errno));
|
||||||
|
ssize_t w;
|
||||||
|
do {
|
||||||
|
w = write(execpipe[1], &exec_errno, sizeof(exec_errno));
|
||||||
|
} while (w < 0 && errno == EINTR);
|
||||||
_exit(127);
|
_exit(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent process
|
// Parent process
|
||||||
close(pipefd[1]); // Close write end
|
close(pipefd[1]); // Close our copy of the stdout write end
|
||||||
|
close(execpipe[1]); // Must close our copy, or the read() below never sees EOF
|
||||||
|
|
||||||
|
// Exec-status handshake: EOF => execv() succeeded (its write end was closed
|
||||||
|
// on exec); a full sizeof(int) => execv() failed and the child sent errno.
|
||||||
|
int child_errno = 0;
|
||||||
|
size_t got = 0;
|
||||||
|
char* ep = reinterpret_cast<char*>(&child_errno);
|
||||||
|
for (;;) {
|
||||||
|
ssize_t n = read(execpipe[0], ep + got, sizeof(child_errno) - got);
|
||||||
|
if (n == 0) break; // EOF: exec succeeded
|
||||||
|
if (n < 0) { if (errno == EINTR) continue; break; } // other error: assume success
|
||||||
|
got += static_cast<size_t>(n);
|
||||||
|
if (got >= sizeof(child_errno)) break; // full errno: exec failed
|
||||||
|
}
|
||||||
|
close(execpipe[0]);
|
||||||
|
|
||||||
|
if (got >= sizeof(child_errno)) {
|
||||||
|
// execv() never replaced the child; it fprintf'd and _exit(127)'d. Reap
|
||||||
|
// the already-dead zombie here — monitorProcess() is only started after
|
||||||
|
// this function returns true, so there is no competing reaper.
|
||||||
|
close(pipefd[0]);
|
||||||
|
int status;
|
||||||
|
waitpid(pid, &status, 0);
|
||||||
|
last_error_ = "dragonxd could not be executed: " + std::string(strerror(child_errno)) +
|
||||||
|
" — not executable or wrong architecture";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
stdout_fd_ = pipefd[0];
|
stdout_fd_ = pipefd[0];
|
||||||
|
|
||||||
// Also set process group from parent side (race with child's setpgid)
|
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
|
||||||
setpgid(pid, pid);
|
// just closes the fork/exec race window. A failure here is not fatal to
|
||||||
|
// startup, so we log rather than abort.
|
||||||
|
if (setpgid(pid, pid) != 0) {
|
||||||
|
DEBUG_LOGF("[WARN] setpgid(%d) from parent failed: %s\n", (int)pid, strerror(errno));
|
||||||
|
}
|
||||||
|
|
||||||
// Set non-blocking
|
// Set non-blocking
|
||||||
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
||||||
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
|
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
|
||||||
|
|
||||||
process_pid_ = pid;
|
process_pid_ = pid;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -1135,17 +1275,21 @@ double EmbeddedDaemon::getMemoryUsageMB() const
|
|||||||
|
|
||||||
bool EmbeddedDaemon::isRunning() const
|
bool EmbeddedDaemon::isRunning() const
|
||||||
{
|
{
|
||||||
|
// Read the atomic state_ instead of calling waitpid() here. monitorProcess()
|
||||||
|
// is the sole thread allowed to waitpid() process_pid_ during normal operation.
|
||||||
|
// Calling waitpid() from this method too (as it used to, and this is invoked
|
||||||
|
// from the UI thread nearly every frame) meant whichever thread reaped the
|
||||||
|
// child's exit first consumed the status; if isRunning() won that race,
|
||||||
|
// monitorProcess() never saw the exit, so crash_count_ / the decoded exit
|
||||||
|
// code / the State::Error transition were all silently lost. Mirrors the
|
||||||
|
// fix already in XmrigManager::isRunning().
|
||||||
if (process_pid_ <= 0) return false;
|
if (process_pid_ <= 0) return false;
|
||||||
|
|
||||||
int status;
|
const State s = state_.load(std::memory_order_relaxed);
|
||||||
pid_t result = waitpid(process_pid_, &status, WNOHANG);
|
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
|
||||||
|
// isRunning() while state_ == Stopping — before the process has actually
|
||||||
if (result == 0) {
|
// terminated — and must keep seeing "alive" to wait/escalate correctly.
|
||||||
// Still running
|
return (s == State::Running || s == State::Stopping);
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void EmbeddedDaemon::drainOutput()
|
void EmbeddedDaemon::drainOutput()
|
||||||
|
|||||||
@@ -79,7 +79,9 @@ public:
|
|||||||
/**
|
/**
|
||||||
* @brief Get last error message
|
* @brief Get last error message
|
||||||
*/
|
*/
|
||||||
const std::string& getLastError() const { return last_error_; }
|
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
|
||||||
|
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
|
||||||
|
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get dragonxd process output (thread-safe copy)
|
* @brief Get dragonxd process output (thread-safe copy)
|
||||||
@@ -180,6 +182,7 @@ public:
|
|||||||
* @brief Set maximum peer connections (0 = use daemon default)
|
* @brief Set maximum peer connections (0 = use daemon default)
|
||||||
*/
|
*/
|
||||||
void setMaxConnections(int v) { max_connections_ = v; }
|
void setMaxConnections(int v) { max_connections_ = v; }
|
||||||
|
void setStratumHosting(bool enabled, const std::string& allowIp) { stratum_enabled_ = enabled; stratum_allowip_ = allowIp; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Request a blockchain rescan on the next daemon start
|
* @brief Request a blockchain rescan on the next daemon start
|
||||||
@@ -206,6 +209,13 @@ public:
|
|||||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||||
|
|
||||||
|
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
|
||||||
|
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
|
||||||
|
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
|
||||||
|
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
|
||||||
|
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
|
||||||
|
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||||
@@ -235,6 +245,32 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool isDaemonProcessRunning();
|
static bool isDaemonProcessRunning();
|
||||||
|
|
||||||
|
/** Decision returned by evaluateDatadirLockGate(): whether start() may spawn now. */
|
||||||
|
struct StartLockGateDecision {
|
||||||
|
bool proceed = true; // false => bail before spawning
|
||||||
|
const char* errorMessage = ""; // set (a string literal) when proceed == false
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Pure decision for start(): bail because a previous dragonxd still holds the
|
||||||
|
* shared datadir lock? Isolated instances (skip_port_check_ / an active -datadir
|
||||||
|
* override) are exempt — they run their own throwaway datadir+port and can coexist
|
||||||
|
* with the main daemon. Does no process/fs I/O itself (the caller does the probing),
|
||||||
|
* so it is directly unit-testable; defined inline so tests need only this header.
|
||||||
|
*/
|
||||||
|
static StartLockGateDecision evaluateDatadirLockGate(bool skipPortCheck,
|
||||||
|
bool isolatedOverride,
|
||||||
|
bool stillRunningAfterWait)
|
||||||
|
{
|
||||||
|
if (skipPortCheck || isolatedOverride) return {true, ""};
|
||||||
|
if (stillRunningAfterWait) {
|
||||||
|
return {false,
|
||||||
|
"A previous dragonxd is still shutting down and holding the data "
|
||||||
|
"directory lock. Retrying shortly…"};
|
||||||
|
}
|
||||||
|
return {true, ""};
|
||||||
|
}
|
||||||
|
|
||||||
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
/** @brief Is an arbitrary TCP port currently in use on localhost? (used to pick a free port) */
|
||||||
static bool tcpPortInUse(int port);
|
static bool tcpPortInUse(int port);
|
||||||
|
|
||||||
@@ -253,6 +289,7 @@ private:
|
|||||||
std::atomic<State> state_{State::Stopped};
|
std::atomic<State> state_{State::Stopped};
|
||||||
std::atomic<bool> external_daemon_detected_{false};
|
std::atomic<bool> external_daemon_detected_{false};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
|
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||||
mutable std::mutex output_mutex_; // protects process_output_
|
mutable std::mutex output_mutex_; // protects process_output_
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
StateCallback state_callback_;
|
StateCallback state_callback_;
|
||||||
@@ -275,11 +312,14 @@ private:
|
|||||||
std::atomic<bool> should_stop_{false};
|
std::atomic<bool> should_stop_{false};
|
||||||
std::set<std::string> debug_categories_;
|
std::set<std::string> debug_categories_;
|
||||||
int max_connections_ = 0; // 0 = daemon default
|
int max_connections_ = 0; // 0 = daemon default
|
||||||
|
bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it)
|
||||||
|
std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only)
|
||||||
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
|
std::string wallet_file_; // -wallet=<name> for the active wallet; empty/"wallet.dat" = default
|
||||||
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
std::atomic<int> crash_count_{0}; // consecutive crash counter
|
||||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||||
|
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
|
||||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||||
|
|||||||
@@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
|||||||
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
|
// RPC port. So the wallet lives in <base>/DRAGONX; `base` is the migration root we clean up.
|
||||||
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
||||||
const std::string dataDir = base + "/DRAGONX";
|
const std::string dataDir = base + "/DRAGONX";
|
||||||
|
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
|
||||||
|
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
|
||||||
|
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
|
||||||
|
// one — refuse and point the user at it rather than silently destroying it.
|
||||||
|
if (fs::exists(dataDir + "/wallet.dat")) {
|
||||||
|
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
|
||||||
|
"\nResume or cancel it first. If you are certain its funds are already in your main "
|
||||||
|
"wallet, delete that folder and try again.";
|
||||||
|
return r;
|
||||||
|
}
|
||||||
fs::remove_all(base, ec);
|
fs::remove_all(base, ec);
|
||||||
fs::create_directories(dataDir, ec);
|
fs::create_directories(dataDir, ec);
|
||||||
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
||||||
@@ -136,6 +146,14 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// W1-2: never hand back a live seed on a failure path. If the mnemonic was exported but a
|
||||||
|
// later step failed (empty address, or z_getnewaddress threw), the caller discards this
|
||||||
|
// result without wiping it, which would leave the seed resident. Success keeps it deliberately.
|
||||||
|
if (!r.ok && !r.seedPhrase.empty()) {
|
||||||
|
sodium_memzero(&r.seedPhrase[0], r.seedPhrase.size());
|
||||||
|
r.seedPhrase.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
|
// 7. Stop the isolated node (graceful; it flushes its tiny empty chain quickly).
|
||||||
cli.disconnect();
|
cli.disconnect();
|
||||||
temp.stop(20000);
|
temp.stop(20000);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Copyright 2024-2026 The Hush Developers
|
// Copyright 2024-2026 The Hush Developers
|
||||||
// Released under the GPLv3
|
// Released under the GPLv3
|
||||||
//
|
//
|
||||||
// xmrig_manager.cpp — Pool mining process management via xmrig-hac.
|
// xmrig_manager.cpp — Pool mining process management via drg-xmrig.
|
||||||
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
|
// Spawns xmrig, monitors via HTTP API, tracks hashrate and shares.
|
||||||
|
|
||||||
#include "xmrig_manager.h"
|
#include "xmrig_manager.h"
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
|
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
|
#include "../util/platform.h"
|
||||||
#include "../util/pool_registry.h"
|
#include "../util/pool_registry.h"
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
|
|||||||
// libcurl write callback
|
// libcurl write callback
|
||||||
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||||
auto* s = static_cast<std::string*>(userdata);
|
auto* s = static_cast<std::string*>(userdata);
|
||||||
s->append(static_cast<char*>(ptr), sz * n);
|
const size_t add = sz * n;
|
||||||
return sz * n;
|
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
|
||||||
|
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
|
||||||
|
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
|
||||||
|
if (s->size() + add > kMaxStatsBytes) return 0;
|
||||||
|
s->append(static_cast<char*>(ptr), add);
|
||||||
|
return add;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
|
||||||
|
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
|
||||||
|
static bool hostLooksInternal(const std::string& host) {
|
||||||
|
if (host.empty() || host == "localhost") return true;
|
||||||
|
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
|
||||||
|
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
|
||||||
|
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
|
||||||
|
const int second = std::atoi(host.c_str() + 4);
|
||||||
|
if (second >= 16 && second <= 31) return true;
|
||||||
|
}
|
||||||
|
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
|
||||||
|
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
|
||||||
|
host.rfind("fe80", 0) == 0) return true;
|
||||||
|
}
|
||||||
|
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
|
||||||
|
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
|||||||
XmrigManager::XmrigManager() = default;
|
XmrigManager::XmrigManager() = default;
|
||||||
|
|
||||||
XmrigManager::~XmrigManager() {
|
XmrigManager::~XmrigManager() {
|
||||||
|
should_stop_ = true;
|
||||||
if (isRunning()) {
|
if (isRunning()) {
|
||||||
stop(3000);
|
stop(3000);
|
||||||
}
|
}
|
||||||
|
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
|
||||||
|
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
|
||||||
|
if (monitor_thread_.joinable())
|
||||||
|
monitor_thread_.join();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: system PATH
|
// Fallback: system PATH — windowless so it never flashes a console.
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
FILE* f = _popen("where xmrig.exe 2>nul", "r");
|
std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
|
||||||
#else
|
#else
|
||||||
FILE* f = popen("which xmrig 2>/dev/null", "r");
|
std::string out = util::Platform::runHiddenCapture("which xmrig");
|
||||||
#endif
|
|
||||||
if (f) {
|
|
||||||
char line[512];
|
|
||||||
if (fgets(line, sizeof(line), f)) {
|
|
||||||
std::string s(line);
|
|
||||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
|
|
||||||
s.pop_back();
|
|
||||||
if (!s.empty() && fs::exists(s)) {
|
|
||||||
#ifdef _WIN32
|
|
||||||
_pclose(f);
|
|
||||||
#else
|
|
||||||
pclose(f);
|
|
||||||
#endif
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#ifdef _WIN32
|
|
||||||
_pclose(f);
|
|
||||||
#else
|
|
||||||
pclose(f);
|
|
||||||
#endif
|
#endif
|
||||||
|
{
|
||||||
|
std::string s = out;
|
||||||
|
const auto nl = s.find_first_of("\r\n"); // first line only
|
||||||
|
if (nl != std::string::npos) s.erase(nl);
|
||||||
|
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
|
||||||
|
if (!s.empty() && fs::exists(s)) return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@@ -208,22 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
fs::create_directories(fs::path(outPath).parent_path());
|
fs::create_directories(fs::path(outPath).parent_path());
|
||||||
std::ofstream ofs(outPath);
|
const std::string dumped = j.dump(4);
|
||||||
if (!ofs.is_open()) {
|
#ifndef _WIN32
|
||||||
last_error_ = "Cannot write xmrig config: " + outPath;
|
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
|
||||||
|
// in a world/group-readable file — even for a local attacker who opened it in the old
|
||||||
|
// create-then-chmod window and held the fd open across the chmod. (L-01)
|
||||||
|
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||||
|
if (fd < 0) {
|
||||||
|
setLastError("Cannot write xmrig config: " + outPath);
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
ofs << j.dump(4);
|
size_t off = 0;
|
||||||
ofs.close();
|
bool wrote = true;
|
||||||
|
while (off < dumped.size()) {
|
||||||
#ifndef _WIN32
|
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
|
||||||
// 0600 permissions — only owner can read/write
|
if (nw <= 0) { wrote = false; break; }
|
||||||
chmod(outPath.c_str(), 0600);
|
off += static_cast<size_t>(nw);
|
||||||
#endif
|
}
|
||||||
|
::close(fd);
|
||||||
|
if (!wrote) {
|
||||||
|
setLastError("Cannot write xmrig config: " + outPath);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
|
#else
|
||||||
|
std::ofstream ofs(outPath, std::ios::trunc);
|
||||||
|
if (!ofs.is_open()) {
|
||||||
|
setLastError("Cannot write xmrig config: " + outPath);
|
||||||
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
ofs << dumped;
|
||||||
|
ofs.close();
|
||||||
|
return true;
|
||||||
|
#endif
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
last_error_ = std::string("Config write error: ") + e.what();
|
setLastError(std::string("Config write error: ") + e.what());
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -235,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
|||||||
|
|
||||||
bool XmrigManager::start(const Config& cfg) {
|
bool XmrigManager::start(const Config& cfg) {
|
||||||
if (state_ == State::Running || state_ == State::Starting) {
|
if (state_ == State::Running || state_ == State::Starting) {
|
||||||
last_error_ = "Already running";
|
setLastError("Already running");
|
||||||
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
state_ = State::Starting;
|
state_ = State::Starting;
|
||||||
should_stop_ = false;
|
should_stop_ = false;
|
||||||
last_error_.clear();
|
setLastError(std::string());
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||||
process_output_.clear();
|
process_output_.clear();
|
||||||
}
|
}
|
||||||
stats_ = PoolStats{};
|
{
|
||||||
|
std::lock_guard<std::mutex> lk(stats_mutex_);
|
||||||
|
stats_ = PoolStats{};
|
||||||
|
}
|
||||||
|
|
||||||
// Extract pool hostname for stats API queries
|
// Extract pool hostname for stats API queries
|
||||||
{
|
{
|
||||||
@@ -264,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
|
|||||||
// Find binary
|
// Find binary
|
||||||
std::string binary = findXmrigBinary();
|
std::string binary = findXmrigBinary();
|
||||||
if (binary.empty()) {
|
if (binary.empty()) {
|
||||||
last_error_ = "xmrig binary not found";
|
setLastError("xmrig binary not found");
|
||||||
state_ = State::Error;
|
state_ = State::Error;
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
|
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
|
||||||
return false;
|
return false;
|
||||||
@@ -292,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start monitor thread
|
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
|
||||||
|
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
|
||||||
|
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
|
||||||
|
if (monitor_thread_.joinable())
|
||||||
|
monitor_thread_.join();
|
||||||
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
|
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
|
||||||
state_ = State::Running;
|
state_ = State::Running;
|
||||||
DEBUG_LOGF("[INFO] XmrigManager: started\n");
|
DEBUG_LOGF("[INFO] XmrigManager: started\n");
|
||||||
@@ -367,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
|||||||
|
|
||||||
HANDLE hRead = nullptr, hWrite = nullptr;
|
HANDLE hRead = nullptr, hWrite = nullptr;
|
||||||
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
|
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
|
||||||
last_error_ = "CreatePipe failed";
|
setLastError("CreatePipe failed");
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -399,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
|||||||
char errBuf[256];
|
char errBuf[256];
|
||||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||||
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
|
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -450,14 +494,14 @@ void XmrigManager::drainOutput() {
|
|||||||
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
|
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
|
||||||
int pipefd[2];
|
int pipefd[2];
|
||||||
if (pipe(pipefd) != 0) {
|
if (pipe(pipefd) != 0) {
|
||||||
last_error_ = "pipe() failed";
|
setLastError("pipe() failed");
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
pid_t pid = fork();
|
pid_t pid = fork();
|
||||||
if (pid < 0) {
|
if (pid < 0) {
|
||||||
last_error_ = "fork() failed";
|
setLastError("fork() failed");
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||||
close(pipefd[0]);
|
close(pipefd[0]);
|
||||||
close(pipefd[1]);
|
close(pipefd[1]);
|
||||||
@@ -628,7 +672,7 @@ void XmrigManager::monitorProcess() {
|
|||||||
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
|
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
|
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
|
||||||
state_ = State::Error;
|
state_ = State::Error;
|
||||||
last_error_ = "xmrig process exited unexpectedly";
|
setLastError("xmrig process exited unexpectedly");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,7 +683,7 @@ void XmrigManager::monitorProcess() {
|
|||||||
if (ret == process_pid_ || ret < 0) {
|
if (ret == process_pid_ || ret < 0) {
|
||||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
|
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
|
||||||
state_ = State::Error;
|
state_ = State::Error;
|
||||||
last_error_ = "xmrig process exited unexpectedly";
|
setLastError("xmrig process exited unexpectedly");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -779,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
|
|||||||
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
||||||
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
||||||
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
||||||
|
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
|
||||||
|
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
|
||||||
|
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
|
||||||
|
// registry statsUrl and are exempt. (M-09)
|
||||||
|
if (!known && hostLooksInternal(pool_host_)) return;
|
||||||
const std::string url = known ? known->statsUrl
|
const std::string url = known ? known->statsUrl
|
||||||
: ("https://" + pool_host_ + "/api/stats");
|
: ("https://" + pool_host_ + "/api/stats");
|
||||||
|
|
||||||
@@ -857,25 +906,18 @@ void XmrigManager::startVersionDetection()
|
|||||||
std::thread([]() {
|
std::thread([]() {
|
||||||
const std::string bin = findXmrigBinary();
|
const std::string bin = findXmrigBinary();
|
||||||
std::string ver;
|
std::string ver;
|
||||||
if (!bin.empty()) {
|
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
|
||||||
const std::string cmd = "\"" + bin + "\" --version 2>&1";
|
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
|
||||||
#ifdef _WIN32
|
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
|
||||||
FILE* fp = _popen(cmd.c_str(), "r");
|
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
|
||||||
#else
|
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
|
||||||
FILE* fp = popen(cmd.c_str(), "r");
|
const bool binShellSafe =
|
||||||
#endif
|
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
|
||||||
if (fp) {
|
if (binShellSafe) {
|
||||||
std::string out;
|
// Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
|
||||||
char buf[256];
|
const std::string cmd = "\"" + bin + "\" --version";
|
||||||
size_t n;
|
const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
|
||||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
|
if (!out.empty()) ver = parseMinerVersion(out);
|
||||||
#ifdef _WIN32
|
|
||||||
_pclose(fp);
|
|
||||||
#else
|
|
||||||
pclose(fp);
|
|
||||||
#endif
|
|
||||||
ver = parseMinerVersion(out);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
||||||
g_installed_ver = ver;
|
g_installed_ver = ver;
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ public:
|
|||||||
bool isRunning() const;
|
bool isRunning() const;
|
||||||
State getState() const { return state_.load(std::memory_order_relaxed); }
|
State getState() const { return state_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
const PoolStats& getStats() const { return stats_; }
|
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
|
||||||
const std::string& getLastError() const { return last_error_; }
|
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
|
||||||
|
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
|
||||||
|
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||||
|
|
||||||
/// Thread count requested at start() — available immediately, unlike
|
/// Thread count requested at start() — available immediately, unlike
|
||||||
/// PoolStats::threads_active which requires an API response.
|
/// PoolStats::threads_active which requires an API response.
|
||||||
@@ -156,11 +158,14 @@ private:
|
|||||||
void monitorProcess();
|
void monitorProcess();
|
||||||
void drainOutput();
|
void drainOutput();
|
||||||
void appendOutput(const char* data, size_t len);
|
void appendOutput(const char* data, size_t len);
|
||||||
|
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
|
||||||
|
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
|
||||||
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
|
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
|
||||||
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
||||||
|
|
||||||
std::atomic<State> state_{State::Stopped};
|
std::atomic<State> state_{State::Stopped};
|
||||||
std::string last_error_;
|
std::string last_error_;
|
||||||
|
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
|
||||||
|
|
||||||
mutable std::mutex output_mutex_;
|
mutable std::mutex output_mutex_;
|
||||||
std::string process_output_;
|
std::string process_output_;
|
||||||
|
|||||||
@@ -46,25 +46,31 @@ bool AddressBook::load()
|
|||||||
entries_.clear();
|
entries_.clear();
|
||||||
|
|
||||||
if (j.contains("entries") && j["entries"].is_array()) {
|
if (j.contains("entries") && j["entries"].is_array()) {
|
||||||
|
size_t skipped = 0;
|
||||||
for (const auto& entry : j["entries"]) {
|
for (const auto& entry : j["entries"]) {
|
||||||
AddressBookEntry e;
|
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
|
||||||
e.label = entry.value("label", "");
|
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
|
||||||
e.address = entry.value("address", "");
|
if (!entry.is_object()) { ++skipped; continue; }
|
||||||
e.notes = entry.value("notes", "");
|
try {
|
||||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
AddressBookEntry e;
|
||||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
e.label = entry.value("label", "");
|
||||||
e.scope = entry.value("scope", "global");
|
e.address = entry.value("address", "");
|
||||||
e.avatar = entry.value("avatar", "");
|
e.notes = entry.value("notes", "");
|
||||||
|
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||||
if (!e.address.empty()) {
|
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||||
entries_.push_back(e);
|
e.scope = entry.value("scope", "global");
|
||||||
}
|
e.avatar = entry.value("avatar", "");
|
||||||
|
if (!e.address.empty()) entries_.push_back(e);
|
||||||
|
} catch (const std::exception&) { ++skipped; }
|
||||||
}
|
}
|
||||||
|
if (skipped > 0)
|
||||||
|
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
|
||||||
}
|
}
|
||||||
|
|
||||||
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||||
|
++revision_;
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("Error loading address book: %s\n", e.what());
|
DEBUG_LOGF("Error loading address book: %s\n", e.what());
|
||||||
return false;
|
return false;
|
||||||
@@ -116,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_.push_back(entry);
|
entries_.push_back(entry);
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_[index] = entry;
|
entries_[index] = entry;
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +149,7 @@ bool AddressBook::removeEntry(size_t index)
|
|||||||
}
|
}
|
||||||
|
|
||||||
entries_.erase(entries_.begin() + index);
|
entries_.erase(entries_.begin() + index);
|
||||||
|
++revision_;
|
||||||
return save();
|
return save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
|||||||
e.scope = scopeId;
|
e.scope = scopeId;
|
||||||
++rescoped;
|
++rescoped;
|
||||||
}
|
}
|
||||||
if (rescoped > 0) save();
|
if (rescoped > 0) { ++revision_; save(); }
|
||||||
return rescoped;
|
return rescoped;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -121,11 +122,18 @@ public:
|
|||||||
*/
|
*/
|
||||||
size_t size() const { return entries_.size(); }
|
size_t size() const { return entries_.size(); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers
|
||||||
|
* (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or
|
||||||
|
* address change that keeps size() constant — still invalidates them.
|
||||||
|
*/
|
||||||
|
std::uint64_t revision() const { return revision_; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
|
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
|
||||||
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
|
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
|
||||||
*/
|
*/
|
||||||
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); }
|
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Check if empty
|
* @brief Check if empty
|
||||||
@@ -134,6 +142,7 @@ public:
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
std::vector<AddressBookEntry> entries_;
|
std::vector<AddressBookEntry> entries_;
|
||||||
|
std::uint64_t revision_ = 0;
|
||||||
std::string file_path_;
|
std::string file_path_;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
37
src/data/seed_migration_resume.h
Normal file
37
src/data/seed_migration_resume.h
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Pure routing decision for resuming a pending migrate-to-seed flow (finding W3-3). Kept free of
|
||||||
|
// App/UI/RPC state so the highest-risk branch — where a reopened migration lands — is unit-testable
|
||||||
|
// and reviewable in isolation. App::showSeedMigrationDialog feeds it the persisted state + live
|
||||||
|
// connectivity and switches on the result. See src/app_network.cpp.
|
||||||
|
namespace dragonx {
|
||||||
|
|
||||||
|
enum class MigrationResume {
|
||||||
|
Intro, // no pending migration → start fresh at the intro
|
||||||
|
Confirming, // a sweep txid is persisted → resume at the confirm/adopt gate (re-derived from chain)
|
||||||
|
RetrackOpid, // a sweep opid (but no txid yet) is persisted AND we're connected → re-poll it
|
||||||
|
SweepGate, // otherwise → the dismissable Sweep step (reload balance, offer re-sweep)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Decide where reopening the migration dialog lands.
|
||||||
|
//
|
||||||
|
// Invariant: the txid outranks the opid — once a sweep resolves to a txid the opid is cleared in the
|
||||||
|
// same settings write, so a persisted txid always means "past the sweep". A persisted opid is only
|
||||||
|
// re-tracked when connected, because the buttonless "Sweeping" spinner relies on the opid poller
|
||||||
|
// (which needs an RPC connection) to ever exit; disconnected, we fall back to the dismissable Sweep
|
||||||
|
// gate (which reloads the balance and, if the earlier sweep already drained it, short-circuits to
|
||||||
|
// adopt) — never trapping the user.
|
||||||
|
inline MigrationResume decideSeedMigrationResume(bool pending,
|
||||||
|
bool haveDest,
|
||||||
|
const std::string& sweepTxid,
|
||||||
|
const std::string& sweepOpid,
|
||||||
|
bool connected) {
|
||||||
|
if (!pending || !haveDest) return MigrationResume::Intro;
|
||||||
|
if (!sweepTxid.empty()) return MigrationResume::Confirming;
|
||||||
|
if (!sweepOpid.empty() && connected) return MigrationResume::RetrackOpid;
|
||||||
|
return MigrationResume::SweepGate;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx
|
||||||
@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
|
|||||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||||
const auto& address = addresses[i];
|
const auto& address = addresses[i];
|
||||||
if (!address.isSpendable()) continue;
|
if (!address.isSpendable()) continue;
|
||||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
|
||||||
|
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
|
||||||
indices.push_back(i);
|
indices.push_back(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||||
return addresses[lhs].balance > addresses[rhs].balance;
|
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
|
||||||
});
|
});
|
||||||
return indices;
|
return indices;
|
||||||
}
|
}
|
||||||
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
|||||||
int bestIndex = -1;
|
int bestIndex = -1;
|
||||||
double bestBalance = 0.0;
|
double bestBalance = 0.0;
|
||||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
|
||||||
bestBalance = addresses[i].balance;
|
bestBalance = addresses[i].spendableBalance;
|
||||||
bestIndex = static_cast<int>(i);
|
bestIndex = static_cast<int>(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,13 +21,17 @@ namespace dragonx {
|
|||||||
*/
|
*/
|
||||||
struct AddressInfo {
|
struct AddressInfo {
|
||||||
std::string address;
|
std::string address;
|
||||||
double balance = 0.0;
|
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
|
||||||
std::string type; // "shielded" or "transparent"
|
std::string type; // "shielded" or "transparent"
|
||||||
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
||||||
|
|
||||||
// For display
|
// For display
|
||||||
std::string label;
|
std::string label;
|
||||||
|
|
||||||
|
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
|
||||||
|
// brace-init of the leading fields (used in tests) still compiles.
|
||||||
|
double spendableBalance = 0.0;
|
||||||
|
|
||||||
// Derived
|
// Derived
|
||||||
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
||||||
bool isShielded() const { return type == "shielded"; }
|
bool isShielded() const { return type == "shielded"; }
|
||||||
@@ -252,12 +256,18 @@ struct WalletState {
|
|||||||
// Sync status
|
// Sync status
|
||||||
SyncInfo sync;
|
SyncInfo sync;
|
||||||
|
|
||||||
// Balances (named to match UI usage)
|
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
|
||||||
double privateBalance = 0.0; // shielded balance
|
// user's own pending change and don't crater during an unconfirmed send.
|
||||||
|
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
|
||||||
double transparentBalance = 0.0;
|
double transparentBalance = 0.0;
|
||||||
double totalBalance = 0.0;
|
double totalBalance = 0.0;
|
||||||
double unconfirmedBalance = 0.0;
|
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
|
||||||
|
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
|
||||||
|
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
|
||||||
|
double spendablePrivateBalance = 0.0;
|
||||||
|
double spendableTransparentBalance = 0.0;
|
||||||
|
double spendableTotalBalance = 0.0;
|
||||||
|
|
||||||
// Aliases for backward compatibility
|
// Aliases for backward compatibility
|
||||||
double& shielded_balance = privateBalance;
|
double& shielded_balance = privateBalance;
|
||||||
double& transparent_balance = transparentBalance;
|
double& transparent_balance = transparentBalance;
|
||||||
@@ -302,6 +312,7 @@ struct WalletState {
|
|||||||
|
|
||||||
// Timestamps for refresh logic
|
// Timestamps for refresh logic
|
||||||
int64_t last_balance_update = 0;
|
int64_t last_balance_update = 0;
|
||||||
|
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
|
||||||
int64_t last_tx_update = 0;
|
int64_t last_tx_update = 0;
|
||||||
int64_t last_peer_update = 0;
|
int64_t last_peer_update = 0;
|
||||||
int64_t last_mining_update = 0;
|
int64_t last_mining_update = 0;
|
||||||
@@ -325,6 +336,7 @@ struct WalletState {
|
|||||||
sync = SyncInfo{};
|
sync = SyncInfo{};
|
||||||
privateBalance = transparentBalance = totalBalance = 0.0;
|
privateBalance = transparentBalance = totalBalance = 0.0;
|
||||||
unconfirmedBalance = 0.0;
|
unconfirmedBalance = 0.0;
|
||||||
|
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
|
||||||
encrypted = false;
|
encrypted = false;
|
||||||
locked = false;
|
locked = false;
|
||||||
unlocked_until = 0;
|
unlocked_until = 0;
|
||||||
@@ -335,6 +347,15 @@ struct WalletState {
|
|||||||
transactions.clear();
|
transactions.clear();
|
||||||
peers.clear();
|
peers.clear();
|
||||||
bannedPeers.clear();
|
bannedPeers.clear();
|
||||||
|
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
|
||||||
|
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
|
||||||
|
mining = MiningInfo{};
|
||||||
|
pool_mining = PoolMiningState{};
|
||||||
|
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
|
||||||
|
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
|
||||||
|
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
|
||||||
|
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
|
||||||
|
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rebuild combined addresses list from z/t lists
|
// Rebuild combined addresses list from z/t lists
|
||||||
|
|||||||
39
src/embedded/logo_dragonx_svg.h
Normal file
39
src/embedded/logo_dragonx_svg.h
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// Embedded source of the DragonX mark (res/img/logos/logo_dragonx.svg). Kept as a string so the logo
|
||||||
|
// is available in every build (dev + portable single-file) with no resource-pipeline or file dependency.
|
||||||
|
// It is rasterized + recolored per theme at runtime (see util/svg_texture.*). Two fills: the crimson
|
||||||
|
// body (.cls-2 #d82652) becomes the theme accent; the white detail (.cls-1 #fff) becomes the light tone.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
// Global `embedded` namespace to match the generated embedded resources (embedded::ui_toml_data, etc.).
|
||||||
|
namespace embedded {
|
||||||
|
|
||||||
|
inline constexpr const char* kLogoDragonXSvg = R"SVG(<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<style>
|
||||||
|
.cls-1 {
|
||||||
|
fill: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cls-2 {
|
||||||
|
fill: #d82652;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</defs>
|
||||||
|
<path class="cls-2" d="M103.98,128s-6.29-24.7-18.73-34.43c-8.53-8.03-15.63-16.49-21.25-24.16-5.62,7.68-12.72,16.17-21.25,24.16-12.4,9.74-18.73,34.43-18.73,34.43-2.38-24.34,7.85-35.82,12.87-41.29,7.82-8.5,15.31-16.56,21.75-25.02-7.64-11.44-11.41-19.72-11.41-19.72-.89-3.27-3.84-6.64-3.84-6.64,6.08-8.1-1.6-16.98-1.6-16.98,5.79-5.62,6.71-10.02,8.32-18.34-1.96,22.35,4.02,39.09,13.93,54.12,9.84-15.03,15.81-31.77,13.86-54.12,1.6,8.35,2.52,12.72,8.32,18.34,0,0-7.68,8.88-1.6,16.98,0,0-2.95,3.38-3.84,6.64,0,0-3.77,8.28-11.37,19.72,6.43,8.45,13.97,16.56,21.75,25.02,4.97,5.47,15.21,16.95,12.83,41.29h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M55.33,61.62c-3.55,4.55-7.39,8.99-11.44,13.47-5.29-4.48-11.23-5.33-11.23-5.33,22.92-7.82,2.81-15.17.28-16.31C9.28,42.78,9.1,14.78,9.1,14.78c11.51,34.15,36.42,32.3,36.42,32.3.35-.21.67-.46.92-.71,1.64,3.27,4.58,8.67,8.88,15.24h0Z"/>
|
||||||
|
<g>
|
||||||
|
<path class="cls-1" d="M68.62,40.41c-1.35,2.98-2.91,5.83-4.62,8.63-1.71-2.81-3.23-5.69-4.62-8.63,1.74-3.45,4.62-20.58,4.62-20.58,0,0,2.88,17.13,4.62,20.58Z"/>
|
||||||
|
<path class="cls-1" d="M76.01,97.93l-3.48,2.34s-.1-4.44-3.52-1.84c-.42.32-2.38,2.21-.03,4.27,0,0-4.05,4.08-4.97,8.21-.92-4.12-4.97-8.21-4.97-8.21,2.34-2.06.39-3.95-.03-4.27-3.41-2.59-3.52,1.84-3.52,1.84l-3.48-2.34c.28-3.55.1-6.68-.46-9.42,4.69-4.94,8.85-9.88,12.47-14.61,3.66,4.72,7.78,9.67,12.47,14.61-.57,2.74-.75,5.86-.46,9.42Z"/>
|
||||||
|
<path class="cls-1" d="M95.34,69.76s-5.94.85-11.23,5.33c-4.02-4.48-7.89-8.92-11.44-13.47,4.3-6.57,7.25-11.98,8.88-15.24.25.25.57.5.92.71,0,0,24.91,1.84,36.42-32.3,0,0-.18,28-23.84,38.66-2.52,1.14-22.64,8.5.28,16.31h0Z"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>)SVG";
|
||||||
|
|
||||||
|
} // namespace embedded
|
||||||
157
src/main.cpp
157
src/main.cpp
@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
#include <csignal>
|
||||||
|
#include <cstring>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <fcntl.h>
|
||||||
|
#if defined(__has_include)
|
||||||
|
# if __has_include(<execinfo.h>)
|
||||||
|
# include <execinfo.h>
|
||||||
|
# define DRAGONX_HAVE_BACKTRACE 1
|
||||||
|
# endif
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
|
||||||
|
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
|
||||||
|
static char g_crashLogPath[1024] = {0};
|
||||||
|
|
||||||
|
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
|
||||||
|
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
|
||||||
|
static void PosixCrashHandler(int sig)
|
||||||
|
{
|
||||||
|
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
|
||||||
|
if (fd >= 0) {
|
||||||
|
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
|
||||||
|
put("\n=== CRASH: signal ");
|
||||||
|
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
|
||||||
|
if (v == 0) { num[i++] = '0'; }
|
||||||
|
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
|
||||||
|
while (t > 0) num[i++] = tmp[--t]; }
|
||||||
|
num[i] = '\n';
|
||||||
|
ssize_t nn = write(fd, num, i + 1); (void)nn;
|
||||||
|
#ifdef DRAGONX_HAVE_BACKTRACE
|
||||||
|
void* frames[64];
|
||||||
|
int nframes = backtrace(frames, 64);
|
||||||
|
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
|
||||||
|
#endif
|
||||||
|
put("=== END CRASH ===\n");
|
||||||
|
close(fd);
|
||||||
|
}
|
||||||
|
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
|
||||||
|
signal(sig, SIG_DFL);
|
||||||
|
raise(sig);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void installPosixCrashHandler(const std::string& crashLogPath)
|
||||||
|
{
|
||||||
|
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
|
||||||
|
struct sigaction sa;
|
||||||
|
std::memset(&sa, 0, sizeof(sa));
|
||||||
|
sa.sa_handler = PosixCrashHandler;
|
||||||
|
sigemptyset(&sa.sa_mask);
|
||||||
|
sa.sa_flags = 0;
|
||||||
|
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
|
||||||
|
sigaction(sig, &sa, nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif // !_WIN32
|
||||||
|
|
||||||
int main(int argc, char* argv[])
|
int main(int argc, char* argv[])
|
||||||
{
|
{
|
||||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||||
{
|
{
|
||||||
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
||||||
std::error_code ec;
|
std::string odErr;
|
||||||
std::filesystem::create_directories(odDir, ec);
|
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
|
||||||
|
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
|
||||||
|
// Windows log redirect below isn't set up yet — report loudly before any setup.
|
||||||
|
std::fprintf(stderr, "%s\n", odErr.c_str());
|
||||||
|
#ifdef _WIN32
|
||||||
|
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
|
||||||
|
#endif
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#ifdef _WIN32
|
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
|
||||||
// Redirect stdout/stderr to a log file so diagnostic output is visible
|
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
|
||||||
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
|
// file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
|
||||||
{
|
{
|
||||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
const std::string logPath =
|
||||||
|
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
||||||
|
dragonx::util::Logger::instance().init(logPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !defined(_WIN32)
|
||||||
|
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
|
||||||
|
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
|
||||||
|
{
|
||||||
|
const std::string crashPath =
|
||||||
|
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
|
||||||
|
installPosixCrashHandler(crashPath);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
|
||||||
|
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
|
||||||
|
// above so the two writers don't interleave/contend on one file.
|
||||||
|
{
|
||||||
|
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
|
||||||
freopen(logPath.c_str(), "w", stdout);
|
freopen(logPath.c_str(), "w", stdout);
|
||||||
freopen(logPath.c_str(), "a", stderr);
|
freopen(logPath.c_str(), "a", stderr);
|
||||||
}
|
}
|
||||||
@@ -1178,6 +1263,36 @@ int main(int argc, char* argv[])
|
|||||||
SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale));
|
SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DEV/TEST hook (dormant unless the env is set): DRAGONX_WIN_GEOM="WxH" forces an exact window
|
||||||
|
// size, placing it on the largest display that can hold it and bypassing the primary-monitor
|
||||||
|
// clamp. Lets a headless WSLg sweep render at sizes wider than the 1280 primary (e.g. 2560x1440
|
||||||
|
// on the mirrored 4K/1440p outputs). Needs the x11 backend so absolute positioning takes effect.
|
||||||
|
int wantW = 0, wantH = 0;
|
||||||
|
if (const char* geom = std::getenv("DRAGONX_WIN_GEOM"))
|
||||||
|
sscanf(geom, "%dx%d", &wantW, &wantH);
|
||||||
|
if (wantW > 0 && wantH > 0) {
|
||||||
|
int count = 0;
|
||||||
|
SDL_DisplayID* disp = SDL_GetDisplays(&count);
|
||||||
|
SDL_DisplayID best = 0; SDL_Rect bestUsable{0, 0, 0, 0};
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
SDL_Rect u;
|
||||||
|
if (!SDL_GetDisplayUsableBounds(disp[i], &u)) continue;
|
||||||
|
bool fits = (u.w >= wantW && u.h >= wantH);
|
||||||
|
bool bestFits = (bestUsable.w >= wantW && bestUsable.h >= wantH);
|
||||||
|
// Prefer a display that fits; among those, the smallest; else the largest available.
|
||||||
|
if ((fits && !bestFits) ||
|
||||||
|
(fits && bestFits && (long)u.w * u.h < (long)bestUsable.w * bestUsable.h) ||
|
||||||
|
(!fits && !bestFits && (long)u.w * u.h > (long)bestUsable.w * bestUsable.h)) {
|
||||||
|
bestUsable = u; best = disp[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (disp) SDL_free(disp);
|
||||||
|
if (best) {
|
||||||
|
SDL_SetWindowMinimumSize(window, 320, 240);
|
||||||
|
SDL_SetWindowPosition(window, bestUsable.x + 10, bestUsable.y + 10);
|
||||||
|
SDL_SetWindowSize(window, wantW, wantH);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Clamp to the current display's work area — runs on EVERY startup (this clamp used to live
|
// Clamp to the current display's work area — runs on EVERY startup (this clamp used to live
|
||||||
// inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the
|
// inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the
|
||||||
// window off-screen or bigger than the screen on a same-DPI cold start).
|
// window off-screen or bigger than the screen on a same-DPI cold start).
|
||||||
@@ -1196,6 +1311,7 @@ int main(int argc, char* argv[])
|
|||||||
DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n",
|
DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n",
|
||||||
curW, curH, newW, newH, currentDpiScale);
|
curW, curH, newW, newH, currentDpiScale);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale);
|
winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale);
|
||||||
@@ -1384,6 +1500,7 @@ int main(int argc, char* argv[])
|
|||||||
// WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H.
|
// WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H.
|
||||||
int dpiSettleFrames = 0;
|
int dpiSettleFrames = 0;
|
||||||
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
|
SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes
|
||||||
|
Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update
|
||||||
{
|
{
|
||||||
float s = dragonx::ui::material::Typography::instance().getDpiScale();
|
float s = dragonx::ui::material::Typography::instance().getDpiScale();
|
||||||
int w = 0, h = 0;
|
int w = 0, h = 0;
|
||||||
@@ -1467,6 +1584,7 @@ int main(int argc, char* argv[])
|
|||||||
// Window restored from minimized — trigger immediate data refresh
|
// Window restored from minimized — trigger immediate data refresh
|
||||||
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
|
if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||||
waitEvent.window.windowID == SDL_GetWindowID(window)) {
|
waitEvent.window.windowID == SDL_GetWindowID(window)) {
|
||||||
|
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||||
app.refreshNow();
|
app.refreshNow();
|
||||||
}
|
}
|
||||||
// Handle DPI change that arrived while idle (same logic as poll loop)
|
// Handle DPI change that arrived while idle (same logic as poll loop)
|
||||||
@@ -1596,6 +1714,7 @@ int main(int argc, char* argv[])
|
|||||||
// Window restored from minimized — trigger immediate data refresh
|
// Window restored from minimized — trigger immediate data refresh
|
||||||
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
|
if (event.type == SDL_EVENT_WINDOW_RESTORED &&
|
||||||
event.window.windowID == SDL_GetWindowID(window)) {
|
event.window.windowID == SDL_GetWindowID(window)) {
|
||||||
|
app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast)
|
||||||
app.refreshNow();
|
app.refreshNow();
|
||||||
}
|
}
|
||||||
// Handle DPI/display scale changes (e.g. window dragged to a
|
// Handle DPI/display scale changes (e.g. window dragged to a
|
||||||
@@ -1616,13 +1735,28 @@ int main(int argc, char* argv[])
|
|||||||
|
|
||||||
// Check if window is minimized
|
// Check if window is minimized
|
||||||
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
|
if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) {
|
||||||
// Still check shouldQuit while minimized to avoid hang
|
// Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the
|
||||||
if (app.shouldQuit()) {
|
// refresh scheduler, keeps the daemon connection/reconnect + sync status live) but skip the
|
||||||
running = false;
|
// ImGui frame + GPU present since nothing is visible. app.update() only reads
|
||||||
}
|
// GetIO()/GetTime()/IsAnyItemActive() — all valid outside a frame — so it's safe without a
|
||||||
SDL_Delay(10);
|
// NewFrame; feed it a real-clock DeltaTime (NewFrame, which normally sets it, is skipped) and
|
||||||
|
// let app.update() clamp it. Throttled to ~5 Hz so CPU stays near-idle (refresh cadences are
|
||||||
|
// seconds-scale). shouldQuit is still checked so a quit request never hangs behind minimize.
|
||||||
|
Uint64 nowMs = SDL_GetTicks();
|
||||||
|
float minDelta = (minimizedLastTickMs == 0) ? 0.001f
|
||||||
|
: (float)(nowMs - minimizedLastTickMs) / 1000.0f;
|
||||||
|
minimizedLastTickMs = nowMs;
|
||||||
|
ImGui::GetIO().DeltaTime = (minDelta > 0.0f) ? minDelta : 0.001f;
|
||||||
|
try {
|
||||||
|
app.update();
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
DEBUG_LOGF("[Main] minimized app.update() threw: %s\n", e.what());
|
||||||
|
} catch (...) {}
|
||||||
|
if (app.shouldQuit()) running = false;
|
||||||
|
SDL_Delay(200);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
minimizedLastTickMs = 0; // visible again — reset the minimized clock
|
||||||
|
|
||||||
// --- PerfLog: begin frame ---
|
// --- PerfLog: begin frame ---
|
||||||
dragonx::util::PerfLog::instance().beginFrame();
|
dragonx::util::PerfLog::instance().beginFrame();
|
||||||
@@ -2042,6 +2176,7 @@ int main(int argc, char* argv[])
|
|||||||
// deadlocks waiting for detached pthreads. On Linux, static
|
// deadlocks waiting for detached pthreads. On Linux, static
|
||||||
// destructors and atexit handlers can also block. _Exit() bypasses
|
// destructors and atexit handlers can also block. _Exit() bypasses
|
||||||
// all of that.
|
// all of that.
|
||||||
|
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
|
||||||
fflush(stdout);
|
fflush(stdout);
|
||||||
fflush(stderr);
|
fflush(stderr);
|
||||||
_Exit(0);
|
_Exit(0);
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
|
|||||||
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
|
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
|
||||||
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
|
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
|
||||||
#endif
|
#endif
|
||||||
|
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||||
|
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
|
||||||
|
#endif
|
||||||
#ifdef HAS_EMBEDDED_XMRIG
|
#ifdef HAS_EMBEDDED_XMRIG
|
||||||
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
|
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
|
||||||
#endif
|
#endif
|
||||||
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||||
|
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
|
||||||
|
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
|
||||||
|
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||||
|
if (rebuildRes) {
|
||||||
|
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||||
|
if (!std::filesystem::exists(dest)) {
|
||||||
|
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
|
||||||
|
if (!extractResource(rebuildRes, dest)) {
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
#ifndef _WIN32
|
||||||
|
else { chmod(dest.c_str(), 0755); }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||||
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string ensureWalletRebuildHelperExtracted()
|
||||||
|
{
|
||||||
|
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||||
|
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||||
|
if (!res || res->size == 0) return {};
|
||||||
|
#ifdef _WIN32
|
||||||
|
const char sep = '\\';
|
||||||
|
#else
|
||||||
|
const char sep = '/';
|
||||||
|
#endif
|
||||||
|
const std::string dir = getDaemonDirectory();
|
||||||
|
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||||
|
std::error_code ec;
|
||||||
|
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
|
||||||
|
std::filesystem::create_directories(dir, ec);
|
||||||
|
if (!extractResource(res, dest)) return {};
|
||||||
|
#ifndef _WIN32
|
||||||
|
chmod(dest.c_str(), 0755);
|
||||||
|
#endif
|
||||||
|
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||||
|
return dest;
|
||||||
|
#else
|
||||||
|
return {};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
std::string getDaemonDirectory()
|
std::string getDaemonDirectory()
|
||||||
{
|
{
|
||||||
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
|
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
|
|||||||
// caller should stop the daemon first. Returns true if all present resources were written.
|
// caller should stop the daemon first. Returns true if all present resources were written.
|
||||||
bool reextractBundledDaemon();
|
bool reextractBundledDaemon();
|
||||||
|
|
||||||
|
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
|
||||||
|
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
|
||||||
|
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
|
||||||
|
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
|
||||||
|
std::string ensureWalletRebuildHelperExtracted();
|
||||||
|
|
||||||
// Resource names
|
// Resource names
|
||||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||||
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
|||||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||||
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
||||||
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
||||||
|
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
|
||||||
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
|
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
|
||||||
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
|
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
|
||||||
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
|
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
|
||||||
|
|||||||
@@ -14,8 +14,12 @@
|
|||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <vector>
|
||||||
|
#include <chrono>
|
||||||
|
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
|
#include "../util/platform.h"
|
||||||
|
#include "../util/xmrig_updater.h" // util::sha256Hex
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <shlobj.h>
|
#include <shlobj.h>
|
||||||
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
|
|||||||
return resources::getDaemonDirectory();
|
return resources::getDaemonDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::verifySaplingParams()
|
namespace {
|
||||||
|
|
||||||
|
std::string joinParamPath(const std::string& dir, const std::string& file) {
|
||||||
|
#ifdef _WIN32
|
||||||
|
return dir + "\\" + file;
|
||||||
|
#else
|
||||||
|
return dir + "/" + file;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
|
||||||
|
std::string paramStatLine(const std::string& path) {
|
||||||
|
std::error_code ec;
|
||||||
|
auto sz = fs::file_size(path, ec);
|
||||||
|
if (ec) return {};
|
||||||
|
auto mtime = fs::last_write_time(path, ec);
|
||||||
|
long long ticks = ec ? 0 :
|
||||||
|
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
|
||||||
|
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
|
||||||
|
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||||
|
if (!f) return false;
|
||||||
|
std::streamsize sz = f.tellg();
|
||||||
|
if (sz <= 0) return false;
|
||||||
|
f.seekg(0, std::ios::beg);
|
||||||
|
std::vector<char> buf(static_cast<size_t>(sz));
|
||||||
|
if (!f.read(buf.data(), sz)) return false;
|
||||||
|
std::string got = util::sha256Hex(buf.data(), buf.size());
|
||||||
|
return !got.empty() && got == expectedHex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
|
||||||
|
// in list order, from the last successful hash check.
|
||||||
|
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
|
||||||
|
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
|
||||||
|
std::ifstream f(markerPath);
|
||||||
|
if (!f) return false;
|
||||||
|
std::vector<std::string> lines;
|
||||||
|
std::string l;
|
||||||
|
while (std::getline(f, l)) lines.push_back(l);
|
||||||
|
return lines == expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
|
||||||
|
std::ofstream f(markerPath, std::ios::trunc);
|
||||||
|
if (!f) return;
|
||||||
|
for (const auto& l : lines) f << l << "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
|
||||||
|
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
|
||||||
|
// Keep in sync if the params are ever rotated.
|
||||||
|
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
|
||||||
|
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
|
||||||
|
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||||
{
|
{
|
||||||
std::string params_dir = getSaplingParamsDir();
|
if (dir.empty()) {
|
||||||
if (params_dir.empty()) {
|
|
||||||
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (digests.empty()) return false;
|
||||||
#ifdef _WIN32
|
|
||||||
std::string spend_path = params_dir + "\\sapling-spend.params";
|
// 1) Every param must exist.
|
||||||
std::string output_path = params_dir + "\\sapling-output.params";
|
std::vector<std::string> paths;
|
||||||
#else
|
paths.reserve(digests.size());
|
||||||
std::string spend_path = params_dir + "/sapling-spend.params";
|
for (const auto& d : digests) {
|
||||||
std::string output_path = params_dir + "/sapling-output.params";
|
std::string p = joinParamPath(dir, d.first);
|
||||||
#endif
|
if (!fs::exists(p)) {
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
|
||||||
bool spend_exists = fs::exists(spend_path);
|
return false;
|
||||||
bool output_exists = fs::exists(output_path);
|
}
|
||||||
|
paths.push_back(std::move(p));
|
||||||
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
|
}
|
||||||
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
|
|
||||||
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
|
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
|
||||||
|
// the previous successful hash instead of re-hashing ~48MB on every startup.
|
||||||
return spend_exists && output_exists;
|
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
|
||||||
|
std::vector<std::string> current;
|
||||||
|
current.reserve(paths.size());
|
||||||
|
for (const auto& p : paths) current.push_back(paramStatLine(p));
|
||||||
|
if (saplingMarkerMatches(markerPath, current)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
|
||||||
|
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
|
||||||
|
// here instead of being handed to the daemon and failing later on a shielded operation.
|
||||||
|
for (size_t i = 0; i < paths.size(); ++i) {
|
||||||
|
if (!paramHashMatches(paths[i], digests[i].second)) {
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
|
||||||
|
paths[i].c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Record the verified state so later startups take the fast path.
|
||||||
|
writeSaplingMarker(markerPath, current);
|
||||||
|
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool Connection::verifySaplingParams()
|
||||||
|
{
|
||||||
|
std::vector<std::pair<std::string, std::string>> digests;
|
||||||
|
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||||
|
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||||
}
|
}
|
||||||
|
|
||||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||||
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
|||||||
config.proxy = value;
|
config.proxy = value;
|
||||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||||
config.use_tls = parseBoolValue(value);
|
config.use_tls = parseBoolValue(value);
|
||||||
|
} else if (key == "rpcallowplaintext") {
|
||||||
|
config.allow_plaintext_remote = parseBoolValue(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
|
|||||||
{
|
{
|
||||||
ConnectionConfig config;
|
ConnectionConfig config;
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure the data directory exists. Use the non-throwing helper and report any failure
|
||||||
|
// via config.dir_error so callers can surface it — the old throwing create_directories()
|
||||||
|
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
|
||||||
|
// callers (read-only home, permission denied, etc.).
|
||||||
std::string data_dir = getDefaultDataDir();
|
std::string data_dir = getDefaultDataDir();
|
||||||
if (!fs::exists(data_dir)) {
|
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
|
||||||
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
|
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
|
||||||
fs::create_directories(data_dir);
|
return config; // data dir unusable — bail early with dir_error set
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find DRAGONX.conf
|
// Try to find DRAGONX.conf
|
||||||
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||||
|
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||||
|
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||||
|
static bool isExactIPv4Loopback(const std::string& host)
|
||||||
|
{
|
||||||
|
int octets = 0, value = 0, digits = 0;
|
||||||
|
bool firstIs127 = false;
|
||||||
|
for (size_t i = 0; i <= host.size(); ++i) {
|
||||||
|
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||||
|
if (c == '.') {
|
||||||
|
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||||
|
if (octets == 0) firstIs127 = (value == 127);
|
||||||
|
++octets;
|
||||||
|
value = 0;
|
||||||
|
digits = 0;
|
||||||
|
} else if (c >= '0' && c <= '9') {
|
||||||
|
value = value * 10 + (c - '0');
|
||||||
|
++digits;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return octets == 4 && firstIs127;
|
||||||
|
}
|
||||||
|
|
||||||
bool Connection::isLocalHost(const std::string& host)
|
bool Connection::isLocalHost(const std::string& host)
|
||||||
{
|
{
|
||||||
std::string lowered = lowercase(host);
|
std::string lowered = lowercase(host);
|
||||||
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
|||||||
|
|
||||||
return lowered == "localhost" || lowered == "localhost." ||
|
return lowered == "localhost" || lowered == "localhost." ||
|
||||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
isExactIPv4Loopback(lowered);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||||
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
|||||||
return !config.use_tls && !isLocalHost(config.host);
|
return !config.use_tls && !isLocalHost(config.host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||||
|
{
|
||||||
|
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||||
|
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||||
|
return config.allow_plaintext_remote;
|
||||||
|
}
|
||||||
|
|
||||||
const char* Connection::authSourceName(AuthSource source)
|
const char* Connection::authSourceName(AuthSource source)
|
||||||
{
|
{
|
||||||
switch (source) {
|
switch (source) {
|
||||||
@@ -324,11 +456,11 @@ bool Connection::createDefaultConfig(const std::string& path)
|
|||||||
file << "exportdir=" << dataDir << "\n";
|
file << "exportdir=" << dataDir << "\n";
|
||||||
file << "experimentalfeatures=1\n";
|
file << "experimentalfeatures=1\n";
|
||||||
file << "developerencryptwallet=1\n";
|
file << "developerencryptwallet=1\n";
|
||||||
file << "addnode=node.dragonx.is\n";
|
// Round-robin DNS seed (self-updating) + static fallbacks; mirrors the daemon's vSeeds and keeps
|
||||||
|
// pre-1.3.0 daemons (broken peer discovery) able to find peers. Works on every daemon version.
|
||||||
|
file << "addnode=seed.dragonx.is\n";
|
||||||
file << "addnode=node1.dragonx.is\n";
|
file << "addnode=node1.dragonx.is\n";
|
||||||
file << "addnode=node2.dragonx.is\n";
|
file << "addnode=node5.dragonx.is\n";
|
||||||
file << "addnode=node3.dragonx.is\n";
|
|
||||||
file << "addnode=node4.dragonx.is\n";
|
|
||||||
|
|
||||||
file.close();
|
file.close();
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace rpc {
|
namespace rpc {
|
||||||
@@ -27,7 +29,11 @@ struct ConnectionConfig {
|
|||||||
std::string proxy; // SOCKS5 proxy for Tor
|
std::string proxy; // SOCKS5 proxy for Tor
|
||||||
bool use_embedded = true;
|
bool use_embedded = true;
|
||||||
bool use_tls = false;
|
bool use_tls = false;
|
||||||
|
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||||
AuthSource auth_source = AuthSource::Missing;
|
AuthSource auth_source = AuthSource::Missing;
|
||||||
|
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||||
|
// should surface it and abort the connect rather than proceeding blindly.
|
||||||
|
std::string dir_error;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,6 +75,14 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool verifySaplingParams();
|
static bool verifySaplingParams();
|
||||||
|
|
||||||
|
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
|
||||||
|
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
|
||||||
|
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
|
||||||
|
// pinned production digests and getSaplingParamsDir().
|
||||||
|
static bool verifySaplingParamsIn(
|
||||||
|
const std::string& dir,
|
||||||
|
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get the Sapling params directory
|
* @brief Get the Sapling params directory
|
||||||
*/
|
*/
|
||||||
@@ -119,6 +133,11 @@ public:
|
|||||||
*/
|
*/
|
||||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
|
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||||
|
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||||
|
// means the connect is refused.
|
||||||
|
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||||
|
|
||||||
static const char* authSourceName(AuthSource source);
|
static const char* authSourceName(AuthSource source);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -25,9 +25,11 @@ namespace {
|
|||||||
|
|
||||||
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
|
// Recursively zero every string value in a JSON tree in place — used to wipe a discarded parse tree
|
||||||
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
|
// that held a secret (B7). Operates on the underlying std::string buffers via get_ref.
|
||||||
void scrubJsonSecrets(nlohmann::json& j) {
|
// Templated so it works on both nlohmann::json and nlohmann::ordered_json (callRaw uses the latter).
|
||||||
|
template <typename J>
|
||||||
|
void scrubJsonSecrets(J& j) {
|
||||||
if (j.is_string()) {
|
if (j.is_string()) {
|
||||||
auto& s = j.get_ref<std::string&>();
|
auto& s = j.template get_ref<std::string&>();
|
||||||
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
if (!s.empty()) sodium_memzero(&s[0], s.size());
|
||||||
} else if (j.is_object() || j.is_array()) {
|
} else if (j.is_object() || j.is_array()) {
|
||||||
for (auto& el : j) scrubJsonSecrets(el);
|
for (auto& el : j) scrubJsonSecrets(el);
|
||||||
@@ -96,6 +98,10 @@ void RPCClient::setTraceSource(std::string source)
|
|||||||
// Callback for libcurl to write response data
|
// Callback for libcurl to write response data
|
||||||
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
static size_t WriteCallback(void* contents, size_t size, size_t nmemb, std::string* userp) {
|
||||||
size_t totalSize = size * nmemb;
|
size_t totalSize = size * nmemb;
|
||||||
|
// Bound accumulation so a hostile/compromised daemon cannot OOM the client with an unbounded
|
||||||
|
// response body. 256 MiB is far above any legitimate JSON-RPC response yet prevents exhaustion.
|
||||||
|
static constexpr size_t kMaxRpcResponseBytes = 256u * 1024 * 1024;
|
||||||
|
if (userp->size() + totalSize > kMaxRpcResponseBytes) return 0; // short count aborts the transfer
|
||||||
userp->append((char*)contents, totalSize);
|
userp->append((char*)contents, totalSize);
|
||||||
return totalSize;
|
return totalSize;
|
||||||
}
|
}
|
||||||
@@ -140,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
|
|||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
RPCClient::~RPCClient() = default;
|
RPCClient::~RPCClient() {
|
||||||
|
// Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is
|
||||||
|
// still destroyed normally afterward (curl cleanup unchanged). (L-04)
|
||||||
|
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size());
|
||||||
|
}
|
||||||
|
|
||||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||||
const std::string& user, const std::string& password)
|
const std::string& user, const std::string& password)
|
||||||
@@ -160,6 +170,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
|||||||
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
|
// Create Basic auth header with proper base64 encoding, then wipe the plaintext
|
||||||
// "user:password" temporary (std::string does not zero its buffer on destruction).
|
// "user:password" temporary (std::string does not zero its buffer on destruction).
|
||||||
std::string credentials = user + ":" + password;
|
std::string credentials = user + ":" + password;
|
||||||
|
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04)
|
||||||
auth_ = util::base64_encode(credentials);
|
auth_ = util::base64_encode(credentials);
|
||||||
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
|
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
|
||||||
|
|
||||||
@@ -187,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
|||||||
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
|
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
|
||||||
std::string auth_header = "Authorization: Basic " + auth_;
|
std::string auth_header = "Authorization: Basic " + auth_;
|
||||||
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
|
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
|
||||||
|
if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04)
|
||||||
|
|
||||||
// Configure curl
|
// Configure curl
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
|
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
|
||||||
@@ -202,6 +214,10 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
|||||||
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
// budget for the TCP + TLS handshake over real network latency (1s would spuriously fail).
|
||||||
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
const long connectTimeout = Connection::isLocalHost(host) ? 2L : 10L;
|
||||||
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
curl_easy_setopt(impl_->curl, CURLOPT_CONNECTTIMEOUT, connectTimeout);
|
||||||
|
// Enforce TLS certificate + hostname verification explicitly rather than relying on libcurl's
|
||||||
|
// build defaults. Harmless on the localhost http:// case; essential for a remote https daemon.
|
||||||
|
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYPEER, 1L);
|
||||||
|
curl_easy_setopt(impl_->curl, CURLOPT_SSL_VERIFYHOST, 2L);
|
||||||
|
|
||||||
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
|
// Test connection with getinfo. Use a SHORT timeout for the probe on localhost: a healthy
|
||||||
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
|
// local daemon answers in milliseconds and a warming one returns -28 just as fast, so a long
|
||||||
@@ -289,6 +305,7 @@ void RPCClient::disconnect()
|
|||||||
curl_slist_free_all(impl_->headers);
|
curl_slist_free_all(impl_->headers);
|
||||||
impl_->headers = nullptr;
|
impl_->headers = nullptr;
|
||||||
}
|
}
|
||||||
|
if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04)
|
||||||
}
|
}
|
||||||
|
|
||||||
json RPCClient::makePayload(const std::string& method, const json& params)
|
json RPCClient::makePayload(const std::string& method, const json& params)
|
||||||
@@ -508,14 +525,22 @@ std::string RPCClient::callRaw(const std::string& method, const json& params)
|
|||||||
}
|
}
|
||||||
|
|
||||||
auto& result = oj["result"];
|
auto& result = oj["result"];
|
||||||
|
std::string out;
|
||||||
if (result.is_null()) {
|
if (result.is_null()) {
|
||||||
return "null";
|
out = "null";
|
||||||
} else if (result.is_string()) {
|
} else if (result.is_string()) {
|
||||||
// Return the raw string (not JSON-encoded) — caller wraps as needed
|
// Return the raw string (not JSON-encoded) — caller wraps as needed
|
||||||
return result.get<std::string>();
|
out = result.get<std::string>();
|
||||||
} else {
|
} else {
|
||||||
return result.dump(4);
|
out = result.dump(4);
|
||||||
}
|
}
|
||||||
|
// B7: this raw path serves arbitrary console commands including dumpprivkey / z_exportkey,
|
||||||
|
// whose response carries plaintext key material. Zero the raw buffer and the parsed tree so
|
||||||
|
// the secret does not linger in freed heap (matching callSecret). The single returned copy is
|
||||||
|
// the caller's to manage.
|
||||||
|
scrubJsonSecrets(oj);
|
||||||
|
if (!response_data.empty()) sodium_memzero(&response_data[0], response_data.size());
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
void RPCClient::doRPC(const std::string& method, const json& params, Callback cb, ErrorCallback err)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <chrono>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <map>
|
#include <map>
|
||||||
@@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
|||||||
{
|
{
|
||||||
if (!unspent.is_array()) return;
|
if (!unspent.is_array()) return;
|
||||||
|
|
||||||
std::map<std::string, double> balances;
|
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||||
|
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||||
|
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||||
|
std::map<std::string, double> total;
|
||||||
|
std::map<std::string, double> spendable;
|
||||||
for (const auto& output : unspent) {
|
for (const auto& output : unspent) {
|
||||||
auto address = readOptional<std::string>(output, "address");
|
auto address = readOptional<std::string>(output, "address");
|
||||||
auto amount = readOptional<double>(output, "amount");
|
auto amount = readOptional<double>(output, "amount");
|
||||||
if (address && amount) balances[*address] += *amount;
|
if (!address || !amount) continue;
|
||||||
|
total[*address] += *amount;
|
||||||
|
auto conf = readOptional<int>(output, "confirmations");
|
||||||
|
if (conf && *conf >= 1) spendable[*address] += *amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||||
|
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
|
||||||
for (auto& info : addresses) {
|
for (auto& info : addresses) {
|
||||||
auto balance = balances.find(info.address);
|
auto t = total.find(info.address);
|
||||||
if (balance != balances.end()) info.balance = balance->second;
|
auto s = spendable.find(info.address);
|
||||||
|
info.balance = (t != total.end()) ? t->second : 0.0;
|
||||||
|
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,7 +261,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
|
|||||||
}
|
}
|
||||||
|
|
||||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
|
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
|
||||||
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||||
{
|
{
|
||||||
CoreRefreshResult result;
|
CoreRefreshResult result;
|
||||||
result.balanceOk = balanceOk && totalBalance.is_object();
|
result.balanceOk = balanceOk && totalBalance.is_object();
|
||||||
@@ -258,6 +270,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
|||||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||||
}
|
}
|
||||||
|
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
|
||||||
|
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
|
||||||
|
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
|
||||||
|
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
|
||||||
|
}
|
||||||
|
|
||||||
result.blockchainOk = blockOk && blockInfo.is_object();
|
result.blockchainOk = blockOk && blockInfo.is_object();
|
||||||
if (result.blockchainOk) {
|
if (result.blockchainOk) {
|
||||||
@@ -274,19 +291,17 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
|||||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
||||||
{
|
{
|
||||||
json totalBalance;
|
json totalBalance;
|
||||||
|
json spendableBalance;
|
||||||
json blockInfo;
|
json blockInfo;
|
||||||
bool balanceOk = false;
|
bool balanceOk = false;
|
||||||
bool blockOk = false;
|
bool blockOk = false;
|
||||||
|
double balanceScanMs = 0.0;
|
||||||
|
|
||||||
if (includeBalance) {
|
// getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else
|
||||||
try {
|
// (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance
|
||||||
totalBalance = rpc.call("z_gettotalbalance", json::array());
|
// scan keeps sync detection from being delayed behind (or, under contention, starved by) that
|
||||||
balanceOk = true;
|
// scan — the failure mode where the wallet kept reading "synced" while actually falling behind,
|
||||||
} catch (const std::exception& e) {
|
// so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch.
|
||||||
DEBUG_LOGF("Balance error: %s\n", e.what());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
blockInfo = rpc.call("getblockchaininfo", json::array());
|
blockInfo = rpc.call("getblockchaininfo", json::array());
|
||||||
blockOk = true;
|
blockOk = true;
|
||||||
@@ -294,7 +309,39 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
|||||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
|
// If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip
|
||||||
|
// the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync
|
||||||
|
// anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the
|
||||||
|
// end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the
|
||||||
|
// sticky-behind latch engage promptly the first time the node falls behind.
|
||||||
|
bool behind = false;
|
||||||
|
if (blockOk && blockInfo.is_object()) {
|
||||||
|
const long long b = blockInfo.value("blocks", 0LL);
|
||||||
|
const long long lc = blockInfo.value("longestchain", 0LL);
|
||||||
|
if (lc > 0 && b < lc - 2) behind = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeBalance && !behind) {
|
||||||
|
// z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration —
|
||||||
|
// seconds on a large shielded wallet. Time it so the caller can throttle how often it polls
|
||||||
|
// (balanceRefreshDue()), keeping balance scans from starving block connection.
|
||||||
|
const auto balanceStart = std::chrono::steady_clock::now();
|
||||||
|
try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater
|
||||||
|
totalBalance = rpc.call("z_gettotalbalance", json::array({0}));
|
||||||
|
balanceOk = true;
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
DEBUG_LOGF("Balance error: %s\n", e.what());
|
||||||
|
}
|
||||||
|
try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply.
|
||||||
|
spendableBalance = rpc.call("z_gettotalbalance", json::array({1}));
|
||||||
|
} catch (...) {}
|
||||||
|
balanceScanMs = std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now() - balanceStart).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||||
|
result.balanceScanMs = balanceScanMs;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||||
@@ -426,16 +473,32 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
|
|||||||
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
||||||
|
|
||||||
const auto& data = parsed["dragonx-2"];
|
const auto& data = parsed["dragonx-2"];
|
||||||
|
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
|
||||||
|
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
|
||||||
|
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
|
||||||
|
// which the outer catch turns into "no price update at all", so read null-tolerantly and
|
||||||
|
// keep the valid usd/btc rather than discarding the whole refresh.
|
||||||
|
auto num = [&data](const char* key, double def) {
|
||||||
|
auto it = data.find(key);
|
||||||
|
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
|
||||||
|
};
|
||||||
PriceRefreshResult result;
|
PriceRefreshResult result;
|
||||||
result.market.price_usd = data.value("usd", 0.0);
|
result.market.price_usd = num("usd", 0.0);
|
||||||
result.market.price_btc = data.value("btc", 0.0);
|
result.market.price_btc = num("btc", 0.0);
|
||||||
result.market.change_24h = data.value("usd_24h_change", 0.0);
|
result.market.change_24h = num("usd_24h_change", 0.0);
|
||||||
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
|
result.market.volume_24h = num("usd_24h_vol", 0.0);
|
||||||
result.market.market_cap = data.value("usd_market_cap", 0.0);
|
result.market.market_cap = num("usd_market_cap", 0.0);
|
||||||
|
|
||||||
char buf[64];
|
char buf[64];
|
||||||
std::tm* tm = std::localtime(&fetchedAt);
|
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
||||||
if (tm && std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm) > 0) {
|
// reentrant variant into a local tm (matches the rest of the codebase).
|
||||||
|
std::tm tmv{};
|
||||||
|
#ifdef _WIN32
|
||||||
|
localtime_s(&tmv, &fetchedAt);
|
||||||
|
#else
|
||||||
|
localtime_r(&fetchedAt, &tmv);
|
||||||
|
#endif
|
||||||
|
if (std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tmv) > 0) {
|
||||||
result.market.last_updated = buf;
|
result.market.last_updated = buf;
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -557,6 +620,10 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
|||||||
const AddressRefreshSnapshot& snapshot)
|
const AddressRefreshSnapshot& snapshot)
|
||||||
{
|
{
|
||||||
AddressRefreshResult result;
|
AddressRefreshResult result;
|
||||||
|
// Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's
|
||||||
|
// cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the
|
||||||
|
// caller's adaptive throttle so the address poll can't starve block connection.
|
||||||
|
const auto scanStart = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json zList = rpc.call("z_listaddresses", json::array());
|
json zList = rpc.call("z_listaddresses", json::array());
|
||||||
@@ -592,18 +659,39 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
|||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||||
|
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json unspent = rpc.call("z_listunspent", json::array());
|
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
|
||||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||||
|
// Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead
|
||||||
|
// of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is
|
||||||
|
// dPoW-clamped to 1 and understates it.
|
||||||
|
if (unspent.is_array()) {
|
||||||
|
result.unspentNotes.reserve(unspent.size());
|
||||||
|
for (const auto& nz : unspent) {
|
||||||
|
if (!nz.is_object()) continue;
|
||||||
|
UnspentNoteLite lite;
|
||||||
|
lite.amount = nz.value("amount", 0.0);
|
||||||
|
lite.locked = nz.value("locked", false);
|
||||||
|
lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer())
|
||||||
|
? nz["rawconfirmations"].get<int>()
|
||||||
|
: nz.value("confirmations", 0);
|
||||||
|
result.unspentNotes.push_back(lite);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||||
for (auto& info : result.shieldedAddresses) {
|
for (auto& info : result.shieldedAddresses) {
|
||||||
try {
|
try { // display total (minconf=0, includes pending change)
|
||||||
json balance = rpc.call("z_getbalance", json::array({info.address}));
|
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||||
if (!balance.is_null()) info.balance = balance.get<double>();
|
if (!total.is_null()) info.balance = total.get<double>();
|
||||||
} catch (...) {}
|
} catch (...) {}
|
||||||
|
try { // spendable (minconf=1); degrade to the display value on old daemons
|
||||||
|
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
|
||||||
|
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||||
|
} catch (...) { info.spendableBalance = info.balance; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,15 +700,18 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
|||||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||||
|
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
json unspent = rpc.call("listunspent", json::array());
|
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
|
||||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now() - scanStart).count();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,6 +903,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
result.blockHeight = currentBlockHeight;
|
result.blockHeight = currentBlockHeight;
|
||||||
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
result.shieldedAddressCount = snapshot.shieldedAddresses.size();
|
||||||
result.shieldedScanHeights = snapshot.shieldedScanHeights;
|
result.shieldedScanHeights = snapshot.shieldedScanHeights;
|
||||||
|
// Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the
|
||||||
|
// daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the
|
||||||
|
// routine full history rescan can't starve block connection on a large wallet.
|
||||||
|
const auto scanStart = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
std::set<std::string> knownTxids;
|
std::set<std::string> knownTxids;
|
||||||
HushChatMemoOutputMap hushChatReceivedOutputs;
|
HushChatMemoOutputMap hushChatReceivedOutputs;
|
||||||
@@ -992,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr
|
|||||||
}
|
}
|
||||||
|
|
||||||
sortTransactionsNewestFirst(result.transactions);
|
sortTransactionsNewestFirst(result.transactions);
|
||||||
|
result.scanMs = std::chrono::duration<double, std::milli>(
|
||||||
|
std::chrono::steady_clock::now() - scanStart).count();
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
? &hushChatReceivedOutputs
|
? &hushChatReceivedOutputs
|
||||||
: nullptr;
|
: nullptr;
|
||||||
|
|
||||||
|
// Index result.transactions by (txid, type) once so the two replace-loops below
|
||||||
|
// can find-and-replace in O(1) instead of a nested linear scan over the full
|
||||||
|
// (potentially thousands-large) tx list on every 'recent' refresh cycle. The map
|
||||||
|
// holds the index of the FIRST occurrence of each key (preserving the linear
|
||||||
|
// scan's break-on-first-match), and is kept in sync on every append so a later
|
||||||
|
// entry in the same cycle still finds an earlier appended one — exactly as the
|
||||||
|
// re-scanned vector did before.
|
||||||
|
auto txKey = [](const TransactionInfo& tx) {
|
||||||
|
return tx.txid + '\x1f' + tx.type;
|
||||||
|
};
|
||||||
|
std::unordered_map<std::string, std::size_t> byKey;
|
||||||
|
byKey.reserve(result.transactions.size());
|
||||||
|
for (std::size_t i = 0; i < result.transactions.size(); ++i) {
|
||||||
|
byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
std::set<std::string> recentTxids;
|
std::set<std::string> recentTxids;
|
||||||
std::vector<TransactionInfo> recentTransactions;
|
std::vector<TransactionInfo> recentTransactions;
|
||||||
@@ -1018,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses);
|
||||||
|
|
||||||
for (auto& recent : recentTransactions) {
|
for (auto& recent : recentTransactions) {
|
||||||
bool replaced = false;
|
auto it = byKey.find(txKey(recent));
|
||||||
for (auto& existing : result.transactions) {
|
if (it != byKey.end()) {
|
||||||
if (existing.txid == recent.txid && existing.type == recent.type) {
|
result.transactions[it->second] = recent;
|
||||||
existing = recent;
|
} else {
|
||||||
replaced = true;
|
byKey.emplace(txKey(recent), result.transactions.size());
|
||||||
break;
|
result.transactions.push_back(std::move(recent));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!replaced) result.transactions.push_back(std::move(recent));
|
|
||||||
}
|
}
|
||||||
} catch (const std::exception& e) {
|
} catch (const std::exception& e) {
|
||||||
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
DEBUG_LOGF("recent listtransactions error: %s\n", e.what());
|
||||||
@@ -1052,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe
|
|||||||
snapshot.miningAddresses,
|
snapshot.miningAddresses,
|
||||||
hushChatReceivedOutputsPtr);
|
hushChatReceivedOutputsPtr);
|
||||||
for (auto& scanned : scannedTransactions) {
|
for (auto& scanned : scannedTransactions) {
|
||||||
bool replaced = false;
|
auto it = byKey.find(txKey(scanned));
|
||||||
for (auto& existing : result.transactions) {
|
if (it != byKey.end()) {
|
||||||
if (existing.txid == scanned.txid && existing.type == scanned.type) {
|
result.transactions[it->second] = scanned;
|
||||||
existing = scanned;
|
} else {
|
||||||
replaced = true;
|
byKey.emplace(txKey(scanned), result.transactions.size());
|
||||||
break;
|
result.transactions.push_back(std::move(scanned));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!replaced) result.transactions.push_back(std::move(scanned));
|
|
||||||
}
|
}
|
||||||
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight;
|
||||||
++result.shieldedAddressesScanned;
|
++result.shieldedAddressesScanned;
|
||||||
@@ -1094,12 +1203,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
|||||||
std::set<std::string> reported;
|
std::set<std::string> reported;
|
||||||
for (const auto& op : result) {
|
for (const auto& op : result) {
|
||||||
if (!op.is_object()) continue;
|
if (!op.is_object()) continue;
|
||||||
std::string opid = op.value("id", std::string());
|
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
|
||||||
|
// type, which would abort the whole poll (and wedge it for the session — see the call site).
|
||||||
|
if (!op.contains("id") || !op["id"].is_string()) continue;
|
||||||
|
std::string opid = op["id"].get<std::string>();
|
||||||
if (opid.empty()) continue;
|
if (opid.empty()) continue;
|
||||||
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
||||||
reported.insert(opid);
|
reported.insert(opid);
|
||||||
|
|
||||||
std::string status = op.value("status", std::string());
|
std::string status = (op.contains("status") && op["status"].is_string())
|
||||||
|
? op["status"].get<std::string>() : std::string();
|
||||||
if (status == "success") {
|
if (status == "success") {
|
||||||
parsed.doneOpids.push_back(opid);
|
parsed.doneOpids.push_back(opid);
|
||||||
parsed.anySuccess = true;
|
parsed.anySuccess = true;
|
||||||
@@ -1177,6 +1290,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
|||||||
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
||||||
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
||||||
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
||||||
|
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
|
||||||
|
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
|
||||||
|
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
|
||||||
|
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
|
||||||
|
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
|
||||||
|
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
|
||||||
state.last_balance_update = updatedAt;
|
state.last_balance_update = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -98,9 +98,12 @@ public:
|
|||||||
|
|
||||||
struct CoreRefreshResult {
|
struct CoreRefreshResult {
|
||||||
bool balanceOk = false;
|
bool balanceOk = false;
|
||||||
std::optional<double> shieldedBalance;
|
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
|
||||||
std::optional<double> transparentBalance;
|
std::optional<double> transparentBalance;
|
||||||
std::optional<double> totalBalance;
|
std::optional<double> totalBalance;
|
||||||
|
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
|
||||||
|
std::optional<double> spendableTransparentBalance;
|
||||||
|
std::optional<double> spendableTotalBalance;
|
||||||
bool blockchainOk = false;
|
bool blockchainOk = false;
|
||||||
std::optional<int> blocks;
|
std::optional<int> blocks;
|
||||||
std::optional<int> headers;
|
std::optional<int> headers;
|
||||||
@@ -108,6 +111,7 @@ public:
|
|||||||
std::optional<double> verificationProgress;
|
std::optional<double> verificationProgress;
|
||||||
std::optional<int> longestChain;
|
std::optional<int> longestChain;
|
||||||
std::optional<int> notarized;
|
std::optional<int> notarized;
|
||||||
|
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||||
};
|
};
|
||||||
|
|
||||||
struct MiningRefreshResult {
|
struct MiningRefreshResult {
|
||||||
@@ -143,9 +147,27 @@ public:
|
|||||||
std::string errorMessage;
|
std::string errorMessage;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Minimal per-note view of a z_listunspent entry — just what a downstream consumer needs to derive
|
||||||
|
// spendable-note budgets without re-scanning. Kept UI/feature-agnostic (no chat specifics here).
|
||||||
|
struct UnspentNoteLite {
|
||||||
|
double amount = 0.0; // note value, DRGX
|
||||||
|
bool locked = false; // tied up by an in-flight send
|
||||||
|
int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations)
|
||||||
|
};
|
||||||
|
|
||||||
struct AddressRefreshResult {
|
struct AddressRefreshResult {
|
||||||
std::vector<AddressInfo> shieldedAddresses;
|
std::vector<AddressInfo> shieldedAddresses;
|
||||||
std::vector<AddressInfo> transparentAddresses;
|
std::vector<AddressInfo> transparentAddresses;
|
||||||
|
// False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the
|
||||||
|
// lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the
|
||||||
|
// empty-wallet warning) must not trust a 0 count unless this is true.
|
||||||
|
bool addressListOk = true;
|
||||||
|
// Wall-clock spent in the address scan (dominated by z_listunspent — O(mapWallet), holds the
|
||||||
|
// daemon's cs_main). Lets the caller throttle how often it polls (addressRefreshDue()).
|
||||||
|
double scanMs = 0.0;
|
||||||
|
// The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat
|
||||||
|
// note-budget) can be fed for free instead of running its own duplicate z_listunspent.
|
||||||
|
std::vector<UnspentNoteLite> unspentNotes;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct AddressRefreshSnapshot {
|
struct AddressRefreshSnapshot {
|
||||||
@@ -197,6 +219,9 @@ public:
|
|||||||
std::size_t shieldedAddressCount = 0;
|
std::size_t shieldedAddressCount = 0;
|
||||||
std::unordered_map<std::string, int> shieldedScanHeights;
|
std::unordered_map<std::string, int> shieldedScanHeights;
|
||||||
bool shieldedScanComplete = true;
|
bool shieldedScanComplete = true;
|
||||||
|
// Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main).
|
||||||
|
// Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()).
|
||||||
|
double scanMs = 0.0;
|
||||||
};
|
};
|
||||||
|
|
||||||
struct OperationStatusPollResult {
|
struct OperationStatusPollResult {
|
||||||
@@ -227,6 +252,7 @@ public:
|
|||||||
RefreshRpcGateway& rpc,
|
RefreshRpcGateway& rpc,
|
||||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||||
|
const nlohmann::json& spendableBalance,
|
||||||
bool balanceOk,
|
bool balanceOk,
|
||||||
const nlohmann::json& blockInfo,
|
const nlohmann::json& blockInfo,
|
||||||
bool blockOk);
|
bool blockOk);
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
#include "wallet_security_controller.h"
|
#include "wallet_security_controller.h"
|
||||||
#include "../util/secure_vault.h"
|
#include "../util/secure_vault.h"
|
||||||
|
#include "../util/address_validation.h"
|
||||||
|
|
||||||
#include <cctype>
|
#include <cctype>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
namespace dragonx {
|
namespace dragonx {
|
||||||
namespace services {
|
namespace services {
|
||||||
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
|
|||||||
|
|
||||||
bool WalletSecurityController::isViewingKey(const std::string& key)
|
bool WalletSecurityController::isViewingKey(const std::string& key)
|
||||||
{
|
{
|
||||||
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
|
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
|
||||||
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
|
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
|
||||||
return key.rfind("zxview", 0) == 0;
|
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
|
||||||
|
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
|
||||||
|
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
|
||||||
|
const std::string hrp = util::bech32Hrp(key);
|
||||||
|
return hrp == "zivks" // mainnet
|
||||||
|
|| hrp == "zivktestsapling" // testnet
|
||||||
|
|| hrp == "zivkregtestsapling"; // regtest
|
||||||
}
|
}
|
||||||
|
|
||||||
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
||||||
{
|
{
|
||||||
|
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
|
||||||
|
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
|
||||||
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
||||||
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
||||||
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
|
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
|
||||||
if (key.size() >= 51 && key.size() <= 52 &&
|
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
|
||||||
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
|
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
|
||||||
|
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
|
||||||
|
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
|
||||||
|
std::vector<std::uint8_t> payload;
|
||||||
|
if (util::decodeBase58Check(key, payload) &&
|
||||||
|
(payload.size() == 33 || payload.size() == 34) &&
|
||||||
|
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
|
||||||
|
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ public:
|
|||||||
std::size_t minLength = 4);
|
std::size_t minLength = 4);
|
||||||
static KeyKind classifyAddress(const std::string& address);
|
static KeyKind classifyAddress(const std::string& address);
|
||||||
static KeyKind classifyPrivateKey(const std::string& key);
|
static KeyKind classifyPrivateKey(const std::string& key);
|
||||||
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
|
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
|
||||||
static bool isViewingKey(const std::string& key);
|
static bool isViewingKey(const std::string& key);
|
||||||
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
||||||
static bool isRecognizedPrivateKey(const std::string& key);
|
static bool isRecognizedPrivateKey(const std::string& key);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "theme_effects.h"
|
#include "theme_effects.h"
|
||||||
#include "low_spec.h"
|
#include "low_spec.h"
|
||||||
#include "../schema/ui_schema.h"
|
#include "../schema/ui_schema.h"
|
||||||
|
#include "../layout.h"
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
@@ -58,6 +59,7 @@ void ThemeEffects::beginFrame() {
|
|||||||
|
|
||||||
void ThemeEffects::loadFromTheme() {
|
void ThemeEffects::loadFromTheme() {
|
||||||
auto& S = schema::UI();
|
auto& S = schema::UI();
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
auto eff = [&](const char* name) {
|
auto eff = [&](const char* name) {
|
||||||
return S.drawElement("effects", name);
|
return S.drawElement("effects", name);
|
||||||
};
|
};
|
||||||
@@ -98,7 +100,7 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
// ---- Shimmer ----
|
// ---- Shimmer ----
|
||||||
shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f;
|
shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f;
|
||||||
shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f);
|
shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f);
|
||||||
shimmer_.width = eff("shimmer-width").sizeOr(80.0f);
|
shimmer_.width = eff("shimmer-width").sizeOr(80.0f) * dp;
|
||||||
shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f);
|
shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f);
|
||||||
shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f);
|
shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f);
|
||||||
// Shimmer color: read from the schema's color resolver
|
// Shimmer color: read from the schema's color resolver
|
||||||
@@ -124,7 +126,7 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f);
|
glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f);
|
||||||
glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f);
|
glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f);
|
||||||
glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f);
|
glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f);
|
||||||
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f);
|
glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f) * dp;
|
||||||
auto glowColorElem = eff("glow-pulse-color");
|
auto glowColorElem = eff("glow-pulse-color");
|
||||||
if (!glowColorElem.color.empty()) {
|
if (!glowColorElem.color.empty()) {
|
||||||
glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255));
|
glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255));
|
||||||
@@ -136,7 +138,7 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f;
|
edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f;
|
||||||
edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f);
|
edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f);
|
||||||
edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f);
|
edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f);
|
||||||
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f);
|
edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f) * dp;
|
||||||
edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f);
|
edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f);
|
||||||
auto edgeColorElem = eff("edge-trace-color");
|
auto edgeColorElem = eff("edge-trace-color");
|
||||||
if (!edgeColorElem.color.empty()) {
|
if (!edgeColorElem.color.empty()) {
|
||||||
@@ -149,7 +151,7 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f;
|
ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f;
|
||||||
ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f);
|
ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f);
|
||||||
ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f);
|
ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f);
|
||||||
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f);
|
ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f) * dp;
|
||||||
ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f);
|
ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f);
|
||||||
auto emberColorElem = eff("ember-rise-color");
|
auto emberColorElem = eff("ember-rise-color");
|
||||||
if (!emberColorElem.color.empty()) {
|
if (!emberColorElem.color.empty()) {
|
||||||
@@ -160,8 +162,12 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
|
|
||||||
// ---- Gradient Border Shift ----
|
// ---- Gradient Border Shift ----
|
||||||
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f;
|
gradient_border_.enabled = eff("gradient-border-enabled").sizeOr(0.0f) > 0.5f;
|
||||||
|
// Opt-in: also draw the shifting border on every glass panel (not just the
|
||||||
|
// active nav button). Off by default so themes that only want the button
|
||||||
|
// accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero.
|
||||||
|
gradient_border_.panels = eff("gradient-border-panels").sizeOr(0.0f) > 0.5f;
|
||||||
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
|
gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f);
|
||||||
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f);
|
gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f) * dp;
|
||||||
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
|
gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f);
|
||||||
auto gbColorA = eff("gradient-border-color-a");
|
auto gbColorA = eff("gradient-border-color-a");
|
||||||
if (!gbColorA.color.empty()) {
|
if (!gbColorA.color.empty()) {
|
||||||
@@ -190,7 +196,7 @@ void ThemeEffects::loadFromTheme() {
|
|||||||
sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f);
|
sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f);
|
||||||
sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f);
|
sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f);
|
||||||
sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f);
|
sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f);
|
||||||
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f);
|
sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f) * dp;
|
||||||
sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f);
|
sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f);
|
||||||
sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f);
|
sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f);
|
||||||
sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f);
|
sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f);
|
||||||
@@ -512,11 +518,15 @@ void ThemeEffects::drawGlowPulse(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||||
float rounding) const {
|
float rounding, float phaseOffset,
|
||||||
|
float alphaMul) const {
|
||||||
if (!enabled_ || !gradient_border_.enabled) return;
|
if (!enabled_ || !gradient_border_.enabled) return;
|
||||||
|
|
||||||
// Smooth sinusoidal oscillation between color A and color B
|
// Smooth sinusoidal oscillation between color A and color B.
|
||||||
float phase = std::sin(time_ * gradient_border_.speed * 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
// phaseOffset shifts where in the cycle this element sits so a wall of
|
||||||
|
// panels reads like veins at different depths rather than one pulse.
|
||||||
|
float phase = std::sin((time_ * gradient_border_.speed + phaseOffset)
|
||||||
|
* 2.0f * 3.14159265f) * 0.5f + 0.5f;
|
||||||
|
|
||||||
// Extract RGBA from both colors and lerp
|
// Extract RGBA from both colors and lerp
|
||||||
RGB ca = unpackRGB(gradient_border_.colorA);
|
RGB ca = unpackRGB(gradient_border_.colorA);
|
||||||
@@ -525,7 +535,7 @@ void ThemeEffects::drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 p
|
|||||||
int r = ca.r + (int)((cb.r - ca.r) * phase);
|
int r = ca.r + (int)((cb.r - ca.r) * phase);
|
||||||
int g = ca.g + (int)((cb.g - ca.g) * phase);
|
int g = ca.g + (int)((cb.g - ca.g) * phase);
|
||||||
int b = ca.b + (int)((cb.b - ca.b) * phase);
|
int b = ca.b + (int)((cb.b - ca.b) * phase);
|
||||||
int a = scaledAlpha(gradient_border_.alpha, bgOpacity_);
|
int a = scaledAlpha(gradient_border_.alpha * alphaMul, bgOpacity_);
|
||||||
|
|
||||||
// Draw the shifting border
|
// Draw the shifting border
|
||||||
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a),
|
dl->AddRect(pMin, pMax, IM_COL32(r, g, b, a),
|
||||||
@@ -686,6 +696,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
|||||||
void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const {
|
void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const {
|
||||||
if (!enabled_ || !ember_rise_.enabled) return;
|
if (!enabled_ || !ember_rise_.enabled) return;
|
||||||
|
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
float w = pMax.x - pMin.x;
|
float w = pMax.x - pMin.x;
|
||||||
float h = pMax.y - pMin.y;
|
float h = pMax.y - pMin.y;
|
||||||
if (w <= 0 || h <= 0) return;
|
if (w <= 0 || h <= 0) return;
|
||||||
@@ -699,7 +710,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
|
|||||||
// Deterministic pseudo-random x position per particle
|
// Deterministic pseudo-random x position per particle
|
||||||
// Simple hash: sin of large prime multiples
|
// Simple hash: sin of large prime multiples
|
||||||
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
|
float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f;
|
||||||
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f; // gentle sway
|
float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f * dp; // gentle sway
|
||||||
|
|
||||||
float x = pMin.x + w * xHash + xDrift;
|
float x = pMin.x + w * xHash + xDrift;
|
||||||
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
|
float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top
|
||||||
@@ -737,6 +748,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const
|
|||||||
void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
||||||
if (!enabled_ || !ember_rise_.enabled) return;
|
if (!enabled_ || !ember_rise_.enabled) return;
|
||||||
|
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||||
float vpW = vp->WorkSize.x;
|
float vpW = vp->WorkSize.x;
|
||||||
float vpH = vp->WorkSize.y;
|
float vpH = vp->WorkSize.y;
|
||||||
@@ -757,7 +769,7 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const {
|
|||||||
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
|
float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f;
|
||||||
xHash = xHash - (int)xHash; // fractional part
|
xHash = xHash - (int)xHash; // fractional part
|
||||||
if (xHash < 0) xHash += 1.0f;
|
if (xHash < 0) xHash += 1.0f;
|
||||||
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f;
|
float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f * dp;
|
||||||
|
|
||||||
float x = vpX + vpW * xHash + xDrift;
|
float x = vpX + vpW * xHash + xDrift;
|
||||||
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
|
float y = vpY + vpH * (1.0f - phase); // rise from bottom to top
|
||||||
@@ -896,6 +908,22 @@ void ThemeEffects::drawPanelEffects(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
|||||||
float rounding) const {
|
float rounding) const {
|
||||||
if (!enabled_ || effects::isLowSpecMode()) return;
|
if (!enabled_ || effects::isLowSpecMode()) return;
|
||||||
|
|
||||||
|
// Gradient border on panels — a slow color-shifting outline that hugs the
|
||||||
|
// panel's rounded corners (drawn via AddRect, so it follows the rounding
|
||||||
|
// exactly — no polygonal corners). Position-based phase offset makes each
|
||||||
|
// panel drift like a vein at a different depth; softer than the active
|
||||||
|
// nav button (alphaMul 0.6) so a screenful of panels stays calm.
|
||||||
|
if (gradient_border_.enabled && gradient_border_.panels) {
|
||||||
|
float w = pMax.x - pMin.x;
|
||||||
|
float h = pMax.y - pMin.y;
|
||||||
|
if (w > 80 && h > 40) { // skip small panels
|
||||||
|
float posKey = (pMin.x * 0.0073f + pMin.y * 0.0137f);
|
||||||
|
posKey = posKey - (int)posKey; // fractional 0..1
|
||||||
|
if (posKey < 0) posKey += 1.0f;
|
||||||
|
drawGradientBorderShift(dl, pMin, pMax, rounding, posKey, 0.6f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Edge trace on panels — use position-based phase offset so each
|
// Edge trace on panels — use position-based phase offset so each
|
||||||
// panel's tracer is at a different position around the border
|
// panel's tracer is at a different position around the border
|
||||||
if (edge_trace_.enabled) {
|
if (edge_trace_.enabled) {
|
||||||
|
|||||||
@@ -69,9 +69,12 @@ public:
|
|||||||
void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
void drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||||
float rounding) const;
|
float rounding) const;
|
||||||
|
|
||||||
/// Draw a border that shifts between two colors over time (gem-like)
|
/// Draw a border that shifts between two colors over time (gem-like).
|
||||||
|
/// phaseOffset (0..1) shifts this element's point in the color cycle so
|
||||||
|
/// many panels don't pulse in unison; alphaMul scales the whole effect.
|
||||||
void drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
void drawGradientBorderShift(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax,
|
||||||
float rounding) const;
|
float rounding, float phaseOffset = 0.0f,
|
||||||
|
float alphaMul = 1.0f) const;
|
||||||
|
|
||||||
/// Draw ember particles that rise from an element (fire theme)
|
/// Draw ember particles that rise from an element (fire theme)
|
||||||
void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const;
|
void drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const;
|
||||||
@@ -186,6 +189,7 @@ private:
|
|||||||
|
|
||||||
struct GradientBorderConfig {
|
struct GradientBorderConfig {
|
||||||
bool enabled = false;
|
bool enabled = false;
|
||||||
|
bool panels = false; ///< also draw on glass panels, not just the active nav button
|
||||||
float speed = 0.15f; ///< full color shift cycles per second
|
float speed = 0.15f; ///< full color shift cycles per second
|
||||||
float thickness = 1.5f; ///< border line thickness in pixels
|
float thickness = 1.5f; ///< border line thickness in pixels
|
||||||
float alpha = 0.6f; ///< peak alpha
|
float alpha = 0.6f; ///< peak alpha
|
||||||
|
|||||||
@@ -173,6 +173,24 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels",
|
|||||||
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
|
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
|
||||||
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
|
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
|
||||||
|
|
||||||
|
// Overall content-column cap: the max width a tab's content occupies before it is centered in wider
|
||||||
|
// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default —
|
||||||
|
// requested so large windows don't leave a big empty gutter on the right). Set a positive
|
||||||
|
// ui.toml [layout] content-max-width to re-enable a centered readable column.
|
||||||
|
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); }
|
||||||
|
|
||||||
|
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
|
||||||
|
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
|
||||||
|
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
|
||||||
|
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
|
||||||
|
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
|
||||||
|
struct CardBox { float width; float offsetX; };
|
||||||
|
inline CardBox mainComposeCardBox(float availW) {
|
||||||
|
float cap = kContentMaxWidth();
|
||||||
|
float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width
|
||||||
|
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
|
||||||
|
}
|
||||||
|
|
||||||
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
|
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
|
||||||
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }
|
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,26 @@ inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warni
|
|||||||
// Convenience Functions for Common Patterns
|
// Convenience Functions for Common Patterns
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Theme-aware translucent overlay for tracks / hover fills / dividers.
|
||||||
|
*
|
||||||
|
* A raw white overlay (IM_COL32(255,255,255,a)) reads on dark skins but vanishes
|
||||||
|
* on light/pastel skins (white-on-white). This picks a dark overlay on light
|
||||||
|
* themes and a white overlay on dark themes so the alpha reads either way.
|
||||||
|
* (Self-contained luminance check so colors.h stays free of draw_helpers.h.)
|
||||||
|
*
|
||||||
|
* @param alpha 0-255 opacity of the overlay
|
||||||
|
*/
|
||||||
|
inline ImU32 SurfaceOverlay(int alpha)
|
||||||
|
{
|
||||||
|
ImU32 bg = Background();
|
||||||
|
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||||
|
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||||
|
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||||
|
bool light = (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
|
||||||
|
return light ? IM_COL32(0, 0, 0, alpha) : IM_COL32(255, 255, 255, alpha);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Get color with applied state overlay
|
* @brief Get color with applied state overlay
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -57,6 +57,21 @@ inline ImU32 ReadableError() {
|
|||||||
return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
|
return IM_COL32(r, g, b, (e >> IM_COL32_A_SHIFT) & 0xFF);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Middle-ellipsis truncation ("front...back", roughly equal halves) so `text` fits within
|
||||||
|
// maxWidth pixels when drawn with `font` at `fontSize`. Returns `text` unchanged if it already
|
||||||
|
// fits (or maxWidth is non-positive). Display-only — never mutate the underlying value with this.
|
||||||
|
inline std::string TruncateToWidth(const std::string& text, ImFont* font, float fontSize, float maxWidth) {
|
||||||
|
if (text.empty() || !font || maxWidth <= 0.0f) return text;
|
||||||
|
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, text.c_str()).x <= maxWidth) return text;
|
||||||
|
const int n = static_cast<int>(text.size());
|
||||||
|
for (int f = n / 2; f >= 3; --f) {
|
||||||
|
const int b = (f - 2 > 3) ? (f - 2) : 3; // keep the two halves roughly equal
|
||||||
|
std::string t = text.substr(0, f) + "..." + text.substr(n - b);
|
||||||
|
if (font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, t.c_str()).x <= maxWidth) return t;
|
||||||
|
}
|
||||||
|
return n > 6 ? (text.substr(0, 3) + "..." + text.substr(n - 3)) : text;
|
||||||
|
}
|
||||||
|
|
||||||
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
|
// Animated "loading" ellipsis: "", ".", "..", "..." cycling on a ~3Hz phase.
|
||||||
inline const char* LoadingDots() {
|
inline const char* LoadingDots() {
|
||||||
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
int n = ((int)(ImGui::GetTime() * 3.0f)) % 4;
|
||||||
@@ -64,6 +79,63 @@ inline const char* LoadingDots() {
|
|||||||
return kDots[n];
|
return kDots[n];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Centered empty state ─────────────────────────────────────────────────
|
||||||
|
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
|
||||||
|
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
|
||||||
|
// read the same across tabs. Call at the start of the region you want it centered in
|
||||||
|
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
|
||||||
|
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
|
||||||
|
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
|
||||||
|
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
|
||||||
|
{
|
||||||
|
auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
|
||||||
|
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||||
|
const ImVec2 origin = ImGui::GetCursorPos();
|
||||||
|
ImFont* iconF = Type().iconXL();
|
||||||
|
ImFont* titleF = Type().subtitle1();
|
||||||
|
ImFont* hintF = Type().body2();
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
const float gap = 8.0f * dp;
|
||||||
|
const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
|
||||||
|
const float iconSz = iconF ? scaled(iconF) : 40.0f;
|
||||||
|
const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
|
||||||
|
const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
|
||||||
|
const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
|
||||||
|
const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
|
||||||
|
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
|
||||||
|
|
||||||
|
if (iconF && iconGlyph && iconGlyph[0]) {
|
||||||
|
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
|
||||||
|
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
|
||||||
|
ImGui::PushFont(iconF);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
|
||||||
|
ImGui::TextUnformatted(iconGlyph);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
y += iconH + gap;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x;
|
||||||
|
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
|
||||||
|
ImGui::PushFont(titleF);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
|
||||||
|
ImGui::TextUnformatted(title);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
y += titleH + gap;
|
||||||
|
}
|
||||||
|
if (hint) {
|
||||||
|
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
|
||||||
|
ImGui::PushFont(hintF);
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120));
|
||||||
|
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||||
|
ImGui::TextUnformatted(hint);
|
||||||
|
ImGui::PopTextWrapPos();
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Text Drop Shadow
|
// Text Drop Shadow
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -472,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
|
|||||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||||
|
|
||||||
// For icon fonts, manually draw centered icon after getting button rect
|
// For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
|
||||||
|
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
|
||||||
|
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
|
||||||
|
// shove the glyph left off-center (and try to draw the notdef id chars).
|
||||||
if (isIconFont && size.x > 0 && size.y > 0) {
|
if (isIconFont && size.x > 0 && size.y > 0) {
|
||||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
|
const char* labelEnd = label;
|
||||||
|
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
|
||||||
|
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
|
||||||
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
||||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
|
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
float rounding = ImGui::GetStyle().FrameRounding;
|
float rounding = ImGui::GetStyle().FrameRounding;
|
||||||
@@ -848,7 +925,7 @@ inline void DrawStatCard(ImDrawList* dl,
|
|||||||
// Draw a full-height rounded rect with card rounding (left corners)
|
// Draw a full-height rounded rect with card rounding (left corners)
|
||||||
// and clip to stripe width so the shape follows the corner radius.
|
// and clip to stripe width so the shape follows the corner radius.
|
||||||
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
|
if ((card.accentCol & IM_COL32_A_MASK) != 0) {
|
||||||
float stripeW = 4.0f;
|
float stripeW = 4.0f * Layout::dpiScale();
|
||||||
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
|
dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true);
|
||||||
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
|
dl->AddRectFilled(cMin, cMax, card.accentCol, rnd,
|
||||||
ImDrawFlags_RoundCornersLeft);
|
ImDrawFlags_RoundCornersLeft);
|
||||||
@@ -1205,7 +1282,8 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
|||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
ImVec2 winPos = ImGui::GetWindowPos();
|
ImVec2 winPos = ImGui::GetWindowPos();
|
||||||
float winWidth = ImGui::GetWindowWidth();
|
float winWidth = ImGui::GetWindowWidth();
|
||||||
float barHeight = 36.0f;
|
const float dp = Layout::dpiScale();
|
||||||
|
float barHeight = 36.0f * dp;
|
||||||
|
|
||||||
// Get accent color from theme if not provided
|
// Get accent color from theme if not provided
|
||||||
if (!accent_col) {
|
if (!accent_col) {
|
||||||
@@ -1229,15 +1307,15 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
|||||||
ImFont* titleFont = Type().subtitle1();
|
ImFont* titleFont = Type().subtitle1();
|
||||||
ImGui::PushFont(titleFont);
|
ImGui::PushFont(titleFont);
|
||||||
ImVec2 titleSize = ImGui::CalcTextSize(title);
|
ImVec2 titleSize = ImGui::CalcTextSize(title);
|
||||||
float titleX = barMin.x + 16.0f;
|
float titleX = barMin.x + 16.0f * dp;
|
||||||
float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
|
float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f;
|
||||||
DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
|
DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title);
|
||||||
ImGui::PopFont();
|
ImGui::PopFont();
|
||||||
|
|
||||||
// Close button (X) on right side
|
// Close button (X) on right side
|
||||||
if (p_open) {
|
if (p_open) {
|
||||||
float btnSize = 24.0f;
|
float btnSize = 24.0f * dp;
|
||||||
float btnX = barMax.x - btnSize - 12.0f;
|
float btnX = barMax.x - btnSize - 12.0f * dp;
|
||||||
float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
|
float btnY = barMin.y + (barHeight - btnSize) * 0.5f;
|
||||||
ImVec2 btnMin(btnX, btnY);
|
ImVec2 btnMin(btnX, btnY);
|
||||||
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
|
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
|
||||||
@@ -1250,7 +1328,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
|||||||
|
|
||||||
// Button background on hover
|
// Button background on hover
|
||||||
if (hovered) {
|
if (hovered) {
|
||||||
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f);
|
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f * dp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw X icon
|
// Draw X icon
|
||||||
@@ -1271,7 +1349,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reserve space for title bar so content starts below it
|
// Reserve space for title bar so content starts below it
|
||||||
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f);
|
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f * dp);
|
||||||
|
|
||||||
return closeClicked;
|
return closeClicked;
|
||||||
}
|
}
|
||||||
@@ -1347,10 +1425,17 @@ inline int SegmentedControl(ImDrawList* dl, ImVec2 origin, float totalW, float h
|
|||||||
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
||||||
WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f);
|
WithAlpha(Primary(), 210), (height - 4.0f * dp) * 0.5f);
|
||||||
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]);
|
ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0, labels[i]);
|
||||||
|
// Center when the label fits; otherwise left-align with a small pad (so a long translation clips
|
||||||
|
// on the right, not on BOTH sides). Clip to the cell so no label can bleed into a neighbouring
|
||||||
|
// segment or past the rounded track — English fits, but de/es/fr/pt/ru labels can overrun.
|
||||||
|
const float lpad = 4.0f * dp;
|
||||||
|
const float tx = (ts.x <= cellW - 2.0f * lpad) ? (cellW - ts.x) * 0.5f : lpad;
|
||||||
|
dl->PushClipRect(cMin, cMax, true);
|
||||||
dl->AddText(font, font->LegacySize,
|
dl->AddText(font, font->LegacySize,
|
||||||
ImVec2(cMin.x + (cellW - ts.x) * 0.5f, cMin.y + (height - ts.y) * 0.5f),
|
ImVec2(cMin.x + tx, cMin.y + (height - ts.y) * 0.5f),
|
||||||
active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()),
|
active ? IM_COL32(255, 255, 255, 255) : (hov ? OnSurface() : OnSurfaceMedium()),
|
||||||
labels[i]);
|
labels[i]);
|
||||||
|
dl->PopClipRect();
|
||||||
ImGui::PushID(i);
|
ImGui::PushID(i);
|
||||||
ImGui::SetCursorScreenPos(cMin);
|
ImGui::SetCursorScreenPos(cMin);
|
||||||
if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i;
|
if (ImGui::InvisibleButton(idBase, ImVec2(cellW, height))) clicked = i;
|
||||||
@@ -1381,6 +1466,7 @@ struct OverlayCardState {
|
|||||||
int stableCount = 0; // consecutive frames the height held steady (within 1px)
|
int stableCount = 0; // consecutive frames the height held steady (within 1px)
|
||||||
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
|
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
|
||||||
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
|
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
|
||||||
|
bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
|
||||||
};
|
};
|
||||||
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
|
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
|
||||||
inline std::string g_overlayCurrentKey;
|
inline std::string g_overlayCurrentKey;
|
||||||
@@ -1506,6 +1592,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
|||||||
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
||||||
float cardY, cardBottomY;
|
float cardY, cardBottomY;
|
||||||
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
|
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
|
||||||
|
bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll
|
||||||
const bool fixedHeight = (spec.cardHeight > 0.0f);
|
const bool fixedHeight = (spec.cardHeight > 0.0f);
|
||||||
if (fixedHeight) {
|
if (fixedHeight) {
|
||||||
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
|
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
|
||||||
@@ -1515,9 +1602,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
|||||||
} else {
|
} else {
|
||||||
g_overlayCurrentKey = childId;
|
g_overlayCurrentKey = childId;
|
||||||
OverlayCardState& cs = g_overlayCardHeights[childId];
|
OverlayCardState& cs = g_overlayCardHeights[childId];
|
||||||
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
|
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
|
||||||
if (!cs.shown) cs.appearFrames++;
|
if (!cs.shown) cs.appearFrames++;
|
||||||
const float measuredH = cs.height;
|
const float measuredH = cs.height;
|
||||||
|
const float maxCardH = vp_size.y - 32.0f;
|
||||||
|
// Once the measured content is taller than the viewport, lock the card to the viewport height and
|
||||||
|
// let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
|
||||||
|
// open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
|
||||||
|
// oscillate — decide once and hold until the dialog re-opens.
|
||||||
|
if (measuredH > maxCardH) cs.overflow = true;
|
||||||
|
autoOverflow = cs.overflow;
|
||||||
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
|
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
|
||||||
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
|
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
|
||||||
// guarantees a pathological ever-changing height can't hide the dialog forever.
|
// guarantees a pathological ever-changing height can't hide the dialog forever.
|
||||||
@@ -1525,11 +1619,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
|||||||
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
|
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
|
||||||
if (ready) {
|
if (ready) {
|
||||||
cs.shown = true;
|
cs.shown = true;
|
||||||
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
if (autoOverflow) {
|
||||||
cardY = (measuredH < vp_size.y - 32.0f)
|
// Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
|
||||||
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
// content child (below) becomes the scroll region so the footer/actions stay reachable.
|
||||||
: vp_pos.y + 16.0f;
|
cardY = vp_pos.y + 16.0f;
|
||||||
cardBottomY = cardY + measuredH;
|
cardBottomY = cardY + maxCardH;
|
||||||
|
} else {
|
||||||
|
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
||||||
|
cardY = (measuredH < maxCardH)
|
||||||
|
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
||||||
|
: vp_pos.y + 16.0f;
|
||||||
|
cardBottomY = cardY + measuredH;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Still settling: lay the content out (so the auto-height child gets measured) but keep
|
// Still settling: lay the content out (so the auto-height child gets measured) but keep
|
||||||
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
|
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
|
||||||
@@ -1545,7 +1646,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
|||||||
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
|
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
|
||||||
if (!floating && !hideForMeasure) {
|
if (!floating && !hideForMeasure) {
|
||||||
GlassPanelSpec cardGlass;
|
GlassPanelSpec cardGlass;
|
||||||
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
|
// Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
|
||||||
|
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
|
||||||
|
// tiles, chat) while staying translucent rather than opaque.
|
||||||
|
cardGlass.rounding = 16.0f * dp; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
|
||||||
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1559,17 +1663,24 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
|||||||
|
|
||||||
// Content child.
|
// Content child.
|
||||||
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
ImGui::SetCursorScreenPos(ImVec2(cardX, cardY));
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
|
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f * dp : 16.0f * dp);
|
||||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
|
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28 * dp, 20 * dp) : ImVec2(28 * dp, 24 * dp));
|
||||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
|
||||||
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY);
|
// A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
|
||||||
|
// content overflowed the viewport); otherwise the child auto-resizes to its content.
|
||||||
|
const bool clampedCard = fixedHeight || autoOverflow;
|
||||||
|
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
|
||||||
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
|
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
|
||||||
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
|
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
|
||||||
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
|
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
|
||||||
// their own; auto-height cards resize to content so they never overflow anyway.
|
// their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
|
||||||
|
// content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
|
||||||
|
ImGuiWindowFlags childScroll = autoOverflow
|
||||||
|
? ImGuiWindowFlags_None
|
||||||
|
: (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||||
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
|
ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
|
||||||
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
cflags, childScroll);
|
||||||
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
|
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
|
||||||
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
|
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
|
||||||
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
|
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
|
||||||
@@ -1692,7 +1803,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa
|
|||||||
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
|
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
|
||||||
bool danger, bool& outCancel, bool& outConfirm)
|
bool danger, bool& outCancel, bool& outConfirm)
|
||||||
{
|
{
|
||||||
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
|
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
|
||||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||||
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
|
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
|
||||||
outCancel = true;
|
outCancel = true;
|
||||||
|
|||||||
195
src/ui/material/settings_controls.h
Normal file
195
src/ui/material/settings_controls.h
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
// DragonX Wallet - ImGui Edition
|
||||||
|
// Copyright 2024-2026 The Hush Developers
|
||||||
|
// Released under the GPLv3
|
||||||
|
//
|
||||||
|
// Settings design-system controls — the polished chat-settings look (accent subsection headers,
|
||||||
|
// labeled rows with right-aligned controls, iOS-style segmented controls) promoted to reusable
|
||||||
|
// components, plus a tiered ActionButton (Primary/Secondary/Tertiary/Destructive) with optional
|
||||||
|
// leading Material icon and a ButtonFlow that wraps rows of buttons instead of shrinking them.
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "draw_helpers.h" // colors, type, layout, icons, WithAlpha, ScaleAlpha, DrawButtonGlassOverlay
|
||||||
|
#include "imgui.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace dragonx {
|
||||||
|
namespace ui {
|
||||||
|
namespace material {
|
||||||
|
|
||||||
|
// Accent small-caps subsection header ("KEYS & BACKUP", "APPEARANCE"…) — the chat-settings section() look.
|
||||||
|
inline void SettingsSubheader(const char* text) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
ImGui::Dummy(ImVec2(0.0f, 8.0f * dp));
|
||||||
|
ImGui::PushFont(Type().caption());
|
||||||
|
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(Primary(), 235));
|
||||||
|
ImGui::TextUnformatted(text);
|
||||||
|
ImGui::PopStyleColor();
|
||||||
|
ImGui::PopFont();
|
||||||
|
ImGui::Dummy(ImVec2(0.0f, 2.0f * dp));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A labeled settings row: label left, control right-aligned in a fixed column. Construct once per card
|
||||||
|
// section with the content width (0 = auto), then call .label(text) before drawing each control (leaves
|
||||||
|
// the cursor at the control origin and sets the next item width to the control column).
|
||||||
|
struct SettingsRow {
|
||||||
|
float leftX, rowW, ctrlW, rowGap;
|
||||||
|
explicit SettingsRow(float contentWidth, float controlWidth = 250.0f) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
leftX = ImGui::GetCursorPosX();
|
||||||
|
rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
|
||||||
|
ctrlW = controlWidth * dp;
|
||||||
|
rowGap = 5.0f * dp;
|
||||||
|
}
|
||||||
|
void label(const char* text) {
|
||||||
|
ImGui::Dummy(ImVec2(0.0f, rowGap));
|
||||||
|
ImGui::AlignTextToFramePadding();
|
||||||
|
ImGui::TextUnformatted(text);
|
||||||
|
ImGui::SameLine();
|
||||||
|
ImGui::SetCursorPosX(std::max(ImGui::GetCursorPosX(), leftX + std::max(0.0f, rowW - ctrlW)));
|
||||||
|
ImGui::SetNextItemWidth(ctrlW);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// iOS-style segmented control (rounded track + inset pill on the selection). Draws its own labeled row
|
||||||
|
// and returns the (possibly changed) index.
|
||||||
|
inline int SegmentedControl(SettingsRow& row, const char* label, const char* const* items, int count, int value) {
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
row.label(label);
|
||||||
|
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||||
|
const float h = ImGui::GetFrameHeight();
|
||||||
|
const float seg = row.ctrlW / static_cast<float>(count);
|
||||||
|
const float round = 7.0f * dp;
|
||||||
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
|
dl->AddRectFilled(origin, ImVec2(origin.x + row.ctrlW, origin.y + h), WithAlpha(OnSurface(), 20), round);
|
||||||
|
int result = value;
|
||||||
|
ImGui::PushID(label);
|
||||||
|
for (int i = 0; i < count; ++i) {
|
||||||
|
ImGui::PushID(i);
|
||||||
|
const ImVec2 mn(origin.x + i * seg, origin.y), mx(origin.x + (i + 1) * seg, origin.y + h);
|
||||||
|
ImGui::SetCursorScreenPos(mn);
|
||||||
|
if (ImGui::InvisibleButton("##s", ImVec2(seg, h))) result = i;
|
||||||
|
const bool hov = ImGui::IsItemHovered();
|
||||||
|
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
const bool sel = (value == i);
|
||||||
|
if (sel) {
|
||||||
|
const float in = 2.0f * dp;
|
||||||
|
dl->AddRectFilled(ImVec2(mn.x + in, mn.y + in), ImVec2(mx.x - in, mx.y - in),
|
||||||
|
WithAlpha(Primary(), 210), std::max(1.0f, round - in));
|
||||||
|
} else if (hov) {
|
||||||
|
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 26), round);
|
||||||
|
}
|
||||||
|
const ImVec2 ts = ImGui::CalcTextSize(items[i]);
|
||||||
|
const float lpad = 4.0f * dp;
|
||||||
|
const float tx = (ts.x <= seg - 2.0f * lpad) ? (seg - ts.x) * 0.5f : lpad;
|
||||||
|
ImGui::PushClipRect(mn, mx, true);
|
||||||
|
dl->AddText(ImVec2(mn.x + tx, mn.y + (h - ts.y) * 0.5f),
|
||||||
|
sel ? IM_COL32(255, 255, 255, 236) : OnSurfaceMedium(), items[i]);
|
||||||
|
ImGui::PopClipRect();
|
||||||
|
ImGui::PopID();
|
||||||
|
}
|
||||||
|
ImGui::PopID();
|
||||||
|
ImGui::SetCursorScreenPos(origin);
|
||||||
|
ImGui::Dummy(ImVec2(row.ctrlW, h)); // reserve the control's rect for layout flow
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tiered action buttons ───────────────────────────────────────────────────
|
||||||
|
// Primary = filled accent (the one main action of a group)
|
||||||
|
// Secondary = glass (default — common actions)
|
||||||
|
// Tertiary = ghost / low-emphasis (rarely used)
|
||||||
|
// Destructive = error-tinted outline (delete / reset)
|
||||||
|
enum class ActionTier { Primary, Secondary, Tertiary, Destructive };
|
||||||
|
|
||||||
|
// The width an ActionButton will occupy (for ButtonFlow / manual layout).
|
||||||
|
inline float ActionButtonWidth(const char* label, const char* icon, float minWidth = 0.0f) {
|
||||||
|
ImFont* lf = Type().button();
|
||||||
|
ImFont* icf = Type().iconSmall();
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
const float padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal
|
||||||
|
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
|
||||||
|
const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
|
||||||
|
const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
|
||||||
|
return std::max(w, minWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tiered action button with an optional leading Material icon (ICON_MD_* or nullptr). Auto-sizes to
|
||||||
|
// its content (>= minWidth). Respects BeginDisabled() (dims via the style alpha, not clickable).
|
||||||
|
inline bool ActionButton(const char* id, const char* label, const char* icon, ActionTier tier, float minWidth = 0.0f) {
|
||||||
|
ImFont* lf = Type().button();
|
||||||
|
ImFont* icf = Type().iconSmall();
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
const float gap = 6.0f * dp;
|
||||||
|
const float h = ImGui::GetFrameHeight();
|
||||||
|
const bool hasIcon = icon && icon[0] && icf;
|
||||||
|
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
|
||||||
|
const float iconW = hasIcon ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
|
||||||
|
const float w = ActionButtonWidth(label, icon, minWidth);
|
||||||
|
|
||||||
|
const ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||||
|
const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, h));
|
||||||
|
const bool hov = ImGui::IsItemHovered(), act = ImGui::IsItemActive();
|
||||||
|
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
|
const ImVec2 pMax(pos.x + w, pos.y + h);
|
||||||
|
const float round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame)
|
||||||
|
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
|
||||||
|
|
||||||
|
ImU32 bg = 0, border = 0, fg = OnSurface();
|
||||||
|
bool glass = false;
|
||||||
|
switch (tier) {
|
||||||
|
case ActionTier::Primary:
|
||||||
|
// Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button.
|
||||||
|
bg = WithAlpha(Primary(), hov ? 52 : 38);
|
||||||
|
border = WithAlpha(Primary(), hov ? 150 : 110);
|
||||||
|
fg = Primary();
|
||||||
|
break;
|
||||||
|
case ActionTier::Secondary:
|
||||||
|
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
|
||||||
|
border = WithAlpha(OnSurface(), 48);
|
||||||
|
glass = true;
|
||||||
|
fg = OnSurface();
|
||||||
|
break;
|
||||||
|
case ActionTier::Tertiary:
|
||||||
|
bg = hov ? WithAlpha(OnSurface(), 18) : 0;
|
||||||
|
fg = OnSurfaceMedium();
|
||||||
|
break;
|
||||||
|
case ActionTier::Destructive:
|
||||||
|
bg = hov ? WithAlpha(Error(), 32) : WithAlpha(Error(), 12);
|
||||||
|
border = WithAlpha(Error(), 90);
|
||||||
|
fg = Error();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (bg) dl->AddRectFilled(pos, pMax, ScaleAlpha(bg, a), round);
|
||||||
|
if (border) dl->AddRect(pos, pMax, ScaleAlpha(border, a), round, 0, 1.0f);
|
||||||
|
if (glass) DrawButtonGlassOverlay(dl, pos, pMax, round, act, hov);
|
||||||
|
fg = ScaleAlpha(fg, a);
|
||||||
|
|
||||||
|
const float contentW = iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
|
||||||
|
float cx = pos.x + (w - contentW) * 0.5f;
|
||||||
|
if (hasIcon) {
|
||||||
|
dl->AddText(icf, icf->LegacySize, ImVec2(cx, pos.y + (h - icf->LegacySize) * 0.5f), fg, icon);
|
||||||
|
cx += iconW + gap;
|
||||||
|
}
|
||||||
|
dl->AddText(lf, lf->LegacySize, ImVec2(cx, pos.y + (h - lf->LegacySize) * 0.5f), fg, label);
|
||||||
|
return pressed;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Places ActionButtons left→right, wrapping to a new row when the next one won't fit (instead of the
|
||||||
|
// old font-scale-to-fit). Call next(width) before each ActionButton.
|
||||||
|
struct ButtonFlow {
|
||||||
|
float availW, gap; float x = 0.0f; bool firstOnRow = true;
|
||||||
|
explicit ButtonFlow(float availWidth, float gapPx = 8.0f) : availW(availWidth) {
|
||||||
|
gap = gapPx * Layout::dpiScale();
|
||||||
|
}
|
||||||
|
void next(float w) {
|
||||||
|
if (firstOnRow) { firstOnRow = false; x = w; return; }
|
||||||
|
if (x + gap + w <= availW) { ImGui::SameLine(0, gap); x += gap + w; }
|
||||||
|
else { x = w; } // natural newline wraps to the next row
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace material
|
||||||
|
} // namespace ui
|
||||||
|
} // namespace dragonx
|
||||||
111
src/ui/node_status_banner.h
Normal file
111
src/ui/node_status_banner.h
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
// Persistent node-connectivity banner shown at the top of the content column when the wallet
|
||||||
|
// cannot reach its node. Distinct from the transient toast notifications: it stays visible for
|
||||||
|
// as long as the fault persists, so an offline wallet is never silently mistaken for a working
|
||||||
|
// one. The decision (whether to show, how severe, which action) is a pure function of a state
|
||||||
|
// snapshot so it can be unit-tested; App::renderNodeStatusBanner() feeds it the live state and
|
||||||
|
// draws the strip. See src/app.cpp.
|
||||||
|
namespace dragonx::ui {
|
||||||
|
|
||||||
|
// Visual weight. Warning (amber) = recoverable / a reconnect is offered; Error (red) = a hard
|
||||||
|
// fault the user must act on (the daemon gave up crashing, or a lite wallet failed to open).
|
||||||
|
enum class NodeBannerSeverity {
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
};
|
||||||
|
|
||||||
|
// What the banner's action button does. App maps this to the concrete call.
|
||||||
|
enum class NodeBannerAction {
|
||||||
|
None, // no button — nothing the user can usefully do from here
|
||||||
|
Reconnect, // full node: re-run the RPC connect state machine (App::tryConnect)
|
||||||
|
RestartNode, // full node: the embedded daemon crashed & auto-restart gave up (App::restartDaemon)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Why the banner is up. App maps this to a translated headline; `detail` carries the live,
|
||||||
|
// already-human-readable status text (connection_status_ / daemon lastError / lite open error).
|
||||||
|
enum class NodeBannerReason {
|
||||||
|
None,
|
||||||
|
FullNodeOffline, // a reachable node was lost, or never came up; reconnect offered
|
||||||
|
DaemonCrashed, // the embedded daemon crashed repeatedly and auto-restart stopped
|
||||||
|
LiteOpenFailed, // lite build: the wallet failed to open
|
||||||
|
};
|
||||||
|
|
||||||
|
struct NodeBannerState {
|
||||||
|
bool show = false;
|
||||||
|
NodeBannerSeverity severity = NodeBannerSeverity::Warning;
|
||||||
|
NodeBannerReason reason = NodeBannerReason::None;
|
||||||
|
NodeBannerAction action = NodeBannerAction::None;
|
||||||
|
std::string detail; // passthrough status/error text (may be empty)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Snapshot of the connection state the banner reads. Plain values so the decision is testable
|
||||||
|
// without an App instance.
|
||||||
|
struct NodeBannerInputs {
|
||||||
|
bool lite = false; // lite build (no embedded daemon / RPC)
|
||||||
|
bool connected = false; // state_.connected — the master "online" flag
|
||||||
|
bool warming_up = false; // daemon reachable, RPC warmup (code -28)
|
||||||
|
bool daemon_initializing = false; // daemon launching / block index loading
|
||||||
|
bool connection_in_progress = false; // a connect attempt is actively running
|
||||||
|
|
||||||
|
// Full-node embedded-daemon crash signal.
|
||||||
|
bool using_embedded_daemon = false;
|
||||||
|
bool has_daemon_controller = false;
|
||||||
|
bool daemon_running = false;
|
||||||
|
int daemon_crash_count = 0;
|
||||||
|
|
||||||
|
std::string connection_status; // human-readable status line (already translated)
|
||||||
|
std::string daemon_last_error; // DaemonController::lastError() (may be empty)
|
||||||
|
std::string lite_open_error; // lite: last wallet-open failure reason
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auto-restart give-up threshold — mirrors the crash cap in app_network.cpp's connect loop.
|
||||||
|
inline constexpr int kNodeBannerCrashGiveUpCount = 3;
|
||||||
|
|
||||||
|
inline NodeBannerState evaluateNodeStatusBanner(const NodeBannerInputs& in) {
|
||||||
|
NodeBannerState s;
|
||||||
|
|
||||||
|
if (in.lite) {
|
||||||
|
// Lite has no daemon/RPC; "online" == wallet open. Only a genuine open failure is a
|
||||||
|
// fault worth a persistent banner (a not-yet-created wallet is handled by the normal
|
||||||
|
// "No wallet open" prompt, and leaves lite_open_error empty).
|
||||||
|
if (!in.connected && !in.lite_open_error.empty()) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::LiteOpenFailed;
|
||||||
|
s.action = NodeBannerAction::None;
|
||||||
|
s.detail = in.lite_open_error;
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full node. Connected, or in an expected startup phase → the loading/warmup overlay owns
|
||||||
|
// the screen, so no banner. An active connect attempt likewise shows progress, not an
|
||||||
|
// error — don't flicker a banner over it.
|
||||||
|
if (in.connected) return s;
|
||||||
|
if (in.warming_up || in.daemon_initializing) return s;
|
||||||
|
if (in.connection_in_progress) return s;
|
||||||
|
|
||||||
|
// Genuinely offline. Distinguish "the embedded daemon crashed and we stopped retrying" (a
|
||||||
|
// hard fault needing a manual restart) from an ordinary lost/failed connection (retryable).
|
||||||
|
if (in.using_embedded_daemon && in.has_daemon_controller && !in.daemon_running &&
|
||||||
|
in.daemon_crash_count >= kNodeBannerCrashGiveUpCount) {
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Error;
|
||||||
|
s.reason = NodeBannerReason::DaemonCrashed;
|
||||||
|
s.action = NodeBannerAction::RestartNode;
|
||||||
|
s.detail = !in.daemon_last_error.empty() ? in.daemon_last_error : in.connection_status;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
s.show = true;
|
||||||
|
s.severity = NodeBannerSeverity::Warning;
|
||||||
|
s.reason = NodeBannerReason::FullNodeOffline;
|
||||||
|
s.action = NodeBannerAction::Reconnect;
|
||||||
|
s.detail = in.connection_status;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::ui
|
||||||
@@ -32,19 +32,22 @@ void Notifications::render()
|
|||||||
return v >= 0 ? v : fb;
|
return v >= 0 ? v : fb;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Status bar geometry
|
// Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill
|
||||||
float sbHeight = S.window("components.status-bar").height;
|
// are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar
|
||||||
if (sbHeight <= 0.0f) sbHeight = 30.0f;
|
// and keep the icon/text inside the pill at HiDPI.
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
|
float sbHeight = S.window("components.status-bar").height * dp;
|
||||||
|
if (sbHeight <= 0.0f) sbHeight = 30.0f * dp;
|
||||||
|
|
||||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||||
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
|
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
|
||||||
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
|
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
|
||||||
|
|
||||||
// Toast pill sizing — fit inside status bar with margin
|
// Toast pill sizing — fit inside status bar with margin
|
||||||
float pillMarginY = nde("pill-margin-y", 3.0f);
|
float pillMarginY = nde("pill-margin-y", 3.0f) * dp;
|
||||||
float pillHeight = sbHeight - pillMarginY * 2.0f;
|
float pillHeight = sbHeight - pillMarginY * 2.0f;
|
||||||
float pillPadX = nde("padding-x", 12.0f);
|
float pillPadX = nde("padding-x", 12.0f) * dp;
|
||||||
float pillRounding = nde("pill-rounding", 12.0f);
|
float pillRounding = nde("pill-rounding", 12.0f) * dp;
|
||||||
|
|
||||||
// Get accent color based on type — resolved from theme palette
|
// Get accent color based on type — resolved from theme palette
|
||||||
ImVec4 accent_color, text_color;
|
ImVec4 accent_color, text_color;
|
||||||
@@ -89,7 +92,7 @@ void Notifications::render()
|
|||||||
ImFont* textFont = material::Type().caption();
|
ImFont* textFont = material::Type().caption();
|
||||||
ImFont* iconFont = material::Type().iconSmall();
|
ImFont* iconFont = material::Type().iconSmall();
|
||||||
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
|
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
|
||||||
float iconGap = 4.0f;
|
float iconGap = 4.0f * dp;
|
||||||
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
||||||
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
||||||
// Clamp to reasonable bounds
|
// Clamp to reasonable bounds
|
||||||
@@ -122,7 +125,7 @@ void Notifications::render()
|
|||||||
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
||||||
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
||||||
// bottom-left progress strip so both bottom corners are respected.
|
// bottom-left progress strip so both bottom corners are respected.
|
||||||
float progH = nde("progress-bar-height", 2.0f);
|
float progH = nde("progress-bar-height", 2.0f) * dp;
|
||||||
float progW = pillWidth * (1.0f - progress);
|
float progW = pillWidth * (1.0f - progress);
|
||||||
if (progW > 0.0f) {
|
if (progW > 0.0f) {
|
||||||
ImVec2 clipMin(pillX, pMax.y - progH);
|
ImVec2 clipMin(pillX, pMax.y - progH);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <ctime>
|
||||||
#include "../util/logger.h"
|
#include "../util/logger.h"
|
||||||
#include "schema/ui_schema.h"
|
#include "schema/ui_schema.h"
|
||||||
|
|
||||||
@@ -22,6 +24,17 @@ enum class NotificationType {
|
|||||||
Error
|
Error
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
|
||||||
|
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
|
||||||
|
// after the toast is gone. See App::renderAlertHistoryPanel.
|
||||||
|
struct AlertRecord {
|
||||||
|
std::string message;
|
||||||
|
NotificationType type;
|
||||||
|
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
|
||||||
|
std::function<void()> onClick; // optional: makes this bell-panel entry actionable
|
||||||
|
std::string actionHint; // optional: accent link label rendered for the action
|
||||||
|
};
|
||||||
|
|
||||||
struct Notification {
|
struct Notification {
|
||||||
std::string message;
|
std::string message;
|
||||||
NotificationType type;
|
NotificationType type;
|
||||||
@@ -81,23 +94,43 @@ public:
|
|||||||
if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f);
|
if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f);
|
||||||
push(message, NotificationType::Error, duration);
|
push(message, NotificationType::Error, duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel.
|
||||||
|
// onClick fires when the user clicks the accent `actionHint` link in that panel.
|
||||||
|
void action(const std::string& message, NotificationType type, std::function<void()> onClick,
|
||||||
|
const std::string& actionHint, float duration = -1.0f) {
|
||||||
|
if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f);
|
||||||
|
push(message, type, duration, std::move(onClick), actionHint);
|
||||||
|
}
|
||||||
|
|
||||||
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
void push(const std::string& message, NotificationType type, float duration = 5.0f,
|
||||||
|
std::function<void()> onClick = nullptr, const std::string& actionHint = "") {
|
||||||
notifications_.emplace_back(message, type, duration);
|
notifications_.emplace_back(message, type, duration);
|
||||||
|
|
||||||
|
// Retain a copy in the persistent history (the toast above will fade in seconds; this
|
||||||
|
// survives so the user can review what happened). Thread note: every push is on the UI
|
||||||
|
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
|
||||||
|
// consistent with the rest of this class. Do NOT push from a raw worker thread.
|
||||||
|
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr)),
|
||||||
|
std::move(onClick), actionHint});
|
||||||
|
++total_pushed_;
|
||||||
|
while (history_.size() > kMaxHistory) {
|
||||||
|
history_.pop_front();
|
||||||
|
}
|
||||||
|
|
||||||
// Log errors and warnings (debug-only output)
|
// Log errors and warnings (debug-only output)
|
||||||
if (type == NotificationType::Error) {
|
if (type == NotificationType::Error) {
|
||||||
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
||||||
} else if (type == NotificationType::Warning) {
|
} else if (type == NotificationType::Warning) {
|
||||||
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward errors and warnings to console callback
|
// Forward errors and warnings to console callback
|
||||||
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
||||||
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
||||||
console_callback_(prefix + message, type == NotificationType::Error);
|
console_callback_(prefix + message, type == NotificationType::Error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Limit max notifications
|
// Limit max notifications
|
||||||
while (notifications_.size() > max_notifications_) {
|
while (notifications_.size() > max_notifications_) {
|
||||||
notifications_.pop_front();
|
notifications_.pop_front();
|
||||||
@@ -122,21 +155,34 @@ public:
|
|||||||
void clear() {
|
void clear() {
|
||||||
notifications_.clear();
|
notifications_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setMaxNotifications(size_t max) {
|
void setMaxNotifications(size_t max) {
|
||||||
max_notifications_ = max;
|
max_notifications_ = max;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Persistent alert history (for the status-bar bell panel) ──
|
||||||
|
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
|
||||||
|
const std::deque<AlertRecord>& history() const { return history_; }
|
||||||
|
bool hasHistory() const { return !history_.empty(); }
|
||||||
|
void clearHistory() { history_.clear(); }
|
||||||
|
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
|
||||||
|
/// the correct basis for an "unseen since last opened" count (deque size is not).
|
||||||
|
std::uint64_t totalPushed() const { return total_pushed_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
Notifications() = default;
|
Notifications() = default;
|
||||||
~Notifications() = default;
|
~Notifications() = default;
|
||||||
Notifications(const Notifications&) = delete;
|
Notifications(const Notifications&) = delete;
|
||||||
Notifications& operator=(const Notifications&) = delete;
|
Notifications& operator=(const Notifications&) = delete;
|
||||||
|
|
||||||
std::deque<Notification> notifications_;
|
std::deque<Notification> notifications_;
|
||||||
size_t max_notifications_ = 5;
|
size_t max_notifications_ = 5;
|
||||||
std::function<void(const std::string&, bool)> console_callback_;
|
std::function<void(const std::string&, bool)> console_callback_;
|
||||||
|
|
||||||
|
std::deque<AlertRecord> history_;
|
||||||
|
std::uint64_t total_pushed_ = 0;
|
||||||
|
static constexpr size_t kMaxHistory = 100;
|
||||||
|
|
||||||
static float schemaDuration(const char* key, float fallback) {
|
static float schemaDuration(const char* key, float fallback) {
|
||||||
float v = schema::UI().drawElement("components.notifications", key).size;
|
float v = schema::UI().drawElement("components.notifications", key).size;
|
||||||
return v > 0.0f ? v : fallback;
|
return v > 0.0f ? v : fallback;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -219,7 +219,7 @@ inline void DrawGlassCutout(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
|||||||
float lineW = s_cc.lineW;
|
float lineW = s_cc.lineW;
|
||||||
|
|
||||||
// --- Outer glow pass: wider, softer dark edge on top-left ---
|
// --- Outer glow pass: wider, softer dark edge on top-left ---
|
||||||
float glowExpand = s_cc.glowExpand;
|
float glowExpand = s_cc.glowExpand * Layout::dpiScale();
|
||||||
int glowA = (int)s_cc.glowAlpha;
|
int glowA = (int)s_cc.glowAlpha;
|
||||||
float glowLineW = s_cc.glowLineW;
|
float glowLineW = s_cc.glowLineW;
|
||||||
{
|
{
|
||||||
@@ -289,6 +289,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
|||||||
// inner pass gives crisp bevel edge. All use AddRect with rounding so
|
// inner pass gives crisp bevel edge. All use AddRect with rounding so
|
||||||
// every layer follows the rounded corners perfectly — no clip rects needed.
|
// every layer follows the rounded corners perfectly — no clip rects needed.
|
||||||
{
|
{
|
||||||
|
const float dp = Layout::dpiScale();
|
||||||
float cx = (mn.x + mx.x) * 0.5f;
|
float cx = (mn.x + mx.x) * 0.5f;
|
||||||
float cy = (mn.y + mx.y) * 0.5f;
|
float cy = (mn.y + mx.y) * 0.5f;
|
||||||
|
|
||||||
@@ -304,7 +305,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
|||||||
|
|
||||||
struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; };
|
struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; };
|
||||||
BevelPass passes[] = {
|
BevelPass passes[] = {
|
||||||
{ 0.5f, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
|
{ 0.5f * dp, 0.75f, 0.30f, 0.55f }, // Outer glow (thin)
|
||||||
{ 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel
|
{ 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -387,7 +388,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (depth > s_ic.threshold) {
|
if (depth > s_ic.threshold) {
|
||||||
float baseInset = s_ic.inset;
|
float baseInset = s_ic.inset * Layout::dpiScale();
|
||||||
int shadowMax = (int)(s_ic.maxAlpha * depth);
|
int shadowMax = (int)(s_ic.maxAlpha * depth);
|
||||||
float fadeRatio = s_ic.fadeRatio;
|
float fadeRatio = s_ic.fadeRatio;
|
||||||
float bW = mx.x - mn.x - baseInset * 2.0f;
|
float bW = mx.x - mn.x - baseInset * 2.0f;
|
||||||
@@ -487,7 +488,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
float fixedH = stripH; // collapse strip
|
float fixedH = stripH; // collapse strip
|
||||||
for (int i = 0; i < (int)NavPage::Count_; ++i)
|
for (int i = 0; i < (int)NavPage::Count_; ++i)
|
||||||
if (IsNavPageVisible(kNavItems[i].page) && kNavItems[i].section_label && showLabels)
|
if (IsNavPageVisible(kNavItems[i].page) && kNavItems[i].section_label && showLabels)
|
||||||
fixedH += olFsz + 2.0f + sectionLabelPadBot; // section label + pad below
|
fixedH += olFsz + 2.0f * dp + sectionLabelPadBot; // section label + pad below
|
||||||
fixedH += bottomPadding + stripH; // exit area
|
fixedH += bottomPadding + stripH; // exit area
|
||||||
|
|
||||||
float baseFlexH = baseNavGap;
|
float baseFlexH = baseNavGap;
|
||||||
@@ -525,7 +526,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
curY += sectionGap;
|
curY += sectionGap;
|
||||||
if (nSectionLabels < 4) sectionLabelY[nSectionLabels++] = curY;
|
if (nSectionLabels < 4) sectionLabelY[nSectionLabels++] = curY;
|
||||||
curY += olFsz + 2.0f + sectionLabelPadBot;
|
curY += olFsz + 2.0f * dp + sectionLabelPadBot;
|
||||||
} else {
|
} else {
|
||||||
curY += sectionGap * 0.4f;
|
curY += sectionGap * 0.4f;
|
||||||
if (nSeparators < 4) separatorY[nSeparators++] = curY;
|
if (nSeparators < 4) separatorY[nSeparators++] = curY;
|
||||||
@@ -538,11 +539,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
float exitRelY = curY + bottomPadding;
|
float exitRelY = curY + bottomPadding;
|
||||||
float panelH = exitRelY + stripH;
|
float panelH = exitRelY + stripH;
|
||||||
|
|
||||||
// Vertical centering — offset so panel is centered in the child window
|
|
||||||
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
|
||||||
if (centerOffset + panelH > contentHeight)
|
|
||||||
centerOffset = std::max(0.0f, contentHeight - panelH);
|
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// PASS 2: Render using computed positions
|
// PASS 2: Render using computed positions
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -552,6 +548,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
ImVec2 wp = ImGui::GetWindowPos();
|
ImVec2 wp = ImGui::GetWindowPos();
|
||||||
|
|
||||||
|
// Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight)
|
||||||
|
// to the visible area (child top -> status-bar top) using window-local geometry, so this yields
|
||||||
|
// equal top/bottom gaps at any height on every platform, no viewport dependency.
|
||||||
|
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
||||||
|
if (centerOffset + panelH > contentHeight)
|
||||||
|
centerOffset = std::max(0.0f, contentHeight - panelH);
|
||||||
|
|
||||||
float panelLeft = wp.x + glassMarginL;
|
float panelLeft = wp.x + glassMarginL;
|
||||||
float panelRight = wp.x + sidebarWidth - glassMarginR;
|
float panelRight = wp.x + sidebarWidth - glassMarginR;
|
||||||
float panelTopY = wp.y + centerOffset;
|
float panelTopY = wp.y + centerOffset;
|
||||||
@@ -651,7 +654,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
fx.drawShimmer(dl, indMin, indMax, btnRnd);
|
fx.drawShimmer(dl, indMin, indMax, btnRnd);
|
||||||
fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd);
|
fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd);
|
||||||
}
|
}
|
||||||
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f);
|
DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f * dp);
|
||||||
DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18);
|
DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18);
|
||||||
buttonRects.push_back({indMin, indMax, btnRnd});
|
buttonRects.push_back({indMin, indMax, btnRnd});
|
||||||
}
|
}
|
||||||
@@ -676,10 +679,31 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
|
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
|
||||||
|
|
||||||
if (showLabels) {
|
if (showLabels) {
|
||||||
|
// The badge is a fixed top-right corner overlay, so it must NOT move
|
||||||
|
// the icon+label — otherwise the text jumps sideways the moment a live
|
||||||
|
// count toggles the badge on/off. Reserve clearance from whether the
|
||||||
|
// page CAN show a badge (constant per item), never from the current
|
||||||
|
// count, and keep the icon+label centered in the FULL button width so
|
||||||
|
// the text position and size stay identical with or without a badge.
|
||||||
|
bool itemBadgeCapable =
|
||||||
|
item.page == NavPage::History ||
|
||||||
|
item.page == NavPage::Mining ||
|
||||||
|
item.page == NavPage::Chat;
|
||||||
|
float badgeReserve = 0.0f;
|
||||||
|
if (itemBadgeCapable) {
|
||||||
|
bool dotOnlyReserve = (item.page == NavPage::Mining);
|
||||||
|
float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber;
|
||||||
|
float badgeInsetXReserve = sde("badge-inset-x", 6.0f);
|
||||||
|
badgeReserve = badgeRReserve * 2.0f + badgeInsetXReserve;
|
||||||
|
}
|
||||||
|
|
||||||
ImFont* font = selected ? Type().subtitle2() : Type().body2();
|
ImFont* font = selected ? Type().subtitle2() : Type().body2();
|
||||||
float lblFsz = ScaledFontSize(font);
|
float lblFsz = ScaledFontSize(font);
|
||||||
float btnW = indMax.x - indMin.x;
|
float btnW = indMax.x - indMin.x;
|
||||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2;
|
// Clearance is symmetric (2x) because the group stays centered in the
|
||||||
|
// full width: reserving on both sides keeps the label's right edge clear
|
||||||
|
// of the right-side corner badge without shifting the center off-axis.
|
||||||
|
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve * 2.0f;
|
||||||
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||||
if (labelSz.x > maxLabelW && maxLabelW > 0) {
|
if (labelSz.x > maxLabelW && maxLabelW > 0) {
|
||||||
lblFsz *= maxLabelW / labelSz.x;
|
lblFsz *= maxLabelW / labelSz.x;
|
||||||
@@ -714,16 +738,16 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
|||||||
badgeCol = Warning(); badgeTextCol = OnWarning();
|
badgeCol = Warning(); badgeTextCol = OnWarning();
|
||||||
} else if (item.page == NavPage::Mining && status.miningActive) {
|
} else if (item.page == NavPage::Mining && status.miningActive) {
|
||||||
dotOnly = true; badgeCol = Success();
|
dotOnly = true; badgeCol = Success();
|
||||||
} else if (item.page == NavPage::Peers && status.peerCount > 0) {
|
|
||||||
badgeCount = status.peerCount;
|
|
||||||
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
|
} else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) {
|
||||||
badgeCount = status.chatUnreadCount;
|
badgeCount = status.chatUnreadCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (badgeCount > 0 || dotOnly) {
|
if (badgeCount > 0 || dotOnly) {
|
||||||
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
|
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
|
||||||
float bx = indMax.x - badgeR - 6.0f;
|
float badgeInsetX = sde("badge-inset-x", 6.0f);
|
||||||
float by = indMin.y + badgeR + 5.0f;
|
float badgeInsetY = sde("badge-inset-y", 5.0f);
|
||||||
|
float bx = indMax.x - badgeR - badgeInsetX;
|
||||||
|
float by = indMin.y + badgeR + badgeInsetY;
|
||||||
dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol);
|
dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol);
|
||||||
if (!dotOnly && showLabels) {
|
if (!dotOnly && showLabels) {
|
||||||
char buf[16];
|
char buf[16];
|
||||||
|
|||||||
48
src/ui/staleness_badge.h
Normal file
48
src/ui/staleness_badge.h
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
|
||||||
|
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
|
||||||
|
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
|
||||||
|
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
|
||||||
|
// This badge is the surface that reflects that "connected but the number may be out of date" state.
|
||||||
|
//
|
||||||
|
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
|
||||||
|
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
|
||||||
|
// stamps with, so age = now - last_update is consistent.
|
||||||
|
namespace dragonx::ui {
|
||||||
|
|
||||||
|
enum class StalenessSeverity {
|
||||||
|
Warning, // amber — noticeably behind
|
||||||
|
Error, // red — very stale, something is likely wrong
|
||||||
|
};
|
||||||
|
|
||||||
|
struct StalenessBadge {
|
||||||
|
bool show = false;
|
||||||
|
StalenessSeverity severity = StalenessSeverity::Warning;
|
||||||
|
std::int64_t seconds_old = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
|
||||||
|
// with no successful update means refreshes are failing, not merely slow.
|
||||||
|
inline constexpr std::int64_t kStaleAfterSeconds = 45;
|
||||||
|
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
|
||||||
|
|
||||||
|
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
|
||||||
|
StalenessBadge b;
|
||||||
|
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
|
||||||
|
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
|
||||||
|
if (!connected || last_update <= 0) return b;
|
||||||
|
|
||||||
|
std::int64_t age = now - last_update;
|
||||||
|
if (age < 0) age = 0; // clock skew guard
|
||||||
|
if (age < kStaleAfterSeconds) return b;
|
||||||
|
|
||||||
|
b.show = true;
|
||||||
|
b.seconds_old = age;
|
||||||
|
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace dragonx::ui
|
||||||
@@ -125,29 +125,26 @@ void RenderAboutDialog(App* app, bool* p_open)
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::TextWrapped("%s", TR("about_license_text"));
|
ImGui::TextWrapped("%s", TR("about_license_text"));
|
||||||
|
|
||||||
ImGui::Spacing();
|
// Links — 3-button action row, centered via the shared footer helper (draws its own
|
||||||
ImGui::Separator();
|
// Spacing/Separator/Spacing above the row, replacing the hand-rolled divider block).
|
||||||
ImGui::Spacing();
|
const float linksTotalW = linkW * 3.0f + ImGui::GetStyle().ItemSpacing.x * 2.0f;
|
||||||
|
material::BeginOverlayDialogFooter(linksTotalW);
|
||||||
// Links
|
if (material::TactileButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||||
if (material::StyledButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
|
||||||
util::Platform::openUrl("https://dragonx.is");
|
util::Platform::openUrl("https://dragonx.is");
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (material::StyledButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
if (material::TactileButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||||
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
if (material::TactileButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||||
util::Platform::openUrl("https://explorer.dragonx.is");
|
util::Platform::openUrl("https://explorer.dragonx.is");
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
// Close button — lone dismiss action, centered via the shared footer helper (no extra
|
||||||
|
// divider above it, so it sits directly under the links row).
|
||||||
// Close button
|
material::BeginOverlayDialogFooter(closeW, false);
|
||||||
float button_width = closeW;
|
if (material::TactileButton(TR("close"), ImVec2(closeW, 0), S.resolveFont(closeBtn.font))) {
|
||||||
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - button_width) * 0.5f);
|
|
||||||
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
|
||||||
*p_open = false;
|
*p_open = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -138,8 +138,13 @@ public:
|
|||||||
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
|
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
|
||||||
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
|
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
|
||||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
|
||||||
|
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
|
||||||
|
// Scrollbar visible (not NoScrollbar) — the icon set exceeds the fixed-height
|
||||||
|
// grid, so a real scrollbar is the discoverable way to reach the rest.
|
||||||
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
|
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
|
||||||
ImGuiWindowFlags_NoScrollbar);
|
ImGuiWindowFlags_NoScrollWithMouse);
|
||||||
|
ApplySmoothScroll();
|
||||||
|
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
|
|
||||||
@@ -185,6 +190,7 @@ public:
|
|||||||
}
|
}
|
||||||
|
|
||||||
ImGui::EndChild();
|
ImGui::EndChild();
|
||||||
|
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
|
||||||
ImGui::PopStyleColor();
|
ImGui::PopStyleColor();
|
||||||
|
|
||||||
if (ImGui::GetCursorPosY() < controlsTopY) {
|
if (ImGui::GetCursorPosY() < controlsTopY) {
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ public:
|
|||||||
// Arrow
|
// Arrow
|
||||||
{
|
{
|
||||||
float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f;
|
float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f;
|
||||||
ImGui::SetCursorPosX(arrowCX - 8.0f);
|
ImGui::SetCursorPosX(arrowCX - 8.0f * dp);
|
||||||
ImFont* iconFont = Type().iconMed();
|
ImFont* iconFont = Type().iconMed();
|
||||||
float fsz = ScaledFontSize(iconFont);
|
float fsz = ScaledFontSize(iconFont);
|
||||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||||
@@ -169,9 +169,9 @@ public:
|
|||||||
}
|
}
|
||||||
if (amountValid && newFromBal < 1e-9) {
|
if (amountValid && newFromBal < 1e-9) {
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
||||||
ImGui::TextWrapped("%s", TR("sends_full_balance_warning"));
|
// so this stakes-bearing line reads as distinct from the neutral preview text.
|
||||||
ImGui::PopStyleColor();
|
DialogWarningHeader(TR("sends_full_balance_warning"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
@@ -180,16 +180,14 @@ public:
|
|||||||
const char* sendingLabel = TR("sending");
|
const char* sendingLabel = TR("sending");
|
||||||
ImFont* buttonFont = Type().button();
|
ImFont* buttonFont = Type().button();
|
||||||
float buttonFontSize = ScaledFontSize(buttonFont);
|
float buttonFontSize = ScaledFontSize(buttonFont);
|
||||||
float minBtnW = 120.0f * dp;
|
|
||||||
float confirmMinW = 160.0f * dp;
|
float confirmMinW = 160.0f * dp;
|
||||||
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
||||||
float cancelW = std::max(minBtnW,
|
// Both footer buttons share one width (equal-width primary/Close pair), sized to fit the
|
||||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
|
// widest label — the "Sending…" swap label included — so nothing clips.
|
||||||
float confirmTextW = std::max(
|
float confirmTextW = std::max(
|
||||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
||||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
||||||
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
float btnW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||||
float totalW = cancelW + confirmW + Layout::spacingMd();
|
|
||||||
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
|
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
|
||||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||||
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
||||||
@@ -201,19 +199,18 @@ public:
|
|||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::Spacing();
|
// Standardized primary + Close footer (centered, no divider). The primary is the
|
||||||
|
// Confirm/"Sending…" action; the Close button dismisses the dialog.
|
||||||
|
bool outConfirm = false;
|
||||||
|
bool outClose = false;
|
||||||
|
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
||||||
|
amountValid && !s_sending,
|
||||||
|
cancelLabel, outConfirm, outClose, btnW);
|
||||||
|
|
||||||
float rowStartX = ImGui::GetCursorPosX();
|
if (outClose) {
|
||||||
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;
|
s_open = false;
|
||||||
}
|
}
|
||||||
ImGui::SameLine(0, Layout::spacingMd());
|
if (outConfirm) {
|
||||||
|
|
||||||
ImGui::BeginDisabled(!amountValid || s_sending);
|
|
||||||
if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) {
|
|
||||||
s_sending = true;
|
s_sending = true;
|
||||||
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
||||||
amount, s_fee, "",
|
amount, s_fee, "",
|
||||||
@@ -231,7 +228,6 @@ public:
|
|||||||
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
|
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
|
||||||
// (with its own Close button). Previously closing here made that result screen dead code.
|
// (with its own Close button). Previously closing here made that result screen dead code.
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
|
||||||
|
|
||||||
EndOverlayDialog();
|
EndOverlayDialog();
|
||||||
}
|
}
|
||||||
@@ -316,7 +312,8 @@ private:
|
|||||||
|
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
float btnW = 120.0f;
|
const float dp = Layout::dpiScale();
|
||||||
|
float btnW = 120.0f * dp;
|
||||||
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f);
|
ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f);
|
||||||
if (TactileButton(TR("close"), ImVec2(btnW, 0))) {
|
if (TactileButton(TR("close"), ImVec2(btnW, 0))) {
|
||||||
s_open = false;
|
s_open = false;
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
|
|||||||
float spacingSm,
|
float spacingSm,
|
||||||
float spacingXs)
|
float spacingXs)
|
||||||
{
|
{
|
||||||
|
(void)spacingXs; // trailing button now insets by rowPadLeft (mirrors the left margin)
|
||||||
AddressRowLayout layout;
|
AddressRowLayout layout;
|
||||||
layout.contentStartX = rowX + rowPadLeft;
|
layout.contentStartX = rowX + rowPadLeft;
|
||||||
layout.contentStartY = rowY + spacingMd;
|
layout.contentStartY = rowY + spacingMd;
|
||||||
@@ -77,7 +78,9 @@ AddressRowLayout ComputeAddressRowLayout(float rowX,
|
|||||||
|
|
||||||
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
|
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
|
||||||
const float rightEdge = rowX + rowWidth;
|
const float rightEdge = rowX + rowWidth;
|
||||||
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
|
// Inset the trailing (favorite/star) button by the card's inner padding — the same margin the
|
||||||
|
// left content uses (rowPadLeft) — so it mirrors the left edge instead of hugging the card edge.
|
||||||
|
const float favoriteX = rightEdge - layout.buttonSize - rowPadLeft;
|
||||||
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
|
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
|
||||||
|
|
||||||
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
|
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
|
|||||||
|
|
||||||
// Render the shared address list section (used by all layouts)
|
// Render the shared address list section (used by all layouts)
|
||||||
void RenderSharedAddressList(App* app, float listH, float availW,
|
void RenderSharedAddressList(App* app, float listH, float availW,
|
||||||
float glassRound, float hs, float vs) {
|
float glassRound, float hs, float vs, float reserveBelow) {
|
||||||
using namespace material;
|
using namespace material;
|
||||||
const auto& S = schema::UISchema::instance();
|
const auto& S = schema::UISchema::instance();
|
||||||
const float dp = Layout::dpiScale();
|
const float dp = Layout::dpiScale();
|
||||||
@@ -188,8 +188,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float buttonWidth = (addrBtn.width > 0) ? addrBtn.width : 140.0f;
|
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
|
||||||
float spacing = (addrBtn.gap > 0) ? addrBtn.gap : 8.0f;
|
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
|
||||||
float totalButtonsWidth = buttonWidth * 2 + spacing;
|
float totalButtonsWidth = buttonWidth * 2 + spacing;
|
||||||
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
|
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
|
||||||
S.drawElement("tabs.balance", "buttons-position").size * hs);
|
S.drawElement("tabs.balance", "buttons-position").size * hs);
|
||||||
@@ -225,6 +225,14 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
|
|
||||||
// ---- Glass panel container ----
|
// ---- Glass panel container ----
|
||||||
float addrListH = listH;
|
float addrListH = listH;
|
||||||
|
// Cap the card to the space that actually remains here (measured AFTER the title + toolbar are laid
|
||||||
|
// out, so no chrome modelling is needed) minus what the caller reserves for the section below it
|
||||||
|
// (recent-tx). Without this, a fixed dp-scaled listH grows ~1.5x at high font scale and evicts the
|
||||||
|
// Recent Transactions list off the bottom of the fixed, non-scrolling tab host.
|
||||||
|
if (reserveBelow > 0.0f) {
|
||||||
|
float maxH = ImGui::GetContentRegionAvail().y - reserveBelow;
|
||||||
|
if (maxH < addrListH) addrListH = maxH;
|
||||||
|
}
|
||||||
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
|
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
|
||||||
|
|
||||||
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
|
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
|
||||||
@@ -256,7 +264,7 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
} else if (rows.empty()) {
|
} else if (rows.empty()) {
|
||||||
float cw = ImGui::GetContentRegionAvail().x;
|
float cw = ImGui::GetContentRegionAvail().x;
|
||||||
float ch = ImGui::GetContentRegionAvail().y;
|
float ch = ImGui::GetContentRegionAvail().y;
|
||||||
if (ch < 60) ch = 60;
|
if (ch < 60.0f * dp) ch = 60.0f * dp;
|
||||||
const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet");
|
const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet");
|
||||||
ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg);
|
ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg);
|
||||||
ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f);
|
ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f);
|
||||||
@@ -324,12 +332,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
s_dragIdx < (int)rows.size()) {
|
s_dragIdx < (int)rows.size()) {
|
||||||
const auto& srcRow = rows[s_dragIdx];
|
const auto& srcRow = rows[s_dragIdx];
|
||||||
const auto& dstRow = rows[s_dropTargetIdx];
|
const auto& dstRow = rows[s_dropTargetIdx];
|
||||||
if (srcRow.info->balance > 1e-9) {
|
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
|
||||||
AddressTransferDialog::TransferInfo ti;
|
AddressTransferDialog::TransferInfo ti;
|
||||||
ti.fromAddr = srcRow.info->address;
|
ti.fromAddr = srcRow.info->address;
|
||||||
ti.toAddr = dstRow.info->address;
|
ti.toAddr = dstRow.info->address;
|
||||||
ti.fromBalance = srcRow.info->balance;
|
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
|
||||||
ti.toBalance = dstRow.info->balance;
|
ti.toBalance = dstRow.info->balance; // destination display only
|
||||||
ti.fromIsZ = srcRow.isZ;
|
ti.fromIsZ = srcRow.isZ;
|
||||||
ti.toIsZ = dstRow.isZ;
|
ti.toIsZ = dstRow.isZ;
|
||||||
AddressTransferDialog::show(app, ti);
|
AddressTransferDialog::show(app, ti);
|
||||||
@@ -466,7 +474,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
{
|
{
|
||||||
const auto& starRect = rowLayout.favoriteButton;
|
const auto& starRect = rowLayout.favoriteButton;
|
||||||
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
||||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||||
|
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||||
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
|
dl->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);
|
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
|
||||||
ImFont* iconFont = Type().iconSmall();
|
ImFont* iconFont = Type().iconSmall();
|
||||||
@@ -492,7 +501,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
if (showEye) {
|
if (showEye) {
|
||||||
const auto& eyeRect = rowLayout.visibilityButton;
|
const auto& eyeRect = rowLayout.visibilityButton;
|
||||||
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
||||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||||
|
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||||
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
||||||
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
||||||
ImFont* iconFont = Type().iconSmall();
|
ImFont* iconFont = Type().iconSmall();
|
||||||
@@ -539,12 +549,24 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
|||||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||||
|
|
||||||
// User label next to type
|
// User label next to type — clip to the gap before the right-aligned
|
||||||
|
// balance so a long custom label can't overrun the balance on this line
|
||||||
|
// (mirrors the address-line width guard below).
|
||||||
if (!row.label.empty()) {
|
if (!row.label.empty()) {
|
||||||
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
|
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
float userLabelX = labelX + typeLabelW + Layout::spacingLg();
|
||||||
ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy),
|
// Balance is drawn right-aligned at contentRight; recompute its left edge here.
|
||||||
OnSurfaceMedium(), row.label.c_str());
|
char balBufPeek[32];
|
||||||
|
snprintf(balBufPeek, sizeof(balBufPeek), "%.8f", addr.balance);
|
||||||
|
float balW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBufPeek).x;
|
||||||
|
float userLabelAvailW = (contentRight - balW - Layout::spacingMd()) - userLabelX;
|
||||||
|
std::string userLabel = material::TruncateToWidth(
|
||||||
|
row.label, capFont, capFont->LegacySize, userLabelAvailW);
|
||||||
|
if (userLabelAvailW > 0.0f) {
|
||||||
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
|
ImVec2(userLabelX, cy),
|
||||||
|
OnSurfaceMedium(), userLabel.c_str());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -783,6 +805,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
|
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
|
||||||
const auto& state = app->state();
|
const auto& state = app->state();
|
||||||
|
|
||||||
|
float headerStartY = ImGui::GetCursorPosY();
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
@@ -790,6 +813,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
app->setCurrentPage(NavPage::History);
|
app->setCurrentPage(NavPage::History);
|
||||||
}
|
}
|
||||||
ImGui::Spacing();
|
ImGui::Spacing();
|
||||||
|
float headerHeight = ImGui::GetCursorPosY() - headerStartY;
|
||||||
|
|
||||||
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
||||||
float availableListH = ImGui::GetContentRegionAvail().y;
|
float availableListH = ImGui::GetContentRegionAvail().y;
|
||||||
@@ -798,14 +822,17 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
ImGuiWindowFlags_NoBackground);
|
ImGuiWindowFlags_NoBackground);
|
||||||
|
|
||||||
const auto& txs = state.transactions;
|
const auto& txs = state.transactions;
|
||||||
int count = std::min(4, (int)txs.size()); // show only the 4 most recent (state.transactions is newest-first)
|
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||||
|
// Only draw as many rows as fully fit within the reserved section height (header + rows);
|
||||||
|
// dropping the overflow row is fine since "View All" already links to full History.
|
||||||
|
int maxRows = std::max(1, (int)((recentH - headerHeight) / rowH));
|
||||||
|
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
||||||
|
|
||||||
if (count == 0) {
|
if (count == 0) {
|
||||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
||||||
} else {
|
} else {
|
||||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||||
ImFont* capFont = Type().caption();
|
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,
|
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
||||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||||
|
|
||||||
@@ -820,7 +847,11 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.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);
|
// Start the address column past the MEASURED type-label width (plus a fixed gap) so it can
|
||||||
|
// never overlap the label — a fixed schema offset shrinks below the label width at narrow
|
||||||
|
// widths (hs < 1) and collides. Mirrors how amtX/agoSz measure their own text below.
|
||||||
|
ImVec2 typeSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.typeText.c_str());
|
||||||
|
float addrX = tx_x + typeSz.x + Layout::spacingMd();
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||||
|
|
||||||
@@ -835,13 +866,13 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
|||||||
|
|
||||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs, rowPos.y + 2 * dp),
|
||||||
OnSurfaceDisabled(), display.timeText.c_str());
|
OnSurfaceDisabled(), display.timeText.c_str());
|
||||||
|
|
||||||
float rowW = ImGui::GetContentRegionAvail().x;
|
float rowW = ImGui::GetContentRegionAvail().x;
|
||||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||||
if (material::IsRectHovered(rowPos, rowEnd)) {
|
if (material::IsRectHovered(rowPos, rowEnd)) {
|
||||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
dl->AddRectFilled(rowPos, rowEnd, SurfaceOverlay(15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::History);
|
app->setCurrentPage(NavPage::History);
|
||||||
@@ -868,7 +899,7 @@ void RenderSyncBar(App* app, ImDrawList* dl, float vs) {
|
|||||||
ImVec2 barPos = ImGui::GetCursorScreenPos();
|
ImVec2 barPos = ImGui::GetCursorScreenPos();
|
||||||
dl->AddRectFilled(barPos,
|
dl->AddRectFilled(barPos,
|
||||||
ImVec2(barPos.x + barW, barPos.y + barH),
|
ImVec2(barPos.x + barW, barPos.y + barH),
|
||||||
IM_COL32(255, 255, 255, 15), 1.0f * dp);
|
SurfaceOverlay(15), 1.0f * dp);
|
||||||
dl->AddRectFilled(barPos,
|
dl->AddRectFilled(barPos,
|
||||||
ImVec2(barPos.x + barW * prog, barPos.y + barH),
|
ImVec2(barPos.x + barW * prog, barPos.y + barH),
|
||||||
WithAlpha(Warning(), 200), 1.0f * dp);
|
WithAlpha(Warning(), 200), 1.0f * dp);
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ extern bool s_generating_z_address;
|
|||||||
void UpdateBalanceLerp(App* app);
|
void UpdateBalanceLerp(App* app);
|
||||||
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs,
|
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs,
|
||||||
float heroHeightOverride = -1.0f);
|
float heroHeightOverride = -1.0f);
|
||||||
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs);
|
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs,
|
||||||
|
float reserveBelow = 0.0f);
|
||||||
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
||||||
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,12 @@
|
|||||||
#include "../effects/imgui_acrylic.h"
|
#include "../effects/imgui_acrylic.h"
|
||||||
#include "../sidebar.h"
|
#include "../sidebar.h"
|
||||||
#include "../notifications.h"
|
#include "../notifications.h"
|
||||||
|
#include "../staleness_badge.h"
|
||||||
#include "../../embedded/IconsMaterialDesign.h"
|
#include "../../embedded/IconsMaterialDesign.h"
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include <toml++/toml.hpp>
|
#include <toml++/toml.hpp>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <ctime>
|
#include <ctime>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
@@ -195,7 +197,9 @@ void RenderBalanceTab(App* app)
|
|||||||
for (const auto& l : allLayouts) {
|
for (const auto& l : allLayouts) {
|
||||||
if (l.id == layoutId) { displayName = l.name; break; }
|
if (l.id == layoutId) { displayName = l.name; break; }
|
||||||
}
|
}
|
||||||
Notifications::instance().info("Layout: " + displayName);
|
char layoutToast[128];
|
||||||
|
snprintf(layoutToast, sizeof(layoutToast), TR("balance_layout_switched"), displayName.c_str());
|
||||||
|
Notifications::instance().info(layoutToast);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,9 +302,9 @@ static void RenderBalanceClassic(App* app)
|
|||||||
float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg();
|
float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg();
|
||||||
|
|
||||||
// Card height: must fit the Market card's content (overline + price + 24h)
|
// Card height: must fit the Market card's content (overline + price + 24h)
|
||||||
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f);
|
const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f) * dp;
|
||||||
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f);
|
const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f) * dp;
|
||||||
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f);
|
const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f) * dp;
|
||||||
float marketContentH = cardPadLg
|
float marketContentH = cardPadLg
|
||||||
+ ovFont->LegacySize + ovGap
|
+ ovFont->LegacySize + ovGap
|
||||||
+ sub1->LegacySize + 2.0f * dp
|
+ sub1->LegacySize + 2.0f * dp
|
||||||
@@ -319,7 +323,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
// Helper: draw accent stripe on left edge, clipped to card rounded corners.
|
// Helper: draw accent stripe on left edge, clipped to card rounded corners.
|
||||||
// We draw a full-size rounded rect (left corners only) and clip it to the
|
// We draw a full-size rounded rect (left corners only) and clip it to the
|
||||||
// stripe width so the shape itself follows the card rounding.
|
// stripe width so the shape itself follows the card rounding.
|
||||||
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
|
const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
|
||||||
auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) {
|
auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) {
|
||||||
dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true);
|
dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true);
|
||||||
dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding,
|
dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding,
|
||||||
@@ -358,8 +362,11 @@ static void RenderBalanceClassic(App* app)
|
|||||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
|
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string totalLabelUpper = TR("total_balance_label");
|
||||||
|
std::transform(totalLabelUpper.begin(), totalLabelUpper.end(), totalLabelUpper.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||||
OnSurfaceMedium(), "TOTAL BALANCE");
|
OnSurfaceMedium(), totalLabelUpper.c_str());
|
||||||
cy += ovFont->LegacySize + ovGap;
|
cy += ovFont->LegacySize + ovGap;
|
||||||
|
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||||
@@ -386,7 +393,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
// Sync progress or mining indicator (whichever fits)
|
// Sync progress or mining indicator (whichever fits)
|
||||||
if (state.sync.syncing && state.sync.headers > 0) {
|
if (state.sync.syncing && state.sync.headers > 0) {
|
||||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||||
Warning(), buf);
|
Warning(), buf);
|
||||||
|
|
||||||
@@ -400,7 +407,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
|
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
|
||||||
// Background track
|
// Background track
|
||||||
dl->AddRectFilled(cMin, cMax,
|
dl->AddRectFilled(cMin, cMax,
|
||||||
IM_COL32(255, 255, 255, 15), cardSpec.rounding);
|
SurfaceOverlay(15), cardSpec.rounding);
|
||||||
// Progress fill — additional horizontal clip
|
// Progress fill — additional horizontal clip
|
||||||
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
|
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
|
||||||
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
|
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
|
||||||
@@ -417,16 +424,39 @@ static void RenderBalanceClassic(App* app)
|
|||||||
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
|
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
|
||||||
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
|
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
|
||||||
double hr = state.mining.localHashrate;
|
double hr = state.mining.localHashrate;
|
||||||
snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str());
|
// Leading indent clears the mining dot drawn at cx+4dp; the text
|
||||||
|
// itself starts at cx+12dp so keep the two spaces for spacing parity.
|
||||||
|
char mineFmt[64];
|
||||||
|
snprintf(mineFmt, sizeof(mineFmt), " %s", TR("balance_mining_rate"));
|
||||||
|
snprintf(buf, sizeof(buf), mineFmt, FormatHashrate(hr).c_str());
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(cx + 12 * dp, cy),
|
ImVec2(cx + 12 * dp, cy),
|
||||||
WithAlpha(Success(), 200), buf);
|
WithAlpha(Success(), 200), buf);
|
||||||
|
} else {
|
||||||
|
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
|
||||||
|
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
|
||||||
|
// dropping the whole connection). The node banner only covers full disconnects, so
|
||||||
|
// this pill is the sole signal that the shown number may be out of date.
|
||||||
|
StalenessBadge badge = evaluateStalenessBadge(
|
||||||
|
state.last_balance_update, std::time(nullptr), state.connected);
|
||||||
|
if (badge.show) {
|
||||||
|
const bool err = (badge.severity == StalenessSeverity::Error);
|
||||||
|
ImU32 fg = err ? Error() : Warning();
|
||||||
|
ImU32 bg = WithAlpha(fg, 38);
|
||||||
|
ImU32 bd = WithAlpha(fg, 90);
|
||||||
|
snprintf(buf, sizeof(buf), "%s %s",
|
||||||
|
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
|
||||||
|
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd, ImVec2(4.0f * dp, 2.0f * dp));
|
||||||
|
// Hover → explain what stale means and how old the data actually is.
|
||||||
|
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y)))
|
||||||
|
Tooltip("%s", TR("data_stale_tooltip"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hover glow
|
// Hover glow
|
||||||
if (material::IsRectHovered(cMin, cMax)) {
|
if (material::IsRectHovered(cMin, cMax)) {
|
||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -457,7 +487,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
{
|
{
|
||||||
float privPct = (s_dispTotal > 1e-9)
|
float privPct = (s_dispTotal > 1e-9)
|
||||||
? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
|
? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f;
|
||||||
snprintf(buf, sizeof(buf), "%.0f%% of total · %d Z-addr",
|
snprintf(buf, sizeof(buf), TR("baltab_pct_of_total_zaddr"),
|
||||||
privPct, (int)state.z_addresses.size());
|
privPct, (int)state.z_addresses.size());
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||||
WithAlpha(Success(), 160), buf);
|
WithAlpha(Success(), 160), buf);
|
||||||
@@ -468,8 +498,8 @@ static void RenderBalanceClassic(App* app)
|
|||||||
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
|
snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance);
|
||||||
ImVec2 ts = capFont->CalcTextSizeA(
|
ImVec2 ts = capFont->CalcTextSizeA(
|
||||||
capFont->LegacySize, 10000, 0, buf);
|
capFont->LegacySize, 10000, 0, buf);
|
||||||
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f);
|
float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp;
|
||||||
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f);
|
float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f) * dp;
|
||||||
ImVec2 bMin(cMax.x - ts.x - bp * 3,
|
ImVec2 bMin(cMax.x - ts.x - bp * 3,
|
||||||
cMin.y + cardPadLg);
|
cMin.y + cardPadLg);
|
||||||
ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp);
|
ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp);
|
||||||
@@ -483,7 +513,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
// Hover glow + click to Receive
|
// Hover glow + click to Receive
|
||||||
if (material::IsRectHovered(cMin, cMax)) {
|
if (material::IsRectHovered(cMin, cMax)) {
|
||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::Receive);
|
app->setCurrentPage(NavPage::Receive);
|
||||||
@@ -513,7 +543,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
OnSurfaceMedium(), DRAGONX_TICKER);
|
OnSurfaceMedium(), DRAGONX_TICKER);
|
||||||
cy += sub1->LegacySize + valGap;
|
cy += sub1->LegacySize + valGap;
|
||||||
|
|
||||||
snprintf(buf, sizeof(buf), "%d T-addresses",
|
snprintf(buf, sizeof(buf), TR("baltab_t_addresses_count"),
|
||||||
(int)state.t_addresses.size());
|
(int)state.t_addresses.size());
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||||
OnSurfaceDisabled(), buf);
|
OnSurfaceDisabled(), buf);
|
||||||
@@ -521,7 +551,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
// Hover glow + click to Receive
|
// Hover glow + click to Receive
|
||||||
if (material::IsRectHovered(cMin, cMax)) {
|
if (material::IsRectHovered(cMin, cMax)) {
|
||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::Receive);
|
app->setCurrentPage(NavPage::Receive);
|
||||||
@@ -554,15 +584,18 @@ static void RenderBalanceClassic(App* app)
|
|||||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
|
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
|
||||||
|
|
||||||
// Measure widest text line to determine sparkline left edge
|
// Measure widest text line to determine sparkline left edge
|
||||||
|
std::string marketLabel = TR("market");
|
||||||
|
std::transform(marketLabel.begin(), marketLabel.end(), marketLabel.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
float textW = std::max(pSz.x + tickGap + usdSz.x,
|
float textW = std::max(pSz.x + tickGap + usdSz.x,
|
||||||
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, "MARKET").x);
|
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, marketLabel.c_str()).x);
|
||||||
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f);
|
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||||
float sparkLeft = cx + textW + sparkGap;
|
float sparkLeft = cx + textW + sparkGap;
|
||||||
float sparkRight = cMax.x - cardPadLg;
|
float sparkRight = cMax.x - cardPadLg;
|
||||||
|
|
||||||
// Left side: label + price + 24h change
|
// Left side: label + price + 24h change
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||||
OnSurfaceMedium(), "MARKET");
|
OnSurfaceMedium(), marketLabel.c_str());
|
||||||
cy += ovFont->LegacySize + ovGap;
|
cy += ovFont->LegacySize + ovGap;
|
||||||
|
|
||||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
|
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
|
||||||
@@ -578,7 +611,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
bool pos = market.change_24h >= 0;
|
bool pos = market.change_24h >= 0;
|
||||||
ImU32 chgCol = pos ? Success()
|
ImU32 chgCol = pos ? Success()
|
||||||
: Error();
|
: Error();
|
||||||
snprintf(buf, sizeof(buf), "%s%.1f%% 24h",
|
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"),
|
||||||
pos ? "+" : "", market.change_24h);
|
pos ? "+" : "", market.change_24h);
|
||||||
dl->AddText(capFont, capFont->LegacySize,
|
dl->AddText(capFont, capFont->LegacySize,
|
||||||
ImVec2(cx, cy), chgCol, buf);
|
ImVec2(cx, cy), chgCol, buf);
|
||||||
@@ -600,7 +633,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
// Hover glow + click to Market
|
// Hover glow + click to Market
|
||||||
if (material::IsRectHovered(cMin, cMax)) {
|
if (material::IsRectHovered(cMin, cMax)) {
|
||||||
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(NavPage::Market);
|
app->setCurrentPage(NavPage::Market);
|
||||||
@@ -628,7 +661,7 @@ static void RenderBalanceClassic(App* app)
|
|||||||
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
|
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,7 +695,7 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
else
|
else
|
||||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
|
||||||
{
|
{
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||||
ImFont* heroFont = Type().h2();
|
ImFont* heroFont = Type().h2();
|
||||||
@@ -757,20 +790,20 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
ImFont* capFont = Type().caption();
|
ImFont* capFont = Type().caption();
|
||||||
ImFont* body2 = Type().body2();
|
ImFont* body2 = Type().body2();
|
||||||
|
|
||||||
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f);
|
float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f) * dp;
|
||||||
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f);
|
float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f) * dp;
|
||||||
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f);
|
float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f) * dp;
|
||||||
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f);
|
float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f) * dp;
|
||||||
|
|
||||||
// Shielded legend
|
// Shielded legend
|
||||||
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success());
|
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success());
|
||||||
snprintf(buf, sizeof(buf), "Shielded %.8f", s_dispShielded);
|
snprintf(buf, sizeof(buf), TR("baltab_shielded_amount"), s_dispShielded);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf);
|
||||||
legendY += capFont->LegacySize + legendLineGap;
|
legendY += capFont->LegacySize + legendLineGap;
|
||||||
|
|
||||||
// Transparent legend
|
// Transparent legend
|
||||||
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
|
dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning());
|
||||||
snprintf(buf, sizeof(buf), "Transparent %.8f", s_dispTransparent);
|
snprintf(buf, sizeof(buf), TR("baltab_transparent_amount"), s_dispTransparent);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf);
|
||||||
legendY += capFont->LegacySize + legendSectionGap;
|
legendY += capFont->LegacySize + legendSectionGap;
|
||||||
|
|
||||||
@@ -778,15 +811,15 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
const auto& market = state.market;
|
const auto& market = state.market;
|
||||||
if (market.price_usd > 0) {
|
if (market.price_usd > 0) {
|
||||||
if (market.price_usd >= 0.01)
|
if (market.price_usd >= 0.01)
|
||||||
snprintf(buf, sizeof(buf), "Market: $%.4f", market.price_usd);
|
snprintf(buf, sizeof(buf), TR("baltab_market_price_4dp"), market.price_usd);
|
||||||
else
|
else
|
||||||
snprintf(buf, sizeof(buf), "Market: $%.8f", market.price_usd);
|
snprintf(buf, sizeof(buf), TR("baltab_market_price_8dp"), market.price_usd);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
||||||
OnSurfaceMedium(), buf);
|
OnSurfaceMedium(), buf);
|
||||||
legendY += capFont->LegacySize + 4 * dp;
|
legendY += capFont->LegacySize + 4 * dp;
|
||||||
|
|
||||||
bool pos = market.change_24h >= 0;
|
bool pos = market.change_24h >= 0;
|
||||||
snprintf(buf, sizeof(buf), "%s%.1f%% 24h", pos ? "+" : "", market.change_24h);
|
snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"), pos ? "+" : "", market.change_24h);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY),
|
||||||
pos ? Success() : Error(), buf);
|
pos ? Success() : Error(), buf);
|
||||||
}
|
}
|
||||||
@@ -802,7 +835,7 @@ static void RenderBalanceDonut(App* app) {
|
|||||||
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
|
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +946,7 @@ static void RenderBalanceConsolidated(App* app) {
|
|||||||
float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f);
|
float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f);
|
||||||
dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY),
|
dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY),
|
||||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)),
|
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)),
|
||||||
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f));
|
S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f) * dp);
|
||||||
|
|
||||||
// Bottom half: proportion bars
|
// Bottom half: proportion bars
|
||||||
float barY = divY + Layout::spacingSm();
|
float barY = divY + Layout::spacingSm();
|
||||||
@@ -927,10 +960,13 @@ static void RenderBalanceConsolidated(App* app) {
|
|||||||
|
|
||||||
// Shielded bar
|
// Shielded bar
|
||||||
float shieldX = cardMin.x + pad;
|
float shieldX = cardMin.x + pad;
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), "SHIELDED");
|
std::string shieldLabelUpper = TR("shielded");
|
||||||
|
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), shieldLabelUpper.c_str());
|
||||||
barY += ovFont->LegacySize + 4 * dp;
|
barY += ovFont->LegacySize + 4 * dp;
|
||||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
|
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
|
||||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
SurfaceOverlay(15), barH * 0.5f);
|
||||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
|
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
|
||||||
WithAlpha(Success(), 180), barH * 0.5f);
|
WithAlpha(Success(), 180), barH * 0.5f);
|
||||||
barY += barH + 2 * dp;
|
barY += barH + 2 * dp;
|
||||||
@@ -940,10 +976,13 @@ static void RenderBalanceConsolidated(App* app) {
|
|||||||
// Transparent bar
|
// Transparent bar
|
||||||
float transX = cardMin.x + pad * 2 + halfW;
|
float transX = cardMin.x + pad * 2 + halfW;
|
||||||
barY = divY + Layout::spacingSm();
|
barY = divY + Layout::spacingSm();
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), "TRANSPARENT");
|
std::string transLabelUpper = TR("transparent");
|
||||||
|
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), transLabelUpper.c_str());
|
||||||
barY += ovFont->LegacySize + 4 * dp;
|
barY += ovFont->LegacySize + 4 * dp;
|
||||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
|
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
|
||||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
SurfaceOverlay(15), barH * 0.5f);
|
||||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
|
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
|
||||||
WithAlpha(Warning(), 180), barH * 0.5f);
|
WithAlpha(Warning(), 180), barH * 0.5f);
|
||||||
barY += barH + 2 * dp;
|
barY += barH + 2 * dp;
|
||||||
@@ -962,7 +1001,7 @@ static void RenderBalanceConsolidated(App* app) {
|
|||||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
|
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
|
||||||
// Background track
|
// Background track
|
||||||
dl->AddRectFilled(cardMin, cardMax,
|
dl->AddRectFilled(cardMin, cardMax,
|
||||||
IM_COL32(255, 255, 255, 15), glassRound);
|
SurfaceOverlay(15), glassRound);
|
||||||
// Progress fill — additional horizontal clip
|
// Progress fill — additional horizontal clip
|
||||||
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
|
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
|
||||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
|
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
|
||||||
@@ -979,7 +1018,7 @@ static void RenderBalanceConsolidated(App* app) {
|
|||||||
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
|
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,11 +1094,20 @@ static void RenderBalanceDashboard(App* app) {
|
|||||||
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
|
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
|
||||||
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
|
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
|
||||||
|
|
||||||
|
// Localized captions — raw dl->AddText (no auto-uppercase), so uppercase to preserve the caption look.
|
||||||
|
auto upperTR = [](const char* key) {
|
||||||
|
std::string s = TR(key);
|
||||||
|
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("transparent");
|
||||||
|
std::string lblQuickSend = upperTR("quick_send"), lblQuickReceive = upperTR("quick_receive");
|
||||||
|
|
||||||
TileInfo tiles[4] = {
|
TileInfo tiles[4] = {
|
||||||
{"SHIELDED", shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
{lblShielded.c_str(), shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
||||||
{"TRANSPARENT", trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
{lblTransparent.c_str(), trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
||||||
{"QUICK SEND", "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
{lblQuickSend.c_str(), "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
||||||
{"QUICK RECEIVE", "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
{lblQuickReceive.c_str(), "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
@@ -1074,7 +1122,7 @@ static void RenderBalanceDashboard(App* app) {
|
|||||||
|
|
||||||
// Accent stripe — clipped to tile rounded corners
|
// Accent stripe — clipped to tile rounded corners
|
||||||
{
|
{
|
||||||
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f);
|
float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp;
|
||||||
dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true);
|
dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true);
|
||||||
dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding,
|
dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding,
|
||||||
ImDrawFlags_RoundCornersLeft);
|
ImDrawFlags_RoundCornersLeft);
|
||||||
@@ -1105,13 +1153,13 @@ static void RenderBalanceDashboard(App* app) {
|
|||||||
tiles[i].accent, tiles[i].value);
|
tiles[i].accent, tiles[i].value);
|
||||||
} else {
|
} else {
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
|
||||||
OnSurfaceMedium(), "Click to open");
|
OnSurfaceMedium(), TR("tile_click_to_open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click
|
// Click
|
||||||
if (material::IsRectHovered(tMin, tMax)) {
|
if (material::IsRectHovered(tMin, tMax)) {
|
||||||
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)),
|
||||||
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f));
|
tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp);
|
||||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||||
if (ImGui::IsMouseClicked(0))
|
if (ImGui::IsMouseClicked(0))
|
||||||
app->setCurrentPage(tiles[i].nav);
|
app->setCurrentPage(tiles[i].nav);
|
||||||
@@ -1127,7 +1175,7 @@ static void RenderBalanceDashboard(App* app) {
|
|||||||
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
|
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,7 +1208,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
// Font-content floor per row: icon + label + value must fit
|
// Font-content floor per row: icon + label + value must fit
|
||||||
float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize)
|
float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize)
|
||||||
+ Layout::spacingSm() * 2;
|
+ Layout::spacingSm() * 2;
|
||||||
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f);
|
float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f) * dp;
|
||||||
float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3;
|
float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3;
|
||||||
float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size;
|
float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size;
|
||||||
float stackH;
|
float stackH;
|
||||||
@@ -1190,10 +1238,10 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
RowInfo rowInfos[4] = {
|
RowInfo rowInfos[4] = {
|
||||||
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
|
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f},
|
||||||
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
|
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio},
|
||||||
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
|
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio},
|
||||||
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
|
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f},
|
||||||
};
|
};
|
||||||
|
|
||||||
for (int i = 0; i < 4; i++) {
|
for (int i = 0; i < 4; i++) {
|
||||||
@@ -1248,7 +1296,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
|
// Proportion bar (for shielded/transparent rows — fills gap between label and amount)
|
||||||
if (i == 1 || i == 2) {
|
if (i == 1 || i == 2) {
|
||||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
||||||
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
|
float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||||
float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
|
float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
|
||||||
float barH = std::max(
|
float barH = std::max(
|
||||||
S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f),
|
S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f),
|
||||||
@@ -1259,7 +1307,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
float barW = barRight - barLeft;
|
float barW = barRight - barLeft;
|
||||||
float barY = rowPos.y + (rowH - barH) * 0.5f;
|
float barY = rowPos.y + (rowH - barH) * 0.5f;
|
||||||
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
|
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
|
||||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
SurfaceOverlay(15), barH * 0.5f);
|
||||||
dl->AddRectFilled(ImVec2(barLeft, barY),
|
dl->AddRectFilled(ImVec2(barLeft, barY),
|
||||||
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
|
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
|
||||||
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
|
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
|
||||||
@@ -1278,8 +1326,8 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
// Sparkline in the gap between label and 24h change
|
// Sparkline in the gap between label and 24h change
|
||||||
if (state.market.price_history.size() >= 2) {
|
if (state.market.price_history.size() >= 2) {
|
||||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label);
|
||||||
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f);
|
float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp;
|
||||||
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f);
|
float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f) * dp;
|
||||||
float sparkLeft = px + labelSz.x + sparkGap;
|
float sparkLeft = px + labelSz.x + sparkGap;
|
||||||
float sparkRight = chgX - sparkGap;
|
float sparkRight = chgX - sparkGap;
|
||||||
if (sparkLeft < sparkRight) {
|
if (sparkLeft < sparkRight) {
|
||||||
@@ -1306,7 +1354,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
|||||||
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
|
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1339,8 +1387,8 @@ static void RenderBalanceVertical2x2(App* app) {
|
|||||||
ImFont* iconFont = Type().iconSmall();
|
ImFont* iconFont = Type().iconSmall();
|
||||||
// Font-content floor per row: caption text + vertical padding
|
// Font-content floor per row: caption text + vertical padding
|
||||||
float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2;
|
float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2;
|
||||||
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f);
|
float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f) * dp;
|
||||||
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f);
|
float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f) * dp;
|
||||||
float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap;
|
float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap;
|
||||||
float cardHOverride = S.drawElement(cfgSec, "card-height").size;
|
float cardHOverride = S.drawElement(cfgSec, "card-height").size;
|
||||||
float stackH;
|
float stackH;
|
||||||
@@ -1383,13 +1431,13 @@ static void RenderBalanceVertical2x2(App* app) {
|
|||||||
CellInfo cells[2][2] = {
|
CellInfo cells[2][2] = {
|
||||||
// Row 0: Total Balance (left), Shielded (right)
|
// Row 0: Total Balance (left), Shielded (right)
|
||||||
{
|
{
|
||||||
{"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
|
{TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false},
|
||||||
{"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
|
{TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true},
|
||||||
},
|
},
|
||||||
// Row 1: Market (left), Transparent (right)
|
// Row 1: Market (left), Transparent (right)
|
||||||
{
|
{
|
||||||
{"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
|
{TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false},
|
||||||
{"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
|
{TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1453,7 +1501,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
|||||||
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
|
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
|
||||||
float barY = cellMin.y + (rowH - barH) * 0.5f;
|
float barY = cellMin.y + (rowH - barH) * 0.5f;
|
||||||
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
|
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
|
||||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
SurfaceOverlay(15), barH * 0.5f);
|
||||||
dl->AddRectFilled(ImVec2(barX, barY),
|
dl->AddRectFilled(ImVec2(barX, barY),
|
||||||
ImVec2(barX + barW * cell.ratio, barY + barH),
|
ImVec2(barX + barW * cell.ratio, barY + barH),
|
||||||
WithAlpha(cell.accent, 180), barH * 0.5f);
|
WithAlpha(cell.accent, 180), barH * 0.5f);
|
||||||
@@ -1471,8 +1519,8 @@ static void RenderBalanceVertical2x2(App* app) {
|
|||||||
// Sparkline between label and 24h change
|
// Sparkline between label and 24h change
|
||||||
if (state.market.price_history.size() >= 2) {
|
if (state.market.price_history.size() >= 2) {
|
||||||
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label);
|
ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label);
|
||||||
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f);
|
float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f) * dp;
|
||||||
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f);
|
float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f) * dp;
|
||||||
float sparkLeft = px + labelSz.x + sparkGap;
|
float sparkLeft = px + labelSz.x + sparkGap;
|
||||||
float sparkRight = chgX - sparkGap;
|
float sparkRight = chgX - sparkGap;
|
||||||
if (sparkLeft < sparkRight) {
|
if (sparkLeft < sparkRight) {
|
||||||
@@ -1501,7 +1549,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
|||||||
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
|
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1531,7 +1579,7 @@ static void RenderBalanceShield(App* app) {
|
|||||||
else
|
else
|
||||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||||
{
|
{
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||||
ImFont* heroFont = Type().h2();
|
ImFont* heroFont = Type().h2();
|
||||||
@@ -1617,7 +1665,7 @@ static void RenderBalanceShield(App* app) {
|
|||||||
ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen,
|
ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen,
|
||||||
gaugeCy + sinf(needleAngle) * needleLen);
|
gaugeCy + sinf(needleAngle) * needleLen);
|
||||||
dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol,
|
dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol,
|
||||||
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f));
|
S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f) * dp);
|
||||||
|
|
||||||
// Center text: percentage
|
// Center text: percentage
|
||||||
ImFont* sub1 = Type().subtitle1();
|
ImFont* sub1 = Type().subtitle1();
|
||||||
@@ -1645,13 +1693,19 @@ static void RenderBalanceShield(App* app) {
|
|||||||
float infoY = panelMin.y + shieldPad;
|
float infoY = panelMin.y + shieldPad;
|
||||||
ImFont* ovFont = Type().overline();
|
ImFont* ovFont = Type().overline();
|
||||||
|
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), "SHIELDED");
|
std::string shieldLabelUpper = TR("shielded");
|
||||||
|
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
std::string transLabelUpper = TR("transparent");
|
||||||
|
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||||
|
[](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), shieldLabelUpper.c_str());
|
||||||
infoY += ovFont->LegacySize + 2 * dp;
|
infoY += ovFont->LegacySize + 2 * dp;
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
|
||||||
infoY += capFont->LegacySize + 6 * dp;
|
infoY += capFont->LegacySize + 6 * dp;
|
||||||
|
|
||||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), "TRANSPARENT");
|
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), transLabelUpper.c_str());
|
||||||
infoY += ovFont->LegacySize + 2 * dp;
|
infoY += ovFont->LegacySize + 2 * dp;
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
|
||||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
|
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
|
||||||
@@ -1677,7 +1731,7 @@ static void RenderBalanceShield(App* app) {
|
|||||||
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
|
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1707,7 +1761,7 @@ static void RenderBalanceTimeline(App* app) {
|
|||||||
else
|
else
|
||||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||||
{
|
{
|
||||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||||
ImFont* heroFont = Type().h2();
|
ImFont* heroFont = Type().h2();
|
||||||
@@ -1796,10 +1850,16 @@ static void RenderBalanceTimeline(App* app) {
|
|||||||
spec.rounding = glassRound;
|
spec.rounding = glassRound;
|
||||||
|
|
||||||
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
|
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
|
||||||
|
auto upperTR = [](const char* key) {
|
||||||
|
std::string s = TR(key);
|
||||||
|
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("transparent"), cMarket = upperTR("market");
|
||||||
SumCard cards[3] = {
|
SumCard cards[3] = {
|
||||||
{"SHIELDED", Success(), s_dispShielded, false},
|
{cShielded.c_str(), Success(), s_dispShielded, false},
|
||||||
{"TRANSPARENT", Warning(), s_dispTransparent, false},
|
{cTransparent.c_str(), Warning(), s_dispTransparent, false},
|
||||||
{"MARKET", Primary(), state.market.price_usd, true},
|
{cMarket.c_str(), Primary(), state.market.price_usd, true},
|
||||||
};
|
};
|
||||||
for (int i = 0; i < 3; i++) {
|
for (int i = 0; i < 3; i++) {
|
||||||
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
|
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
|
||||||
@@ -1830,7 +1890,7 @@ static void RenderBalanceTimeline(App* app) {
|
|||||||
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
|
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1867,32 +1927,36 @@ static void RenderBalanceTwoRow(App* app) {
|
|||||||
ImFont* capFont = Type().caption();
|
ImFont* capFont = Type().caption();
|
||||||
if (state.sync.syncing && state.sync.headers > 0) {
|
if (state.sync.syncing && state.sync.headers > 0) {
|
||||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||||
Type().textColored(TypeStyle::Caption, Warning(), buf);
|
Type().textColored(TypeStyle::Caption, Warning(), buf);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
}
|
}
|
||||||
if (state.mining.generate) {
|
if (state.mining.generate) {
|
||||||
double hr = state.mining.localHashrate;
|
double hr = state.mining.localHashrate;
|
||||||
snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str());
|
snprintf(buf, sizeof(buf), TR("balance_mining_rate"), FormatHashrate(hr).c_str());
|
||||||
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
|
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Action buttons right-aligned
|
// Action buttons right-aligned
|
||||||
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f);
|
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f) * dp;
|
||||||
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
|
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
|
||||||
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
|
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
|
||||||
if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
// Stable ## ids keep the button identity fixed across translations.
|
||||||
|
char sendBtn[64], recvBtn[64];
|
||||||
|
snprintf(sendBtn, sizeof(sendBtn), "%s##tworow-send", TR("send"));
|
||||||
|
snprintf(recvBtn, sizeof(recvBtn), "%s##tworow-receive", TR("receive"));
|
||||||
|
if (TactileButton(sendBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||||
app->setCurrentPage(NavPage::Send);
|
app->setCurrentPage(NavPage::Send);
|
||||||
}
|
}
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
if (TactileButton(recvBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||||
app->setCurrentPage(NavPage::Receive);
|
app->setCurrentPage(NavPage::Receive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RenderSyncBar(app, dl, vs);
|
RenderSyncBar(app, dl, vs);
|
||||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f)));
|
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f) * dp));
|
||||||
|
|
||||||
// Row 2: 3 mini-cards inline
|
// Row 2: 3 mini-cards inline
|
||||||
{
|
{
|
||||||
@@ -1915,7 +1979,7 @@ static void RenderBalanceTwoRow(App* app) {
|
|||||||
S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f),
|
S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f),
|
||||||
glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f));
|
glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f));
|
||||||
ImFont* capFont = Type().caption();
|
ImFont* capFont = Type().caption();
|
||||||
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f);
|
float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f) * dp;
|
||||||
int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f);
|
int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f);
|
||||||
float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size;
|
float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size;
|
||||||
float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm();
|
float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm();
|
||||||
@@ -1990,8 +2054,8 @@ static void RenderBalanceTwoRow(App* app) {
|
|||||||
|
|
||||||
// Sparkline between price and percentage
|
// Sparkline between price and percentage
|
||||||
if (market.price_history.size() >= 2) {
|
if (market.price_history.size() >= 2) {
|
||||||
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f);
|
float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f) * dp;
|
||||||
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f);
|
float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f) * dp;
|
||||||
float sparkLeft = cx + priceSz.x + sparkGap;
|
float sparkLeft = cx + priceSz.x + sparkGap;
|
||||||
float sparkRightEdge = sparkRight - sparkGap;
|
float sparkRightEdge = sparkRight - sparkGap;
|
||||||
if (sparkLeft < sparkRightEdge) {
|
if (sparkLeft < sparkRightEdge) {
|
||||||
@@ -2017,7 +2081,7 @@ static void RenderBalanceTwoRow(App* app) {
|
|||||||
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
|
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2089,10 +2153,10 @@ static void RenderBalanceMinimal(App* app) {
|
|||||||
{
|
{
|
||||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||||
ImVec2 sepPos = ImGui::GetCursorScreenPos();
|
ImVec2 sepPos = ImGui::GetCursorScreenPos();
|
||||||
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f);
|
float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f) * dp;
|
||||||
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f);
|
float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f) * dp;
|
||||||
float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f);
|
float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f);
|
||||||
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f);
|
float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f) * dp;
|
||||||
float x = sepPos.x;
|
float x = sepPos.x;
|
||||||
float endX = sepPos.x + availW;
|
float endX = sepPos.x + availW;
|
||||||
while (x < endX) {
|
while (x < endX) {
|
||||||
@@ -2110,7 +2174,7 @@ static void RenderBalanceMinimal(App* app) {
|
|||||||
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
|
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
|
||||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
|
|||||||
s_loading = false;
|
s_loading = false;
|
||||||
|
|
||||||
if (!error.empty()) {
|
if (!error.empty()) {
|
||||||
s_error = "Error: " + error;
|
s_error = std::string(TR("grpa_error_prefix")) + error;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er
|
|||||||
|
|
||||||
s_has_data = true;
|
s_has_data = true;
|
||||||
} else {
|
} else {
|
||||||
s_error = "Invalid response from daemon";
|
s_error = TR("grpa_invalid_response_from_daemon");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +112,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
|
|
||||||
// Height input
|
// Height input
|
||||||
ImGui::Text("%s", TR("block_height"));
|
ImGui::Text("%s", TR("block_height"));
|
||||||
ImGui::SetNextItemWidth(heightInput.width);
|
ImGui::SetNextItemWidth(heightInput.width * Layout::dpiScale());
|
||||||
ImGui::InputInt("##Height", &s_height);
|
ImGui::InputInt("##Height", &s_height);
|
||||||
if (s_height < 1) s_height = 1;
|
if (s_height < 1) s_height = 1;
|
||||||
// Clamp to the chain tip so navigation/typing can't request a height
|
// Clamp to the chain tip so navigation/typing can't request a height
|
||||||
@@ -125,7 +125,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
|
|
||||||
// Current block info
|
// Current block info
|
||||||
if (state.sync.blocks > 0) {
|
if (state.sync.blocks > 0) {
|
||||||
ImGui::TextDisabled("(Current: %d)", state.sync.blocks);
|
ImGui::TextDisabled(TR("grpa_current_block_paren"), state.sync.blocks);
|
||||||
}
|
}
|
||||||
|
|
||||||
ImGui::SameLine();
|
ImGui::SameLine();
|
||||||
@@ -135,7 +135,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
ImGui::BeginDisabled();
|
ImGui::BeginDisabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
if (material::TactileButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||||
if (rpc && rpc->isConnected() && app->worker()) {
|
if (rpc && rpc->isConnected() && app->worker()) {
|
||||||
s_loading = true;
|
s_loading = true;
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
@@ -152,7 +152,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
rpc::RPCClient::TraceScope trace("Explorer / Block info");
|
rpc::RPCClient::TraceScope trace("Explorer / Block info");
|
||||||
auto hashResult = rpc->call("getblockhash", {height});
|
auto hashResult = rpc->call("getblockhash", {height});
|
||||||
if (!hashResult.is_string()) {
|
if (!hashResult.is_string()) {
|
||||||
error = "unexpected getblockhash result";
|
error = TR("grpa_unexpected_getblockhash_result");
|
||||||
} else {
|
} else {
|
||||||
block = rpc->call("getblock", {hashResult.get<std::string>()});
|
block = rpc->call("getblock", {hashResult.get<std::string>()});
|
||||||
}
|
}
|
||||||
@@ -303,7 +303,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
// Navigation buttons
|
// Navigation buttons
|
||||||
if (s_has_data) {
|
if (s_has_data) {
|
||||||
if (s_height > 1) {
|
if (s_height > 1) {
|
||||||
if (material::StyledButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
if (material::TactileButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||||
s_height--;
|
s_height--;
|
||||||
s_has_data = false;
|
s_has_data = false;
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
@@ -315,7 +315,7 @@ void BlockInfoDialog::render(App* app)
|
|||||||
// nextblockhash, so this stays hidden there).
|
// nextblockhash, so this stays hidden there).
|
||||||
if (!s_next_hash.empty() &&
|
if (!s_next_hash.empty() &&
|
||||||
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
|
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
|
||||||
if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
if (material::TactileButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||||
s_height++;
|
s_height++;
|
||||||
s_has_data = false;
|
s_has_data = false;
|
||||||
s_error.clear();
|
s_error.clear();
|
||||||
@@ -323,9 +323,10 @@ void BlockInfoDialog::render(App* app)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close button at bottom
|
// Close button at bottom — centered via the shared footer helper (no separator, matching
|
||||||
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 40);
|
// the prior hand-rolled placement).
|
||||||
if (material::StyledButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
material::BeginOverlayDialogFooter(closeBtn.width, /*drawSeparator=*/false);
|
||||||
|
if (material::TactileButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
||||||
s_open = false;
|
s_open = false;
|
||||||
}
|
}
|
||||||
material::EndOverlayDialog();
|
material::EndOverlayDialog();
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ private:
|
|||||||
|
|
||||||
if (!s_bootstrap) {
|
if (!s_bootstrap) {
|
||||||
s_state = State::Failed;
|
s_state = State::Failed;
|
||||||
s_errorMsg = "Bootstrap not initialized";
|
s_errorMsg = TR("grpc_bootstrap_not_initialized");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ private:
|
|||||||
s_state = State::Done;
|
s_state = State::Done;
|
||||||
} else {
|
} else {
|
||||||
s_errorMsg = finalProg.error;
|
s_errorMsg = finalProg.error;
|
||||||
if (s_errorMsg.empty()) s_errorMsg = "Bootstrap failed";
|
if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed");
|
||||||
s_state = State::Failed;
|
s_state = State::Failed;
|
||||||
}
|
}
|
||||||
s_bootstrap.reset();
|
s_bootstrap.reset();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ void RenderChatTab(App* app);
|
|||||||
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
|
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
|
||||||
* (use the current content region, correct inside the chat modal's dialog).
|
* (use the current content region, correct inside the chat modal's dialog).
|
||||||
*/
|
*/
|
||||||
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
|
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCards = false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
|
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
#include "console_command_executor.h"
|
#include "console_command_executor.h"
|
||||||
#include "console_channel.h"
|
#include "console_channel.h"
|
||||||
|
#include "console_command_reference.h" // consoleCommandCategories / liteConsoleCommandCategories
|
||||||
#include "console_input_model.h" // BuildConsoleRpcCall
|
#include "console_input_model.h" // BuildConsoleRpcCall
|
||||||
|
|
||||||
#include "../../app.h"
|
#include "../../app.h"
|
||||||
@@ -28,6 +29,10 @@ namespace ui {
|
|||||||
|
|
||||||
using namespace material;
|
using namespace material;
|
||||||
|
|
||||||
|
// Classifies a diagnostics line into a console channel (defined below in an anonymous namespace); both
|
||||||
|
// executors drain the same LiteDiagnostics ring, so declare it up front for the full-node drain.
|
||||||
|
namespace { ConsoleChannel liteLogChannel(const std::string& line); }
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// FullNodeConsoleExecutor
|
// FullNodeConsoleExecutor
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -172,6 +177,25 @@ void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
|
|||||||
} else {
|
} else {
|
||||||
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chat diagnostics ring: chat code logs via wallet::liteLog on both variants (send lifecycle, the
|
||||||
|
// 0-conf harvest, the note buffer). Surface those lines here as App messages so the full-node console
|
||||||
|
// shows chat activity too. Same generation-cursor drain the lite console uses.
|
||||||
|
{
|
||||||
|
auto& diag = wallet::LiteDiagnostics::instance();
|
||||||
|
const std::uint64_t gen = diag.generation();
|
||||||
|
if (gen != diag_gen_) {
|
||||||
|
const auto snap = diag.snapshot();
|
||||||
|
std::size_t startIdx = 0;
|
||||||
|
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
|
||||||
|
const std::uint64_t added = gen - diag_gen_;
|
||||||
|
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
|
||||||
|
}
|
||||||
|
for (std::size_t i = startIdx; i < snap.size(); ++i)
|
||||||
|
add(snap[i], liteLogChannel(snap[i]));
|
||||||
|
diag_gen_ = gen;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
||||||
@@ -194,6 +218,11 @@ void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
|||||||
add(TR("console_tab_completion"), ConsoleChannel::Info);
|
add(TR("console_tab_completion"), ConsoleChannel::Info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::vector<ConsoleCommandCategory>* FullNodeConsoleExecutor::commandReference() const
|
||||||
|
{
|
||||||
|
return &consoleCommandCategories();
|
||||||
|
}
|
||||||
|
|
||||||
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
|
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
|
||||||
{
|
{
|
||||||
ConsoleStatusLine s;
|
ConsoleStatusLine s;
|
||||||
@@ -258,7 +287,7 @@ bool LiteConsoleExecutor::pollResult(std::string& result, bool& isError)
|
|||||||
if (!lw) return false;
|
if (!lw) return false;
|
||||||
wallet::LiteConsoleResult res;
|
wallet::LiteConsoleResult res;
|
||||||
if (!lw->takeConsoleResult(res)) return false;
|
if (!lw->takeConsoleResult(res)) return false;
|
||||||
result = res.response.empty() ? std::string("(no output)") : res.response;
|
result = res.response.empty() ? std::string(TR("console_no_output")) : res.response;
|
||||||
isError = !res.ok;
|
isError = !res.ok;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -286,6 +315,19 @@ void LiteConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
|
|||||||
add(TR("console_help_clear"), ConsoleChannel::None);
|
add(TR("console_help_clear"), ConsoleChannel::None);
|
||||||
add(TR("console_help_help"), ConsoleChannel::None);
|
add(TR("console_help_help"), ConsoleChannel::None);
|
||||||
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
|
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
|
||||||
|
// The lite backend's own command verbs (literal command tokens, not RPC methods — mirrors the
|
||||||
|
// backend's get_commands() registry). Listed for discoverability: the C++ tab intercepts `help`
|
||||||
|
// before it can reach the backend's own HelpCommand, so its "type help" advice would dead-end.
|
||||||
|
add(TR("lite_console_backend_commands"), ConsoleChannel::Info);
|
||||||
|
add(" sync syncstatus balance addresses height info list notes encryptionstatus", ConsoleChannel::None);
|
||||||
|
add(" send shield new seed import timport export", ConsoleChannel::None);
|
||||||
|
add(" encrypt decrypt lock unlock rescan save sietch saplingtree coinsupply", ConsoleChannel::None);
|
||||||
|
add(TR("console_click_commands"), ConsoleChannel::Info);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<ConsoleCommandCategory>* LiteConsoleExecutor::commandReference() const
|
||||||
|
{
|
||||||
|
return &liteConsoleCommandCategories();
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
||||||
@@ -294,20 +336,20 @@ std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
|
|||||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||||
if (lw && lw->walletOpen()) {
|
if (lw && lw->walletOpen()) {
|
||||||
const SyncInfo& sync = app_->state().sync;
|
const SyncInfo& sync = app_->state().sync;
|
||||||
char buf[96];
|
char buf[128];
|
||||||
if (sync.syncing && !sync.isSynced()) {
|
if (sync.syncing && !sync.isSynced()) {
|
||||||
double vp = sync.verification_progress;
|
double vp = sync.verification_progress;
|
||||||
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
|
||||||
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
|
std::snprintf(buf, sizeof(buf), "%s %.1f%% (block %d / %d)",
|
||||||
vp * 100.0, sync.blocks, sync.headers);
|
TR("lite_net_syncing"), vp * 100.0, sync.blocks, sync.headers);
|
||||||
} else {
|
} else {
|
||||||
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
|
std::snprintf(buf, sizeof(buf), "%s (block %d)", TR("lite_net_synced"), sync.blocks);
|
||||||
}
|
}
|
||||||
out.push_back({std::string(buf), OnSurfaceMedium(), false});
|
out.push_back({std::string(buf), OnSurfaceMedium(), false});
|
||||||
}
|
}
|
||||||
const std::string& err = app_->liteOpenError();
|
const std::string& err = app_->liteOpenError();
|
||||||
if (!err.empty() && (!lw || !lw->walletOpen()))
|
if (!err.empty() && (!lw || !lw->walletOpen()))
|
||||||
out.push_back({std::string("Last error: ") + err, Error(), false});
|
out.push_back({std::string(TR("console_last_error")) + " " + err, Error(), false});
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,10 +357,10 @@ ConsoleStatusLine LiteConsoleExecutor::toolbarStatus() const
|
|||||||
{
|
{
|
||||||
ConsoleStatusLine s;
|
ConsoleStatusLine s;
|
||||||
wallet::LiteWalletController* lw = app_->liteWallet();
|
wallet::LiteWalletController* lw = app_->liteWallet();
|
||||||
if (!lw) { s.text = "No backend"; s.color = Error(); return s; }
|
if (!lw) { s.text = TR("console_backend_unavailable"); s.color = Error(); return s; }
|
||||||
if (lw->walletOpen()) { s.text = "Connected"; s.color = Success(); }
|
if (lw->walletOpen()) { s.text = TR("lite_net_connected"); s.color = Success(); }
|
||||||
else if (lw->openInProgress()) { s.text = "Connecting"; s.color = Warning(); s.pulse = true; }
|
else if (lw->openInProgress()) { s.text = TR("lite_net_connecting"); s.color = Warning(); s.pulse = true; }
|
||||||
else { s.text = "Disconnected"; s.color = Error(); }
|
else { s.text = TR("lite_net_disconnected"); s.color = Error(); }
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ namespace dragonx {
|
|||||||
class App;
|
class App;
|
||||||
namespace ui {
|
namespace ui {
|
||||||
|
|
||||||
|
struct ConsoleCommandCategory; // console_command_reference.h — command-reference table
|
||||||
|
|
||||||
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
|
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
|
||||||
|
|
||||||
struct ConsoleStatusLine {
|
struct ConsoleStatusLine {
|
||||||
@@ -64,7 +66,13 @@ public:
|
|||||||
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
|
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
|
||||||
|
|
||||||
// UI-chrome capabilities.
|
// UI-chrome capabilities.
|
||||||
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
|
// True when this backend speaks JSON-RPC to a daemon (the full node). Gates daemon-specific
|
||||||
|
// console behavior (the 'stop' shutdown confirm, "not connected to daemon" wording) and the
|
||||||
|
// command-reference modal's JSON string-arg quoting — the lite backend takes bare tokens.
|
||||||
|
virtual bool hasRpcReference() const { return false; }
|
||||||
|
// The command-reference table this backend offers (browsed by the console's reference modal),
|
||||||
|
// or nullptr for none. Full node = the JSON-RPC reference; lite = its own backend verbs.
|
||||||
|
virtual const std::vector<ConsoleCommandCategory>* commandReference() const { return nullptr; }
|
||||||
// Which log-filter toggles the toolbar should show (default: none).
|
// Which log-filter toggles the toolbar should show (default: none).
|
||||||
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
|
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
|
||||||
|
|
||||||
@@ -88,6 +96,7 @@ public:
|
|||||||
bool pollResult(std::string& result, bool& isError) override;
|
bool pollResult(std::string& result, bool& isError) override;
|
||||||
void pollLogLines(const ConsoleAddLineFn& add) override;
|
void pollLogLines(const ConsoleAddLineFn& add) override;
|
||||||
bool hasRpcReference() const override { return true; }
|
bool hasRpcReference() const override { return true; }
|
||||||
|
const std::vector<ConsoleCommandCategory>* commandReference() const override;
|
||||||
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
|
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
|
||||||
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
|
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
|
||||||
void printHelp(const ConsoleAddLineFn& add) override;
|
void printHelp(const ConsoleAddLineFn& add) override;
|
||||||
@@ -104,6 +113,9 @@ private:
|
|||||||
bool last_rpc_connected_ = false;
|
bool last_rpc_connected_ = false;
|
||||||
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
|
||||||
std::mutex results_mutex_;
|
std::mutex results_mutex_;
|
||||||
|
// Chat diagnostics ring cursor: chat code logs via wallet::liteLog on BOTH variants, so the full-node
|
||||||
|
// console surfaces those lines (as App messages) too — mirrors LiteConsoleExecutor's diag drain.
|
||||||
|
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1);
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
// ── Lite: diagnostics ring + backend console command ─────────────────────────
|
||||||
@@ -119,6 +131,7 @@ public:
|
|||||||
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
|
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
|
||||||
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
|
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
|
||||||
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
|
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
|
||||||
|
const std::vector<ConsoleCommandCategory>* commandReference() const override;
|
||||||
void printHelp(const ConsoleAddLineFn& add) override;
|
void printHelp(const ConsoleAddLineFn& add) override;
|
||||||
std::vector<ConsoleStatusLine> statusLines() const override;
|
std::vector<ConsoleStatusLine> statusLines() const override;
|
||||||
ConsoleStatusLine toolbarStatus() const override;
|
ConsoleStatusLine toolbarStatus() const override;
|
||||||
|
|||||||
@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
|
|||||||
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
|
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
|
||||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
|
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
|
||||||
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
|
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
|
||||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"},
|
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true},
|
||||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
|
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
|
||||||
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
|
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
|
||||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"},
|
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true},
|
||||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
|
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
|
||||||
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
|
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
|
||||||
"listtransactions", "transactions history recent payments received sent"},
|
"listtransactions", "transactions history recent payments received sent"},
|
||||||
@@ -280,6 +280,105 @@ const ConsoleCommandEntry kUtilityCommands[] = {
|
|||||||
"reconsiderblock \"0000000000abc123\"", "undo invalidate accept block again re-enable block restore chain reconsider fix rollback fork", true},
|
"reconsiderblock \"0000000000abc123\"", "undo invalidate accept block again re-enable block restore chain reconsider fix rollback fork", true},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Lite backend command set — the verbs the SDXL lite backend accepts (see its
|
||||||
|
// commands.rs get_commands()). Unlike the full node these are NOT JSON-RPC: the
|
||||||
|
// backend takes plain, space-separated tokens (no quoting), so the reference
|
||||||
|
// modal inserts them bare. clear/help/quit are intentionally omitted — the C++
|
||||||
|
// console tab intercepts those before they reach the backend.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const ConsoleCommandEntry kLiteWalletCommands[] = {
|
||||||
|
{"balance", "Show your DRGX balance", "",
|
||||||
|
"Shows the DRGX balance held in this wallet across all its shielded and transparent addresses.",
|
||||||
|
"balance", "money funds amount total holdings"},
|
||||||
|
{"addresses", "List all addresses in the wallet", "",
|
||||||
|
"Lists every address this wallet owns \xE2\x80\x94 shielded (zs1...) and transparent (R.../t...) \xE2\x80\x94 so you can pick one to receive to.",
|
||||||
|
"addresses", "receive address list wallet mine deposit"},
|
||||||
|
{"new", "Create a new address in this wallet", "type",
|
||||||
|
"Creates a fresh receive address. Pass zs for a shielded (private) sapling address, or R (a capital R) for a transparent one \xE2\x80\x94 those exact tokens (case-sensitive).",
|
||||||
|
"new zs", "create address receive generate new shielded transparent zs"},
|
||||||
|
{"list", "List all transactions in the wallet", "",
|
||||||
|
"Shows the wallet's transaction history \xE2\x80\x94 sends, receives and shields \xE2\x80\x94 with amounts, addresses and confirmations.",
|
||||||
|
"list", "history transactions txs payments activity sent received"},
|
||||||
|
{"notes", "List sapling notes and UTXOs", "[all]",
|
||||||
|
"Lists the individual shielded notes and transparent UTXOs that make up your balance. Pass all to include spent ones.",
|
||||||
|
"notes", "utxo notes unspent coins inputs sapling"},
|
||||||
|
{"info", "Get the lightwalletd server's info", "",
|
||||||
|
"Reports the lightwalletd server the wallet is connected to \xE2\x80\x94 its version, chain and block height.",
|
||||||
|
"info", "server node lightwalletd version connection status"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConsoleCommandEntry kLiteSyncCommands[] = {
|
||||||
|
{"sync", "Download compact blocks and sync to the server", "",
|
||||||
|
"Fetches new compact blocks from the lightwalletd server and scans them for transactions belonging to this wallet.",
|
||||||
|
"sync", "update refresh scan blocks download catch up"},
|
||||||
|
{"syncstatus", "Get the sync status of the wallet", "",
|
||||||
|
"Reports how far the wallet has synced \xE2\x80\x94 whether a sync is in progress and the block it has reached.",
|
||||||
|
"syncstatus", "progress status syncing scanning percent blocks"},
|
||||||
|
{"height", "Get the latest block height the wallet is at", "",
|
||||||
|
"Shows the block height the wallet has scanned up to. Compare with the network height to gauge sync.",
|
||||||
|
"height", "block height number chain tip synced"},
|
||||||
|
{"rescan", "Rescan the wallet from scratch", "",
|
||||||
|
"Discards the scanned state and re-downloads/re-scans every block from the wallet's birthday. Slow, but fixes a stuck or incomplete balance.",
|
||||||
|
"rescan", "rescan resync repair fix balance rebuild from scratch"},
|
||||||
|
{"save", "Save the wallet file to disk", "",
|
||||||
|
"Writes the current wallet state to disk. The wallet also saves automatically after a sync or send.",
|
||||||
|
"save", "save persist write disk store"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConsoleCommandEntry kLiteSendCommands[] = {
|
||||||
|
{"send", "Send DRGX to an address", "[{\"address\":\"zs1...\",\"amount\":0}]",
|
||||||
|
"Sends DRGX from the console using a JSON array of recipients (the Send tab is the easy way; this is the power-user form). amount is in puposhis (the base unit); memo is optional and delivered privately to shielded recipients.",
|
||||||
|
"send [{\"address\":\"zs1exampleaddress\",\"amount\":100000000,\"memo\":\"thanks\"}]",
|
||||||
|
"pay transfer send spend money transaction json", true},
|
||||||
|
{"shield", "Shield transparent DRGX into a sapling address", "[address]",
|
||||||
|
"Moves your transparent (public) DRGX into a shielded sapling address for privacy. With no address it shields to the wallet's own sapling address.",
|
||||||
|
"shield", "shield private sapling transparent move protect", true},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConsoleCommandEntry kLiteKeyCommands[] = {
|
||||||
|
{"seed", "Display the wallet seed phrase", "",
|
||||||
|
"Reveals the seed phrase that backs up this wallet. Anyone who sees it can spend your funds \xE2\x80\x94 keep it secret and offline.",
|
||||||
|
"seed", "seed phrase mnemonic backup recovery words secret", true},
|
||||||
|
{"export", "Export the private key for an address", "[address]",
|
||||||
|
"Prints the private/spending key for a wallet address \xE2\x80\x94 anyone with it controls the funds. With no address it exports every key.",
|
||||||
|
"export zs1exampleaddress", "export private key spending backup secret", true},
|
||||||
|
{"import", "Import a spending or viewing key", "key",
|
||||||
|
"Imports a shielded spending or viewing key (pass just the key). The wallet rescans from the sapling activation height to find the key's transactions.",
|
||||||
|
"import somekey", "import restore key spending viewing add watch"},
|
||||||
|
{"timport", "Import a transparent WIF private key", "wif",
|
||||||
|
"Imports a transparent private key in WIF format (begins with U, 5, K or L) so the wallet can spend its funds.",
|
||||||
|
"timport somewifkey", "import transparent wif private key taddr"},
|
||||||
|
{"encrypt", "Encrypt the wallet with a password", "password",
|
||||||
|
"Encrypts the wallet with a password and locks it immediately. You will need the password to send or reveal keys afterwards. If you forget it, only the seed phrase can recover the wallet.",
|
||||||
|
"encrypt strongpassword", "encrypt password protect lock secure passphrase", true},
|
||||||
|
{"decrypt", "Completely remove wallet encryption", "password",
|
||||||
|
"Permanently removes the wallet's password encryption, leaving it unprotected on disk. Requires the current password.",
|
||||||
|
"decrypt strongpassword", "decrypt remove encryption password unprotect", true},
|
||||||
|
{"unlock", "Unlock the wallet for spending", "password",
|
||||||
|
"Temporarily unlocks an encrypted wallet so it can send or reveal keys. Use lock to re-lock it.",
|
||||||
|
"unlock strongpassword", "unlock password spend open temporarily"},
|
||||||
|
{"lock", "Lock a temporarily-unlocked wallet", "",
|
||||||
|
"Re-locks a wallet that was unlocked for spending, without removing its encryption.",
|
||||||
|
"lock", "lock secure re-lock protect close"},
|
||||||
|
{"encryptionstatus", "Check if the wallet is encrypted and locked", "",
|
||||||
|
"Reports whether the wallet is encrypted and, if so, whether it is currently locked or unlocked.",
|
||||||
|
"encryptionstatus", "encryption status locked unlocked encrypted state"},
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConsoleCommandEntry kLiteAdvancedCommands[] = {
|
||||||
|
{"sietch", "Create a Sietch address", "[type]",
|
||||||
|
"Creates a Sietch address, used for enhanced-privacy sends. Pass zs for a sapling Sietch address.",
|
||||||
|
"sietch zs", "sietch privacy address decoy sapling advanced"},
|
||||||
|
{"saplingtree", "Dump the latest Sapling commitment tree (debug)", "",
|
||||||
|
"Prints the latest Sapling commitment tree state \xE2\x80\x94 a debugging aid, not needed for everyday use.",
|
||||||
|
"saplingtree", "sapling tree commitment debug advanced merkle"},
|
||||||
|
{"coinsupply", "Get the coin supply info", "",
|
||||||
|
"Reports coin-supply figures for the chain as seen by the wallet's server.",
|
||||||
|
"coinsupply", "supply coins total emission circulating amount"},
|
||||||
|
};
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
||||||
@@ -296,5 +395,17 @@ const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
|||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories()
|
||||||
|
{
|
||||||
|
static const std::vector<ConsoleCommandCategory> categories = {
|
||||||
|
{"Wallet", kLiteWalletCommands, CountOf(kLiteWalletCommands)},
|
||||||
|
{"Sync", kLiteSyncCommands, CountOf(kLiteSyncCommands)},
|
||||||
|
{"Send", kLiteSendCommands, CountOf(kLiteSendCommands)},
|
||||||
|
{"Keys & Security", kLiteKeyCommands, CountOf(kLiteKeyCommands)},
|
||||||
|
{"Advanced", kLiteAdvancedCommands, CountOf(kLiteAdvancedCommands)},
|
||||||
|
};
|
||||||
|
return categories;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ struct ConsoleCommandCategory {
|
|||||||
int count;
|
int count;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The full-node daemon's JSON-RPC command reference (browsed by the console's command-reference modal).
|
||||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
|
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
|
||||||
|
|
||||||
|
// The lite backend's own command set (the ~25 verbs the SDXL backend accepts) — the lite-variant
|
||||||
|
// analog of the RPC reference, shown by the same modal when the executor is the lite one.
|
||||||
|
const std::vector<ConsoleCommandCategory>& liteConsoleCommandCategories();
|
||||||
|
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain()
|
|||||||
lines_.pop_front();
|
lines_.pop_front();
|
||||||
++result.popped;
|
++result.popped;
|
||||||
}
|
}
|
||||||
|
++revision_; // deque changed — lets the view memoize its filter/layout passes
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ConsoleModel::clear()
|
void ConsoleModel::clear()
|
||||||
{
|
{
|
||||||
lines_.clear();
|
lines_.clear();
|
||||||
|
++revision_;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
bool ConsoleModel::toggleCollapsed(std::size_t i)
|
||||||
{
|
{
|
||||||
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
|
||||||
lines_[i].collapsed = !lines_[i].collapsed;
|
lines_[i].collapsed = !lines_[i].collapsed;
|
||||||
|
++revision_; // fold change alters which lines are visible
|
||||||
return lines_[i].collapsed;
|
return lines_[i].collapsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user