Compare commits
74
Commits
v2.0.0
...
90e02b1ddd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -33,6 +33,10 @@ imgui.ini
|
||||
*.bak*
|
||||
*.params
|
||||
asmap.dat
|
||||
# Wallet files hold PRIVATE KEYS — never commit them
|
||||
wallet.dat
|
||||
wallet-*.dat
|
||||
wallet.dat.*
|
||||
/external/drg-xmrig
|
||||
/memory
|
||||
/todo.md
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Changelog
|
||||
|
||||
All notable user-facing changes to ObsidianDragon are documented here. The format loosely
|
||||
follows [Keep a Changelog](https://keepachangelog.com/); the project uses Conventional Commits.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### ⚠️ Breaking changes
|
||||
|
||||
- **Remote RPC over plain HTTP is now refused by default.** If your wallet is configured to
|
||||
reach a **remote** `rpchost`/`rpcconnect` **without TLS**, it will no longer connect — it
|
||||
previously sent your `rpcuser`/`rpcpassword` in cleartext (capturable by anyone on the
|
||||
network path) after only a dismissible warning. To reconnect, either:
|
||||
- add **`rpctls=1`** to `DRAGONX.conf` (preferred, if your daemon supports TLS), or
|
||||
- add **`rpcallowplaintext=1`** to `DRAGONX.conf` to explicitly accept the plaintext link.
|
||||
|
||||
Local and embedded daemons (`127.0.0.0/8`, `localhost`, `::1`) are unaffected.
|
||||
|
||||
### Security
|
||||
|
||||
- Refuse remote plaintext RPC credential transmission by default (see Breaking changes above).
|
||||
- Tightened localhost detection: a hostname that merely *starts* with `127.` (e.g.
|
||||
`127.evil.com`) is no longer mistaken for a loopback address, so it can no longer bypass the
|
||||
plaintext-RPC protection.
|
||||
- Sapling parameters are now integrity-checked (SHA-256) against pinned canonical digests
|
||||
before use, instead of only checking that the files exist. A truncated or corrupt parameter
|
||||
file is caught up front rather than surfacing later as a confusing shielded-operation failure.
|
||||
(Cached via a `size:mtime` marker so it doesn't re-hash ~48 MB on every launch.)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Daemon crashes are no longer occasionally missed: a race between the UI thread and the
|
||||
process monitor could consume the daemon's exit status, hiding a crash and defeating the
|
||||
automatic-restart cap. The monitor is now the sole reaper.
|
||||
- A daemon that fails to launch (missing execute permission, wrong architecture, corrupt
|
||||
binary) now reports a precise error immediately instead of briefly showing "running" and
|
||||
then a generic "exited unexpectedly (exit code 127)".
|
||||
- A quick stop→start no longer triggers a restart storm: the wallet now waits briefly for a
|
||||
previous daemon to release the data-directory lock and shows a clear, non-crash message
|
||||
instead of exhausting the crash-restart budget.
|
||||
- Failures while writing the daemon binaries or Sapling parameters (disk full, permission
|
||||
denied) are now surfaced clearly up front instead of failing opaquely when the daemon later
|
||||
can't start.
|
||||
- Directory-creation failures on startup (read-only home, permission denied) now produce a
|
||||
clear "Cannot create <dir>" message instead of a confusing downstream "config missing" /
|
||||
"binary not found" error (or, in one path, an uncaught exception).
|
||||
|
||||
### Added
|
||||
|
||||
- A "Taking longer than expected" notice now appears if the daemon is reachable but hasn't
|
||||
finished initializing after ~45 s (configurable via `ui.toml`), with guidance to restart the
|
||||
daemon or open the Console — instead of an indefinite silent spinner. It clears itself
|
||||
automatically once the daemon connects.
|
||||
|
||||
---
|
||||
|
||||
Engineering detail and the finding-by-finding rationale for this batch live in
|
||||
`docs/daemon-startup-hardening.md`.
|
||||
+33
-4
@@ -15,7 +15,7 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
project(ObsidianDragon
|
||||
VERSION 2.0.0
|
||||
VERSION 2.0.1
|
||||
LANGUAGES C CXX
|
||||
DESCRIPTION "DragonX Cryptocurrency Wallet"
|
||||
)
|
||||
@@ -213,7 +213,7 @@ include(FetchContent)
|
||||
FetchContent_Declare(
|
||||
json
|
||||
GIT_REPOSITORY https://github.com/nlohmann/json.git
|
||||
GIT_TAG v3.11.3
|
||||
GIT_TAG 9cca280a4d0ccf0c08f47a99aa71d1b0e52f8d03 # v3.11.3 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(json)
|
||||
@@ -222,7 +222,7 @@ FetchContent_MakeAvailable(json)
|
||||
FetchContent_Declare(
|
||||
tomlplusplus
|
||||
GIT_REPOSITORY https://github.com/marzer/tomlplusplus.git
|
||||
GIT_TAG v3.4.0
|
||||
GIT_TAG 30172438cee64926dc41fdd9c11fb3ba5b2ba9de # v3.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
)
|
||||
FetchContent_MakeAvailable(tomlplusplus)
|
||||
@@ -289,7 +289,7 @@ message(STATUS "Fetching libwebp (decode-only, static)...")
|
||||
FetchContent_Declare(
|
||||
libwebp
|
||||
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
|
||||
GIT_TAG v1.4.0
|
||||
GIT_TAG 845d5476a866141ba35ac133f856fa62f0b7445f # v1.4.0 — pinned to immutable commit (L-08); tags are mutable
|
||||
GIT_SHALLOW TRUE
|
||||
# libwebp's cpu.cmake applies -mno-sse2/-mno-sse4.1 to its scalar reference DSP
|
||||
# files when it can't probe SSE support. Under a macOS universal build
|
||||
@@ -544,6 +544,7 @@ set(APP_SOURCES
|
||||
src/util/async_task_manager.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/base64.cpp
|
||||
src/util/single_instance.cpp
|
||||
src/util/i18n.cpp
|
||||
@@ -1074,6 +1075,32 @@ install(DIRECTORY ${CMAKE_RUNTIME_OUTPUT_DIRECTORY}/res
|
||||
OPTIONAL
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat.
|
||||
# Bundled next to the daemon; the app spawns it out-of-process. It is the ONLY thing that links
|
||||
# Berkeley DB, so the AGPLv3 BDB never contaminates the GPLv3 GUI (same boundary as the daemon).
|
||||
# Release builds should point DRAGONX_BDB_ROOT at the vendored static libdb (external/dragonx/depends);
|
||||
# a dev build falls back to the system Berkeley DB. Skipped (with a note) if no BDB is found.
|
||||
# -----------------------------------------------------------------------------
|
||||
find_path(BDB_INCLUDE_DIR db.h HINTS ${DRAGONX_BDB_ROOT}/include /usr/include /usr/local/include)
|
||||
find_library(BDB_LIBRARY NAMES db-6.2 db-6.0 db-5.3 db libdb
|
||||
HINTS ${DRAGONX_BDB_ROOT}/lib /usr/lib /usr/local/lib /usr/lib/x86_64-linux-gnu)
|
||||
if(BDB_INCLUDE_DIR AND BDB_LIBRARY)
|
||||
add_executable(dragonx-wallet-rebuild tools/wallet_rebuild/main.cpp)
|
||||
target_include_directories(dragonx-wallet-rebuild PRIVATE ${CMAKE_SOURCE_DIR}/src ${BDB_INCLUDE_DIR})
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE ${BDB_LIBRARY})
|
||||
if(WIN32)
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE ws2_32) # static libdb-6.2 pulls in winsock
|
||||
else()
|
||||
find_package(Threads REQUIRED)
|
||||
target_link_libraries(dragonx-wallet-rebuild PRIVATE Threads::Threads ${CMAKE_DL_LIBS}) # static libdb needs pthread/dl
|
||||
endif()
|
||||
set_target_properties(dragonx-wallet-rebuild PROPERTIES RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin)
|
||||
message(STATUS "wallet-rebuild helper: ON (Berkeley DB ${BDB_LIBRARY})")
|
||||
else()
|
||||
message(STATUS "wallet-rebuild helper: OFF (no Berkeley DB found; set DRAGONX_BDB_ROOT for release builds)")
|
||||
endif()
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Tests
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1122,6 +1149,7 @@ if(BUILD_TESTING)
|
||||
src/util/payment_uri.cpp
|
||||
src/util/amount_format.cpp
|
||||
src/util/address_validation.cpp
|
||||
src/util/seed_phrase.cpp
|
||||
src/util/i18n.cpp
|
||||
src/util/text_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
@@ -1129,6 +1157,7 @@ if(BUILD_TESTING)
|
||||
src/data/address_book.cpp
|
||||
src/data/wallet_index.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/daemon/embedded_daemon.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/config/settings.cpp
|
||||
src/resources/embedded_resources.cpp
|
||||
|
||||
@@ -195,6 +195,24 @@ should_bundle_full_node_assets() {
|
||||
! $DO_LITE
|
||||
}
|
||||
|
||||
# The offline wallet-rebuild helper is the ONLY thing that repairs a genuinely BDB-inconsistent
|
||||
# wallet.dat — plain "Restore" just re-triggers the daemon's salvage cascade. A full-node release must
|
||||
# NEVER ship without it (the in-app "Repair automatically" option silently disappears otherwise), so
|
||||
# treat a missing helper as a HARD build failure instead of degrading recovery to Restore-only.
|
||||
# $1 = built helper path (e.g. bin/dragonx-wallet-rebuild[.exe]); $2 = the BDB depends dir for the hint.
|
||||
require_wallet_rebuild_helper() {
|
||||
local helper="$1" depends="$2"
|
||||
should_bundle_full_node_assets || return 0 # lite builds have no BDB wallet.dat to rebuild
|
||||
if [[ ! -f "$helper" ]]; then
|
||||
err "wallet-rebuild helper was NOT built: $helper"
|
||||
err " → the recovery 'Repair automatically' option would be MISSING from this release."
|
||||
err " Cause: the vendored Berkeley DB depends are absent, so CMake skipped the dragonx-wallet-rebuild target."
|
||||
err " Fix: provide ${depends}/{lib/libdb-6.2.a,include/db.h} (same static libdb the daemon links), then rebuild."
|
||||
exit 1
|
||||
fi
|
||||
info " wallet-rebuild helper present: $helper"
|
||||
}
|
||||
|
||||
# ── Helper: find resource files ──────────────────────────────────────────────
|
||||
find_sapling_params() {
|
||||
local dirs=(
|
||||
@@ -286,6 +304,9 @@ bundle_linux_daemon() {
|
||||
# asmap.dat
|
||||
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
||||
|
||||
# (The dragonx-wallet-rebuild recovery helper is built into bin/ by CMake and packaged explicitly
|
||||
# by each release path — required via require_wallet_rebuild_helper — so it is not copied here.)
|
||||
|
||||
return $found
|
||||
}
|
||||
|
||||
@@ -336,11 +357,24 @@ build_release_linux() {
|
||||
mkdir -p "$bd" && cd "$bd"
|
||||
|
||||
# ── Compile ──────────────────────────────────────────────────────────────
|
||||
# Point the wallet-rebuild helper at the vendored static Berkeley DB (same libdb the daemon links)
|
||||
# so its output is a v6.2 btree the bundled dragonxd reads. Pass the paths EXPLICITLY (bypasses
|
||||
# find_library + its cache); the helper target is simply not built if the depends tree is absent.
|
||||
local lin_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-unknown-linux-gnu"
|
||||
local BDB_ARGS=()
|
||||
if [[ -f "$lin_bdb/lib/libdb-6.2.a" && -f "$lin_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$lin_bdb/include" -DBDB_LIBRARY="$lin_bdb/lib/libdb-6.2.a" )
|
||||
elif should_bundle_full_node_assets; then
|
||||
err "Vendored Berkeley DB depends missing at $lin_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring ..."
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DCMAKE_CXX_FLAGS_RELEASE="-O3 -DNDEBUG" \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=ON \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
info "Building with $JOBS jobs ..."
|
||||
@@ -348,8 +382,12 @@ build_release_linux() {
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "Linux build failed"; exit 1; }
|
||||
|
||||
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$lin_bdb"
|
||||
|
||||
info "Stripping ..."
|
||||
strip "bin/${APP_BASENAME}"
|
||||
[[ -f "bin/dragonx-wallet-rebuild" ]] && strip "bin/dragonx-wallet-rebuild"
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}" | cut -f1)"
|
||||
|
||||
if should_bundle_full_node_assets; then
|
||||
@@ -384,6 +422,9 @@ build_release_linux() {
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$dist_dir/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$dist_dir/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$dist_dir/"
|
||||
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||
cp bin/dragonx-wallet-rebuild "$dist_dir/" && chmod +x "$dist_dir/dragonx-wallet-rebuild"
|
||||
info " Bundled dragonx-wallet-rebuild"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||
@@ -417,6 +458,8 @@ build_release_linux() {
|
||||
[[ -f bin/asmap.dat ]] && cp bin/asmap.dat "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-spend.params ]] && cp bin/sapling-spend.params "$APPDIR/usr/bin/"
|
||||
[[ -f bin/sapling-output.params ]] && cp bin/sapling-output.params "$APPDIR/usr/bin/"
|
||||
# Offline wallet-rebuild recovery helper — required (asserted above); ships next to the app.
|
||||
cp bin/dragonx-wallet-rebuild "$APPDIR/usr/bin/" && chmod +x "$APPDIR/usr/bin/dragonx-wallet-rebuild"
|
||||
fi
|
||||
# Bundle xmrig for mining support
|
||||
local XMRIG_LINUX_AI="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig/xmrig"
|
||||
@@ -638,6 +681,30 @@ HDR
|
||||
info "Lite mode: skipping embedded daemon binaries"
|
||||
fi
|
||||
|
||||
# ── Wallet-rebuild recovery helper ───────────────────────────────
|
||||
# Built in-tree (not a prebuilt like the daemon), so compile it standalone HERE — before the
|
||||
# main app compiles embedded_resources.cpp — and INCBIN it, so a bare, self-extracting
|
||||
# ObsidianDragon.exe carries the recovery tool exactly like it does the daemon.
|
||||
if should_bundle_full_node_assets; then
|
||||
local WBDB="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||
if [[ -f "$WBDB/lib/libdb-6.2.a" && -f "$WBDB/include/db.h" ]]; then
|
||||
info "Compiling + embedding wallet-rebuild helper ..."
|
||||
x86_64-w64-mingw32-g++ -std=c++17 -O2 -static -static-libgcc -static-libstdc++ \
|
||||
-I"$SCRIPT_DIR/src" -I"$WBDB/include" \
|
||||
"$SCRIPT_DIR/tools/wallet_rebuild/main.cpp" \
|
||||
"$WBDB/lib/libdb-6.2.a" -lws2_32 \
|
||||
-o "$RES/dragonx-wallet-rebuild.exe" \
|
||||
|| { err "wallet-rebuild helper failed to compile for embedding"; exit 1; }
|
||||
x86_64-w64-mingw32-strip "$RES/dragonx-wallet-rebuild.exe" 2>/dev/null || true
|
||||
echo -e "\n#define HAS_EMBEDDED_WALLET_REBUILD 1" >> "$GEN/embedded_data.h"
|
||||
echo "INCBIN(dragonx_wallet_rebuild_exe, \"$RES/dragonx-wallet-rebuild.exe\");" >> "$GEN/embedded_data.h"
|
||||
info " Embedded dragonx-wallet-rebuild.exe ($(du -h "$RES/dragonx-wallet-rebuild.exe" | cut -f1))"
|
||||
else
|
||||
err "Vendored mingw Berkeley DB missing at $WBDB — cannot embed the wallet-rebuild recovery helper."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── xmrig binary (from prebuilt-binaries/drg-xmrig/) ────────────────
|
||||
local XMRIG_DIR="$SCRIPT_DIR/prebuilt-binaries/drg-xmrig"
|
||||
# The published DRG-XMRig archives ship the binary inside a versioned subdir, not as a flat
|
||||
@@ -750,11 +817,24 @@ HDR
|
||||
fi
|
||||
|
||||
# ── CMake + build ────────────────────────────────────────────────────────
|
||||
# The wallet-rebuild helper links the vendored mingw static Berkeley DB (the mingw toolchain's
|
||||
# find_library is sysroot-only, so pass the depends paths EXPLICITLY to bypass the search). Only
|
||||
# enabled if the depends tree is present; guarded with -DBDB_* left empty otherwise.
|
||||
local win_bdb="$SCRIPT_DIR/external/dragonx/depends/x86_64-w64-mingw32"
|
||||
local BDB_ARGS=()
|
||||
if [[ -f "$win_bdb/lib/libdb-6.2.a" && -f "$win_bdb/include/db.h" ]]; then
|
||||
BDB_ARGS=( -DBDB_INCLUDE_DIR="$win_bdb/include" -DBDB_LIBRARY="$win_bdb/lib/libdb-6.2.a" )
|
||||
elif should_bundle_full_node_assets; then
|
||||
err "Vendored Berkeley DB depends missing at $win_bdb — the wallet-rebuild recovery helper cannot be built."
|
||||
err " A full-node release must ship it; aborting rather than degrading recovery to Restore-only."
|
||||
exit 1
|
||||
fi
|
||||
info "Configuring (cross-compile) ..."
|
||||
cmake "$SCRIPT_DIR" \
|
||||
-DCMAKE_TOOLCHAIN_FILE="$bd/mingw-toolchain.cmake" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DDRAGONX_USE_SYSTEM_SDL3=OFF \
|
||||
"${BDB_ARGS[@]}" \
|
||||
"${FT_CMAKE_ARG[@]}" \
|
||||
"${CMAKE_LITE_ARGS[@]}"
|
||||
|
||||
@@ -764,6 +844,9 @@ HDR
|
||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
||||
|
||||
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
|
||||
|
||||
# ── Package: release/windows/ ────────────────────────────────────────────
|
||||
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
||||
mkdir -p "$out"
|
||||
@@ -779,6 +862,9 @@ HDR
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat) — required.
|
||||
cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/" && info " Bundled dragonx-wallet-rebuild.exe"
|
||||
[[ -f "$dist_dir/dragonx-wallet-rebuild.exe" ]] || { err "Failed to bundle dragonx-wallet-rebuild.exe"; exit 1; }
|
||||
|
||||
# Bundle Sapling params + asmap for the zip distribution
|
||||
# (The single-file exe has these embedded via INCBIN, but the zip
|
||||
@@ -1034,6 +1120,11 @@ TOOLCHAIN
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
|
||||
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
|
||||
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
|
||||
|
||||
# Strip — use osxcross strip for cross-builds
|
||||
if $IS_CROSS; then
|
||||
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
||||
@@ -1106,6 +1197,8 @@ TOOLCHAIN
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||
fi
|
||||
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
|
||||
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
|
||||
else
|
||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
# Daemon Startup Hardening — Implementation Plan
|
||||
|
||||
Eight verified edge-case defects in how ObsidianDragon brings up (and watches) the
|
||||
`dragonxd` daemon at launch. Each entry is a buildable fix: the defect (with exact
|
||||
line references), the chosen approach, the call sites, a representative change, and how
|
||||
to verify it.
|
||||
|
||||
- **Scope:** full-node startup path (`--lite` excludes the embedded daemon entirely).
|
||||
- **Source:** line references are exact against branch `dev` @ `45b652f`.
|
||||
- **Provenance:** findings verified by direct source read; each fix designed by an
|
||||
independent agent grounded in the cited files, with a sequencing pass for ordering,
|
||||
shared helpers, and merge conflicts.
|
||||
|
||||
**Severity:** 2 High, 6 Medium · **Effort:** ≈ 25–35 engineering-hours · **7 landing steps.**
|
||||
|
||||
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||
|
||||
**Status: all 8 landed & verified** (build-clean, `ctest` green after each) across four commits on
|
||||
`dev` — lifecycle cluster (F1/F2/F4), filesystem+params cluster (F7/F6/F5), F3, and F8. Six new
|
||||
pure-helper unit tests added.
|
||||
|
||||
**Wrap-up done:** release notes added (`CHANGELOG.md`, F8 breaking change front and center); i18n
|
||||
back-fill applied additively to `res/lang/*.json` (42 keys — all 6 for es/de/fr/pt/ru; 6 zh/ja/ko
|
||||
entries whose glyphs aren't in the current `NotoSansCJK-Subset.ttf` were left on English fallback
|
||||
rather than render as tofu).
|
||||
|
||||
**Still owed before release:** a **CJK subset-font rebuild** (`scripts/build_cjk_subset.py`, needs
|
||||
the Noto CJK source font) to cover the 6 deferred zh/ja/ko strings. *(F1 and F2 now have headless
|
||||
integration-test coverage — see the progress log — so their GUI repros are optional, not blocking.)*
|
||||
|
||||
---
|
||||
|
||||
## Recommended rollout sequence
|
||||
|
||||
A real dependency order, not a checklist. The daemon-lifecycle cluster lands first
|
||||
because it makes the `State::Error` / `crash_count_` contract trustworthy — which the
|
||||
connect-stall panel and the lock gate both build on. The filesystem cluster lands
|
||||
around a single shared helper. The connectivity-breaking security flip lands last.
|
||||
|
||||
| Step | Finding(s) | Site | Why here | Status |
|
||||
|------|-----------|------|----------|--------|
|
||||
| 1 | **F1** | `embedded_daemon.cpp` · `isRunning()` | Smallest/highest-severity; establishes the reliable Error/crash-count transition steps 3 & 6 depend on. | ☑ |
|
||||
| 2 | **F2** | `embedded_daemon.cpp` · `startProcess()` | Same file family, different function; test the F1+F2 pair together with `kill -SEGV` / bad-binary repros. | ☑ |
|
||||
| 3 | **F4** | `embedded_daemon.cpp` · `start()` | After F1/F2 so crash-count semantics are settled; its bail deliberately stays out of the crash path. | ☑ |
|
||||
| 4 | **F7** | `util/platform` · `connection.cpp` | Structural owner of the fs-error idiom + `ConnectionConfig` that F5/F6/F8 reuse. | ☑ |
|
||||
| 5 | **F6 + F5** | `app.cpp` · `verifySaplingParams()` | Same `startEmbeddedDaemon` / `verifySaplingParams` block; land together. | ☑ |
|
||||
| 6 | **F3** | `app.cpp` · `renderLoadingOverlay()` | After F1 — panel is guarded off during `State::Error` (owned by the crash-count hint). | ☑ |
|
||||
| 7 | **F8** | `connection.cpp` · `tryConnect()` | Largest; only connectivity-breaking default flip — land last, with release notes. | ☑ |
|
||||
|
||||
---
|
||||
|
||||
## F1 — Double-`waitpid` race can swallow a daemon crash
|
||||
|
||||
**Severity:** High · **Effort:** S (~1–2h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`EmbeddedDaemon::isRunning()` (`embedded_daemon.cpp:1136`, POSIX branch) calls
|
||||
`waitpid(WNOHANG)` — from the **UI thread, nearly every frame** — racing
|
||||
`monitorProcess()`'s own reap at `:1244`. `waitpid` is one-shot: if the UI thread wins,
|
||||
the monitor never decodes the exit, so `crash_count_` never increments, `State::Error`
|
||||
never fires, and the 3-strike auto-restart cap (`app_network.cpp:479`) is defeated. The
|
||||
sibling `XmrigManager::isRunning()` (`xmrig_manager.cpp:512`) already fixed exactly this
|
||||
with an atomic read.
|
||||
|
||||
### The fix
|
||||
Make `isRunning()` read the existing `std::atomic<State> state_` (member at
|
||||
`embedded_daemon.h:253`) instead of calling `waitpid`, leaving `monitorProcess()` as the
|
||||
sole reaper. Predicate is `Running || Stopping` — `Stopping` must stay "alive" because
|
||||
`stop()`'s graceful/SIGTERM wait loops poll `isRunning()` before the process has exited.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.cpp` — `isRunning()`, POSIX branch (~1136)
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
bool EmbeddedDaemon::isRunning() const // POSIX branch
|
||||
{
|
||||
// Read the atomic state_ instead of waitpid() — monitorProcess() is the
|
||||
// sole reaper. Previously both threads reaped; if the UI thread won, the
|
||||
// monitor never saw the exit (crash_count_ / exit code / Error all lost).
|
||||
if (process_pid_ <= 0) return false;
|
||||
|
||||
State s = state_.load(std::memory_order_relaxed);
|
||||
// Stopping stays "alive": stop()'s wait loops poll isRunning() while
|
||||
// state_ == Stopping, before the process has actually terminated.
|
||||
return (s == State::Running || s == State::Stopping);
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Manual: `kill -SEGV` the daemon 10–20×; the monitor must report the exit and increment `crash_count_` every time (previously intermittent).
|
||||
- Regression: a normal Settings-driven stop still escalates SIGTERM→SIGKILL (the `Stopping` predicate).
|
||||
- Not unit-testable (real fork/exec/waitpid) — consistent with the no-process-spawn harness.
|
||||
|
||||
### Dependencies
|
||||
Mirrors `XmrigManager::isRunning()`. Flags a separate latent hazard (out of scope):
|
||||
`stop()`'s final blocking `waitpid` (`:1220`) can still race a mid-sleep monitor
|
||||
iteration — file as its own ticket.
|
||||
|
||||
---
|
||||
|
||||
## F2 — exec-after-fork silent failure: "Running" for a daemon that never started
|
||||
|
||||
**Severity:** High · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
In `startProcess()` (`embedded_daemon.cpp:957–1061`, POSIX) the parent runs
|
||||
`process_pid_ = pid; return true;` **unconditionally** after `fork()` — with no
|
||||
exec-status handshake. On a non-executable / wrong-arch / corrupt binary the child's
|
||||
`execv` fails and it `_exit(127)`s, but `start()` has already set `State::Running`
|
||||
(`:565`). The real cause never reaches `last_error_`; it surfaces later, generically,
|
||||
as "exited unexpectedly (exit code 127)".
|
||||
|
||||
### The fix
|
||||
Add a **close-on-exec self-pipe** handshake — `pipe() + fcntl(FD_CLOEXEC)`, deliberately
|
||||
**not** `pipe2()` (macOS lacks it; the POSIX branch is shared). The child writes `errno`
|
||||
only on `execv` failure; a successful exec closes the write end for free. Parent reads:
|
||||
EOF ⇒ success; 4 bytes ⇒ reap the zombie, set a precise `last_error_` ("not executable
|
||||
or wrong architecture"), and return `false` so `start()` never reports Running. EINTR-safe
|
||||
on both ends. Also comments the unchecked parent-side `setpgid` at `:1053`.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.cpp` — `startProcess()` parent read path
|
||||
- `src/daemon/embedded_daemon.cpp` — child `execv`-failure write (~1043)
|
||||
- `src/daemon/embedded_daemon.cpp` — `setpgid` best-effort comment (~1053)
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// Self-pipe exec handshake (pipe()+FD_CLOEXEC; NOT pipe2 — macOS lacks it).
|
||||
int execpipe[2]; pipe(execpipe);
|
||||
fcntl(execpipe[0], F_SETFD, FD_CLOEXEC);
|
||||
fcntl(execpipe[1], F_SETFD, FD_CLOEXEC);
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == 0) { // child
|
||||
close(execpipe[0]);
|
||||
/* setpgid / chdir / dup2 / argv … */
|
||||
execv(binary_path.c_str(), argv.data());
|
||||
int e = errno; // execv failed
|
||||
while (write(execpipe[1], &e, sizeof e) < 0 && errno == EINTR) {}
|
||||
_exit(127);
|
||||
}
|
||||
|
||||
close(execpipe[1]); // parent: must close or read() never EOFs
|
||||
int child_errno = 0, total = 0;
|
||||
for (;;) { // EOF ⇒ exec ok; 4 bytes ⇒ exec failed
|
||||
ssize_t n = read(execpipe[0], (char*)&child_errno + total, sizeof(int) - total);
|
||||
if (n == 0) break;
|
||||
if (n < 0) { if (errno == EINTR) continue; break; }
|
||||
if ((total += n) >= (int)sizeof(int)) break;
|
||||
}
|
||||
close(execpipe[0]);
|
||||
if (total >= (int)sizeof(int)) { // exec never happened
|
||||
waitpid(pid, nullptr, 0); // reap the zombie
|
||||
last_error_ = "dragonxd could not be executed: " +
|
||||
std::string(strerror(child_errno)) +
|
||||
" — not executable or wrong architecture";
|
||||
return false; // start() no longer reports Running
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Point at a `chmod -x` / wrong-arch file → `start()` returns false immediately, precise message, no leftover zombie.
|
||||
- Success path: real binary still starts with no perceptible added latency.
|
||||
- Optional pure `formatExecFailureError(errno)` helper for a `test_phase4.cpp` unit test.
|
||||
|
||||
### Dependencies
|
||||
F1 (same function family; sequence F1→F2). **Highest-risk mistake:** forgetting
|
||||
`FD_CLOEXEC` makes every successful start hang the parent read forever.
|
||||
|
||||
---
|
||||
|
||||
## F4 — Stale datadir-lock start → restart storm that wedges the UI
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`start()` (`embedded_daemon.cpp:466`) gates only on the RPC port (`:482`), never on
|
||||
`isDaemonProcessRunning()` (`:1292`). A graceful shutdown frees the port but keeps the
|
||||
datadir `.lock` for up to ~90s. A rapid stop→start spawns a daemon that dies "Cannot
|
||||
obtain a lock on data directory" — routed to the generic crash path. With a ~4s retry
|
||||
cadence, **three lock races in ~12s exhaust the 3-strike budget** and wedge the UI long
|
||||
before the lock actually clears.
|
||||
|
||||
### The fix
|
||||
Fail-fast with a **short bounded local wait (~300ms), not a 90s block**. After the port
|
||||
bail, consult `isDaemonProcessRunning()` — gated by `!skip_port_check_` and exempt when
|
||||
`override_datadir_` is set, so the isolated migrate-to-seed daemon still works. A pure
|
||||
`evaluateDatadirLockGate()` returns a **distinct non-crash Error** that never increments
|
||||
`crash_count_`. The connect loop's own retry then absorbs the transient.
|
||||
|
||||
### Files touched
|
||||
- `src/daemon/embedded_daemon.h` — decision struct, helper decl, poll constants
|
||||
- `src/daemon/embedded_daemon.cpp` — `start()` gate + `evaluateDatadirLockGate()`
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
static StartLockGateDecision evaluateDatadirLockGate(
|
||||
bool skipPortCheck, bool isolatedOverride, bool stillRunningAfterWait) {
|
||||
if (skipPortCheck || isolatedOverride) return {true, ""}; // migrate-to-seed exempt
|
||||
if (!stillRunningAfterWait) return {true, ""};
|
||||
return {false, "A previous dragonxd is still shutting down and holding the "
|
||||
"data directory lock. Retrying shortly…"};
|
||||
}
|
||||
|
||||
// start() — after the isPortInUse() bail, before setState(Starting):
|
||||
if (!skip_port_check_ && override_datadir_.empty()) {
|
||||
bool stillLocked = false; // ~300ms bounded wait, NOT ~90s
|
||||
for (int i = 0; i < kDatadirLockWaitMaxPolls; ++i) {
|
||||
if (!isDaemonProcessRunning()) { stillLocked = false; break; }
|
||||
stillLocked = true;
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(kDatadirLockWaitPollMs));
|
||||
}
|
||||
auto gate = evaluateDatadirLockGate(false, false, stillLocked);
|
||||
if (!gate.proceed) { setState(State::Error, gate.errorMessage); return false; }
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `evaluateDatadirLockGate()` across the skip / isolated / still-running matrix.
|
||||
- Manual: rapid restart into a lingering lock → distinct message, no crash-cap wedge.
|
||||
- Migrate-to-seed second daemon still starts (isolated exemption).
|
||||
|
||||
### Dependencies
|
||||
F1/F2 (must not touch `crash_count_`; wording must not collide with the monitor's
|
||||
"exited unexpectedly"). Same TU, different function.
|
||||
|
||||
---
|
||||
|
||||
## F5 — Extraction / copy write-failures never surfaced up front
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~2–3h) · **Status:** ☑ landed & verified
|
||||
|
||||
### The defect
|
||||
`startEmbeddedDaemon()` discards `extractEmbeddedResources()`'s `bool` return
|
||||
(`app.cpp:4152`) and the second copy-fallback loop drops `copy_file`'s `error_code`
|
||||
entirely (`:4236`). Only Sapling params **existence** is re-checked — never the daemon
|
||||
binary/CLI/tx/asmap. A disk-full or truncated `dragonxd` write falls straight through to
|
||||
spawn and fails opaquely. The innermost write already returns `false`
|
||||
(`embedded_resources.cpp:307`) — the signal is simply thrown away.
|
||||
|
||||
### The fix
|
||||
Minimal, surgical wiring — no new abstraction. Capture the extraction return and, on
|
||||
failure, set `daemon_status_ = TR("sb_daemon_extract_failed")` and `return false` before
|
||||
spawning. In the second copy loop, check `ec` after each `copy_file`, track `copyFailed`,
|
||||
and abort with a dir-parameterized `sb_daemon_files_failed`. An **absent source** stays
|
||||
fine (optional files); only an actual `error_code` counts. Written so F6/F7 slot in later
|
||||
without re-touching this control flow.
|
||||
|
||||
### Files touched
|
||||
- `src/app.cpp` — `startEmbeddedDaemon()` extraction check (~4152)
|
||||
- `src/app.cpp` — second copy-fallback loop (~4210–4242)
|
||||
- `src/util/i18n.cpp` + `res/lang/*.json` — 2 additive keys
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// stop discarding the extraction result (~4152)
|
||||
if (!resources::extractEmbeddedResources()) {
|
||||
daemon_status_ = TR("sb_daemon_extract_failed"); // disk full / permission denied
|
||||
return false; // abort before spawning
|
||||
}
|
||||
|
||||
// second copy-fallback loop — was dropping ec entirely (~4236)
|
||||
bool copyFailed = false;
|
||||
for (const char* name : { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }) {
|
||||
fs::path dst = fs::path(daemon_dir) / name;
|
||||
if (fs::exists(dst)) continue; // already present — skip
|
||||
for (const auto& dir : searchDirs) {
|
||||
fs::path src = fs::path(dir) / name;
|
||||
if (!fs::exists(src)) continue; // absent source is OK, not a failure
|
||||
fs::copy_file(src, dst, ec);
|
||||
if (ec) { copyFailed = true; ec.clear(); }
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (copyFailed) {
|
||||
char buf[512];
|
||||
snprintf(buf, sizeof buf, TR("sb_daemon_files_failed"), daemon_dir.c_str());
|
||||
daemon_status_ = buf;
|
||||
return false; // don't fall through to spawn
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `extractEmbeddedResources()` returns false without embedded resources.
|
||||
- Extract the copy loop into a testable helper; force one dst write to fail (dst is an existing directory).
|
||||
- Manual: near-full tmpfs / read-only dir → clear status, daemon controller never constructed.
|
||||
|
||||
### Dependencies
|
||||
Shares the `daemon_status_` surfacing convention with F6; its early-return pattern is the
|
||||
template F7 matches. Open item: remove truncated dst files so a retry re-copies.
|
||||
|
||||
---
|
||||
|
||||
## F6 — Sapling params validated by existence/size only, never hashed
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built note.** `verifySaplingParams()` now delegates to a public, injectable
|
||||
> `verifySaplingParamsIn(dir, digests)` so the integrity + marker-cache logic is unit-testable
|
||||
> with synthetic small files (the real 48 MB params aren't in the repo). i18n keys for F5 were
|
||||
> added to `i18n.cpp` (English source of truth); the `res/lang/*.json` back-fill via
|
||||
> `scripts/add_missing_translations.py` is deferred to a single run at the end of the batch,
|
||||
> per the cross-cutting note. Non-English locales fall back to English until then.
|
||||
|
||||
### The defect
|
||||
`verifySaplingParams()` (`connection.cpp:123`) only calls `fs::exists()`;
|
||||
`resourceNeedsUpdate()` (`embedded_resources.cpp:250`) is size-only. On Linux (no
|
||||
embedded resources) a **truncated-but-present** param passes and is handed to the daemon,
|
||||
which then fails to build shielded proofs mid-operation — far from the real cause.
|
||||
|
||||
### The fix
|
||||
Add a pinned `{ filename → size, sha256 }` table (one source of truth, cross-referenced
|
||||
to `scripts/build-lite-backend-artifact.sh`) and hash-check each param after the
|
||||
existence check, reusing the existing `util::sha256Hex` (no second implementation). Since
|
||||
these are ~48 MB, **cache the result** via a `.sapling_verified` marker keyed on
|
||||
`size:mtime` — re-hash only when the stat line changes, so startup isn't slowed.
|
||||
|
||||
### Files touched
|
||||
- `src/rpc/connection.h` — `verifySaplingParams` decl
|
||||
- `src/rpc/connection.cpp` — digest table, marker helpers, rewrite
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// connection.cpp — pinned known-good digests
|
||||
// (source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params)
|
||||
constexpr SaplingParamDigest kSaplingParamDigests[] = {
|
||||
{ "sapling-spend.params", 47958396, "8e48ffd2…efc13" },
|
||||
{ "sapling-output.params", 3592860, "2f0ebbcb…fb0e4" },
|
||||
};
|
||||
|
||||
bool Connection::verifySaplingParams() {
|
||||
// existence check (unchanged) …
|
||||
// cache: skip re-hashing a ~48 MB file unless size:mtime changed
|
||||
if (readMarkerMatches(marker, statLines)) return true;
|
||||
for (auto& d : kSaplingParamDigests)
|
||||
if (util::sha256Hex(bytes) != d.sha256) return false; // reuse existing helper
|
||||
writeMarker(marker, statLines);
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: good params pass; truncated / wrong-bytes rejected; marker cache short-circuits re-hash unless size/mtime changed. Real temp-file fixtures (matches existing `sha256Hex` tests).
|
||||
|
||||
### Dependencies
|
||||
F7 (reuse fs-error idiom; shares the `startEmbeddedDaemon`/`verifySaplingParams` block).
|
||||
Third caller of the existing `util::sha256Hex`.
|
||||
|
||||
---
|
||||
|
||||
## F7 — Directory-create errors universally ignored on the daemon-env path
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–4h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built notes.** Two deviations from the original design, both confirmed against the code:
|
||||
> (1) `embedded_resources.cpp:270` already checks its `error_code` and returns `false` on failure — it was **not** a bug, so it is left untouched.
|
||||
> (2) Of the four `autoDetectConfig` callers, only the primary connect path (`app_network.cpp:243`) was wired to check `dir_error`; the other three degrade gracefully on their own — `app.cpp:4306` and `app_wizard.cpp:912` are stop paths that already gate on empty creds, and `settings_page.cpp:434` is read-only display. `dir_error` is set by `autoDetectConfig`, so they can be wired later if desired.
|
||||
|
||||
### The defect
|
||||
Five startup directory-create sites either drop the `error_code` or use the throwing
|
||||
overload with no `catch`: `main.cpp:730`, `connection.cpp:216` (can throw **uncaught**
|
||||
through its callers), `embedded_resources.cpp:270`, `app.cpp:4172`/`4218`. A read-only
|
||||
home or permission-denied yields a confusing "conf missing" / "binary not found"
|
||||
downstream — or an uncaught `filesystem_error` — instead of a clear cause.
|
||||
|
||||
### The fix
|
||||
One shared, non-throwing `Platform::ensureDirectory(dir, outError)` in
|
||||
`util/platform.{h,cpp}` that produces a single consistent message. Replace all five
|
||||
sites; `autoDetectConfig()` moves off the throwing overload and sets a new
|
||||
`ConnectionConfig::dir_error` that its four callers check and bail on. This is the
|
||||
**structural owner** of the fs-error idiom that F5 and F6 reuse.
|
||||
|
||||
### Files touched
|
||||
- `src/util/platform.h` / `.cpp` — `ensureDirectory()`
|
||||
- `src/rpc/connection.h` / `.cpp` — `dir_error` + `autoDetectConfig`
|
||||
- `main.cpp`, `app.cpp`, `app_network.cpp`, `app_wizard.cpp`, `settings_page.cpp`, `embedded_resources.cpp` — 5 sites + 4 callers
|
||||
- `tests/test_phase4.cpp` — `TestPlatformEnsureDirectory`
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// util/platform.cpp — one shared, non-throwing helper
|
||||
bool Platform::ensureDirectory(const std::string& dir, std::string* outError) {
|
||||
std::error_code ec;
|
||||
if (std::filesystem::is_directory(dir, ec)) return true;
|
||||
ec.clear();
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (ec) {
|
||||
if (outError)
|
||||
*outError = "Cannot create " + dir + ": " + ec.message() +
|
||||
". Check permissions / free space.";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
// Replaces 5 ad-hoc sites; autoDetectConfig() now sets ConnectionConfig::dir_error,
|
||||
// and its 4 callers bail on it.
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit `TestPlatformEnsureDirectory`: existing dir → true; fresh nested → created; POSIX unwritable → false + message.
|
||||
- All four `autoDetectConfig` callers tolerate `dir_error`. Pre-App-init site (main.cpp) reports via stderr / MessageBox.
|
||||
|
||||
### Dependencies
|
||||
**Owns** `Platform::ensureDirectory` (used by F5, F6) and the `ConnectionConfig`
|
||||
extension (coordinated with F8). Land before F5/F6/F8.
|
||||
|
||||
---
|
||||
|
||||
## F8 — Plaintext-remote RPC credential transmission is warn-only
|
||||
|
||||
**Severity:** Medium · **Effort:** M (~6–9h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **⚠️ RELEASE NOTES REQUIRED — breaking default flip.** A wallet configured to talk to a
|
||||
> **remote** `rpchost` over **plain HTTP** (no `rpctls=1`) will now be **refused** at connect
|
||||
> time instead of warned. Affected users must add **`rpcallowplaintext=1`** to `DRAGONX.conf`
|
||||
> (or switch to `rpctls=1`) to reconnect. Local/embedded daemons (`127.0.0.0/8`, `localhost`,
|
||||
> `::1`) are unaffected. Call this out prominently in the release notes.
|
||||
>
|
||||
> **As-built note.** Shipped the security-complete core: `isLocalHost` tightened to exact
|
||||
> loopback (`isExactIPv4Loopback` — `127.evil.com` no longer passes), refuse-by-default in
|
||||
> `tryConnect`, and the `rpcallowplaintext` conf-key opt-in. The **Settings toggle UI was
|
||||
> deferred** — the RPC section of `settings_page.cpp` is read-only display and a security
|
||||
> toggle there is riskier surface; the conf-key opt-in fully covers recovery, and the refusal
|
||||
> status/notification tells the user exactly what to add. The toggle can be added later
|
||||
> (persist a `Settings` flag and OR it into `allowsPlaintextRemote`).
|
||||
|
||||
### The defect
|
||||
A remote `rpchost` without `rpctls=1` sends Basic-auth `rpcuser:rpcpassword` over
|
||||
cleartext HTTP. `tryConnect()` (`app_network.cpp:314`) only shows a **dismissible
|
||||
warning** then proceeds — a local-network MITM sees the credentials. Compounding it,
|
||||
`isLocalHost()`'s naive `rfind("127.",0)==0` misclassifies `127.evil.com` as local,
|
||||
suppressing even the warning.
|
||||
|
||||
### The fix
|
||||
Change the policy to **refuse-by-default with an explicit, persisted opt-in** — a
|
||||
`rpcallowplaintext=1` conf key (for hand-editors) and a Settings toggle. Block the
|
||||
connect and show a **blocking modal** explaining the risk and how to enable TLS or opt
|
||||
in; localhost is unaffected. Tighten `isLocalHost()` to exact `127.x.y.z` / `::1` /
|
||||
`localhost` via `isExactIPv4Loopback()`. **Back-compat:** default off ⇒ existing remote
|
||||
users hit a hard stop until they opt in — **ship with prominent release notes.**
|
||||
|
||||
### Files touched
|
||||
- `src/rpc/connection.h` / `.cpp` — `isLocalHost`, `allow_plaintext_remote`, `parseConfFile`
|
||||
- `src/config/settings.h` / `.cpp` — persisted opt-in
|
||||
- `src/app_network.cpp`, `src/app.h` — refuse + modal dispatch
|
||||
- `src/ui/windows/plaintext_remote_rpc_dialog.h` — new blocking modal
|
||||
- `src/ui/pages/settings_page.cpp` — toggle UI
|
||||
|
||||
### Core change
|
||||
```cpp
|
||||
// Tightened loopback test — "127.evil.com" is NOT local
|
||||
bool Connection::isLocalHost(const std::string& host) {
|
||||
std::string h = stripBrackets(lowercase(host));
|
||||
return h == "localhost" || h == "::1" || isExactIPv4Loopback(h); // exact 127.x.y.z
|
||||
}
|
||||
|
||||
// Refuse-by-default with an explicit, persisted opt-in
|
||||
const bool plaintextRemote = rpc::Connection::usesPlaintextRemote(config);
|
||||
const bool plaintextAllowed = config.allow_plaintext_remote // rpcallowplaintext=1
|
||||
|| settings_.getAllowPlaintextRemoteRpc(); // Settings toggle
|
||||
if (plaintextRemote && !plaintextAllowed) {
|
||||
connection_status_ = TR("sb_plaintext_remote_blocked");
|
||||
showPlaintextRemoteRpcDialog(config.host + ":" + config.port); // blocking modal
|
||||
return; // no creds sent
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
- Unit: `isLocalHost` — `127.evil.com` false, `127.0.0.1`/`::1`/`localhost` true; `allowsPlaintextRemote` honors conf key + settings flag.
|
||||
- Manual: remote plaintext blocked; modal fires; opt-in persists across restart.
|
||||
|
||||
### Dependencies
|
||||
F7 (second extender of `ConnectionConfig`/`parseConfFile`; land after so the struct grows
|
||||
once). Wire `renderPlaintextRemoteRpcDialog` into the app modal-dispatch list.
|
||||
|
||||
---
|
||||
|
||||
## Shared helpers & coordination points
|
||||
|
||||
| Helper | Purpose | Used by |
|
||||
|--------|---------|---------|
|
||||
| `Platform::ensureDirectory()` | Single non-throwing directory-create with one consistent message; replaces five ad-hoc sites. Owned by F7. | F7, F5, F6 |
|
||||
| `ConnectionConfig` extension | Coordination point, not a function: F7 adds `dir_error`, F8 adds `allow_plaintext_remote`. Land F7→F8 so it grows once per step. | F7, F8 |
|
||||
| `util::sha256Hex` *(existing)* | Already-compiled, curl-free SHA-256. F6 becomes its third caller — no second hash routine. | F6 |
|
||||
| `connectHasStalled()` *(new, pure)* | Stall predicate split out of the ImGui/App code for unit testing, per the `*_updater_core.cpp` precedent. | F3 |
|
||||
| `evaluateDatadirLockGate()` *(new, pure)* | Lock-gate decision as `{proceed, message}` from three booleans — unit-testable without real process/fs I/O. | F4 |
|
||||
|
||||
## F3 — Unbounded connect spinner (deferred to step 6)
|
||||
|
||||
**Severity:** Medium · **Effort:** S (~3–5h) · **Status:** ☑ landed & verified
|
||||
|
||||
> **As-built note.** `renderLoadingOverlay()` is a pure draw-list overlay with **no interactive
|
||||
> widgets** (the existing crash case at ~5289 already communicates via guidance *text*, relying on
|
||||
> the sidebar staying reachable). So rather than inject `ActionButton`s — which would fight the
|
||||
> non-interactive overlay — the stall notice follows that same idiom: a "Taking longer than
|
||||
> expected" title + a reassuring body (with elapsed seconds) + a full-node-gated hint ("Open
|
||||
> Settings → Restart Daemon, or check the Console"). This let me drop the planned
|
||||
> `WalletState::connect_stalled` flag too: the stalled state is computed locally in the overlay
|
||||
> from `connect_stall_since_`, so the only new member is `App::connect_stall_since_`.
|
||||
|
||||
The connect loop retries forever while `!state_.connected` (`app.cpp:1239`);
|
||||
`loading_timer_` only animates the spinner. Stamp `connect_stall_since_` when
|
||||
"reachable but not ready" is first seen; a pure `connectHasStalled()` helper (new
|
||||
`util/connect_stall.h`, default 45s from `ui.toml`) flips `state_.connect_stalled` at
|
||||
threshold, and `renderLoadingOverlay()` shows a "Taking longer than expected" panel with
|
||||
Retry / Restart daemon / Open console (full-node gated). The background retry keeps
|
||||
firing — recovery clears the panel automatically. Guarded off while the daemon is in
|
||||
`State::Error` (owned by F1's crash-count hint). Full detail lives in the sequencing/
|
||||
design record; see the shared-helper table above.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting notes
|
||||
|
||||
- **One TU, three functions.** `embedded_daemon.cpp` is edited by F1 (`isRunning`),
|
||||
F2 (`startProcess`) and F4 (`start`) — no literal hunk overlap, but land in order to
|
||||
keep "monitorProcess is the sole reaper" coherent.
|
||||
- **Connection struct grows twice.** `connection.h/.cpp` is touched by F6, F7 and F8;
|
||||
F7 and F8 both extend `ConnectionConfig` and `parseConfFile` — highest collision risk.
|
||||
Sequence F7→F6→F8.
|
||||
- **Testability split.** The three new pure predicates all get `tests/test_phase4.cpp`
|
||||
coverage. F1/F2's fork/exec/waitpid changes are **not** unit-testable — they rely on
|
||||
manual `kill` / non-executable-binary repros, consistent with the no-process-spawn harness.
|
||||
- **i18n is additive-only.** Add each finding's English keys to `strings_`, then run
|
||||
`scripts/add_missing_translations.py` **once at the very end**
|
||||
(`json.dump indent=4, sort_keys=True, ensure_ascii=False`) — never bulk-regenerate a
|
||||
`res/lang/*.json`.
|
||||
- **F8 is a breaking default flip.** Refuse-plaintext-by-default stops existing
|
||||
remote-RPC users cold until they opt in. Lands last, gated behind a persisted opt-in,
|
||||
with release notes calling out the new `rpcallowplaintext` key and the Settings toggle.
|
||||
- **Latent hazard, out of scope.** F1 surfaces (but doesn't fix) a second
|
||||
double-`waitpid` window between `stop()`'s final blocking reap (`:1220`) and a
|
||||
mid-sleep monitor iteration — file it as its own ticket.
|
||||
|
||||
---
|
||||
|
||||
## Progress log
|
||||
|
||||
- **F1/F2 integration tests** — ☑ added `testExecFailureReported` (F2) and `testDaemonCrashDetected` (F1) to `test_phase4.cpp`, driving the **real** `EmbeddedDaemon` fork/exec/waitpid code headlessly (POSIX; required linking `embedded_daemon.cpp` into the test target — its deps were already there). The F1 test hammers `isRunning()` from the test thread while the child exits, so it's a genuine regression test for the reap race. **The F2 test caught a real bug:** `start()`'s failure branch overwrote `startProcess()`'s precise `last_error_` ("…not executable or wrong architecture") with a generic "Failed to start dragonxd process" (because `setState(Error, …)` stores its message into `last_error_`), so the precise reason never reached `getLastError()`/the UI — **fixed** to preserve the detail (now also surfaced via the state callback / crash panel). Build-clean; `ctest` 1/1.
|
||||
|
||||
- **F1** — ☑ landed: `isRunning()` (POSIX) now reads the atomic `state_` (predicate `Running || Stopping`) instead of calling `waitpid`, leaving `monitorProcess()` the sole reaper. Clean build (all targets link); `ctest` 1/1 passing. Not unit-testable — needs the manual `kill -SEGV` repro before release.
|
||||
- **F2** — ☑ landed: `startProcess()` (POSIX) now creates a `FD_CLOEXEC` self-pipe before `fork()`; the child writes `errno` to it on `execv` failure, the parent reads EOF-vs-errno and, on failure, reaps the zombie + sets a precise `last_error_` ("not executable or wrong architecture") + returns `false` (so `start()` no longer reports `Running` for a daemon that never started). Parent-side `setpgid` is now best-effort with a `DEBUG_LOGF` on failure. Clean build; `ctest` 1/1 passing. Not unit-testable — needs the manual non-executable / wrong-arch-binary repro before release.
|
||||
- **F8** — ☑ landed: `isLocalHost()` tightened to exact loopback via `isExactIPv4Loopback` (a `127.`-prefixed *hostname* like `127.evil.com` is no longer misclassified as local). `tryConnect()` now **refuses** a plaintext connection to a remote host instead of warn-and-proceeding — a local-network MITM can no longer capture `rpcuser:rpcpassword` — unless the user opts in with `rpcallowplaintext=1` in `DRAGONX.conf` (new `ConnectionConfig::allow_plaintext_remote` + `allowsPlaintextRemote()` policy). The refusal surfaces via status line + a one-time notification. New `testIsLocalHost` (12 assertions) + `testAllowsPlaintextRemote` (5). Clean build; `ctest` 1/1 passing. **Breaking — needs release notes; Settings-toggle UI deferred (see as-built note).**
|
||||
- **F3** — ☑ landed: the connect loop now stamps `connect_stall_since_ = ImGui::GetTime()` the moment the daemon first goes "reachable but not ready" (warmup branch + `applyDaemonInitStatus`), and clears it in `onConnected` / `onDisconnected` / warmup-complete — all in `app_network.cpp`. The pure `util::connectHasStalled(stallSince, now, threshold)` helper (new `util/connect_stall.h`, default 45 s from `ui.toml`) drives a draw-list "Taking longer than expected" notice in `renderLoadingOverlay()` (title + elapsed-seconds body + full-node hint), guarded off while the daemon is in `State::Error`. Background retry continues, so the notice self-clears on connect. New `testConnectHasStalled` unit test (7 assertions). Clean build; `ctest` 1/1 passing. (Draw-list text, not buttons — see as-built note above.)
|
||||
- **F6** — ☑ landed: `verifySaplingParams()` now hash-verifies each Sapling param against its pinned canonical SHA-256 (from `build-lite-backend-artifact.sh`), replacing the existence-only check, so a truncated/corrupt-but-present param is rejected instead of failing later on a shielded op. A `<params_dir>/.sapling_verified` marker keyed on `size:mtime` skips re-hashing ~48 MB on every startup. Logic extracted to the injectable `verifySaplingParamsIn(dir, digests)`; new `testVerifySaplingParams` unit test (valid / marker fast-path / wrong-hash / truncated / missing). Clean build; `ctest` 1/1 passing.
|
||||
- **F5** — ☑ landed: `startEmbeddedDaemon()` now checks `extractEmbeddedResources()`'s return (abort with `sb_daemon_extract_failed` on failure) and the previously-dropped `copy_file` `error_code` in the daemon-binary fallback loop (abort with `sb_daemon_files_failed` incl. the dir), so a disk-full / truncated `dragonxd` write is surfaced up front instead of failing opaquely at spawn. An absent source file stays non-fatal. Two i18n keys added to `i18n.cpp`. Clean build; `ctest` 1/1 passing.
|
||||
- **F7** — ☑ landed: new non-throwing `Platform::ensureDirectory(dir, outError)` in `util/platform.{h,cpp}` with one consistent message. Replaces the unchecked/throwing directory-create sites at `main.cpp:730` (pre-init: now logs + `MessageBoxA` on Windows + `return 1`), `connection.cpp:216` (autoDetectConfig now uses the ec overload — **no more uncaught `filesystem_error`** — and sets the new `ConnectionConfig::dir_error`), and both `app.cpp` daemon-dir sites (surface via `daemon_status_` + `return false`). Primary connect path (`app_network.cpp:243`) checks `dir_error` and bails to the status line instead of mislabelling it "waiting for config". `embedded_resources.cpp:270` left as-is (already correct). New `testPlatformEnsureDirectory` unit test (existing-dir / fresh-nested / empty / parent-is-file). Clean build; `ctest` 1/1 passing.
|
||||
- **F4** — ☑ landed: `start()` now gates on a lingering datadir lock after the port bail. When `!skip_port_check_ && override_datadir_.empty()`, it polls `isDaemonProcessRunning()` with a bounded ~300 ms wait (3 × 100 ms, breaks early), then a pure header-inline `evaluateDatadirLockGate()` decides: if a sibling `dragonxd` is still alive it bails with a distinct **non-crash** `State::Error` ("…holding the data directory lock. Retrying shortly…") that never touches `crash_count_`, so the 3-strike cap can't trip; the connect loop's retry resumes once the lock clears. Isolated migrate-to-seed starts are exempt. New `testDatadirLockGate` unit test (5 assertions, proceed/bail/2× exempt) added to `test_phase4.cpp`. Clean build; `ctest` 1/1 passing.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Wallet Loading & Management — Hardening Plan
|
||||
|
||||
Prioritized, grouped remediation for the wallet loading/management audit (33 verified findings +
|
||||
diagnosability QoL). Companion to the findings artifact. Line references are against `dev`.
|
||||
|
||||
- **Provenance:** 7 parallel subsystem finders, each finding adversarially verified against the
|
||||
code; the 3 highest-impact confirmed findings re-checked by hand. 32 confirmed, 1 refuted
|
||||
(W1-5), 1 raised (W5-3 Low→Med).
|
||||
- **Severity:** 8 High · 12 Medium · 13 Low.
|
||||
|
||||
Status legend: ☐ not started · ◐ in progress · ☑ landed & verified
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (ordered by risk; shared fixes grouped)
|
||||
|
||||
| Phase | Findings | Theme | Status |
|
||||
|-------|----------|-------|--------|
|
||||
| **P0-A** | W7-1, W2-1, W4-1, W4-3, W2-3, W4-5, W5-3 ✓ | Secret hardening (console redaction + delete-export + memzero + lite encrypt-at-create) | ☑ 7/7 |
|
||||
| **P0-B** | W2-2/W4-2, W2-4 | Encryption integrity (never silently unencrypted) | ☑ |
|
||||
| **P1-A** | W3-1, W3-2, W3-4, W3-3 ✓ | Migrate-to-seed correctness (fund-adjacent) | ☑ 4/4 (W3-3 pending a live-mainnet run) |
|
||||
| **P1-B** | W1-1, W1-2, W1-3, W1-4 ✓ + startup guard | Missing/wrong wallet-file safety | ☑ |
|
||||
| **P2** | W5-1, W5-2, W6-1, W6-3, W6-2 ✓ | Stale state & lite save-failure surfacing | ☑ 5/5 |
|
||||
| **F** | W7-2, W7-3, W7-4 ✓ · QoL: copy-diag + open-log + node-error-banner + staleness-badge + alert-history ✓ | Diagnostics foundation + QoL bundle | ☑ |
|
||||
|
||||
---
|
||||
|
||||
## P0-A — Secret hardening
|
||||
|
||||
Shared fix: a `SecureString` RAII buffer (zeroes on destruction) retrofitted onto the un-scrubbed
|
||||
key/passphrase paths, plus console redaction and deleting the plaintext export.
|
||||
|
||||
- **W7-1 (High)** `console_tab.cpp:1419` — RPC console echoes/stores/clipboards raw secrets. Fix: an
|
||||
allowlist of secret-bearing first-tokens (`walletpassphrase`, `walletpassphrasechange`,
|
||||
`encryptwallet`, `importprivkey`, `importwallet`, `z_importkey`, `z_importviewingkey`,
|
||||
`signrawtransaction`, `magicrecoverkey`, lite equivalents); echo `> walletpassphrase ****` and
|
||||
keep the raw text out of `command_history_`. Extract a pure `redactConsoleCommand(cmd)` helper for
|
||||
unit testing. **← implementing first (self-contained + testable).**
|
||||
- **W2-1 (High)** `wallet_security_workflow.cpp:66` — delete the `obsidiandecryptexport<ts>` plaintext
|
||||
key dump after `z_importwallet` succeeds (overwrite-then-unlink).
|
||||
- **W4-3 (High)** `app_network.cpp:4481` — `sodium_memzero` the concatenated all-keys string in
|
||||
`exportAllKeys`; write the backup 0600. (Also unify with `ExportAllKeysDialog` — QoL.)
|
||||
- **W4-1 (High)** `app_network.cpp:3801` — zero the key copies in `importPrivateKey`/`sweepPrivateKey`
|
||||
(local + worker-lambda copies).
|
||||
- **W2-3 (Med)** `app_security.cpp:1481` — zero the passphrase threaded through the decrypt lambda chain.
|
||||
- **W4-5 (Med)** `app.cpp:3577` — the seed-backup `.txt` is a permanent predictable cleartext seed;
|
||||
at minimum warn + offer to delete, ideally discourage file save in favor of the on-screen phrase.
|
||||
- **W5-3 (Med)** `lite_wallet_lifecycle_service.cpp:322` — remove the dead `passphrase` field from the
|
||||
lite create/open/restore requests (unused; a secret copied for nothing).
|
||||
|
||||
## P0-B — Encryption integrity
|
||||
|
||||
- **W2-2 / W4-2 (High)** `wallet_security_controller.h:89` — the wizard's deferred encryption is
|
||||
in-memory only and silently lost if the daemon doesn't connect or the app quits/crashes first, so a
|
||||
wallet the user believes is encrypted stays plaintext. Fix: persist a lightweight
|
||||
`encryption_requested_but_incomplete` settings flag (NEVER the passphrase) when
|
||||
`beginDeferredEncryption` is called; surface a persistent warning banner while it's set; clear it
|
||||
only on confirmed `encryptwallet` success; on next connect, if set, re-prompt for the passphrase to
|
||||
complete it.
|
||||
- **W2-4 (Med)** `app_security.cpp:480` — `lockWallet` only sets `locked` on RPC success; log the
|
||||
failure and notify (currently a silent no-op that can leave the wallet unlocked).
|
||||
|
||||
## P1-A — Migrate-to-seed correctness (fund-adjacent; verify carefully)
|
||||
|
||||
- **W3-1 (High)** `app_network.cpp:4327` — adopt hardcodes `datadir + "/wallet.dat"`; use
|
||||
`settings_->getActiveWalletFile()` so migrating a non-default active wallet swaps the right file.
|
||||
- **W3-2 (High)** `seed_wallet_creator.cpp:57` — `remove_all(<config>/seed-migrate)` unconditionally
|
||||
at Phase-1 start; refuse to wipe if a temp `DRAGONX/wallet.dat` already exists (a prior un-adopted
|
||||
swept wallet) and surface it, so swept funds in the temp wallet can't be destroyed by re-entry.
|
||||
- **W3-4 (Med)** `app_network.cpp:1124` — block wallet switching while a migration is *pending*
|
||||
(`getSeedMigrationPending()`), not only while the dialog is open.
|
||||
- **W3-3 (Med)** `app_network.cpp:4231` — persist the sweep opid so an app-close mid-Sweeping can
|
||||
resume/re-poll it instead of silently dropping the txid.
|
||||
|
||||
## P1-B — Missing/wrong wallet-file safety
|
||||
|
||||
- **W1-1 (High)** `app_network.cpp:1109` — `fs::exists()`-check the target wallet file in
|
||||
`switchToWallet()` and before the first daemon launch at startup; if missing, block with an explicit
|
||||
"Wallet file not found — moved or deleted?" dialog (browse / create-new) instead of letting the
|
||||
daemon fabricate an empty wallet.
|
||||
- **W1-3 (Med)** `app_network.cpp:1095` — defer the `syncedHere=true` stamp to the first successful
|
||||
address/balance readback (idHash non-empty), not bare `onConnected()`.
|
||||
- **W1-2 (Med)** `app_network.cpp:198` — split `DB_CORRUPT`-specific strings from the generic "Error
|
||||
loading wallet" fallback; give `DB_TOO_NEW` its own message/action (not a salvage offer).
|
||||
- **W1-4 (Low)** `wallets_dialog.h:393` — re-`fs::exists()` the in-datadir row before switching (match
|
||||
the out-of-datadir path).
|
||||
|
||||
## P2 — State & lite persistence
|
||||
|
||||
- **W6-2 (Med)** `network_refresh_service.cpp:1183` — record a per-field last-success timestamp / a
|
||||
"refresh failed" flag so the UI can show a staleness badge instead of last-good-as-current.
|
||||
- **W5-1 / W5-2 (Med)** `lite_wallet_controller.cpp:78,603` — `liteLog()` the failed save and bubble a
|
||||
one-shot UI warning (both call sites currently discard the bool).
|
||||
- **W6-1 (Med)** `wallet_state.h:313` — reset `mining`/`pool_mining` in `clear()` (or comment why not).
|
||||
- **W6-3 (Low)** `address_book.cpp:46` — per-entry try/catch: skip + count malformed entries instead
|
||||
of discarding the whole list.
|
||||
|
||||
## F — Diagnostics foundation + QoL
|
||||
|
||||
Land W7-2 first — it unblocks the rest.
|
||||
|
||||
- **W7-2 (Med)** `logger.cpp:31` — call `Logger::instance().init(<config>/dragonx-debug.log)` early in
|
||||
`main()` on all platforms; add an "Open log folder" action.
|
||||
- **W7-3 (Med)** `main.cpp:144` — add a `sigaction`-based crash handler writing `dragonx-crash.log` on
|
||||
POSIX (mirror the Windows SEH path).
|
||||
- **W7-4 (Low)** `logger.cpp:39` — size-cap/rotate the log on `init()`.
|
||||
- **QoL** — "Copy diagnostics for support" bundle; persistent alert history; daemon/RPC error banner;
|
||||
refresh-staleness badge; multi-wallet diagnostic panel; refresh-diagnostics panel; structured
|
||||
switch/migration audit logging; restore-from-seed entry point (W4-4, effort L).
|
||||
|
||||
---
|
||||
|
||||
## Progress log
|
||||
|
||||
- **Adversarial review of the 3 diagnostics UI features** — ran a 5-dimension finder → per-finding verify workflow over the node-banner + staleness-badge + alert-history commits (the hand-laid ImGui I couldn't visually verify). 4 confirmed, 1 refuted (banner title never overlaps its button — button is absolutely positioned + title is short), and the dedicated ImGui-stack-balance finder found **no** Push/Pop imbalance. Fixes landed:
|
||||
- **(Med) Alert popup grew off the right edge** — pivot `(0,1)` pinned the panel's *left* edge at the bell (which sits near the window's right edge), so a 320px panel overflowed rightward (an explicit `SetNextWindowPos` pivot skips ImGui's on-screen clamp). Fixed to anchor the bottom-*right* corner at the bell (pivot `(1,1)`, at `bellMax.x`) so it grows left over the canvas.
|
||||
- **(Low) Staleness badge could flash red on reconnect** — `WalletState::clear()` reset everything *except* the four `last_*_update` stamps, so after a reconnect the pre-outage timestamp survived and the badge briefly showed "Updated Nm ago" (red) on the same frame the node banner cleared — the exact contradiction the design forbids. Fixed by zeroing the four stamps in `clear()` (all readers treat 0 as "never"; verified `app_network.cpp:1473` guards on `!= 0`).
|
||||
- **(Low) Banner min-height floor wasn't DPI-scaled** — `std::max(minH, baseH*vScale())` compared a raw-px floor against a scaled value; now `minH * dpiScale()`.
|
||||
- **(Low) New i18n keys weren't in `res/lang/`** — back-filled all 16 diagnostics/QoL keys (this session's node_banner_*/data_stale_*/alerts_*/settings_*/tt_*) into all 8 language files, additively (128 insertions, 0 deletions). zh/ja/ko reworded around 2 glyphs missing from the CJK subset (提醒→通知; ko tooltip avoids 닐) and hard-asserted tofu-free against the subset font.
|
||||
- **Foundation QoL / Persistent alert history** — ☑ landed. Toasts fade in 1–4s; there was no way to review what scrolled past. `Notifications` now retains every pushed alert in a capped (100) ring buffer with a wall-clock epoch (`AlertRecord`) — separate from the 5-item live-toast deque — plus a monotonic `total_pushed_` counter. A bell in the status-bar right cluster (`ICON_MD_NOTIFICATIONS`) opens an upward popup listing recent alerts newest-first with a severity icon/colour (reusing the toast palette), the message, and a relative age (`formatTimeAgoShort`), with a Clear-all action. An **unread dot** on the bell (coloured by the most-severe unseen alert) marks alerts that arrived since the panel was last opened — driven by `totalPushed()` deltas so it survives capping/clearing. Thread-safety: every push is on the UI thread (RPC results run as main-thread `MainCb`s), matching the class's existing lock-free model — documented as a no-raw-worker-thread invariant. Build-clean; `ctest` 1/1 (adds `testNotificationHistory`: retention, order, cap, monotonic counter, clear). **This closes the QoL bundle and the Foundation tier.**
|
||||
- **W6-2 / Refresh-staleness badge** — ☑ landed. The Total Balance card now shows a small pill on its status line ("Updated 2m ago", amber → red past 3 min) **only when connected but the balance stopped refreshing** — a busy daemon can fail `z_gettotalbalance` without dropping the whole connection (only *both* core RPCs failing 3× triggers a disconnect), leaving stale numbers on screen while the node-status banner stays hidden. No refresh-path changes were needed: `WalletState::last_balance_update` is already stamped only on a successful fetch (`network_refresh_service.cpp:1187`), so the badge just reads it and computes age against the same `std::time` clock (`util::formatTimeAgoShort`). Decision is a pure, unit-tested helper (`ui/staleness_badge.h::evaluateStalenessBadge`, thresholds 45s/180s) gated on `connected` so it never contradicts the banner; hover shows a "may be out of date — check your node connection" tooltip. Build-clean; `ctest` 1/1 (adds `testStalenessBadge`). **This closes P2 (5/5).**
|
||||
- **Foundation QoL / Persistent node-status banner** — ☑ landed. A persistent horizontal strip now sits at the top of the content column whenever the wallet can't reach its node — distinct from the transient toasts, so an offline wallet is never silently mistaken for a working one. The show/severity/action decision is a pure function (`ui/node_status_banner.h` → `evaluateNodeStatusBanner`, unit-tested) fed a state snapshot by `App::renderNodeStatusBanner()`. Three cases: **full-node offline** (amber, "Reconnect" → `tryConnect`), **embedded daemon crashed & auto-restart gave up** (red, "Restart node" → `restartDaemon`), **lite wallet failed to open** (red, message-only). Suppressed during the wizard / wallet-switch / daemon-restart / screenshot-sweep / shutdown, and while an expected startup phase (warmup/init/connect-in-progress) already owns the screen. Height in `res/themes/ui.toml` (`banners.node-status`); colours from the material semantic palette; detail text ellipsis-clipped so it can't shove the action button off-screen. Build-clean; `ctest` 1/1 (added `testNodeStatusBanner`). **Remaining QoL:** persistent alert history, and the W6-2 refresh-staleness badge.
|
||||
- **Foundation QoL / "Copy diagnostics" + "Open log folder"** — ☑ landed: Settings (logging section) now has two actions. **Open log folder** opens the config dir (`Platform::openFolder`) so users can actually find `dragonx-debug.log`/`dragonx-crash.log`. **Copy diagnostics** copies a plaintext support snapshot to the clipboard via the new `App::buildDiagnosticsReport()` — version, build variant, platform, connection status, active wallet path + existence + size, encryption/lock state, sync heights, daemon status/running/crash-count/lastError (full-node), and the log paths. No secrets. Build-clean; `ctest` 1/1. **Remaining QoL:** persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||
- **Foundation / W7-2 · W7-3 · W7-4 (diagnostics infrastructure)** — ☑ landed (answers the original "easier to diagnose" ask — the logging/crash foundation now actually works):
|
||||
- **W7-2 (Med, keystone):** the app-level `Logger` file sink was never initialized, so `LOG`/`LOGF`/`VERBOSE_LOGF` went nowhere and `dragonx-debug.log` didn't exist on Linux/macOS at all. `main()` now calls `Logger::init(<config>/dragonx-debug.log)` on all platforms. Also fixed a **latent deadlock** this exposed: `init()` wrote its banner via `write()`, which re-locks the non-recursive `mutex_` it already holds — now written directly. On Windows the raw stdout/stderr `freopen` was moved to a separate `dragonx-stdout.log` so the two writers don't contend. New `testLoggerFileSink` (also a deadlock guard — it would hang if that regressed).
|
||||
- **W7-3 (Med):** no crash handler existed on Linux/macOS. Added an **async-signal-safe** `sigaction` handler (SIGSEGV/ABRT/BUS/FPE/ILL) that writes a signal id + `backtrace_symbols_fd` backtrace to `dragonx-crash.log`, then re-raises the default disposition for a core dump — the POSIX counterpart of the Windows SEH filter.
|
||||
- **W7-4 (Low):** `Logger::init` now rotates the log to a single `.1` backup when it exceeds 10 MB, so a long/verbose session can't grow it unbounded.
|
||||
Build-clean; `ctest` 1/1. **Remaining Foundation:** the QoL bundle (mostly UI) — "copy diagnostics for support", an "open log folder" action, persistent alert history, a daemon/RPC error banner, and the W6-2 refresh-staleness badge.
|
||||
- **P2 / W5-1 · W5-2 · W6-1 · W6-3 (localized batch)** — ☑ landed:
|
||||
- **W5-1 (Med):** `persistAfterBroadcast` (lite send/shield save) returned false on a persistent save failure but both callers discarded it and it never logged — completely silent. It now `liteLog`s the failure (the note re-derives on next sync, so it's a robustness gap, not fund loss).
|
||||
- **W5-2 (Med):** the post-**sync** and post-**rescan** `save` results (in the detached scan threads) were ignored; both now `liteLog` on failure (`LiteDiagnostics::log` is mutex-guarded, safe from those threads).
|
||||
- **W6-1 (Med):** `WalletState::clear()` didn't reset `mining`/`pool_mining`, so a wallet switch could briefly show the previous wallet's hashrate/blocks. Now reset in `clear()` (the daemon restarts on switch, so mining genuinely stops).
|
||||
- **W6-3 (Low):** `AddressBook::load()` did `entries_.clear()` then threw on the first non-object element — discarding **every** contact. Now it guards `is_object()` + per-entry try/catch, skipping and counting malformed entries.
|
||||
Build-clean; `ctest` 1/1. **Remaining P2:** W6-2 (surface refresh staleness — the timestamps exist in `WalletState`; this needs the UI "updated Xs ago" badge, which overlaps the diagnostics/QoL Foundation bundle).
|
||||
- **P1-B / W1-3 + startup wallet-existence guard** — ☑ landed:
|
||||
- **W1-3 (Med):** `syncedHere` was stamped in the `markOpened` block at bare connect (idHash still empty), letting a freshly-restored wallet skip its needed rescan. It's now stamped only once the identity is verified (idHash non-empty), so it takes effect at the post-address-refresh index update (`updateWalletIndexForActiveWallet` after addresses load), while `lastOpenedEpoch` still records at open.
|
||||
- **Startup guard (the W1-1 launch counterpart):** `App::init` now `exists()`-checks the recorded active wallet before the daemon is configured; a **non-default** active wallet that was moved/deleted between sessions falls back to the default `wallet.dat` with a warning, instead of the daemon silently auto-creating an empty wallet under the missing name. Runs before the PIN-vault init so the vault is scoped to the wallet actually opened.
|
||||
Build-clean; `ctest` 1/1.
|
||||
- **P1-A / W3-3 (sweep opid persistence)** — ☑ **implemented + two rounds of adversarial review** (the "live mainnet run" the migration code mandates is the remaining gate — see below). The deferral's core fear (re-tracking a stale opid hangs forever) was **refuted by the code**: the opid poller (`app.cpp:1122`) + `parseOperationStatusPoll` classify a tracked opid absent from a *successful* `z_getoperationstatus` as stale, remove it, and fire the callback `ok=false` — a thrown RPC aborts the poll so there's never a *false* stale. So re-tracking yields at worst one clean failure, never a hang.
|
||||
- **What landed:** a persisted `seed_migration_sweep_opid` setting; the opid is adopted **atomically** with clearing any prior txid in the *same* `settings.save()` **only once the submit succeeds** (torn-write safe; txid always outranks opid on resume). Resume routing is a pure, unit-tested helper (`data/seed_migration_resume.h::decideSeedMigrationResume`): txid → Confirming; opid **and connected** → re-track (`Sweeping`); otherwise → the dismissable Sweep gate. The shared `makeSweepCompletionCallback(resumed)`: success → Confirming; resumed-stale → Sweep gate (re-fetch balance, honest "may have already completed" copy); fresh-fail → Error.
|
||||
- **Round 1 (design review, 4 skeptics)** confirmed both safety facts (no fund loss — adopt gate + never-deleted `.bak` untouched; no hang) and caught 3 real resume-UX traps, all fixed: a missing **connectivity gate** (would trap the user in the buttonless `Sweeping` spinner while offline), a **missing balance re-fetch** on the stale fallback (permanent "Checking balance…"), and honest messaging since a daemon restart makes even a *successful* sweep read "stale".
|
||||
- **Round 2 (implementation review, 3 reviewers)** caught one regression — clearing the old txid at sweep *entry* would forget an already-mined first sweep if a remainder re-sweep's submit failed; fixed by the atomic-on-success swap above. All other fixes verified present + correct.
|
||||
- **⚑ Remaining gate — live mainnet run (user):** per CLAUDE.md this fund-moving path must be exercised once on mainnet before it ships. The self-verifiable parts (build, unit test, both review rounds) are green; a real interrupted-sweep resume on mainnet is the human gate I cannot perform.
|
||||
- **P1-B / W1-1 (+ W1-4) · W1-2 (wallet-file safety)** — ☑ landed:
|
||||
- **W1-1 (High):** `switchToWallet` never checked the target wallet file exists, so a moved/deleted file "opened" as a fresh empty wallet (dragonxd auto-creates for a missing `-wallet=`), looking exactly like fund loss. It now `std::filesystem::exists`-checks `datadir + "/" + walletFile` before switching and blocks with a "not found (moved or deleted?)" warning. Placed before the daemon-stop prompt, and — since the check runs no matter how `switchToWallet` is invoked — it also **closes W1-4** (the stale-switcher-row TOCTOU).
|
||||
- **W1-2 (Med):** `walletOutputLooksCorrupt` matched the generic "Error loading wallet" string, so a `DB_TOO_NEW` (newer-version) wallet was offered a `-salvagewallet` repair that can't fix it. Now the generic match is excluded when the output also contains "newer version".
|
||||
Build-clean; `ctest` 1/1. **Remaining P1-B:** W1-3 (defer the `syncedHere` stamp to a verified readback) + the startup-path existence check (`app.cpp` hands `getActiveWalletFile()` to the daemon with no `exists()` check — same silent-empty-wallet risk as W1-1 but at launch).
|
||||
- **P1-A / W3-1 · W3-2 · W3-4 (migrate-to-seed correctness)** — ☑ landed (fund-adjacent — reviewed carefully):
|
||||
- **W3-1 (High):** `beginAdoptSeedWallet` hardcoded `datadir + "/wallet.dat"` as the file to swap. With a non-default active wallet (e.g. `wallet-2.dat`), that installed the swept seed wallet into an unloaded `wallet.dat` and left the daemon reloading the emptied legacy — funds only recoverable via the seed phrase. Now swaps `datadir + "/" + getActiveWalletFile()` (captured on the main thread; switching is blocked during migration so it can't race).
|
||||
- **W3-2 (High):** `SeedWalletCreator::create` did `remove_all(<config>/seed-migrate)` unconditionally at the start. A prior migration that swept funds into the temp wallet but was abandoned/crashed before adopting would have that fund-bearing wallet destroyed. It now refuses (with a clear message) when `DRAGONX/wallet.dat` already exists — a completed migration removes the dir on adopt, so a leftover means an unfinished one.
|
||||
- **W3-4 (Med):** `switchToWallet` only blocked switching while the migration *dialog* was open; closing it via "Later" mid-migration dropped the guard. Now also blocks while `getSeedMigrationPending()`.
|
||||
Build-clean; `ctest` 1/1. **Remaining P1-A:** W3-3 (persist the sweep opid so an app-close mid-sweep can resume/re-poll instead of silently dropping the txid).
|
||||
|
||||
- **P0-B / W2-2 (deferred encryption silently lost) + W2-4 (auto-lock silent-fail)** — ☑ landed:
|
||||
- **W2-2:** the wizard's deferred encryption was stored only in memory, so a quit/crash or a failed daemon connect before it applied left the wallet unencrypted with **no record it was ever requested** — the user believing it was encrypted. Now a persisted `encryption_pending` settings flag is set the moment encryption is requested (**never the passphrase** — only the fact). `refreshWalletEncryptionState()` reconciles it on every connect: wallet observed **encrypted** → clear the flag; wallet **not** encrypted while the flag is set and no deferred encryption is pending/in-flight → a once-per-session **"your wallet is NOT encrypted — open Settings to finish"** warning (the flag stays set, so it recurs each launch until resolved). We deliberately don't persist the passphrase to auto-complete — surfacing it is the secure choice.
|
||||
- **W2-4:** `lockWallet()`'s continuation only handled success — a failed `walletlock` silently left the wallet **unlocked** (an unfulfilled auto-lock). It now logs and warns once (reset on the next successful lock), so a failing auto-lock is visible instead of leaving the wallet exposed.
|
||||
Touches `settings.{h,cpp}`, `app_wizard.cpp`, `app_security.cpp`, `app.h`. Not unit-testable at this layer (RPC/connect-driven state machine); build-clean, `ctest` 1/1.
|
||||
|
||||
- **P0-A / W5-3 (lite create-time passphrase)** — ☑ landed (chose option **(b) wire it up**). The lite create/open/restore passphrase was collected but never consumed by the backend — a "passphrase" field that did nothing. It now has a real meaning for all three operations, in `LiteWalletController`: **create/restore** → `encryptWallet(passphrase)` (the backend encrypts + locks + saves the brand-new wallet); **open** → `unlockWallet(passphrase)`, but only when `encryptionStatus()` reports the existing wallet is actually encrypted+locked (skips a spurious unlock otherwise). Encrypt/unlock take their own copy and wipe it; a post-create encrypt failure is `liteLog`'d (the wallet still exists — the create isn't failed). Six existing lite-controller tests carried an incidental `hunter2` create passphrase from the dead-field era; removed (they test non-encryption flows and want an unencrypted wallet), and added `testLiteWalletControllerCreateEncryptsWithPassphrase` to prove the new behavior. Build-clean; `ctest` 1/1. *(Follow-up UX polish: `settings_page` could show the passphrase field's meaning per operation — "encrypt" for create/restore vs "unlock" for open.)*
|
||||
- **P0-A / W4-5 (seed-backup file)** — ☑ landed (proportionate): the seed "Save" already wrote 0600 + zeroed the in-memory buffer, but the success message was a bare "Saved to <path>". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: <path>", so the plaintext-on-disk risk is called out. `i18n.cpp` (English source; `res/lang` back-fill of this changed key is deferred to the batch i18n pass). A stronger fix (pre-save confirmation, or dropping the file-save in favor of on-screen + Copy) is a follow-up UX decision.
|
||||
- **P0-A / W4-1 · W4-3 · W2-3 (memzero cluster)** — ☑ landed, using the file's established `sodium_memzero` pattern (matching the existing lambda-capture scrub at app_network.cpp:2885 and JSON scrub at :4025) rather than a new type, since this is fund-moving code:
|
||||
- **W4-1** `importPrivateKey`/`sweepPrivateKey`: the spending/viewing key is now scrubbed on all paths — the calling-frame copy (after the worker post), the worker-lambda's captured copy (lambda made `mutable`, zeroed after the request is sent), and the JSON request `params` copy.
|
||||
- **W4-3** `exportAllKeys`/`backupWallet`: the concatenated all-keys buffer is zeroed after the consumer uses it, and the backup file is now written via `Platform::writeFileAtomically(..., restrictPermissions=true)` (atomic + 0600) instead of a umask-default `ofstream`.
|
||||
- **W2-3** decrypt-wallet passphrase: `std::move`-captured into the worker lambda (so no plaintext copy is left in the calling frame) and `sodium_memzero`'d right after `unlockWallet` (its only use).
|
||||
Not unit-testable (the scrubbing has no observable RPC effect — the key value sent to the daemon is unchanged; only post-use memory zeroing is added). Build-clean; `ctest` 1/1 (no regression). **Remaining in P0-A:** W5-3 (remove the dead lite `passphrase` field), W4-5 (predictable plaintext seed-backup file).
|
||||
- **P0-A / W2-1** — ☑ landed: the decrypt-wallet flow now scrubs (best-effort in-place zero-overwrite) and removes the plaintext key export (`obsidiandecryptexport…`) as soon as the `z_importwallet` attempt resolves — success or failure — so a full cleartext dump of every private key is no longer left on disk forever. Recovery remains the encrypted backup (`wallet.dat.encrypted.bak`). `app_security.cpp` (after the import call). Not unit-testable (fs I/O in a deep lambda); build-clean, `ctest` 1/1 (no regression).
|
||||
- **P0-A / W7-1** — ☑ landed: `RedactConsoleCommand`/`ConsoleCommandCarriesSecret` in `console_tab_helpers` redact secret-bearing commands (an allowlist of 13 first-tokens: `walletpassphrase`, `encryptwallet`, `z_importkey`, …) to `> walletpassphrase ****` before they hit the console echo AND the recall history; the real command still executes unredacted. Wired into `submitConsoleCommand` (`console_tab.cpp`). New `testConsoleSecretRedaction` (11 assertions). Clean build; `ctest` 1/1. (Output-secret commands like `z_exportkey` — result redaction — remain a follow-up.)
|
||||
Binary file not shown.
+88
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "ERWEITERT",
|
||||
"advanced_effects": "Erweiterte Effekte...",
|
||||
"ago": "her",
|
||||
"alerts_clear": "Meldungsverlauf löschen",
|
||||
"alerts_history_tooltip": "Letzte Meldungen",
|
||||
"alerts_none": "Noch keine Meldungen",
|
||||
"alerts_recent": "LETZTE MELDUNGEN",
|
||||
"all_filter": "Alle",
|
||||
"allow_custom_fees": "Benutzerdefinierte Gebühren erlauben",
|
||||
"amount": "Betrag",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender hat den Miner blockiert",
|
||||
"available": "Verfügbar",
|
||||
"backup_backing_up": "Sicherung läuft...",
|
||||
"backup_col_backup": "SICHERUNG",
|
||||
"backup_col_export": "EXPORTIEREN",
|
||||
"backup_col_import": "IMPORTIEREN & WIEDERHERSTELLEN",
|
||||
"backup_create": "Sicherung erstellen",
|
||||
"backup_created": "Wallet-Sicherung erstellt",
|
||||
"backup_data": "SICHERUNG & DATEN",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "Guthaben",
|
||||
"balance_history_collecting": "Guthabenverlauf — Daten werden gesammelt...",
|
||||
"balance_layout": "Guthaben-Layout",
|
||||
"balance_layout_switched": "Layout: %s",
|
||||
"balance_mining_rate": "Schürfe %s",
|
||||
"balance_shielded_fmt": "Abgeschirmt: %.8f",
|
||||
"balance_syncing_pct": "Synchronisiere %.1f%%",
|
||||
"balance_transparent_fmt": "Transparent: %.8f",
|
||||
"ban": "Sperren",
|
||||
"banned_peers": "Gesperrte Peers",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "Prüfsummen werden überprüft...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat ist geschützt)",
|
||||
"bootstrap_warning": "Vorhandene Blockdaten (blocks, chainstate, notarizations) werden gelöscht und ersetzt. Ihre wallet.dat wird NICHT verändert oder gelöscht.",
|
||||
"byte_count_fmt": "%zu / %zu Byte",
|
||||
"cancel": "Abbrechen",
|
||||
"change_pass_confirm": "Neue bestätigen:",
|
||||
"change_pass_current": "Aktuelle Passphrase:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "Gebündelt",
|
||||
"daemon_install_bundled": "Gebündelten installieren",
|
||||
"daemon_installed": "Installiert",
|
||||
"daemon_maintenance_label": "WARTUNG",
|
||||
"daemon_none_bundled": "keiner in diesem Build",
|
||||
"daemon_not_installed": "nicht installiert",
|
||||
"daemon_status_differ": "Installierte Binärdatei unterscheidet sich von der gebündelten Version.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "Neueste:",
|
||||
"daemon_update_loading": "Releases werden geladen…",
|
||||
"daemon_update_now": "Jetzt aktualisieren",
|
||||
"daemon_update_prompt_title": "Node-Daemon aktualisieren?",
|
||||
"daemon_update_reinstall": "Neu installieren",
|
||||
"daemon_update_restart_note": "Starten Sie den Daemon neu, um die neue Version auszuführen.",
|
||||
"daemon_update_restart_now": "Daemon jetzt neu starten",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "Der Download wird vor der Installation anhand der veröffentlichten SHA-256-Prüfsumme des Releases und einer fest hinterlegten ed25519-Signatur verifiziert.",
|
||||
"daemon_update_verifying": "Wird verifiziert…",
|
||||
"daemon_update_version": "Version:",
|
||||
"daemon_updates_label": "AKTUALISIERUNGEN",
|
||||
"daemon_version": "Daemon",
|
||||
"dark": "Dunkel",
|
||||
"data_stale_prefix": "Aktualisiert",
|
||||
"data_stale_tooltip": "Der Kontostand ist möglicherweise veraltet – die Wallet hat kürzlich keine Aktualisierung erhalten. Überprüfe deine Node-Verbindung.",
|
||||
"date": "Datum",
|
||||
"date_label": "Datum:",
|
||||
"debug_logging": "FEHLERPROTOKOLLIERUNG",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "Bootstrap herunterladen",
|
||||
"dragonx_green": "DragonX (Grün)",
|
||||
"edit": "Bearbeiten",
|
||||
"empty_wallet_keys_suffix": "Schlüssel",
|
||||
"empty_wallet_open_manager": "Wallet-Verwaltung öffnen",
|
||||
"empty_wallet_restore": "Mein Wallet wiederherstellen",
|
||||
"empty_wallet_salvage_body": "Dieses Wallet ist leer, weil eine frühere automatische Reparatur Ihr ursprüngliches Wallet als Sicherung beiseitegelegt hat. Ihre Coins befinden sich fast sicher in dieser Sicherung und sind nicht verloren. Stellen Sie sie wieder her, um Ihr Guthaben erneut zu laden — nichts wird gelöscht; die aktuelle Datei wird zuerst beiseitegelegt.",
|
||||
"empty_wallet_salvage_headline": "Ihre Coins sind sicher in einer Sicherungsdatei.",
|
||||
"empty_wallet_salvage_title": "Ihr Wallet wurde möglicherweise repariert",
|
||||
"empty_wallet_warning_body": "Dieses Wallet hat keine Adressen und kein Guthaben, aber eine andere Wallet-Datei in Ihrem DragonX-Ordner enthält Schlüssel. Ihre Coins befinden sich höchstwahrscheinlich dort und sind nicht verloren. Öffnen Sie die Wallet-Verwaltung, um zu dem Wallet mit Ihrem Guthaben zu wechseln.",
|
||||
"empty_wallet_warning_dismiss": "Für dieses Wallet nicht mehr warnen",
|
||||
"empty_wallet_warning_dismiss_tip": "Beendet diese Warnung nur für die aktuelle Wallet-Datei. Wenn Sie später zu einem anderen leeren Wallet wechseln, kann die Warnung erneut erscheinen.",
|
||||
"empty_wallet_warning_headline": "Möglicherweise haben Sie das falsche Wallet geöffnet.",
|
||||
"empty_wallet_warning_title": "Dieses Wallet ist leer",
|
||||
"enc_confirm": "Bestätigen:",
|
||||
"enc_desc": "Die Verschlüsselung Ihrer Wallet schützt Ihre privaten Schlüssel mit einer Passphrase. Nach der Verschlüsselung wird der Daemon neu gestartet.",
|
||||
"enc_encrypting": "Wallet wird verschlüsselt...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "Hell",
|
||||
"lite_account_label": "Konto",
|
||||
"lite_action": "Aktion",
|
||||
"lite_backend_unavailable": "Lite-Wallet-Backend nicht verfügbar",
|
||||
"lite_backup_keys": "Sicherung & Schlüssel",
|
||||
"lite_birthday_backup": "Geburtstag: %llu (auch diesen sichern)",
|
||||
"lite_birthday_hint": "Blockhöhe, ab der gescannt werden soll. Bei 0 belassen, falls unbekannt (langsamerer vollständiger Scan).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "Backend-Befehle:",
|
||||
"lite_console_help_passthrough": "Jede andere Eingabe wird als Lite-Wallet-Konsolenbefehl ausgeführt.",
|
||||
"lite_copy": "Kopieren",
|
||||
"lite_could_not_start": "Der Vorgang konnte nicht gestartet werden",
|
||||
"lite_could_not_write": "Konnte nicht schreiben ",
|
||||
"lite_encrypt_wallet": "Wallet verschlüsseln",
|
||||
"lite_encryption_removed": "Verschlüsselung entfernt",
|
||||
"lite_enter_all_seed_words": "Alle 24 Seed-Wörter zur Wiederherstellung eingeben (%d erhalten)",
|
||||
"lite_enter_wallet_path": "Wallet-Pfad eingeben",
|
||||
"lite_hide_wipe": "Ausblenden & löschen",
|
||||
"lite_import": "Importieren",
|
||||
"lite_import_key_label": "Schlüssel importieren",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "In Arbeit…",
|
||||
"loading": "Laden...",
|
||||
"loading_addresses": "Adressen werden geladen...",
|
||||
"loading_stall_body": "Der Daemon initialisiert seit %.0f s. Das kann nach einem Update oder beim ersten Start normal sein (Laden des Blockindex oder erneutes Scannen) – die Verbindung wird automatisch hergestellt, sobald er bereit ist.",
|
||||
"loading_stall_hint": "Hängt es noch? Öffne die Einstellungen und nutze „Daemon neu starten“ oder sieh in der Konsole nach Details.",
|
||||
"loading_stall_title": "Dauert länger als erwartet",
|
||||
"loading_transactions": "Transaktionen werden geladen",
|
||||
"local_hashrate": "Lokale Hashrate",
|
||||
"low_spec_mode": "Energiesparmodus",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "Schwierigkeit kopiert",
|
||||
"mining_est_block": "Gesch. Block",
|
||||
"mining_est_daily": "Gesch. täglich",
|
||||
"mining_est_daily_pool_sub": "grobe Solo-Äquivalenz, vor Pool-Gebühr",
|
||||
"mining_filter_all": "Alle",
|
||||
"mining_filter_tip_all": "Alle Einnahmen anzeigen",
|
||||
"mining_filter_tip_pool": "Nur Pool-Einnahmen anzeigen",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "Im Explorer öffnen",
|
||||
"mining_payout_address": "Auszahlungsadresse",
|
||||
"mining_payout_foreign": "⚠ Diese Auszahlungsadresse befindet sich nicht in Ihrer aktuellen Wallet — geschürfte Belohnungen würden an eine andere Wallet gehen. Aktualisieren Sie sie, wenn Sie die Wallet gewechselt haben.",
|
||||
"mining_payout_invalid": "Keine gültige DragonX-Adresse — vor dem Start korrigieren, sonst gehen die Mining-Belohnungen verloren.",
|
||||
"mining_payout_tooltip": "Adresse für Mining-Belohnungen",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Gebühr",
|
||||
"mining_pool_hashrate": "Pool-Hashrate",
|
||||
"mining_pool_needs_payout_tooltip": "Zuerst eine Auszahlungsadresse eingeben (Z-Adresse erzeugen)",
|
||||
"mining_pool_url": "Pool-URL",
|
||||
"mining_pools_header": "POOLS",
|
||||
"mining_recent_blocks": "LETZTE BLÖCKE",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "Blockchain synchronisiert...",
|
||||
"mining_tag": " · Mining",
|
||||
"mining_threads": "Mining-Threads",
|
||||
"mining_threads_input_tooltip": "Genaue Thread-Anzahl eingeben (Enter zum Übernehmen)",
|
||||
"mining_threads_minus_tooltip": "Weniger Threads",
|
||||
"mining_threads_plus_tooltip": "Mehr Threads",
|
||||
"mining_to_save": "zum Speichern",
|
||||
"mining_today": "Heute",
|
||||
"mining_uptime": "Laufzeit",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "Keine Transaktionen gefunden",
|
||||
"no_transactions_yet": "Noch keine Transaktionen",
|
||||
"node": "KNOTEN",
|
||||
"node_banner_crashed_title": "Der Node wurde unerwartet beendet",
|
||||
"node_banner_lite_open_failed": "Wallet konnte nicht geöffnet werden",
|
||||
"node_banner_offline_title": "Nicht mit dem DragonX-Node verbunden",
|
||||
"node_banner_reconnect": "Erneut verbinden",
|
||||
"node_banner_restart": "Node neu starten",
|
||||
"node_security": "KNOTEN & SICHERHEIT",
|
||||
"noise": "Rauschen",
|
||||
"not_connected": "Nicht mit Daemon verbunden...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "QR-Code konnte nicht generiert werden",
|
||||
"qr_title": "QR-Code",
|
||||
"qr_unavailable": "QR nicht verfügbar",
|
||||
"quick_receive": "Schnell empfangen",
|
||||
"quick_send": "Schnell senden",
|
||||
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
|
||||
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
|
||||
"ram_system_gb": "System: %.1f / %.0f GB",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "RPC-Verbindung...",
|
||||
"rpc_host": "RPC-Host",
|
||||
"rpc_pass": "Passwort",
|
||||
"rpc_plaintext_remote_warning": "Die Remote-RPC-Verbindung verwendet unverschlüsseltes HTTP. Füge rpctls=1 zur DRAGONX.conf hinzu, falls dein Daemon TLS unterstützt.",
|
||||
"rpc_port": "Port",
|
||||
"rpc_user": "Benutzername",
|
||||
"save": "Speichern",
|
||||
@@ -1154,6 +1202,8 @@
|
||||
"sb_connecting_external": "Verbindung zu externem Daemon...",
|
||||
"sb_connecting_generic": "Verbindung zum Daemon...",
|
||||
"sb_daemon_crashed": "Daemon ist %d mal abgestürzt",
|
||||
"sb_daemon_extract_failed": "Daemon-Dateien konnten nicht geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
|
||||
"sb_daemon_files_failed": "Daemon-Dateien konnten nicht nach %s geschrieben werden – prüfe freien Speicherplatz und Berechtigungen.",
|
||||
"sb_daemon_not_found": "Daemon nicht gefunden",
|
||||
"sb_daemon_start_failed": "dragonxd konnte nicht gestartet werden",
|
||||
"sb_dragonxd_running": "dragonxd läuft",
|
||||
@@ -1169,6 +1219,7 @@
|
||||
"sb_net_mhs": "Netz: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf nicht gefunden",
|
||||
"sb_peers": "Peers: %zu",
|
||||
"sb_plaintext_remote_blocked": "RPC-Anmeldedaten werden nicht im Klartext an einen entfernten Host gesendet. Füge rpcallowplaintext=1 zu DRAGONX.conf hinzu, um dies zu erlauben, oder aktiviere TLS mit rpctls=1.",
|
||||
"sb_rescanning": "Neuscan",
|
||||
"sb_rescanning_pct": "Neuscan %.0f%%",
|
||||
"sb_restarting_daemon": "Daemon wird neu gestartet...",
|
||||
@@ -1182,6 +1233,7 @@
|
||||
"sb_waiting_daemon_err": "Warten auf dragonxd — %s",
|
||||
"sb_warming_up": "Aufwärmen...",
|
||||
"sb_witness_cache": "Zeugen werden neu aufgebaut",
|
||||
"scale_effects": "SKALIERUNG & EFFEKTE",
|
||||
"screenshot_open_dir": "Speicherort öffnen",
|
||||
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
|
||||
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
|
||||
@@ -1246,6 +1298,7 @@
|
||||
"send_tooltip_not_connected": "Nicht mit Daemon verbunden",
|
||||
"send_tooltip_select_source": "Wählen Sie zuerst eine Quelladresse",
|
||||
"send_tooltip_syncing": "Warten Sie auf die Blockchain-Synchronisierung",
|
||||
"send_tooltip_view_only": "Nur-Lese-Adresse — kein Spending Key, Senden nicht möglich",
|
||||
"send_total": "Gesamt",
|
||||
"send_transaction": "Transaktion senden",
|
||||
"send_tx_failed": "Transaktion fehlgeschlagen",
|
||||
@@ -1265,16 +1318,16 @@
|
||||
"sent_filter": "Gesendet",
|
||||
"sent_type": "Gesendet",
|
||||
"sent_upper": "GESENDET",
|
||||
"set_label": "Label setzen...",
|
||||
"set_label": "Label setzen",
|
||||
"settings": "Einstellungen",
|
||||
"settings_about_text": "Eine geschirmte Kryptowährungs-Wallet für DragonX (DRGX), erstellt mit Dear ImGui für ein leichtes, portables Erlebnis.",
|
||||
"settings_acrylic_level": "Acrylstufe:",
|
||||
"settings_address_book": "Adressbuch...",
|
||||
"settings_address_book": "Adressbuch…",
|
||||
"settings_auto_detected": "Automatisch erkannt aus DRAGONX.conf",
|
||||
"settings_auto_lock": "AUTO-SPERRE",
|
||||
"settings_auto_shield_desc": "Transparente Guthaben automatisch an geschirmte Adressen verschieben",
|
||||
"settings_auto_shield_funds": "Transparente Guthaben automatisch abschirmen",
|
||||
"settings_backup": "Sicherung...",
|
||||
"settings_backup": "Sicherung…",
|
||||
"settings_block_explorer_urls": "Block-Explorer-URLs",
|
||||
"settings_builtin": "Integriert",
|
||||
"settings_change_passphrase": "Passphrase ändern",
|
||||
@@ -1285,60 +1338,71 @@
|
||||
"settings_configure_explorer": "Externe Block-Explorer-Links konfigurieren",
|
||||
"settings_configure_rpc": "Verbindung zum dragonxd-Daemon konfigurieren",
|
||||
"settings_connection": "Verbindung",
|
||||
"settings_copy_diagnostics": "Diagnose kopieren",
|
||||
"settings_copyright": "Copyright 2024-2026 DragonX-Entwickler | GPLv3-Lizenz",
|
||||
"settings_custom": "Benutzerdefiniert",
|
||||
"settings_data_dir": "Datenverzeichnis:",
|
||||
"settings_data_dir": "Datenverzeichnis",
|
||||
"settings_debug_changed": "Debug-Kategorien geändert — Daemon neu starten zum Anwenden",
|
||||
"settings_debug_restart_note": "Änderungen werden nach einem Neustart des Daemons wirksam.",
|
||||
"settings_debug_select": "Kategorien auswählen, um Daemon-Fehlerprotokollierung zu aktivieren (-debug= Flags).",
|
||||
"settings_diagnostics_copied": "Diagnose in die Zwischenablage kopiert",
|
||||
"settings_encrypt_first_pin": "Verschlüsseln Sie zuerst die Wallet, um PIN zu aktivieren",
|
||||
"settings_encrypt_wallet": "Wallet verschlüsseln",
|
||||
"settings_explorer_hint": "URLs sollten einen abschließenden Schrägstrich enthalten. Die txid/Adresse wird angehängt.",
|
||||
"settings_export_all": "Alle exportieren...",
|
||||
"settings_export_csv": "CSV exportieren...",
|
||||
"settings_export_key": "Schlüssel exportieren...",
|
||||
"settings_export_all": "Alle exportieren…",
|
||||
"settings_export_csv": "CSV exportieren…",
|
||||
"settings_export_key": "Schlüssel exportieren…",
|
||||
"settings_gradient_bg": "Hintergrund-Verlauf",
|
||||
"settings_gradient_desc": "Strukturierte Hintergründe durch sanfte Verläufe ersetzen",
|
||||
"settings_idle_after": "nach",
|
||||
"settings_import_key": "Privaten Schlüssel importieren...",
|
||||
"settings_import_viewkey": "Anzeigeschlüssel importieren...",
|
||||
"settings_import_key": "Privaten Schlüssel importieren…",
|
||||
"settings_import_viewkey": "Anzeigeschlüssel importieren…",
|
||||
"settings_language_note": "Hinweis: Manche Texte erfordern einen Neustart zur Aktualisierung",
|
||||
"settings_lock_now": "Jetzt sperren",
|
||||
"settings_locked": "Gesperrt",
|
||||
"settings_merge_to_address": "An Adresse zusammenführen...",
|
||||
"settings_merge_to_address": "An Adresse zusammenführen…",
|
||||
"settings_noise_opacity": "Rauschdichte:",
|
||||
"settings_not_connected": "Nicht mit dem Daemon verbunden",
|
||||
"settings_not_encrypted": "Nicht verschlüsselt",
|
||||
"settings_not_found": "Nicht gefunden",
|
||||
"settings_open_app_dir": "App-Ordner öffnen",
|
||||
"settings_open_data_dir": "Datenordner öffnen",
|
||||
"settings_open_log_folder": "Log-Ordner öffnen",
|
||||
"settings_other": "Sonstiges",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "Datenschutz",
|
||||
"settings_quick_unlock_pin": "Schnell-Entsperr-PIN",
|
||||
"settings_reduce_transparency": "Transparenz reduzieren",
|
||||
"settings_reloaded": "Einstellungen von der Festplatte neu geladen",
|
||||
"settings_remove_encryption": "Verschlüsselung entfernen",
|
||||
"settings_remove_pin": "PIN entfernen",
|
||||
"settings_request_payment": "Zahlung anfordern...",
|
||||
"settings_request_payment": "Zahlung anfordern…",
|
||||
"settings_rescan_desc": "Blockchain nach fehlenden Transaktionen neu scannen",
|
||||
"settings_restart_daemon": "Daemon neu starten",
|
||||
"settings_rpc_connection": "RPC-Verbindung",
|
||||
"settings_rpc_error_prefix": "RPC-Fehler: ",
|
||||
"settings_rpc_note": "Hinweis: Verbindungseinstellungen werden automatisch aus DRAGONX.conf erkannt",
|
||||
"settings_rpc_ok": "RPC-Verbindung OK",
|
||||
"settings_save_shielded_desc": "Speichert z-addr Transaktionen in einer lokalen Datei zur Ansicht",
|
||||
"settings_save_shielded_local": "Geschirmten Transaktionsverlauf lokal speichern",
|
||||
"settings_saved": "Einstellungen gespeichert",
|
||||
"settings_set_pin": "PIN festlegen",
|
||||
"settings_shield_mining": "Mining abschirmen...",
|
||||
"settings_shield_mining": "Mining abschirmen…",
|
||||
"settings_solid_colors_desc": "Feste Farben anstelle von Unschärfe-Effekten verwenden (Barrierefreiheit)",
|
||||
"settings_theme_refreshed": "Themenliste aktualisiert",
|
||||
"settings_tor_desc": "Alle Verbindungen für erhöhte Privatsphäre über Tor leiten",
|
||||
"settings_unlocked": "Entsperrt",
|
||||
"settings_use_tor_network": "Tor für Netzwerkverbindungen verwenden",
|
||||
"settings_validate_address": "Adresse überprüfen...",
|
||||
"settings_validate_address": "Adresse überprüfen…",
|
||||
"settings_visual_effects": "Visuelle Effekte",
|
||||
"settings_wallet_file_size": "Wallet-Dateigröße: %s",
|
||||
"settings_wallet_info": "Wallet-Informationen",
|
||||
"settings_wallet_location": "Wallet-Speicherort: %s",
|
||||
"settings_wallet_maintenance": "Wallet-Wartung",
|
||||
"settings_wallet_not_found": "Wallet-Datei nicht gefunden",
|
||||
"settings_wallet_size_label": "Wallet-Größe:",
|
||||
"settings_wallet_size_label": "Wallet-Größe",
|
||||
"settings_ztx_cleared": "Z-Transaktionsverlauf gelöscht",
|
||||
"settings_ztx_not_found": "Keine Verlaufsdatei gefunden",
|
||||
"setup_wizard": "Einrichtungsassistent",
|
||||
"share": "Teilen",
|
||||
"shield_check_status": "Status prüfen",
|
||||
@@ -1421,6 +1485,7 @@
|
||||
"theme": "Design",
|
||||
"theme_effects": "Design-Effekte",
|
||||
"theme_language": "THEMA & SPRACHE",
|
||||
"tile_click_to_open": "Zum Öffnen klicken",
|
||||
"time_days_ago": "vor %d Tagen",
|
||||
"time_hours_ago": "vor %d Stunden",
|
||||
"time_minutes_ago": "vor %d Minuten",
|
||||
@@ -1435,7 +1500,9 @@
|
||||
"to_upper": "AN",
|
||||
"tools": "WERKZEUGE",
|
||||
"tools_actions": "Werkzeuge & Aktionen...",
|
||||
"tools_actions_hdr": "WERKZEUGE & AKTIONEN",
|
||||
"total": "Gesamt",
|
||||
"total_balance_label": "Gesamtguthaben",
|
||||
"transaction_id": "TRANSAKTIONS-ID",
|
||||
"transaction_sent": "Transaktion erfolgreich gesendet",
|
||||
"transaction_sent_msg": "Transaktion gesendet!",
|
||||
@@ -1457,7 +1524,7 @@
|
||||
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
|
||||
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",
|
||||
"tt_block_explorer": "Den DragonX Block-Explorer im Browser öffnen",
|
||||
"tt_blur": "Unschärfe-Stärke (0%% = aus, 100%% = maximum)",
|
||||
"tt_blur": "Unschärfe-Stärke (0% = aus, 100% = maximum)",
|
||||
"tt_change_pass": "Die Wallet-Verschlüsselungspassphrase ändern",
|
||||
"tt_change_pin": "Ihre Entsperr-PIN ändern",
|
||||
"tt_chat_bubble_accent": "Akzentfarbe für deine ausgehenden Nachrichtenblasen (oder dem aktuellen Theme folgen)",
|
||||
@@ -1470,6 +1537,7 @@
|
||||
"tt_chat_timestamp": "Zeitstempelformat nur für diesen Tab: der app-weiten Uhr folgen oder 24-hour bzw. 12-hour erzwingen",
|
||||
"tt_clear_ztx": "Lokal zwischengespeicherten Z-Transaktionsverlauf löschen",
|
||||
"tt_clock_format": "24- oder 12-Stunden-Uhr, app-weit. Der Chat-Tab kann sie überschreiben.",
|
||||
"tt_copy_diagnostics": "Kopiert eine Support-Übersicht (Version, Daemon-/Wallet-/Log-Status – keine Geheimnisse) in die Zwischenablage",
|
||||
"tt_custom_fees": "Manuelle Gebühreneingabe beim Senden von Transaktionen aktivieren",
|
||||
"tt_custom_theme": "Benutzerdefiniertes Theme aktiv",
|
||||
"tt_daemon_install_bundled": "Node stoppen, den installierten dragonxd mit der in diesem Wallet-Build enthaltenen Version überschreiben und dann neu starten",
|
||||
@@ -1519,10 +1587,11 @@
|
||||
"tt_low_spec": "Alle aufwendigen visuellen Effekte deaktivieren\\nHotkey: Ctrl+Shift+Down",
|
||||
"tt_merge": "Mehrere UTXOs einer Adresse zusammenführen",
|
||||
"tt_mine_idle": "Mining automatisch starten, wenn das\\nSystem inaktiv ist (keine Tastatur-/Mauseingabe)",
|
||||
"tt_noise": "Körnungstextur-Intensität (0%% = aus, 100%% = maximum)",
|
||||
"tt_noise": "Körnungstextur-Intensität (0% = aus, 100% = maximum)",
|
||||
"tt_open_app_dir": "Den ObsidianDragon-Ordner (Einstellungen, Themes, Logs) im Dateimanager öffnen",
|
||||
"tt_open_data_dir": "Den Ordner mit Ihren Wallet- und Blockchain-Daten im Dateimanager öffnen",
|
||||
"tt_open_dir": "Klicken, um im Dateimanager zu öffnen",
|
||||
"tt_open_log_folder": "Öffnet den Ordner mit den Debug- und Absturzprotokollen",
|
||||
"tt_reduce_motion": "Animierte Übergänge und Saldo-Lerp für Barrierefreiheit deaktivieren",
|
||||
"tt_remove_encrypt": "Verschlüsselung entfernen und Wallet ungeschützt speichern",
|
||||
"tt_remove_pin": "PIN entfernen und Passphrase zum Entsperren erfordern",
|
||||
@@ -1557,7 +1626,7 @@
|
||||
"tt_theme_hotkey": "Hotkey: Ctrl+Links/Rechts zum Wechseln der Themes",
|
||||
"tt_tor": "Daemon-Verbindungen für Anonymität über das Tor-Netzwerk leiten",
|
||||
"tt_tx_url": "Basis-URL zum Anzeigen von Transaktionen in einem Block-Explorer",
|
||||
"tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100%% = vollständig undurchsichtig, niedriger = durchsichtiger)",
|
||||
"tt_ui_opacity": "Karten- und Seitenleisten-Deckkraft (100% = vollständig undurchsichtig, niedriger = durchsichtiger)",
|
||||
"tt_validate": "Prüfen, ob eine DragonX-Adresse gültig ist",
|
||||
"tt_verbose": "Detaillierte Verbindungsdiagnosen,\\nDaemon-Status und Port-Besitzer-Info\\nin der Konsolen-Registerkarte protokollieren",
|
||||
"tt_wallets_button": "Ihre Wallet-Dateien auflisten und zwischen ihnen wechseln",
|
||||
@@ -1610,6 +1679,7 @@
|
||||
"validate_not_mine": "Nicht im Besitz dieser Wallet",
|
||||
"validate_ownership": "Eigentum:",
|
||||
"validate_results": "Ergebnisse:",
|
||||
"validate_results_placeholder": "Ergebnisse erscheinen hier",
|
||||
"validate_shielded_type": "Abgeschirmt (z-Adresse)",
|
||||
"validate_status": "Status:",
|
||||
"validate_title": "Adresse validieren",
|
||||
@@ -1750,6 +1820,7 @@
|
||||
"xmrig_loading_releases": "Releases werden geladen…",
|
||||
"xmrig_none": "keiner",
|
||||
"xmrig_reinstall": "Neu installieren",
|
||||
"xmrig_releases": "xmrig-Releases",
|
||||
"xmrig_stop_mining_first": "Stoppen Sie das Mining, bevor Sie den Miner aktualisieren.",
|
||||
"xmrig_unavailable_body": "Für diese Plattform ist kein Miner-Build verfügbar.",
|
||||
"xmrig_unavailable_title": "Miner-Updates nicht verfügbar",
|
||||
|
||||
+88
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "AVANZADO",
|
||||
"advanced_effects": "Efectos Avanzados...",
|
||||
"ago": "atrás",
|
||||
"alerts_clear": "Borrar historial de alertas",
|
||||
"alerts_history_tooltip": "Alertas recientes",
|
||||
"alerts_none": "Aún no hay alertas",
|
||||
"alerts_recent": "ALERTAS RECIENTES",
|
||||
"all_filter": "Todos",
|
||||
"allow_custom_fees": "Permitir comisiones personalizadas",
|
||||
"amount": "Cantidad",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender bloqueó el minero",
|
||||
"available": "Disponible",
|
||||
"backup_backing_up": "Respaldando...",
|
||||
"backup_col_backup": "COPIA DE SEGURIDAD",
|
||||
"backup_col_export": "EXPORTAR",
|
||||
"backup_col_import": "IMPORTAR Y RESTAURAR",
|
||||
"backup_create": "Crear Respaldo",
|
||||
"backup_created": "Respaldo de cartera creado",
|
||||
"backup_data": "RESPALDO Y DATOS",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "Saldo",
|
||||
"balance_history_collecting": "Historial de saldo — recopilando datos...",
|
||||
"balance_layout": "Diseño de Saldo",
|
||||
"balance_layout_switched": "Diseño: %s",
|
||||
"balance_mining_rate": "Minando %s",
|
||||
"balance_shielded_fmt": "Protegido: %.8f",
|
||||
"balance_syncing_pct": "Sincronizando %.1f%%",
|
||||
"balance_transparent_fmt": "Transparente: %.8f",
|
||||
"ban": "Bloquear",
|
||||
"banned_peers": "Nodos Bloqueados",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "Verificando sumas de comprobación...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat está protegido)",
|
||||
"bootstrap_warning": "Los datos de bloques existentes (blocks, chainstate, notarizations) se eliminarán y reemplazarán. Su wallet.dat NO será modificado ni eliminado.",
|
||||
"byte_count_fmt": "%zu / %zu bytes",
|
||||
"cancel": "Cancelar",
|
||||
"change_pass_confirm": "Confirmar nueva:",
|
||||
"change_pass_current": "Frase de contraseña actual:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "Incluido",
|
||||
"daemon_install_bundled": "Instalar integrado",
|
||||
"daemon_installed": "Instalado",
|
||||
"daemon_maintenance_label": "MANTENIMIENTO",
|
||||
"daemon_none_bundled": "ninguno en esta compilación",
|
||||
"daemon_not_installed": "no instalado",
|
||||
"daemon_status_differ": "El binario instalado difiere de la versión incluida.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "Más reciente:",
|
||||
"daemon_update_loading": "Cargando versiones…",
|
||||
"daemon_update_now": "Actualizar ahora",
|
||||
"daemon_update_prompt_title": "¿Actualizar el daemon del nodo?",
|
||||
"daemon_update_reinstall": "Reinstalar",
|
||||
"daemon_update_restart_note": "Reinicia el daemon para empezar a ejecutar la nueva versión.",
|
||||
"daemon_update_restart_now": "Reiniciar el daemon ahora",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "La descarga se verifica frente al SHA-256 publicado de la versión y una firma ed25519 fijada antes de instalarla.",
|
||||
"daemon_update_verifying": "Verificando…",
|
||||
"daemon_update_version": "Versión:",
|
||||
"daemon_updates_label": "ACTUALIZACIONES",
|
||||
"daemon_version": "Daemon",
|
||||
"dark": "Oscuro",
|
||||
"data_stale_prefix": "Actualizado",
|
||||
"data_stale_tooltip": "El saldo puede estar desactualizado: la cartera no ha recibido una actualización reciente. Comprueba la conexión con tu nodo.",
|
||||
"date": "Fecha",
|
||||
"date_label": "Fecha:",
|
||||
"debug_logging": "REGISTRO DE DEPURACIÓN",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "Descargar Bootstrap",
|
||||
"dragonx_green": "DragonX (Verde)",
|
||||
"edit": "Editar",
|
||||
"empty_wallet_keys_suffix": "claves",
|
||||
"empty_wallet_open_manager": "Abrir administrador de carteras",
|
||||
"empty_wallet_restore": "Restaurar mi cartera",
|
||||
"empty_wallet_salvage_body": "Esta cartera está vacía porque una reparación automática anterior apartó tu cartera original como copia de seguridad. Tus monedas casi con certeza están en esa copia, no perdidas. Restáurala para volver a cargar tus fondos: no se elimina nada; primero se aparta el archivo actual.",
|
||||
"empty_wallet_salvage_headline": "Tus monedas están a salvo en un archivo de copia de seguridad.",
|
||||
"empty_wallet_salvage_title": "Es posible que tu cartera haya sido reparada",
|
||||
"empty_wallet_warning_body": "Esta cartera no tiene direcciones ni fondos, pero otro archivo de cartera en tu carpeta de DragonX contiene claves. Lo más probable es que tus monedas estén ahí, no perdidas. Abre el administrador de carteras para cambiar a la cartera que tiene tus fondos.",
|
||||
"empty_wallet_warning_dismiss": "No volver a avisar para esta cartera",
|
||||
"empty_wallet_warning_dismiss_tip": "Detiene este aviso solo para el archivo de cartera actual. Si más tarde cambias a otra cartera vacía, podría avisarte de nuevo.",
|
||||
"empty_wallet_warning_headline": "Es posible que haya abierto la cartera equivocada.",
|
||||
"empty_wallet_warning_title": "Esta cartera está vacía",
|
||||
"enc_confirm": "Confirmar:",
|
||||
"enc_desc": "Cifrar tu monedero protege tus claves privadas con una frase de contraseña. Tras el cifrado, el daemon se reiniciará.",
|
||||
"enc_encrypting": "Cifrando el monedero...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "Claro",
|
||||
"lite_account_label": "Cuenta",
|
||||
"lite_action": "Acción",
|
||||
"lite_backend_unavailable": "Backend de billetera Lite no disponible",
|
||||
"lite_backup_keys": "Respaldo y claves",
|
||||
"lite_birthday_backup": "Cumpleaños: %llu (respalda esto también)",
|
||||
"lite_birthday_hint": "Altura de bloque desde la que empezar a escanear. Deja 0 si se desconoce (escaneo completo más lento).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "Comandos del backend:",
|
||||
"lite_console_help_passthrough": "Cualquier otra entrada se ejecuta como un comando de consola de la cartera lite.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "No se pudo iniciar la operación",
|
||||
"lite_could_not_write": "No se pudo escribir ",
|
||||
"lite_encrypt_wallet": "Cifrar cartera",
|
||||
"lite_encryption_removed": "Cifrado eliminado",
|
||||
"lite_enter_all_seed_words": "Ingrese las 24 palabras semilla para restaurar (se obtuvieron %d)",
|
||||
"lite_enter_wallet_path": "Ingrese una ruta de billetera",
|
||||
"lite_hide_wipe": "Ocultar y borrar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar clave",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "Trabajando…",
|
||||
"loading": "Cargando...",
|
||||
"loading_addresses": "Cargando direcciones...",
|
||||
"loading_stall_body": "El daemon lleva %.0f s inicializándose. Esto puede ser normal tras una actualización o en el primer inicio (cargando el índice de bloques o reescaneando); se conectará automáticamente cuando esté listo.",
|
||||
"loading_stall_hint": "¿Sigue bloqueado? Abre Ajustes y usa Reiniciar daemon, o revisa la Consola para más detalles.",
|
||||
"loading_stall_title": "Está tardando más de lo esperado",
|
||||
"loading_transactions": "Cargando transacciones",
|
||||
"local_hashrate": "Tasa Hash Local",
|
||||
"low_spec_mode": "Modo bajo rendimiento",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "Dificultad copiada",
|
||||
"mining_est_block": "Bloque Est.",
|
||||
"mining_est_daily": "Diario Est.",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes de la comisión del pool",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todas las ganancias",
|
||||
"mining_filter_tip_pool": "Mostrar solo ganancias del pool",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "Abrir en explorador",
|
||||
"mining_payout_address": "Dirección de Pago",
|
||||
"mining_payout_foreign": "⚠ Esta dirección de pago no está en tu cartera actual — las recompensas minadas irían a otra cartera. Actualízala si cambiaste de cartera.",
|
||||
"mining_payout_invalid": "No es una dirección DragonX válida — corrígela antes de empezar, o se pierden las recompensas de minería.",
|
||||
"mining_payout_tooltip": "Dirección para recibir recompensas de minería",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Comisión",
|
||||
"mining_pool_hashrate": "Hashrate del Pool",
|
||||
"mining_pool_needs_payout_tooltip": "Ingrese primero una dirección de pago (genere una dirección Z)",
|
||||
"mining_pool_url": "URL del Pool",
|
||||
"mining_pools_header": "POOLS",
|
||||
"mining_recent_blocks": "BLOQUES RECIENTES",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "El blockchain está sincronizando...",
|
||||
"mining_tag": " · Minería",
|
||||
"mining_threads": "Hilos de Minería",
|
||||
"mining_threads_input_tooltip": "Escribe un número exacto de hilos (pulsa Enter para aplicar)",
|
||||
"mining_threads_minus_tooltip": "Menos hilos",
|
||||
"mining_threads_plus_tooltip": "Más hilos",
|
||||
"mining_to_save": "para guardar",
|
||||
"mining_today": "Hoy",
|
||||
"mining_uptime": "Tiempo activo",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "No se encontraron transacciones",
|
||||
"no_transactions_yet": "Aún no hay transacciones",
|
||||
"node": "NODO",
|
||||
"node_banner_crashed_title": "El nodo se detuvo inesperadamente",
|
||||
"node_banner_lite_open_failed": "No se pudo abrir tu monedero",
|
||||
"node_banner_offline_title": "No conectado al nodo DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nodo",
|
||||
"node_security": "NODO Y SEGURIDAD",
|
||||
"noise": "Ruido",
|
||||
"not_connected": "No conectado al daemon...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "Error al generar código QR",
|
||||
"qr_title": "Código QR",
|
||||
"qr_unavailable": "QR no disponible",
|
||||
"quick_receive": "Recepción rápida",
|
||||
"quick_send": "Envío rápido",
|
||||
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
|
||||
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
|
||||
"ram_system_gb": "Sistema: %.1f / %.0f GB",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "Conexión RPC...",
|
||||
"rpc_host": "Host RPC",
|
||||
"rpc_pass": "Contraseña",
|
||||
"rpc_plaintext_remote_warning": "El RPC remoto está usando HTTP sin cifrar. Agregue rpctls=1 a DRAGONX.conf si su daemon admite TLS.",
|
||||
"rpc_port": "Puerto",
|
||||
"rpc_user": "Usuario",
|
||||
"save": "Guardar",
|
||||
@@ -1154,6 +1202,8 @@
|
||||
"sb_connecting_external": "Conectando a daemon externo...",
|
||||
"sb_connecting_generic": "Conectando al daemon...",
|
||||
"sb_daemon_crashed": "El daemon se bloqueó %d veces",
|
||||
"sb_daemon_extract_failed": "No se pudieron escribir los archivos del daemon: comprueba el espacio libre en disco y los permisos.",
|
||||
"sb_daemon_files_failed": "No se pudieron escribir los archivos del daemon en %s: comprueba el espacio libre en disco y los permisos.",
|
||||
"sb_daemon_not_found": "Daemon no encontrado",
|
||||
"sb_daemon_start_failed": "No se pudo iniciar dragonxd",
|
||||
"sb_dragonxd_running": "dragonxd ejecutándose",
|
||||
@@ -1169,6 +1219,7 @@
|
||||
"sb_net_mhs": "Red: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf no encontrado",
|
||||
"sb_peers": "Pares: %zu",
|
||||
"sb_plaintext_remote_blocked": "Se rechaza enviar credenciales RPC en texto plano a un host remoto. Añade rpcallowplaintext=1 a DRAGONX.conf para permitirlo, o habilita TLS con rpctls=1.",
|
||||
"sb_rescanning": "Reescaneando",
|
||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||
@@ -1182,6 +1233,7 @@
|
||||
"sb_waiting_daemon_err": "Esperando a dragonxd — %s",
|
||||
"sb_warming_up": "Calentando...",
|
||||
"sb_witness_cache": "Reconstruyendo testigos",
|
||||
"scale_effects": "ESCALA Y EFECTOS",
|
||||
"screenshot_open_dir": "Abrir ubicación",
|
||||
"screenshot_sweep": "Ejecutar barrido de capturas",
|
||||
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
|
||||
@@ -1246,6 +1298,7 @@
|
||||
"send_tooltip_not_connected": "No conectado al daemon",
|
||||
"send_tooltip_select_source": "Selecciona una dirección de origen primero",
|
||||
"send_tooltip_syncing": "Espera a que se sincronice el blockchain",
|
||||
"send_tooltip_view_only": "Dirección de solo vista — sin clave de gasto, no se puede enviar",
|
||||
"send_total": "Total",
|
||||
"send_transaction": "Enviar Transacción",
|
||||
"send_tx_failed": "Error en la transacción",
|
||||
@@ -1265,16 +1318,16 @@
|
||||
"sent_filter": "Enviado",
|
||||
"sent_type": "Enviado",
|
||||
"sent_upper": "ENVIADO",
|
||||
"set_label": "Establecer Etiqueta...",
|
||||
"set_label": "Establecer Etiqueta",
|
||||
"settings": "Ajustes",
|
||||
"settings_about_text": "Una billetera de criptomonedas blindada para DragonX (DRGX), creada con Dear ImGui para una experiencia ligera y portátil.",
|
||||
"settings_acrylic_level": "Nivel de acrílico:",
|
||||
"settings_address_book": "Libreta de direcciones...",
|
||||
"settings_address_book": "Libreta de direcciones…",
|
||||
"settings_auto_detected": "Autodetectado de DRAGONX.conf",
|
||||
"settings_auto_lock": "BLOQUEO AUTOMÁTICO",
|
||||
"settings_auto_shield_desc": "Mover automáticamente fondos transparentes a direcciones blindadas",
|
||||
"settings_auto_shield_funds": "Blindar fondos transparentes automáticamente",
|
||||
"settings_backup": "Respaldo...",
|
||||
"settings_backup": "Respaldo…",
|
||||
"settings_block_explorer_urls": "URLs del explorador de bloques",
|
||||
"settings_builtin": "Integrado",
|
||||
"settings_change_passphrase": "Cambiar contraseña",
|
||||
@@ -1285,60 +1338,71 @@
|
||||
"settings_configure_explorer": "Configurar enlaces de explorador de bloques externo",
|
||||
"settings_configure_rpc": "Configurar conexión al daemon dragonxd",
|
||||
"settings_connection": "Conexión",
|
||||
"settings_copy_diagnostics": "Copiar diagnósticos",
|
||||
"settings_copyright": "Copyright 2024-2026 Desarrolladores de DragonX | Licencia GPLv3",
|
||||
"settings_custom": "Personalizado",
|
||||
"settings_data_dir": "Dir. de datos:",
|
||||
"settings_data_dir": "Dir. de datos",
|
||||
"settings_debug_changed": "Categorías de depuración cambiadas — reinicie el daemon para aplicar",
|
||||
"settings_debug_restart_note": "Los cambios surten efecto después de reiniciar el daemon.",
|
||||
"settings_debug_select": "Seleccione categorías para habilitar el registro de depuración del daemon (flags -debug=).",
|
||||
"settings_diagnostics_copied": "Diagnósticos copiados al portapapeles",
|
||||
"settings_encrypt_first_pin": "Primero cifre la billetera para habilitar el PIN",
|
||||
"settings_encrypt_wallet": "Cifrar billetera",
|
||||
"settings_explorer_hint": "Las URLs deben incluir una barra final. Se añadirá el txid/dirección.",
|
||||
"settings_export_all": "Exportar todo...",
|
||||
"settings_export_csv": "Exportar CSV...",
|
||||
"settings_export_key": "Exportar clave...",
|
||||
"settings_export_all": "Exportar todo…",
|
||||
"settings_export_csv": "Exportar CSV…",
|
||||
"settings_export_key": "Exportar clave…",
|
||||
"settings_gradient_bg": "Fondo degradado",
|
||||
"settings_gradient_desc": "Reemplazar fondos con texturas por degradados suaves",
|
||||
"settings_idle_after": "después de",
|
||||
"settings_import_key": "Importar Clave Privada...",
|
||||
"settings_import_viewkey": "Importar clave de visualización...",
|
||||
"settings_import_key": "Importar Clave Privada…",
|
||||
"settings_import_viewkey": "Importar clave de visualización…",
|
||||
"settings_language_note": "Nota: Parte del texto requiere reinicio para actualizarse",
|
||||
"settings_lock_now": "Bloquear ahora",
|
||||
"settings_locked": "Bloqueado",
|
||||
"settings_merge_to_address": "Fusionar a dirección...",
|
||||
"settings_merge_to_address": "Fusionar a dirección…",
|
||||
"settings_noise_opacity": "Opacidad de ruido:",
|
||||
"settings_not_connected": "No conectado al daemon",
|
||||
"settings_not_encrypted": "Sin cifrar",
|
||||
"settings_not_found": "No encontrado",
|
||||
"settings_open_app_dir": "Abrir carpeta de la aplicación",
|
||||
"settings_open_data_dir": "Abrir carpeta de datos",
|
||||
"settings_open_log_folder": "Abrir carpeta de registros",
|
||||
"settings_other": "Otros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "Privacidad",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueo rápido",
|
||||
"settings_reduce_transparency": "Reducir transparencia",
|
||||
"settings_reloaded": "Configuración recargada desde el disco",
|
||||
"settings_remove_encryption": "Quitar cifrado",
|
||||
"settings_remove_pin": "Quitar PIN",
|
||||
"settings_request_payment": "Solicitar pago...",
|
||||
"settings_request_payment": "Solicitar pago…",
|
||||
"settings_rescan_desc": "Reescanear la cadena de bloques en busca de transacciones faltantes",
|
||||
"settings_restart_daemon": "Reiniciar daemon",
|
||||
"settings_rpc_connection": "Conexión RPC",
|
||||
"settings_rpc_error_prefix": "Error de RPC: ",
|
||||
"settings_rpc_note": "Nota: Los ajustes de conexión se detectan automáticamente desde DRAGONX.conf",
|
||||
"settings_rpc_ok": "Conexión RPC correcta",
|
||||
"settings_save_shielded_desc": "Almacena transacciones z-addr en un archivo local para visualización",
|
||||
"settings_save_shielded_local": "Guardar historial de transacciones blindadas localmente",
|
||||
"settings_saved": "Configuración guardada",
|
||||
"settings_set_pin": "Establecer PIN",
|
||||
"settings_shield_mining": "Blindar minería...",
|
||||
"settings_shield_mining": "Blindar minería…",
|
||||
"settings_solid_colors_desc": "Usar colores sólidos en lugar de efectos de desenfoque (accesibilidad)",
|
||||
"settings_theme_refreshed": "Lista de temas actualizada",
|
||||
"settings_tor_desc": "Enrutar todas las conexiones a través de Tor para mayor privacidad",
|
||||
"settings_unlocked": "Desbloqueado",
|
||||
"settings_use_tor_network": "Usar Tor para conexiones de red",
|
||||
"settings_validate_address": "Validar dirección...",
|
||||
"settings_validate_address": "Validar dirección…",
|
||||
"settings_visual_effects": "Efectos visuales",
|
||||
"settings_wallet_file_size": "Tamaño del archivo de billetera: %s",
|
||||
"settings_wallet_info": "Información de billetera",
|
||||
"settings_wallet_location": "Ubicación de billetera: %s",
|
||||
"settings_wallet_maintenance": "Mantenimiento de billetera",
|
||||
"settings_wallet_not_found": "Archivo de billetera no encontrado",
|
||||
"settings_wallet_size_label": "Tamaño de billetera:",
|
||||
"settings_wallet_size_label": "Tamaño de billetera",
|
||||
"settings_ztx_cleared": "Historial de transacciones Z borrado",
|
||||
"settings_ztx_not_found": "No se encontró archivo de historial",
|
||||
"setup_wizard": "Asistente de Configuración",
|
||||
"share": "Compartir",
|
||||
"shield_check_status": "Verificar Estado",
|
||||
@@ -1421,6 +1485,7 @@
|
||||
"theme": "Tema",
|
||||
"theme_effects": "Efectos de tema",
|
||||
"theme_language": "TEMA E IDIOMA",
|
||||
"tile_click_to_open": "Clic para abrir",
|
||||
"time_days_ago": "hace %d días",
|
||||
"time_hours_ago": "hace %d horas",
|
||||
"time_minutes_ago": "hace %d minutos",
|
||||
@@ -1435,7 +1500,9 @@
|
||||
"to_upper": "PARA",
|
||||
"tools": "HERRAMIENTAS",
|
||||
"tools_actions": "Herramientas y Acciones...",
|
||||
"tools_actions_hdr": "HERRAMIENTAS Y ACCIONES",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DE TRANSACCIÓN",
|
||||
"transaction_sent": "Transacción enviada exitosamente",
|
||||
"transaction_sent_msg": "¡Transacción enviada!",
|
||||
@@ -1457,7 +1524,7 @@
|
||||
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
|
||||
"tt_backup": "Crear una copia de seguridad de su wallet.dat",
|
||||
"tt_block_explorer": "Abrir el explorador de bloques DragonX en su navegador",
|
||||
"tt_blur": "Cantidad de desenfoque (0%% = apagado, 100%% = máximo)",
|
||||
"tt_blur": "Cantidad de desenfoque (0% = apagado, 100% = máximo)",
|
||||
"tt_change_pass": "Cambiar la contraseña de cifrado de la billetera",
|
||||
"tt_change_pin": "Cambiar su PIN de desbloqueo",
|
||||
"tt_chat_bubble_accent": "Color de acento para tus burbujas de mensaje salientes (o sigue el tema actual)",
|
||||
@@ -1470,6 +1537,7 @@
|
||||
"tt_chat_timestamp": "Formato de marca de tiempo solo para esta pestaña: seguir el reloj de toda la app, o forzar 24-hour o 12-hour",
|
||||
"tt_clear_ztx": "Eliminar historial de z-transacciones en caché local",
|
||||
"tt_clock_format": "Reloj de 24 o 12 horas, en toda la app. El chat puede anularlo.",
|
||||
"tt_copy_diagnostics": "Copia al portapapeles un resumen para soporte (versión, estado de daemon/cartera/registros, sin datos secretos)",
|
||||
"tt_custom_fees": "Habilitar entrada manual de comisiones al enviar transacciones",
|
||||
"tt_custom_theme": "Tema personalizado activo",
|
||||
"tt_daemon_install_bundled": "Detiene el nodo, sobrescribe el dragonxd instalado con la versión incluida en esta compilación de la cartera y luego lo reinicia",
|
||||
@@ -1519,10 +1587,11 @@
|
||||
"tt_low_spec": "Desactivar todos los efectos visuales pesados\\nAtajo: Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolidar múltiples UTXOs en una dirección",
|
||||
"tt_mine_idle": "Iniciar minería automáticamente cuando el\\nsistema esté inactivo (sin entrada de teclado/ratón)",
|
||||
"tt_noise": "Intensidad de textura granulada (0%% = apagado, 100%% = máximo)",
|
||||
"tt_noise": "Intensidad de textura granulada (0% = apagado, 100% = máximo)",
|
||||
"tt_open_app_dir": "Abrir la carpeta de ObsidianDragon (configuración, temas, registros) en el explorador de archivos",
|
||||
"tt_open_data_dir": "Abre en el gestor de archivos la carpeta con los datos de tu cartera y de la blockchain",
|
||||
"tt_open_dir": "Clic para abrir en explorador de archivos",
|
||||
"tt_open_log_folder": "Abre la carpeta que contiene los registros de depuración y de fallos",
|
||||
"tt_reduce_motion": "Desactivar transiciones animadas y lerp de saldo para accesibilidad",
|
||||
"tt_remove_encrypt": "Quitar cifrado y almacenar la billetera sin protección",
|
||||
"tt_remove_pin": "Quitar PIN y requerir contraseña para desbloquear",
|
||||
@@ -1557,7 +1626,7 @@
|
||||
"tt_theme_hotkey": "Atajo: Ctrl+Izquierda/Derecha para cambiar temas",
|
||||
"tt_tor": "Enrutar conexiones del daemon a través de la red Tor para anonimato",
|
||||
"tt_tx_url": "URL base para ver transacciones en un explorador de bloques",
|
||||
"tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100%% = totalmente opaco, menor = más transparente)",
|
||||
"tt_ui_opacity": "Opacidad de tarjetas y barra lateral (100% = totalmente opaco, menor = más transparente)",
|
||||
"tt_validate": "Comprobar si una dirección DragonX es válida",
|
||||
"tt_verbose": "Registrar diagnósticos detallados de conexión,\\nestado del daemon e info de propietario de puerto\\nen la pestaña de Consola",
|
||||
"tt_wallets_button": "Enumera tus archivos de cartera y cambia entre ellos",
|
||||
@@ -1610,6 +1679,7 @@
|
||||
"validate_not_mine": "No es propiedad de esta cartera",
|
||||
"validate_ownership": "Propiedad:",
|
||||
"validate_results": "Resultados:",
|
||||
"validate_results_placeholder": "Los resultados aparecerán aquí",
|
||||
"validate_shielded_type": "Protegida (dirección z)",
|
||||
"validate_status": "Estado:",
|
||||
"validate_title": "Validar Dirección",
|
||||
@@ -1750,6 +1820,7 @@
|
||||
"xmrig_loading_releases": "Cargando versiones…",
|
||||
"xmrig_none": "ninguno",
|
||||
"xmrig_reinstall": "Reinstalar",
|
||||
"xmrig_releases": "versiones de xmrig",
|
||||
"xmrig_stop_mining_first": "Detén la minería antes de actualizar el minero.",
|
||||
"xmrig_unavailable_body": "No hay ninguna versión del minero disponible para esta plataforma.",
|
||||
"xmrig_unavailable_title": "Actualizaciones del minero no disponibles",
|
||||
|
||||
+88
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "AVANCÉ",
|
||||
"advanced_effects": "Effets avancés...",
|
||||
"ago": "passé",
|
||||
"alerts_clear": "Effacer l'historique des alertes",
|
||||
"alerts_history_tooltip": "Alertes récentes",
|
||||
"alerts_none": "Aucune alerte pour l'instant",
|
||||
"alerts_recent": "ALERTES RÉCENTES",
|
||||
"all_filter": "Tout",
|
||||
"allow_custom_fees": "Autoriser les frais personnalisés",
|
||||
"amount": "Montant",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender a bloqué le mineur",
|
||||
"available": "Disponible",
|
||||
"backup_backing_up": "Sauvegarde en cours...",
|
||||
"backup_col_backup": "SAUVEGARDE",
|
||||
"backup_col_export": "EXPORTER",
|
||||
"backup_col_import": "IMPORTER ET RESTAURER",
|
||||
"backup_create": "Créer une sauvegarde",
|
||||
"backup_created": "Sauvegarde du portefeuille créée",
|
||||
"backup_data": "SAUVEGARDE & DONNÉES",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "Solde",
|
||||
"balance_history_collecting": "Historique du solde — collecte des données...",
|
||||
"balance_layout": "Disposition du solde",
|
||||
"balance_layout_switched": "Disposition : %s",
|
||||
"balance_mining_rate": "Minage %s",
|
||||
"balance_shielded_fmt": "Blindé : %.8f",
|
||||
"balance_syncing_pct": "Synchronisation %.1f%%",
|
||||
"balance_transparent_fmt": "Transparent : %.8f",
|
||||
"ban": "Bannir",
|
||||
"banned_peers": "Pairs bannis",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "Vérification des sommes de contrôle...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat est protégé)",
|
||||
"bootstrap_warning": "Les données de blocs existantes (blocks, chainstate, notarizations) seront supprimées et remplacées. Votre wallet.dat ne sera PAS modifié ni supprimé.",
|
||||
"byte_count_fmt": "%zu / %zu octets",
|
||||
"cancel": "Annuler",
|
||||
"change_pass_confirm": "Confirmer la nouvelle :",
|
||||
"change_pass_current": "Phrase secrète actuelle :",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "Intégré",
|
||||
"daemon_install_bundled": "Installer la version intégrée",
|
||||
"daemon_installed": "Installé",
|
||||
"daemon_maintenance_label": "MAINTENANCE",
|
||||
"daemon_none_bundled": "aucun dans cette version",
|
||||
"daemon_not_installed": "non installé",
|
||||
"daemon_status_differ": "Le binaire installé diffère de la version intégrée.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "Dernière :",
|
||||
"daemon_update_loading": "Chargement des versions…",
|
||||
"daemon_update_now": "Mettre à jour",
|
||||
"daemon_update_prompt_title": "Mettre à jour le démon du nœud ?",
|
||||
"daemon_update_reinstall": "Réinstaller",
|
||||
"daemon_update_restart_note": "Redémarrez le daemon pour lancer la nouvelle version.",
|
||||
"daemon_update_restart_now": "Redémarrer le daemon maintenant",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "Le téléchargement est vérifié par rapport au SHA-256 publié de la version et à une signature ed25519 épinglée avant l'installation.",
|
||||
"daemon_update_verifying": "Vérification…",
|
||||
"daemon_update_version": "Version :",
|
||||
"daemon_updates_label": "MISES À JOUR",
|
||||
"daemon_version": "Daemon",
|
||||
"dark": "Sombre",
|
||||
"data_stale_prefix": "Mis à jour",
|
||||
"data_stale_tooltip": "Le solde est peut-être obsolète — le portefeuille n'a pas reçu de mise à jour récente. Vérifiez la connexion à votre nœud.",
|
||||
"date": "Date",
|
||||
"date_label": "Date :",
|
||||
"debug_logging": "JOURNALISATION DE DÉBOGAGE",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "Télécharger Bootstrap",
|
||||
"dragonx_green": "DragonX (Vert)",
|
||||
"edit": "Modifier",
|
||||
"empty_wallet_keys_suffix": "clés",
|
||||
"empty_wallet_open_manager": "Ouvrir le gestionnaire de portefeuilles",
|
||||
"empty_wallet_restore": "Restaurer mon portefeuille",
|
||||
"empty_wallet_salvage_body": "Ce portefeuille est vide car une réparation automatique antérieure a mis votre portefeuille d'origine de côté comme sauvegarde. Vos pièces se trouvent presque certainement dans cette sauvegarde, elles ne sont pas perdues. Restaurez-la pour recharger vos fonds — rien n'est supprimé ; le fichier actuel est d'abord mis de côté.",
|
||||
"empty_wallet_salvage_headline": "Vos pièces sont en sécurité dans un fichier de sauvegarde.",
|
||||
"empty_wallet_salvage_title": "Votre portefeuille a peut-être été réparé",
|
||||
"empty_wallet_warning_body": "Ce portefeuille n'a aucune adresse ni fonds, mais un autre fichier de portefeuille dans votre dossier DragonX contient des clés. Vos pièces s'y trouvent très probablement, elles ne sont pas perdues. Ouvrez le gestionnaire de portefeuilles pour passer au portefeuille qui contient vos fonds.",
|
||||
"empty_wallet_warning_dismiss": "Ne plus avertir pour ce portefeuille",
|
||||
"empty_wallet_warning_dismiss_tip": "Arrête cet avertissement uniquement pour le fichier de portefeuille actuel. Si vous passez plus tard à un autre portefeuille vide, il pourra avertir à nouveau.",
|
||||
"empty_wallet_warning_headline": "Vous avez peut-être ouvert le mauvais portefeuille.",
|
||||
"empty_wallet_warning_title": "Ce portefeuille est vide",
|
||||
"enc_confirm": "Confirmer :",
|
||||
"enc_desc": "Chiffrer votre portefeuille protège vos clés privées avec une phrase secrète. Après le chiffrement, le daemon redémarrera.",
|
||||
"enc_encrypting": "Chiffrement du portefeuille...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "Clair",
|
||||
"lite_account_label": "Compte",
|
||||
"lite_action": "Action",
|
||||
"lite_backend_unavailable": "Backend du portefeuille léger indisponible",
|
||||
"lite_backup_keys": "Sauvegarde et clés",
|
||||
"lite_birthday_backup": "Date de création : %llu (à sauvegarder également)",
|
||||
"lite_birthday_hint": "Hauteur de bloc à partir de laquelle commencer l'analyse. Laissez 0 si inconnue (analyse complète plus lente).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "Commandes du backend :",
|
||||
"lite_console_help_passthrough": "Toute autre entrée est exécutée comme une commande de la console du portefeuille lite.",
|
||||
"lite_copy": "Copier",
|
||||
"lite_could_not_start": "Impossible de démarrer l'opération",
|
||||
"lite_could_not_write": "Impossible d'écrire ",
|
||||
"lite_encrypt_wallet": "Chiffrer le portefeuille",
|
||||
"lite_encryption_removed": "Chiffrement supprimé",
|
||||
"lite_enter_all_seed_words": "Entrez les 24 mots de la phrase de récupération (%d obtenus)",
|
||||
"lite_enter_wallet_path": "Entrez un chemin de portefeuille",
|
||||
"lite_hide_wipe": "Masquer et effacer",
|
||||
"lite_import": "Importer",
|
||||
"lite_import_key_label": "Importer une clé",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "En cours…",
|
||||
"loading": "Chargement...",
|
||||
"loading_addresses": "Chargement des adresses...",
|
||||
"loading_stall_body": "Le démon s'initialise depuis %.0f s. Cela peut être normal après une mise à jour ou au premier lancement (chargement de l'index des blocs ou nouvelle analyse) — la connexion se fera automatiquement une fois prêt.",
|
||||
"loading_stall_hint": "Toujours bloqué ? Ouvrez les Paramètres et utilisez Redémarrer le démon, ou consultez la Console pour plus de détails.",
|
||||
"loading_stall_title": "Cela prend plus de temps que prévu",
|
||||
"loading_transactions": "Chargement des transactions",
|
||||
"local_hashrate": "Hashrate local",
|
||||
"low_spec_mode": "Mode économie",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "Difficulté copiée",
|
||||
"mining_est_block": "Bloc est.",
|
||||
"mining_est_daily": "Est. quotidien",
|
||||
"mining_est_daily_pool_sub": "équivalent solo approximatif, avant les frais du pool",
|
||||
"mining_filter_all": "Tout",
|
||||
"mining_filter_tip_all": "Afficher tous les gains",
|
||||
"mining_filter_tip_pool": "Afficher uniquement les gains du pool",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "Ouvrir dans l'explorateur",
|
||||
"mining_payout_address": "Adresse de paiement",
|
||||
"mining_payout_foreign": "⚠ Cette adresse de paiement ne fait pas partie de votre portefeuille actuel — les récompenses minées iraient vers un autre portefeuille. Mettez-la à jour si vous avez changé de portefeuille.",
|
||||
"mining_payout_invalid": "Adresse DragonX invalide — corrigez-la avant de démarrer, sinon les récompenses de minage sont perdues.",
|
||||
"mining_payout_tooltip": "Adresse pour recevoir les récompenses de minage",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Frais",
|
||||
"mining_pool_hashrate": "Hashrate du pool",
|
||||
"mining_pool_needs_payout_tooltip": "Entrez d'abord une adresse de paiement (générez une adresse Z)",
|
||||
"mining_pool_url": "URL du pool",
|
||||
"mining_pools_header": "POOLS",
|
||||
"mining_recent_blocks": "BLOCS RÉCENTS",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "La blockchain se synchronise...",
|
||||
"mining_tag": " · Minage",
|
||||
"mining_threads": "Threads de minage",
|
||||
"mining_threads_input_tooltip": "Saisissez un nombre exact de threads (Entrée pour appliquer)",
|
||||
"mining_threads_minus_tooltip": "Moins de threads",
|
||||
"mining_threads_plus_tooltip": "Plus de threads",
|
||||
"mining_to_save": "pour enregistrer",
|
||||
"mining_today": "Aujourd'hui",
|
||||
"mining_uptime": "Temps de fonctionnement",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "Aucune transaction trouvée",
|
||||
"no_transactions_yet": "Aucune transaction pour le moment",
|
||||
"node": "NŒUD",
|
||||
"node_banner_crashed_title": "Le nœud s'est arrêté de façon inattendue",
|
||||
"node_banner_lite_open_failed": "Impossible d'ouvrir votre portefeuille",
|
||||
"node_banner_offline_title": "Non connecté au nœud DragonX",
|
||||
"node_banner_reconnect": "Reconnecter",
|
||||
"node_banner_restart": "Redémarrer le nœud",
|
||||
"node_security": "NŒUD & SÉCURITÉ",
|
||||
"noise": "Bruit",
|
||||
"not_connected": "Non connecté au daemon...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "Échec de la génération du code QR",
|
||||
"qr_title": "Code QR",
|
||||
"qr_unavailable": "QR indisponible",
|
||||
"quick_receive": "Réception rapide",
|
||||
"quick_send": "Envoi rapide",
|
||||
"ram_daemon_gb": "Daemon : %.1f Go (%s)",
|
||||
"ram_daemon_mb": "Daemon : %.0f Mo (%s)",
|
||||
"ram_system_gb": "Système : %.1f / %.0f Go",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "Connexion RPC...",
|
||||
"rpc_host": "Hôte RPC",
|
||||
"rpc_pass": "Mot de passe",
|
||||
"rpc_plaintext_remote_warning": "Le RPC distant utilise du HTTP en clair. Ajoutez rpctls=1 à DRAGONX.conf si votre démon prend en charge TLS.",
|
||||
"rpc_port": "Port",
|
||||
"rpc_user": "Nom d'utilisateur",
|
||||
"save": "Enregistrer",
|
||||
@@ -1154,6 +1202,8 @@
|
||||
"sb_connecting_external": "Connexion au daemon externe...",
|
||||
"sb_connecting_generic": "Connexion au daemon...",
|
||||
"sb_daemon_crashed": "Le daemon a planté %d fois",
|
||||
"sb_daemon_extract_failed": "Échec de l'écriture des fichiers du démon — vérifiez l'espace disque libre et les permissions.",
|
||||
"sb_daemon_files_failed": "Échec de l'écriture des fichiers du démon dans %s — vérifiez l'espace disque libre et les permissions.",
|
||||
"sb_daemon_not_found": "Daemon introuvable",
|
||||
"sb_daemon_start_failed": "Impossible de démarrer dragonxd",
|
||||
"sb_dragonxd_running": "dragonxd en cours",
|
||||
@@ -1169,6 +1219,7 @@
|
||||
"sb_net_mhs": "Rés: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf introuvable",
|
||||
"sb_peers": "Pairs : %zu",
|
||||
"sb_plaintext_remote_blocked": "Refus d'envoyer les identifiants RPC en clair vers un hôte distant. Ajoutez rpcallowplaintext=1 à DRAGONX.conf pour l'autoriser, ou activez TLS avec rpctls=1.",
|
||||
"sb_rescanning": "Rescan",
|
||||
"sb_rescanning_pct": "Rescan %.0f%%",
|
||||
"sb_restarting_daemon": "Redémarrage du daemon...",
|
||||
@@ -1182,6 +1233,7 @@
|
||||
"sb_waiting_daemon_err": "En attente de dragonxd — %s",
|
||||
"sb_warming_up": "Démarrage...",
|
||||
"sb_witness_cache": "Reconstruction des témoins",
|
||||
"scale_effects": "ÉCHELLE ET EFFETS",
|
||||
"screenshot_open_dir": "Ouvrir l'emplacement",
|
||||
"screenshot_sweep": "Lancer la capture d'écran",
|
||||
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
|
||||
@@ -1246,6 +1298,7 @@
|
||||
"send_tooltip_not_connected": "Non connecté au daemon",
|
||||
"send_tooltip_select_source": "Sélectionnez d'abord une adresse source",
|
||||
"send_tooltip_syncing": "Attendez la synchronisation de la blockchain",
|
||||
"send_tooltip_view_only": "Adresse en lecture seule — pas de clé de dépense, envoi impossible",
|
||||
"send_total": "Total",
|
||||
"send_transaction": "Envoyer la transaction",
|
||||
"send_tx_failed": "Transaction échouée",
|
||||
@@ -1265,16 +1318,16 @@
|
||||
"sent_filter": "Envoyé",
|
||||
"sent_type": "Envoyé",
|
||||
"sent_upper": "ENVOYÉ",
|
||||
"set_label": "Définir le libellé...",
|
||||
"set_label": "Définir le libellé",
|
||||
"settings": "Paramètres",
|
||||
"settings_about_text": "Un portefeuille de cryptomonnaie blindé pour DragonX (DRGX), construit avec Dear ImGui pour une expérience légère et portable.",
|
||||
"settings_acrylic_level": "Niveau acrylique :",
|
||||
"settings_address_book": "Carnet d'adresses...",
|
||||
"settings_address_book": "Carnet d'adresses…",
|
||||
"settings_auto_detected": "Détecté automatiquement depuis DRAGONX.conf",
|
||||
"settings_auto_lock": "VERROUILLAGE AUTO",
|
||||
"settings_auto_shield_desc": "Déplacer automatiquement les fonds transparents vers des adresses blindées",
|
||||
"settings_auto_shield_funds": "Blindage automatique des fonds transparents",
|
||||
"settings_backup": "Sauvegarde...",
|
||||
"settings_backup": "Sauvegarde…",
|
||||
"settings_block_explorer_urls": "URLs de l'explorateur de blocs",
|
||||
"settings_builtin": "Intégré",
|
||||
"settings_change_passphrase": "Changer la phrase secrète",
|
||||
@@ -1285,60 +1338,71 @@
|
||||
"settings_configure_explorer": "Configurer les liens vers l'explorateur de blocs externe",
|
||||
"settings_configure_rpc": "Configurer la connexion au daemon dragonxd",
|
||||
"settings_connection": "Connexion",
|
||||
"settings_copy_diagnostics": "Copier les diagnostics",
|
||||
"settings_copyright": "Copyright 2024-2026 Développeurs DragonX | Licence GPLv3",
|
||||
"settings_custom": "Personnalisé",
|
||||
"settings_data_dir": "Rép. de données :",
|
||||
"settings_data_dir": "Rép. de données ",
|
||||
"settings_debug_changed": "Catégories de débogage modifiées — redémarrez le daemon pour appliquer",
|
||||
"settings_debug_restart_note": "Les modifications prennent effet après le redémarrage du daemon.",
|
||||
"settings_debug_select": "Sélectionnez les catégories pour activer la journalisation de débogage du daemon (flags -debug=).",
|
||||
"settings_diagnostics_copied": "Diagnostics copiés dans le presse-papiers",
|
||||
"settings_encrypt_first_pin": "Chiffrez d'abord le portefeuille pour activer le PIN",
|
||||
"settings_encrypt_wallet": "Chiffrer le portefeuille",
|
||||
"settings_explorer_hint": "Les URLs doivent inclure une barre oblique finale. Le txid/adresse sera ajouté.",
|
||||
"settings_export_all": "Tout exporter...",
|
||||
"settings_export_csv": "Exporter CSV...",
|
||||
"settings_export_key": "Exporter la clé...",
|
||||
"settings_export_all": "Tout exporter…",
|
||||
"settings_export_csv": "Exporter CSV…",
|
||||
"settings_export_key": "Exporter la clé…",
|
||||
"settings_gradient_bg": "Fond dégradé",
|
||||
"settings_gradient_desc": "Remplacer les arrière-plans texturés par des dégradés lisses",
|
||||
"settings_idle_after": "après",
|
||||
"settings_import_key": "Importer une clé privée...",
|
||||
"settings_import_viewkey": "Importer la clé de visualisation...",
|
||||
"settings_import_key": "Importer une clé privée…",
|
||||
"settings_import_viewkey": "Importer la clé de visualisation…",
|
||||
"settings_language_note": "Remarque : Certains textes nécessitent un redémarrage pour se mettre à jour",
|
||||
"settings_lock_now": "Verrouiller maintenant",
|
||||
"settings_locked": "Verrouillé",
|
||||
"settings_merge_to_address": "Fusionner vers l'adresse...",
|
||||
"settings_merge_to_address": "Fusionner vers l'adresse…",
|
||||
"settings_noise_opacity": "Opacité du bruit :",
|
||||
"settings_not_connected": "Non connecté au démon",
|
||||
"settings_not_encrypted": "Non chiffré",
|
||||
"settings_not_found": "Non trouvé",
|
||||
"settings_open_app_dir": "Ouvrir le dossier de l'application",
|
||||
"settings_open_data_dir": "Ouvrir le dossier de données",
|
||||
"settings_open_log_folder": "Ouvrir le dossier des journaux",
|
||||
"settings_other": "Autres",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "Confidentialité",
|
||||
"settings_quick_unlock_pin": "PIN de déverrouillage rapide",
|
||||
"settings_reduce_transparency": "Réduire la transparence",
|
||||
"settings_reloaded": "Paramètres rechargés depuis le disque",
|
||||
"settings_remove_encryption": "Supprimer le chiffrement",
|
||||
"settings_remove_pin": "Supprimer le PIN",
|
||||
"settings_request_payment": "Demander un paiement...",
|
||||
"settings_request_payment": "Demander un paiement…",
|
||||
"settings_rescan_desc": "Rescanner la blockchain pour les transactions manquantes",
|
||||
"settings_restart_daemon": "Redémarrer le daemon",
|
||||
"settings_rpc_connection": "Connexion RPC",
|
||||
"settings_rpc_error_prefix": "Erreur RPC : ",
|
||||
"settings_rpc_note": "Remarque : Les paramètres de connexion sont généralement détectés automatiquement depuis DRAGONX.conf",
|
||||
"settings_rpc_ok": "Connexion RPC OK",
|
||||
"settings_save_shielded_desc": "Stocke les transactions z-addr dans un fichier local pour consultation",
|
||||
"settings_save_shielded_local": "Enregistrer l'historique des transactions blindées localement",
|
||||
"settings_saved": "Paramètres enregistrés",
|
||||
"settings_set_pin": "Définir le PIN",
|
||||
"settings_shield_mining": "Blindage minage...",
|
||||
"settings_shield_mining": "Blindage minage…",
|
||||
"settings_solid_colors_desc": "Utiliser des couleurs unies au lieu des effets de flou (accessibilité)",
|
||||
"settings_theme_refreshed": "Liste des thèmes actualisée",
|
||||
"settings_tor_desc": "Acheminer toutes les connexions via Tor pour une confidentialité renforcée",
|
||||
"settings_unlocked": "Déverrouillé",
|
||||
"settings_use_tor_network": "Utiliser Tor pour les connexions réseau",
|
||||
"settings_validate_address": "Valider l'adresse...",
|
||||
"settings_validate_address": "Valider l'adresse…",
|
||||
"settings_visual_effects": "Effets visuels",
|
||||
"settings_wallet_file_size": "Taille du fichier portefeuille : %s",
|
||||
"settings_wallet_info": "Informations du portefeuille",
|
||||
"settings_wallet_location": "Emplacement du portefeuille : %s",
|
||||
"settings_wallet_maintenance": "Maintenance du portefeuille",
|
||||
"settings_wallet_not_found": "Fichier portefeuille introuvable",
|
||||
"settings_wallet_size_label": "Taille du portefeuille :",
|
||||
"settings_wallet_size_label": "Taille du portefeuille ",
|
||||
"settings_ztx_cleared": "Historique des transactions Z effacé",
|
||||
"settings_ztx_not_found": "Aucun fichier d'historique trouvé",
|
||||
"setup_wizard": "Assistant de configuration",
|
||||
"share": "Partager",
|
||||
"shield_check_status": "Vérifier le statut",
|
||||
@@ -1421,6 +1485,7 @@
|
||||
"theme": "Thème",
|
||||
"theme_effects": "Effets de thème",
|
||||
"theme_language": "THÈME & LANGUE",
|
||||
"tile_click_to_open": "Cliquer pour ouvrir",
|
||||
"time_days_ago": "il y a %d jours",
|
||||
"time_hours_ago": "il y a %d heures",
|
||||
"time_minutes_ago": "il y a %d minutes",
|
||||
@@ -1435,7 +1500,9 @@
|
||||
"to_upper": "À",
|
||||
"tools": "OUTILS",
|
||||
"tools_actions": "Outils & Actions...",
|
||||
"tools_actions_hdr": "OUTILS ET ACTIONS",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Solde total",
|
||||
"transaction_id": "ID DE TRANSACTION",
|
||||
"transaction_sent": "Transaction envoyée avec succès",
|
||||
"transaction_sent_msg": "Transaction envoyée !",
|
||||
@@ -1457,7 +1524,7 @@
|
||||
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
|
||||
"tt_backup": "Créer une sauvegarde de votre wallet.dat",
|
||||
"tt_block_explorer": "Ouvrir l'explorateur de blocs DragonX dans votre navigateur",
|
||||
"tt_blur": "Quantité de flou (0%% = désactivé, 100%% = maximum)",
|
||||
"tt_blur": "Quantité de flou (0% = désactivé, 100% = maximum)",
|
||||
"tt_change_pass": "Changer la phrase secrète de chiffrement du portefeuille",
|
||||
"tt_change_pin": "Changer votre PIN de déverrouillage",
|
||||
"tt_chat_bubble_accent": "Couleur d'accent de vos bulles de message sortantes (ou suivre le thème actuel)",
|
||||
@@ -1470,6 +1537,7 @@
|
||||
"tt_chat_timestamp": "Format d'horodatage pour cet onglet uniquement : suivre l'horloge de l'application, ou forcer 24-hour ou 12-hour",
|
||||
"tt_clear_ztx": "Supprimer l'historique des z-transactions mis en cache localement",
|
||||
"tt_clock_format": "Horloge 24 h ou 12 h, dans toute l'app. Le chat peut la remplacer.",
|
||||
"tt_copy_diagnostics": "Copie un récapitulatif de support (version, état daemon/portefeuille/journaux — sans données secrètes) dans le presse-papiers",
|
||||
"tt_custom_fees": "Activer la saisie manuelle des frais lors de l'envoi de transactions",
|
||||
"tt_custom_theme": "Thème personnalisé actif",
|
||||
"tt_daemon_install_bundled": "Arrêter le nœud, remplacer le dragonxd installé par la version intégrée dans cette version du portefeuille, puis redémarrer",
|
||||
@@ -1519,10 +1587,11 @@
|
||||
"tt_low_spec": "Désactiver tous les effets visuels lourds\\nRaccourci : Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolider plusieurs UTXOs vers une adresse",
|
||||
"tt_mine_idle": "Démarrer le minage automatiquement quand le\\nsystème est inactif (aucune entrée clavier/souris)",
|
||||
"tt_noise": "Intensité de texture grainée (0%% = désactivé, 100%% = maximum)",
|
||||
"tt_noise": "Intensité de texture grainée (0% = désactivé, 100% = maximum)",
|
||||
"tt_open_app_dir": "Ouvrir le dossier ObsidianDragon (paramètres, thèmes, journaux) dans le gestionnaire de fichiers",
|
||||
"tt_open_data_dir": "Ouvrir le dossier contenant les données de votre portefeuille et de la blockchain dans le gestionnaire de fichiers",
|
||||
"tt_open_dir": "Cliquer pour ouvrir dans l'explorateur de fichiers",
|
||||
"tt_open_log_folder": "Ouvre le dossier contenant les journaux de débogage et de plantage",
|
||||
"tt_reduce_motion": "Désactiver les transitions animées et le lerp de solde pour l'accessibilité",
|
||||
"tt_remove_encrypt": "Supprimer le chiffrement et stocker le portefeuille sans protection",
|
||||
"tt_remove_pin": "Supprimer le PIN et exiger la phrase secrète pour déverrouiller",
|
||||
@@ -1557,7 +1626,7 @@
|
||||
"tt_theme_hotkey": "Raccourci : Ctrl+Gauche/Droite pour changer de thème",
|
||||
"tt_tor": "Acheminer les connexions du daemon via le réseau Tor pour l'anonymat",
|
||||
"tt_tx_url": "URL de base pour consulter les transactions dans un explorateur de blocs",
|
||||
"tt_ui_opacity": "Opacité des cartes et de la barre latérale (100%% = entièrement opaque, plus bas = plus transparent)",
|
||||
"tt_ui_opacity": "Opacité des cartes et de la barre latérale (100% = entièrement opaque, plus bas = plus transparent)",
|
||||
"tt_validate": "Vérifier si une adresse DragonX est valide",
|
||||
"tt_verbose": "Journaliser les diagnostics de connexion détaillés,\\nl'état du daemon et les informations de propriétaire de port\\ndans l'onglet Console",
|
||||
"tt_wallets_button": "Répertoriez vos fichiers de portefeuille et passez de l'un à l'autre",
|
||||
@@ -1610,6 +1679,7 @@
|
||||
"validate_not_mine": "N'appartient pas à ce portefeuille",
|
||||
"validate_ownership": "Propriété :",
|
||||
"validate_results": "Résultats :",
|
||||
"validate_results_placeholder": "Les résultats apparaîtront ici",
|
||||
"validate_shielded_type": "Blindée (z-adresse)",
|
||||
"validate_status": "Statut :",
|
||||
"validate_title": "Valider l'adresse",
|
||||
@@ -1750,6 +1820,7 @@
|
||||
"xmrig_loading_releases": "Chargement des versions…",
|
||||
"xmrig_none": "aucun",
|
||||
"xmrig_reinstall": "Réinstaller",
|
||||
"xmrig_releases": "versions de xmrig",
|
||||
"xmrig_stop_mining_first": "Arrêtez le minage avant de mettre à jour le mineur.",
|
||||
"xmrig_unavailable_body": "Aucune version du mineur n'est disponible pour cette plateforme.",
|
||||
"xmrig_unavailable_title": "Mises à jour du mineur indisponibles",
|
||||
|
||||
+83
-15
@@ -48,6 +48,10 @@
|
||||
"advanced": "詳細設定",
|
||||
"advanced_effects": "高度なエフェクト...",
|
||||
"ago": "前",
|
||||
"alerts_clear": "通知履歴を消去",
|
||||
"alerts_history_tooltip": "最近の通知",
|
||||
"alerts_none": "通知はまだありません",
|
||||
"alerts_recent": "最近の通知",
|
||||
"all_filter": "すべて",
|
||||
"allow_custom_fees": "カスタム手数料を許可",
|
||||
"amount": "金額",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender がマイナーをブロックしました",
|
||||
"available": "利用可能",
|
||||
"backup_backing_up": "バックアップ中...",
|
||||
"backup_col_backup": "バックアップ",
|
||||
"backup_col_export": "エクスポート",
|
||||
"backup_col_import": "インポートと復元",
|
||||
"backup_create": "バックアップを作成",
|
||||
"backup_created": "ウォレットのバックアップを作成しました",
|
||||
"backup_data": "バックアップとデータ",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "残高",
|
||||
"balance_history_collecting": "残高履歴 — データを収集中...",
|
||||
"balance_layout": "残高レイアウト",
|
||||
"balance_layout_switched": "レイアウト: %s",
|
||||
"balance_mining_rate": "マイニング中 %s",
|
||||
"balance_shielded_fmt": "シールド: %.8f",
|
||||
"balance_syncing_pct": "同期中 %.1f%%",
|
||||
"balance_transparent_fmt": "透明: %.8f",
|
||||
"ban": "ブロック",
|
||||
"banned_peers": "ブロック済みピア",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "チェックサムを検証中...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat は保護されています)",
|
||||
"bootstrap_warning": "既存のブロックデータ(blocks、chainstate、notarizations)は削除され置き換えられます。wallet.dat は変更・削除されません。",
|
||||
"byte_count_fmt": "%zu / %zu バイト",
|
||||
"cancel": "キャンセル",
|
||||
"change_pass_confirm": "新しいパスフレーズ(確認):",
|
||||
"change_pass_current": "現在のパスフレーズ:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "バンドル版",
|
||||
"daemon_install_bundled": "バンドル版をインストール",
|
||||
"daemon_installed": "インストール済み",
|
||||
"daemon_maintenance_label": "メンテナンス",
|
||||
"daemon_none_bundled": "このビルドにはなし",
|
||||
"daemon_not_installed": "未インストール",
|
||||
"daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "最新:",
|
||||
"daemon_update_loading": "リリースを読み込み中…",
|
||||
"daemon_update_now": "今すぐ更新",
|
||||
"daemon_update_prompt_title": "ノードデーモンを更新しますか?",
|
||||
"daemon_update_reinstall": "再インストール",
|
||||
"daemon_update_restart_note": "新しいバージョンを実行するにはデーモンを再起動してください。",
|
||||
"daemon_update_restart_now": "今すぐデーモンを再起動",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "ダウンロードは、インストール前にリリースで公開された SHA-256 と固定された ed25519 署名で検証されます。",
|
||||
"daemon_update_verifying": "検証中…",
|
||||
"daemon_update_version": "バージョン:",
|
||||
"daemon_updates_label": "アップデート",
|
||||
"daemon_version": "デーモン",
|
||||
"dark": "ダーク",
|
||||
"data_stale_prefix": "更新",
|
||||
"data_stale_tooltip": "残高が最新でない可能性があります。ウォレットは最近更新を受信していません。ノード接続を確認してください。",
|
||||
"date": "日付",
|
||||
"date_label": "日付:",
|
||||
"debug_logging": "デバッグログ",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "ブートストラップをダウンロード",
|
||||
"dragonx_green": "DragonX(グリーン)",
|
||||
"edit": "編集",
|
||||
"empty_wallet_keys_suffix": "個の鍵",
|
||||
"empty_wallet_open_manager": "ウォレットマネージャーを開く",
|
||||
"empty_wallet_restore": "ウォレットを復元",
|
||||
"empty_wallet_salvage_body": "このウォレットが空なのは、以前の自動修復によって元のウォレットがバックアップとして脇に保存されたためです。コインはほぼ確実にそのバックアップの中にあり、失われていません。復元すれば資金を再び読み込めます。何も削除されません。現在のファイルは先に脇へ保存されます。",
|
||||
"empty_wallet_salvage_headline": "コインはバックアップファイルに安全に保管されています。",
|
||||
"empty_wallet_salvage_title": "ウォレットが修復された可能性があります",
|
||||
"empty_wallet_warning_body": "このウォレットにはアドレスも資金もありませんが、DragonX フォルダー内の別のウォレットファイルに鍵が含まれています。コインはおそらくそちらにあり、失われていません。ウォレットマネージャーを開いて、資金のあるウォレットに切り替えてください。",
|
||||
"empty_wallet_warning_dismiss": "このウォレットでは今後警告しない",
|
||||
"empty_wallet_warning_dismiss_tip": "現在のウォレットファイルに対してのみこの警告を停止します。後で別の空のウォレットに切り替えると、再び警告される場合があります。",
|
||||
"empty_wallet_warning_headline": "間違ったウォレットを開いた可能性があります。",
|
||||
"empty_wallet_warning_title": "このウォレットは空です",
|
||||
"enc_confirm": "確認:",
|
||||
"enc_desc": "ウォレットを暗号化すると、パスフレーズで秘密鍵が保護されます。暗号化後、デーモンが再起動します。",
|
||||
"enc_encrypting": "ウォレットを暗号化しています...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "ライト",
|
||||
"lite_account_label": "アカウント",
|
||||
"lite_action": "アクション",
|
||||
"lite_backend_unavailable": "ライトウォレットのバックエンドが利用できません",
|
||||
"lite_backup_keys": "バックアップと鍵",
|
||||
"lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)",
|
||||
"lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "バックエンドコマンド:",
|
||||
"lite_console_help_passthrough": "その他の入力はライトウォレットのコンソールコマンドとして実行されます。",
|
||||
"lite_copy": "コピー",
|
||||
"lite_could_not_start": "操作を開始できませんでした",
|
||||
"lite_could_not_write": "書き込めませんでした ",
|
||||
"lite_encrypt_wallet": "ウォレットを暗号化",
|
||||
"lite_encryption_removed": "暗号化を解除しました",
|
||||
"lite_enter_all_seed_words": "復元するには24個のシードワードをすべて入力してください(現在 %d 個)",
|
||||
"lite_enter_wallet_path": "ウォレットのパスを入力してください",
|
||||
"lite_hide_wipe": "非表示にして消去",
|
||||
"lite_import": "インポート",
|
||||
"lite_import_key_label": "鍵をインポート",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "処理中…",
|
||||
"loading": "読み込み中...",
|
||||
"loading_addresses": "アドレスを読み込み中...",
|
||||
"loading_stall_body": "デーモンは %.0f 秒間初期化しています。アップデート後や初回起動時(ブロックインデックスの読み込みや再スキャン)は正常な場合があります。準備ができ次第、自動的に接続します。",
|
||||
"loading_stall_hint": "まだ動かない場合は、設定を開いて「デーモンを再起動」を使うか、コンソールで詳細を確認してください。",
|
||||
"loading_stall_title": "予想より時間がかかっています",
|
||||
"loading_transactions": "トランザクションを読み込み中",
|
||||
"local_hashrate": "ローカルハッシュレート",
|
||||
"low_spec_mode": "省電力モード",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "難易度をコピーしました",
|
||||
"mining_est_block": "予測ブロック",
|
||||
"mining_est_daily": "予測日収",
|
||||
"mining_est_daily_pool_sub": "おおよそのソロ換算(プール手数料前)",
|
||||
"mining_filter_all": "すべて",
|
||||
"mining_filter_tip_all": "すべての収益を表示",
|
||||
"mining_filter_tip_pool": "プール収益のみ表示",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "エクスプローラーで開く",
|
||||
"mining_payout_address": "支払いアドレス",
|
||||
"mining_payout_foreign": "⚠ この支払いアドレスは現在のウォレットに含まれていません — マイニング報酬が別のウォレットに送られます。ウォレットを切り替えた場合は更新してください。",
|
||||
"mining_payout_invalid": "有効な DragonX アドレスではありません — 開始前に修正してください。さもないとマイニング報酬が失われます。",
|
||||
"mining_payout_tooltip": "マイニング報酬の受取アドレス",
|
||||
"mining_pool": "プール",
|
||||
"mining_pool_fee": "手数料",
|
||||
"mining_pool_hashrate": "プールハッシュレート",
|
||||
"mining_pool_needs_payout_tooltip": "先に支払い先アドレスを入力してください(Zアドレスを生成)",
|
||||
"mining_pool_url": "プールURL",
|
||||
"mining_pools_header": "プール",
|
||||
"mining_recent_blocks": "最近のブロック",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "ブロックチェーン同期中...",
|
||||
"mining_tag": " · マイニング",
|
||||
"mining_threads": "マイニングスレッド",
|
||||
"mining_threads_input_tooltip": "正確なスレッド数を入力(Enter で適用)",
|
||||
"mining_threads_minus_tooltip": "スレッドを減らす",
|
||||
"mining_threads_plus_tooltip": "スレッドを増やす",
|
||||
"mining_to_save": "保存する",
|
||||
"mining_today": "今日",
|
||||
"mining_uptime": "稼働時間",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "取引が見つかりません",
|
||||
"no_transactions_yet": "まだ取引がありません",
|
||||
"node": "ノード",
|
||||
"node_banner_crashed_title": "ノードが予期せず停止しました",
|
||||
"node_banner_lite_open_failed": "ウォレットを開けませんでした",
|
||||
"node_banner_offline_title": "DragonX ノードに接続されていません",
|
||||
"node_banner_reconnect": "再接続",
|
||||
"node_banner_restart": "ノードを再起動",
|
||||
"node_security": "ノードとセキュリティ",
|
||||
"noise": "ノイズ",
|
||||
"not_connected": "デーモンに未接続...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "QRコードの生成に失敗しました",
|
||||
"qr_title": "QRコード",
|
||||
"qr_unavailable": "QR利用不可",
|
||||
"quick_receive": "クイック受取",
|
||||
"quick_send": "クイック送金",
|
||||
"ram_daemon_gb": "デーモン:%.1f GB (%s)",
|
||||
"ram_daemon_mb": "デーモン:%.0f MB (%s)",
|
||||
"ram_system_gb": "システム:%.1f / %.0f GB",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "RPC接続...",
|
||||
"rpc_host": "RPCホスト",
|
||||
"rpc_pass": "パスワード",
|
||||
"rpc_plaintext_remote_warning": "リモートRPCは暗号化されていないHTTPを使用しています。デーモンがTLSに対応している場合は、DRAGONX.confにrpctls=1を追加してください。",
|
||||
"rpc_port": "ポート",
|
||||
"rpc_user": "ユーザー名",
|
||||
"save": "保存",
|
||||
@@ -1182,6 +1230,7 @@
|
||||
"sb_waiting_daemon_err": "dragonxd を待機中 — %s",
|
||||
"sb_warming_up": "ウォームアップ中...",
|
||||
"sb_witness_cache": "ウィットネスを再構築中",
|
||||
"scale_effects": "スケールとエフェクト",
|
||||
"screenshot_open_dir": "場所を開く",
|
||||
"screenshot_sweep": "スクリーンショットスイープを実行",
|
||||
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
|
||||
@@ -1246,6 +1295,7 @@
|
||||
"send_tooltip_not_connected": "デーモンに未接続",
|
||||
"send_tooltip_select_source": "まず送信元アドレスを選択してください",
|
||||
"send_tooltip_syncing": "ブロックチェーンの同期をお待ちください",
|
||||
"send_tooltip_view_only": "閲覧専用アドレス — 送金鍵がないため送金できません",
|
||||
"send_total": "合計",
|
||||
"send_transaction": "取引を送信",
|
||||
"send_tx_failed": "取引に失敗しました",
|
||||
@@ -1265,16 +1315,16 @@
|
||||
"sent_filter": "送信済み",
|
||||
"sent_type": "送信済み",
|
||||
"sent_upper": "送信済み",
|
||||
"set_label": "ラベルを設定...",
|
||||
"set_label": "ラベルを設定",
|
||||
"settings": "設定",
|
||||
"settings_about_text": "DragonX (DRGX) 用のシールド暗号通貨ウォレット。Dear ImGui で構築された軽量でポータブルな体験。",
|
||||
"settings_acrylic_level": "アクリルレベル:",
|
||||
"settings_address_book": "アドレス帳...",
|
||||
"settings_address_book": "アドレス帳…",
|
||||
"settings_auto_detected": "DRAGONX.conf から自動検出",
|
||||
"settings_auto_lock": "オートロック",
|
||||
"settings_auto_shield_desc": "透明資金を自動的にシールドアドレスに移動",
|
||||
"settings_auto_shield_funds": "透明資金を自動シールド",
|
||||
"settings_backup": "バックアップ...",
|
||||
"settings_backup": "バックアップ…",
|
||||
"settings_block_explorer_urls": "ブロックエクスプローラーURL",
|
||||
"settings_builtin": "内蔵",
|
||||
"settings_change_passphrase": "パスフレーズを変更",
|
||||
@@ -1285,53 +1335,62 @@
|
||||
"settings_configure_explorer": "外部ブロックエクスプローラーリンクを設定",
|
||||
"settings_configure_rpc": "dragonxd デーモンへの接続を設定",
|
||||
"settings_connection": "接続",
|
||||
"settings_copy_diagnostics": "診断情報をコピー",
|
||||
"settings_copyright": "Copyright 2024-2026 DragonX 開発者 | GPLv3 ライセンス",
|
||||
"settings_custom": "カスタム",
|
||||
"settings_data_dir": "データディレクトリ:",
|
||||
"settings_debug_changed": "デバッグカテゴリが変更されました — デーモンを再起動して適用",
|
||||
"settings_debug_restart_note": "変更はデーモンの再起動後に有効になります。",
|
||||
"settings_debug_select": "デーモンのデバッグログを有効にするカテゴリを選択(-debug= フラグ)。",
|
||||
"settings_diagnostics_copied": "診断情報をクリップボードにコピーしました",
|
||||
"settings_encrypt_first_pin": "PIN を有効にするには、まずウォレットを暗号化してください",
|
||||
"settings_encrypt_wallet": "ウォレットを暗号化",
|
||||
"settings_explorer_hint": "URLには末尾のスラッシュを含めてください。txid/アドレスが追加されます。",
|
||||
"settings_export_all": "すべてエクスポート...",
|
||||
"settings_export_csv": "CSV エクスポート...",
|
||||
"settings_export_key": "鍵をエクスポート...",
|
||||
"settings_export_all": "すべてエクスポート…",
|
||||
"settings_export_csv": "CSV エクスポート…",
|
||||
"settings_export_key": "鍵をエクスポート…",
|
||||
"settings_gradient_bg": "グラデーション背景",
|
||||
"settings_gradient_desc": "テクスチャ背景を滑らかなグラデーションに置換",
|
||||
"settings_idle_after": "経過後",
|
||||
"settings_import_key": "秘密鍵をインポート...",
|
||||
"settings_import_viewkey": "閲覧鍵をインポート...",
|
||||
"settings_import_key": "秘密鍵をインポート…",
|
||||
"settings_import_viewkey": "閲覧鍵をインポート…",
|
||||
"settings_language_note": "注意:一部のテキストは更新に再起動が必要です",
|
||||
"settings_lock_now": "今すぐロック",
|
||||
"settings_locked": "ロック済み",
|
||||
"settings_merge_to_address": "アドレスにマージ...",
|
||||
"settings_merge_to_address": "アドレスにマージ…",
|
||||
"settings_noise_opacity": "ノイズ不透明度:",
|
||||
"settings_not_connected": "デーモンに接続されていません",
|
||||
"settings_not_encrypted": "暗号化されていません",
|
||||
"settings_not_found": "見つかりません",
|
||||
"settings_open_app_dir": "アプリフォルダを開く",
|
||||
"settings_open_data_dir": "データフォルダを開く",
|
||||
"settings_open_log_folder": "ログフォルダを開く",
|
||||
"settings_other": "その他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "プライバシー",
|
||||
"settings_quick_unlock_pin": "クイックアンロック PIN",
|
||||
"settings_reduce_transparency": "透明度を下げる",
|
||||
"settings_reloaded": "ディスクから設定を再読み込みしました",
|
||||
"settings_remove_encryption": "暗号化を解除",
|
||||
"settings_remove_pin": "PIN を削除",
|
||||
"settings_request_payment": "支払い請求...",
|
||||
"settings_request_payment": "支払い請求…",
|
||||
"settings_rescan_desc": "欠落したトランザクションのためにブロックチェーンを再スキャン",
|
||||
"settings_restart_daemon": "デーモンを再起動",
|
||||
"settings_rpc_connection": "RPC 接続",
|
||||
"settings_rpc_error_prefix": "RPCエラー: ",
|
||||
"settings_rpc_note": "注意:接続設定は通常 DRAGONX.conf から自動検出されます",
|
||||
"settings_rpc_ok": "RPC接続は正常です",
|
||||
"settings_save_shielded_desc": "z-addr トランザクションをローカルファイルに保存して表示",
|
||||
"settings_save_shielded_local": "シールドトランザクション履歴をローカルに保存",
|
||||
"settings_saved": "設定を保存しました",
|
||||
"settings_set_pin": "PIN を設定",
|
||||
"settings_shield_mining": "マイニングシールド...",
|
||||
"settings_shield_mining": "マイニングシールド…",
|
||||
"settings_solid_colors_desc": "ぼかし効果の代わりに単色を使用(アクセシビリティ)",
|
||||
"settings_theme_refreshed": "テーマ一覧を更新しました",
|
||||
"settings_tor_desc": "プライバシー向上のため全接続を Tor 経由にする",
|
||||
"settings_unlocked": "ロック解除",
|
||||
"settings_use_tor_network": "ネットワーク接続に Tor を使用",
|
||||
"settings_validate_address": "アドレス検証...",
|
||||
"settings_validate_address": "アドレス検証…",
|
||||
"settings_visual_effects": "視覚効果",
|
||||
"settings_wallet_file_size": "ウォレットファイルサイズ:%s",
|
||||
"settings_wallet_info": "ウォレット情報",
|
||||
@@ -1339,6 +1398,8 @@
|
||||
"settings_wallet_maintenance": "ウォレットメンテナンス",
|
||||
"settings_wallet_not_found": "ウォレットファイルが見つかりません",
|
||||
"settings_wallet_size_label": "ウォレットサイズ:",
|
||||
"settings_ztx_cleared": "Zトランザクション履歴を消去しました",
|
||||
"settings_ztx_not_found": "履歴ファイルが見つかりません",
|
||||
"setup_wizard": "セットアップウィザード",
|
||||
"share": "共有",
|
||||
"shield_check_status": "ステータスを確認",
|
||||
@@ -1421,6 +1482,7 @@
|
||||
"theme": "テーマ",
|
||||
"theme_effects": "テーマ効果",
|
||||
"theme_language": "テーマと言語",
|
||||
"tile_click_to_open": "クリックして開く",
|
||||
"time_days_ago": "%d日前",
|
||||
"time_hours_ago": "%d時間前",
|
||||
"time_minutes_ago": "%d分前",
|
||||
@@ -1435,7 +1497,9 @@
|
||||
"to_upper": "宛先",
|
||||
"tools": "ツール",
|
||||
"tools_actions": "ツールとアクション...",
|
||||
"tools_actions_hdr": "ツールと操作",
|
||||
"total": "合計",
|
||||
"total_balance_label": "総残高",
|
||||
"transaction_id": "取引ID",
|
||||
"transaction_sent": "取引の送信に成功しました",
|
||||
"transaction_sent_msg": "取引を送信しました!",
|
||||
@@ -1457,7 +1521,7 @@
|
||||
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
|
||||
"tt_backup": "wallet.dat のバックアップを作成",
|
||||
"tt_block_explorer": "ブラウザで DragonX ブロックエクスプローラーを開く",
|
||||
"tt_blur": "ぼかし量(0%% = オフ、100%% = 最大)",
|
||||
"tt_blur": "ぼかし量(0% = オフ、100% = 最大)",
|
||||
"tt_change_pass": "ウォレットの暗号化パスフレーズを変更",
|
||||
"tt_change_pin": "アンロック PIN を変更",
|
||||
"tt_chat_bubble_accent": "送信メッセージの吹き出しのアクセントカラー(または現在のテーマに従う)",
|
||||
@@ -1470,6 +1534,7 @@
|
||||
"tt_chat_timestamp": "このタブのみのタイムスタンプ形式:アプリ全体の時計に従うか、24-hourまたは12-hourを強制します",
|
||||
"tt_clear_ztx": "ローカルにキャッシュされた z-トランザクション履歴を削除",
|
||||
"tt_clock_format": "24時間または12時間表示(アプリ全体)。チャットで上書きできます。",
|
||||
"tt_copy_diagnostics": "サポート用の概要(バージョン、デーモン/ウォレット/ログの状態 — 秘密情報なし)をクリップボードにコピーします",
|
||||
"tt_custom_fees": "トランザクション送信時に手動手数料入力を有効化",
|
||||
"tt_custom_theme": "カスタムテーマがアクティブ",
|
||||
"tt_daemon_install_bundled": "ノードを停止し、インストール済みの dragonxd をこのウォレットビルドにバンドルされたバージョンで上書きしてから再起動します",
|
||||
@@ -1519,10 +1584,11 @@
|
||||
"tt_low_spec": "すべての重い視覚効果を無効化\\nホットキー:Ctrl+Shift+Down",
|
||||
"tt_merge": "複数の UTXO を一つのアドレスに統合",
|
||||
"tt_mine_idle": "システムがアイドル状態(キーボード/マウス入力なし)\\nのとき自動的にマイニングを開始",
|
||||
"tt_noise": "グレインテクスチャ強度(0%% = オフ、100%% = 最大)",
|
||||
"tt_noise": "グレインテクスチャ強度(0% = オフ、100% = 最大)",
|
||||
"tt_open_app_dir": "ObsidianDragon フォルダ(設定、テーマ、ログ)をファイルマネージャーで開く",
|
||||
"tt_open_data_dir": "ファイルマネージャーでウォレットとブロックチェーンデータのフォルダを開きます",
|
||||
"tt_open_dir": "クリックしてファイルエクスプローラーで開く",
|
||||
"tt_open_log_folder": "デバッグログとクラッシュログが入ったフォルダを開きます",
|
||||
"tt_reduce_motion": "アクセシビリティのためにアニメーション遷移と残高補間を無効にする",
|
||||
"tt_remove_encrypt": "暗号化を解除してウォレットを保護なしで保存",
|
||||
"tt_remove_pin": "PIN を削除しアンロックにパスフレーズを要求",
|
||||
@@ -1557,7 +1623,7 @@
|
||||
"tt_theme_hotkey": "ホットキー:Ctrl+左/右でテーマを切り替え",
|
||||
"tt_tor": "匿名性のためにデーモン接続を Tor ネットワーク経由でルーティング",
|
||||
"tt_tx_url": "ブロックエクスプローラーでトランザクションを表示するためのベース URL",
|
||||
"tt_ui_opacity": "カードとサイドバーの不透明度(100%% = 完全不透明、低い = より透過)",
|
||||
"tt_ui_opacity": "カードとサイドバーの不透明度(100% = 完全不透明、低い = より透過)",
|
||||
"tt_validate": "DragonX アドレスが有効かどうかを確認",
|
||||
"tt_verbose": "詳細な接続診断、デーモン状態、\\nポート所有者情報をコンソールタブに記録",
|
||||
"tt_wallets_button": "ウォレットファイルを一覧表示して切り替えます",
|
||||
@@ -1610,6 +1676,7 @@
|
||||
"validate_not_mine": "このウォレットに属していません",
|
||||
"validate_ownership": "所有者:",
|
||||
"validate_results": "結果:",
|
||||
"validate_results_placeholder": "ここに結果が表示されます",
|
||||
"validate_shielded_type": "シールド(zアドレス)",
|
||||
"validate_status": "ステータス:",
|
||||
"validate_title": "アドレスを検証",
|
||||
@@ -1750,6 +1817,7 @@
|
||||
"xmrig_loading_releases": "リリースを読み込み中…",
|
||||
"xmrig_none": "なし",
|
||||
"xmrig_reinstall": "再インストール",
|
||||
"xmrig_releases": "xmrig リリース",
|
||||
"xmrig_stop_mining_first": "マイナーを更新する前にマイニングを停止してください。",
|
||||
"xmrig_unavailable_body": "このプラットフォーム向けのマイナービルドは利用できません。",
|
||||
"xmrig_unavailable_title": "マイナーの更新は利用できません",
|
||||
|
||||
+87
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "고급 설정",
|
||||
"advanced_effects": "고급 효과...",
|
||||
"ago": "전",
|
||||
"alerts_clear": "알림 기록 지우기",
|
||||
"alerts_history_tooltip": "최근 알림",
|
||||
"alerts_none": "아직 알림이 없습니다",
|
||||
"alerts_recent": "최근 알림",
|
||||
"all_filter": "전체",
|
||||
"allow_custom_fees": "사용자 정의 수수료 허용",
|
||||
"amount": "금액",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender가 채굴기를 차단했습니다",
|
||||
"available": "사용 가능",
|
||||
"backup_backing_up": "백업 중...",
|
||||
"backup_col_backup": "백업",
|
||||
"backup_col_export": "내보내기",
|
||||
"backup_col_import": "가져오기 및 복원",
|
||||
"backup_create": "백업 생성",
|
||||
"backup_created": "지갑 백업이 생성되었습니다",
|
||||
"backup_data": "백업 및 데이터",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "잔액",
|
||||
"balance_history_collecting": "잔액 내역 — 데이터 수집 중...",
|
||||
"balance_layout": "잔액 레이아웃",
|
||||
"balance_layout_switched": "레이아웃: %s",
|
||||
"balance_mining_rate": "채굴 중 %s",
|
||||
"balance_shielded_fmt": "차폐: %.8f",
|
||||
"balance_syncing_pct": "동기화 중 %.1f%%",
|
||||
"balance_transparent_fmt": "투명: %.8f",
|
||||
"ban": "차단",
|
||||
"banned_peers": "차단된 피어",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "체크섬 확인 중...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat 보호됨)",
|
||||
"bootstrap_warning": "기존 블록 데이터(blocks, chainstate, notarizations)가 삭제되고 교체됩니다. wallet.dat는 수정되거나 삭제되지 않습니다.",
|
||||
"byte_count_fmt": "%zu / %zu 바이트",
|
||||
"cancel": "취소",
|
||||
"change_pass_confirm": "새 암호 확인:",
|
||||
"change_pass_current": "현재 암호:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "번들",
|
||||
"daemon_install_bundled": "번들 버전 설치",
|
||||
"daemon_installed": "설치됨",
|
||||
"daemon_maintenance_label": "유지 관리",
|
||||
"daemon_none_bundled": "이 빌드에 없음",
|
||||
"daemon_not_installed": "설치되지 않음",
|
||||
"daemon_status_differ": "설치된 바이너리가 번들 버전과 다릅니다.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "최신:",
|
||||
"daemon_update_loading": "릴리스 불러오는 중…",
|
||||
"daemon_update_now": "지금 업데이트",
|
||||
"daemon_update_prompt_title": "노드 데몬을 업데이트하시겠습니까?",
|
||||
"daemon_update_reinstall": "다시 설치",
|
||||
"daemon_update_restart_note": "새 버전을 실행하려면 데몬을 재시작하세요.",
|
||||
"daemon_update_restart_now": "지금 데몬 재시작",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "다운로드는 설치 전에 릴리스에 게시된 SHA-256과 고정된 ed25519 서명으로 검증됩니다.",
|
||||
"daemon_update_verifying": "확인 중…",
|
||||
"daemon_update_version": "버전:",
|
||||
"daemon_updates_label": "업데이트",
|
||||
"daemon_version": "데몬",
|
||||
"dark": "다크",
|
||||
"data_stale_prefix": "업데이트",
|
||||
"data_stale_tooltip": "잔액이 오래되었을 수 있습니다 — 지갑이 최근에 업데이트를 받지 못했습니다. 노드 연결을 확인하세요.",
|
||||
"date": "날짜",
|
||||
"date_label": "날짜:",
|
||||
"debug_logging": "디버그 로깅",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "부트스트랩 다운로드",
|
||||
"dragonx_green": "DragonX(그린)",
|
||||
"edit": "편집",
|
||||
"empty_wallet_keys_suffix": "개 키",
|
||||
"empty_wallet_open_manager": "지갑 관리자 열기",
|
||||
"empty_wallet_restore": "내 지갑 복원",
|
||||
"empty_wallet_salvage_body": "이 지갑이 비어 있는 것은 이전의 자동 복구가 원본 지갑을 백업으로 따로 보관했기 때문입니다. 코인은 거의 확실히 그 백업에 있으며 사라지지 않았습니다. 복원하면 자금을 다시 불러올 수 있습니다. 아무것도 삭제되지 않으며, 현재 파일은 먼저 따로 보관됩니다.",
|
||||
"empty_wallet_salvage_headline": "코인은 백업 파일에 안전하게 보관되어 있습니다.",
|
||||
"empty_wallet_salvage_title": "지갑이 복구되었을 수 있습니다",
|
||||
"empty_wallet_warning_body": "이 지갑에는 주소도 자금도 없지만, DragonX 폴더의 다른 지갑 파일에 키가 들어 있습니다. 코인은 대부분 그 안에 있으며 사라진 것이 아닙니다. 지갑 관리자를 열어 자금이 있는 지갑으로 전환하세요.",
|
||||
"empty_wallet_warning_dismiss": "이 지갑에 대해 다시 경고하지 않기",
|
||||
"empty_wallet_warning_dismiss_tip": "현재 지갑 파일에 대해서만 이 경고를 중지합니다. 나중에 다른 빈 지갑으로 전환하면 다시 경고할 수 있습니다.",
|
||||
"empty_wallet_warning_headline": "잘못된 지갑을 열었을 수 있습니다.",
|
||||
"empty_wallet_warning_title": "이 지갑은 비어 있습니다",
|
||||
"enc_confirm": "확인:",
|
||||
"enc_desc": "지갑을 암호화하면 암호로 개인 키를 보호합니다. 암호화 후 데몬이 다시 시작됩니다.",
|
||||
"enc_encrypting": "지갑을 암호화하는 중...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "라이트",
|
||||
"lite_account_label": "계정",
|
||||
"lite_action": "작업",
|
||||
"lite_backend_unavailable": "라이트 지갑 백엔드를 사용할 수 없습니다",
|
||||
"lite_backup_keys": "백업 및 키",
|
||||
"lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)",
|
||||
"lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "백엔드 명령:",
|
||||
"lite_console_help_passthrough": "그 외 입력은 라이트 지갑 콘솔 명령으로 실행됩니다.",
|
||||
"lite_copy": "복사",
|
||||
"lite_could_not_start": "작업을 시작할 수 없습니다",
|
||||
"lite_could_not_write": "쓸 수 없습니다: ",
|
||||
"lite_encrypt_wallet": "지갑 암호화",
|
||||
"lite_encryption_removed": "암호화가 제거되었습니다",
|
||||
"lite_enter_all_seed_words": "복구하려면 24개의 시드 단어를 모두 입력하세요 (현재 %d개)",
|
||||
"lite_enter_wallet_path": "지갑 경로를 입력하세요",
|
||||
"lite_hide_wipe": "숨기고 삭제",
|
||||
"lite_import": "가져오기",
|
||||
"lite_import_key_label": "키 가져오기",
|
||||
@@ -734,6 +765,8 @@
|
||||
"lite_working": "작업 중…",
|
||||
"loading": "로딩 중...",
|
||||
"loading_addresses": "주소 로딩 중...",
|
||||
"loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.",
|
||||
"loading_stall_title": "예상보다 오래 걸리고 있습니다",
|
||||
"loading_transactions": "거래를 불러오는 중",
|
||||
"local_hashrate": "로컬 해시레이트",
|
||||
"low_spec_mode": "저사양 모드",
|
||||
@@ -866,6 +899,7 @@
|
||||
"mining_difficulty_copied": "난이도가 복사되었습니다",
|
||||
"mining_est_block": "예상 블록",
|
||||
"mining_est_daily": "예상 일일 수익",
|
||||
"mining_est_daily_pool_sub": "대략적인 솔로 환산, 풀 수수료 전",
|
||||
"mining_filter_all": "전체",
|
||||
"mining_filter_tip_all": "모든 수익 표시",
|
||||
"mining_filter_tip_pool": "풀 수익만 표시",
|
||||
@@ -894,10 +928,12 @@
|
||||
"mining_open_in_explorer": "탐색기에서 열기",
|
||||
"mining_payout_address": "지급 주소",
|
||||
"mining_payout_foreign": "⚠ 이 지급 주소는 현재 지갑에 없습니다 — 채굴한 보상이 다른 지갑으로 전송됩니다. 지갑을 전환했다면 주소를 업데이트하세요.",
|
||||
"mining_payout_invalid": "유효한 DragonX 주소가 아닙니다 — 시작하기 전에 수정하세요. 그렇지 않으면 채굴 보상이 사라집니다.",
|
||||
"mining_payout_tooltip": "채굴 보상 수신 주소",
|
||||
"mining_pool": "풀",
|
||||
"mining_pool_fee": "수수료",
|
||||
"mining_pool_hashrate": "풀 해시레이트",
|
||||
"mining_pool_needs_payout_tooltip": "먼저 지급 주소를 입력하세요 (Z 주소를 생성하세요)",
|
||||
"mining_pool_url": "풀 URL",
|
||||
"mining_pools_header": "풀",
|
||||
"mining_recent_blocks": "최근 블록",
|
||||
@@ -927,6 +963,9 @@
|
||||
"mining_syncing_tooltip": "블록체인 동기화 중...",
|
||||
"mining_tag": " · 채굴",
|
||||
"mining_threads": "채굴 스레드",
|
||||
"mining_threads_input_tooltip": "정확한 스레드 수 입력 (Enter로 적용)",
|
||||
"mining_threads_minus_tooltip": "스레드 줄이기",
|
||||
"mining_threads_plus_tooltip": "스레드 늘리기",
|
||||
"mining_to_save": "저장하려면",
|
||||
"mining_today": "오늘",
|
||||
"mining_uptime": "가동 시간",
|
||||
@@ -953,6 +992,11 @@
|
||||
"no_transactions": "거래 내역이 없습니다",
|
||||
"no_transactions_yet": "아직 거래 내역이 없습니다",
|
||||
"node": "노드",
|
||||
"node_banner_crashed_title": "노드가 예기치 않게 중지되었습니다",
|
||||
"node_banner_lite_open_failed": "지갑을 열 수 없습니다",
|
||||
"node_banner_offline_title": "DragonX 노드에 연결되지 않음",
|
||||
"node_banner_reconnect": "재연결",
|
||||
"node_banner_restart": "노드 재시작",
|
||||
"node_security": "노드 및 보안",
|
||||
"noise": "노이즈",
|
||||
"not_connected": "데몬에 연결되지 않음...",
|
||||
@@ -1091,6 +1135,8 @@
|
||||
"qr_failed": "QR 코드 생성 실패",
|
||||
"qr_title": "QR 코드",
|
||||
"qr_unavailable": "QR 사용 불가",
|
||||
"quick_receive": "빠른 받기",
|
||||
"quick_send": "빠른 보내기",
|
||||
"ram_daemon_gb": "데몬: %.1f GB (%s)",
|
||||
"ram_daemon_mb": "데몬: %.0f MB (%s)",
|
||||
"ram_system_gb": "시스템: %.1f / %.0f GB",
|
||||
@@ -1140,6 +1186,7 @@
|
||||
"rpc_connection": "RPC 연결...",
|
||||
"rpc_host": "RPC 호스트",
|
||||
"rpc_pass": "비밀번호",
|
||||
"rpc_plaintext_remote_warning": "원격 RPC가 암호화되지 않은 HTTP를 사용하고 있습니다. 데몬이 TLS를 지원하면 DRAGONX.conf에 rpctls=1을 추가하세요.",
|
||||
"rpc_port": "포트",
|
||||
"rpc_user": "사용자명",
|
||||
"save": "저장",
|
||||
@@ -1154,6 +1201,8 @@
|
||||
"sb_connecting_external": "외부 데몬에 연결 중...",
|
||||
"sb_connecting_generic": "데몬에 연결 중...",
|
||||
"sb_daemon_crashed": "데몬이 %d회 충돌함",
|
||||
"sb_daemon_extract_failed": "데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||
"sb_daemon_files_failed": "%s에 데몬 파일을 쓰지 못했습니다. 디스크 여유 공간과 권한을 확인하세요.",
|
||||
"sb_daemon_not_found": "데몬을 찾을 수 없음",
|
||||
"sb_daemon_start_failed": "dragonxd를 시작할 수 없습니다",
|
||||
"sb_dragonxd_running": "dragonxd 실행 중",
|
||||
@@ -1169,6 +1218,7 @@
|
||||
"sb_net_mhs": "네트: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf를 찾을 수 없음",
|
||||
"sb_peers": "피어: %zu",
|
||||
"sb_plaintext_remote_blocked": "원격 호스트로 RPC 자격 증명을 평문으로 보내는 것을 거부했습니다. 허용하려면 DRAGONX.conf에 rpcallowplaintext=1을 추가하거나 rpctls=1로 TLS를 활성화하세요.",
|
||||
"sb_rescanning": "재스캔",
|
||||
"sb_rescanning_pct": "재스캔 %.0f%%",
|
||||
"sb_restarting_daemon": "데몬 재시작 중...",
|
||||
@@ -1182,6 +1232,7 @@
|
||||
"sb_waiting_daemon_err": "dragonxd 대기 중 — %s",
|
||||
"sb_warming_up": "워밍업 중...",
|
||||
"sb_witness_cache": "증인 재구축 중",
|
||||
"scale_effects": "배율 및 효과",
|
||||
"screenshot_open_dir": "위치 열기",
|
||||
"screenshot_sweep": "스크린샷 스윕 실행",
|
||||
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
|
||||
@@ -1246,6 +1297,7 @@
|
||||
"send_tooltip_not_connected": "데몬에 연결되지 않음",
|
||||
"send_tooltip_select_source": "먼저 보낼 주소를 선택하세요",
|
||||
"send_tooltip_syncing": "블록체인 동기화를 기다려 주세요",
|
||||
"send_tooltip_view_only": "조회 전용 주소 — 지출 키가 없어 보낼 수 없습니다",
|
||||
"send_total": "합계",
|
||||
"send_transaction": "거래 전송",
|
||||
"send_tx_failed": "거래 실패",
|
||||
@@ -1265,16 +1317,16 @@
|
||||
"sent_filter": "전송됨",
|
||||
"sent_type": "전송됨",
|
||||
"sent_upper": "전송됨",
|
||||
"set_label": "라벨 설정...",
|
||||
"set_label": "라벨 설정",
|
||||
"settings": "설정",
|
||||
"settings_about_text": "DragonX (DRGX)용 차폐 암호화폐 지갑으로, Dear ImGui로 제작되어 가볍고 휴대 가능합니다.",
|
||||
"settings_acrylic_level": "아크릴 레벨:",
|
||||
"settings_address_book": "주소록...",
|
||||
"settings_address_book": "주소록…",
|
||||
"settings_auto_detected": "DRAGONX.conf에서 자동 감지",
|
||||
"settings_auto_lock": "자동 잠금",
|
||||
"settings_auto_shield_desc": "투명 자금을 자동으로 차폐 주소로 이동",
|
||||
"settings_auto_shield_funds": "투명 자금 자동 차폐",
|
||||
"settings_backup": "백업...",
|
||||
"settings_backup": "백업…",
|
||||
"settings_block_explorer_urls": "블록 탐색기 URL",
|
||||
"settings_builtin": "내장",
|
||||
"settings_change_passphrase": "비밀번호 변경",
|
||||
@@ -1285,60 +1337,71 @@
|
||||
"settings_configure_explorer": "외부 블록 탐색기 링크 구성",
|
||||
"settings_configure_rpc": "dragonxd 데몬 연결 구성",
|
||||
"settings_connection": "연결",
|
||||
"settings_copy_diagnostics": "진단 정보 복사",
|
||||
"settings_copyright": "Copyright 2024-2026 DragonX 개발자 | GPLv3 라이선스",
|
||||
"settings_custom": "사용자 지정",
|
||||
"settings_data_dir": "데이터 디렉터리:",
|
||||
"settings_data_dir": "데이터 디렉터리",
|
||||
"settings_debug_changed": "디버그 카테고리가 변경되었습니다 — 데몬을 재시작하여 적용",
|
||||
"settings_debug_restart_note": "변경 사항은 데몬을 다시 시작한 후에 적용됩니다.",
|
||||
"settings_debug_select": "데몬 디버그 로깅을 활성화할 카테고리를 선택하세요 (-debug= 플래그).",
|
||||
"settings_diagnostics_copied": "진단 정보를 클립보드에 복사했습니다",
|
||||
"settings_encrypt_first_pin": "PIN을 활성화하려면 먼저 지갑을 암호화하세요",
|
||||
"settings_encrypt_wallet": "지갑 암호화",
|
||||
"settings_explorer_hint": "URL에 후행 슬래시를 포함해야 합니다. txid/주소가 추가됩니다.",
|
||||
"settings_export_all": "모두 내보내기...",
|
||||
"settings_export_csv": "CSV 내보내기...",
|
||||
"settings_export_key": "키 내보내기...",
|
||||
"settings_export_all": "모두 내보내기…",
|
||||
"settings_export_csv": "CSV 내보내기…",
|
||||
"settings_export_key": "키 내보내기…",
|
||||
"settings_gradient_bg": "그라데이션 배경",
|
||||
"settings_gradient_desc": "텍스처 배경을 부드러운 그라데이션으로 교체",
|
||||
"settings_idle_after": "후",
|
||||
"settings_import_key": "개인 키 가져오기...",
|
||||
"settings_import_viewkey": "조회 키 가져오기...",
|
||||
"settings_import_key": "개인 키 가져오기…",
|
||||
"settings_import_viewkey": "조회 키 가져오기…",
|
||||
"settings_language_note": "참고: 일부 텍스트는 업데이트하려면 다시 시작해야 합니다",
|
||||
"settings_lock_now": "지금 잠금",
|
||||
"settings_locked": "잠김",
|
||||
"settings_merge_to_address": "주소로 병합...",
|
||||
"settings_merge_to_address": "주소로 병합…",
|
||||
"settings_noise_opacity": "노이즈 불투명도:",
|
||||
"settings_not_connected": "데몬에 연결되지 않음",
|
||||
"settings_not_encrypted": "암호화되지 않음",
|
||||
"settings_not_found": "찾을 수 없음",
|
||||
"settings_open_app_dir": "앱 폴더 열기",
|
||||
"settings_open_data_dir": "데이터 폴더 열기",
|
||||
"settings_open_log_folder": "로그 폴더 열기",
|
||||
"settings_other": "기타",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "개인 정보",
|
||||
"settings_quick_unlock_pin": "빠른 잠금 해제 PIN",
|
||||
"settings_reduce_transparency": "투명도 줄이기",
|
||||
"settings_reloaded": "디스크에서 설정을 다시 불러왔습니다",
|
||||
"settings_remove_encryption": "암호화 제거",
|
||||
"settings_remove_pin": "PIN 제거",
|
||||
"settings_request_payment": "결제 요청...",
|
||||
"settings_request_payment": "결제 요청…",
|
||||
"settings_rescan_desc": "누락된 거래를 찾기 위해 블록체인 재스캔",
|
||||
"settings_restart_daemon": "데몬 재시작",
|
||||
"settings_rpc_connection": "RPC 연결",
|
||||
"settings_rpc_error_prefix": "RPC 오류: ",
|
||||
"settings_rpc_note": "참고: 연결 설정은 보통 DRAGONX.conf에서 자동 감지됩니다",
|
||||
"settings_rpc_ok": "RPC 연결 정상",
|
||||
"settings_save_shielded_desc": "z-addr 거래를 로컬 파일에 저장하여 조회",
|
||||
"settings_save_shielded_local": "차폐 거래 기록을 로컬에 저장",
|
||||
"settings_saved": "설정이 저장되었습니다",
|
||||
"settings_set_pin": "PIN 설정",
|
||||
"settings_shield_mining": "채굴 차폐...",
|
||||
"settings_shield_mining": "채굴 차폐…",
|
||||
"settings_solid_colors_desc": "블러 효과 대신 단색 사용 (접근성)",
|
||||
"settings_theme_refreshed": "테마 목록을 새로고침했습니다",
|
||||
"settings_tor_desc": "향상된 개인 정보 보호를 위해 모든 연결을 Tor를 통해 라우팅",
|
||||
"settings_unlocked": "잠금 해제",
|
||||
"settings_use_tor_network": "네트워크 연결에 Tor 사용",
|
||||
"settings_validate_address": "주소 확인...",
|
||||
"settings_validate_address": "주소 확인…",
|
||||
"settings_visual_effects": "시각 효과",
|
||||
"settings_wallet_file_size": "지갑 파일 크기: %s",
|
||||
"settings_wallet_info": "지갑 정보",
|
||||
"settings_wallet_location": "지갑 위치: %s",
|
||||
"settings_wallet_maintenance": "지갑 유지보수",
|
||||
"settings_wallet_not_found": "지갑 파일을 찾을 수 없음",
|
||||
"settings_wallet_size_label": "지갑 크기:",
|
||||
"settings_wallet_size_label": "지갑 크기",
|
||||
"settings_ztx_cleared": "Z-거래 내역이 삭제되었습니다",
|
||||
"settings_ztx_not_found": "내역 파일을 찾을 수 없습니다",
|
||||
"setup_wizard": "설정 마법사",
|
||||
"share": "공유",
|
||||
"shield_check_status": "상태 확인",
|
||||
@@ -1421,6 +1484,7 @@
|
||||
"theme": "테마",
|
||||
"theme_effects": "테마 효과",
|
||||
"theme_language": "테마 및 언어",
|
||||
"tile_click_to_open": "클릭하여 열기",
|
||||
"time_days_ago": "%d일 전",
|
||||
"time_hours_ago": "%d시간 전",
|
||||
"time_minutes_ago": "%d분 전",
|
||||
@@ -1435,7 +1499,9 @@
|
||||
"to_upper": "받는 곳",
|
||||
"tools": "도구",
|
||||
"tools_actions": "도구 및 작업...",
|
||||
"tools_actions_hdr": "도구 및 작업",
|
||||
"total": "합계",
|
||||
"total_balance_label": "총 잔액",
|
||||
"transaction_id": "거래 ID",
|
||||
"transaction_sent": "거래 전송 성공",
|
||||
"transaction_sent_msg": "거래가 전송되었습니다!",
|
||||
@@ -1457,7 +1523,7 @@
|
||||
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
|
||||
"tt_backup": "wallet.dat 백업 만들기",
|
||||
"tt_block_explorer": "브라우저에서 DragonX 블록 탐색기 열기",
|
||||
"tt_blur": "블러 양 (0%% = 끔, 100%% = 최대)",
|
||||
"tt_blur": "블러 양 (0% = 끔, 100% = 최대)",
|
||||
"tt_change_pass": "지갑 암호화 비밀번호 변경",
|
||||
"tt_change_pin": "잠금 해제 PIN 변경",
|
||||
"tt_chat_bubble_accent": "보내는 메시지 말풍선의 강조 색상(또는 현재 테마를 따름)",
|
||||
@@ -1470,6 +1536,7 @@
|
||||
"tt_chat_timestamp": "이 탭에만 적용되는 타임스탬프 형식: 앱 전체 시계를 따르거나 24-hour 또는 12-hour로 강제합니다",
|
||||
"tt_clear_ztx": "로컬에 캐시된 z-트랜잭션 기록 삭제",
|
||||
"tt_clock_format": "24시간 또는 12시간 형식(앱 전체). 채팅에서 재정의할 수 있습니다.",
|
||||
"tt_copy_diagnostics": "지원용 요약(버전, 데몬/지갑/로그 상태 — 비밀 정보 없음)을 클립보드에 복사합니다",
|
||||
"tt_custom_fees": "거래 전송 시 수동 수수료 입력 활성화",
|
||||
"tt_custom_theme": "사용자 지정 테마 활성화됨",
|
||||
"tt_daemon_install_bundled": "노드를 중지하고 설치된 dragonxd를 이 지갑 빌드에 번들된 버전으로 덮어쓴 다음 재시작합니다",
|
||||
@@ -1519,10 +1586,11 @@
|
||||
"tt_low_spec": "모든 고부하 시각 효과 비활성화\\n단축키: Ctrl+Shift+Down",
|
||||
"tt_merge": "여러 UTXO를 하나의 주소로 통합",
|
||||
"tt_mine_idle": "시스템이 유휴 상태(키보드/마우스 입력 없음)일 때\\n자동으로 채굴 시작",
|
||||
"tt_noise": "그레인 텍스처 강도 (0%% = 끔, 100%% = 최대)",
|
||||
"tt_noise": "그레인 텍스처 강도 (0% = 끔, 100% = 최대)",
|
||||
"tt_open_app_dir": "파일 관리자에서 ObsidianDragon 폴더(설정, 테마, 로그)를 엽니다",
|
||||
"tt_open_data_dir": "지갑 및 블록체인 데이터가 있는 폴더를 파일 탐색기에서 엽니다",
|
||||
"tt_open_dir": "파일 탐색기에서 열려면 클릭",
|
||||
"tt_open_log_folder": "디버그 및 충돌 로그가 있는 폴더를 엽니다",
|
||||
"tt_reduce_motion": "접근성을 위해 애니메이션 전환 및 잔액 보간 비활성화",
|
||||
"tt_remove_encrypt": "암호화를 제거하고 지갑을 보호 없이 저장",
|
||||
"tt_remove_pin": "PIN을 제거하고 잠금 해제 시 비밀번호 요구",
|
||||
@@ -1557,7 +1625,7 @@
|
||||
"tt_theme_hotkey": "단축키: Ctrl+왼쪽/오른쪽으로 테마 전환",
|
||||
"tt_tor": "익명성을 위해 데몬 연결을 Tor 네트워크를 통해 라우팅",
|
||||
"tt_tx_url": "블록 탐색기에서 거래를 보기 위한 기본 URL",
|
||||
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100%% = 완전 불투명, 낮을수록 더 투명)",
|
||||
"tt_ui_opacity": "카드 및 사이드바 불투명도 (100% = 완전 불투명, 낮을수록 더 투명)",
|
||||
"tt_validate": "DragonX 주소가 유효한지 확인",
|
||||
"tt_verbose": "콘솔 탭에 상세 연결 진단,\\n데몬 상태 및 포트 소유자 정보 기록",
|
||||
"tt_wallets_button": "지갑 파일 목록을 보고 전환합니다",
|
||||
@@ -1610,6 +1678,7 @@
|
||||
"validate_not_mine": "이 지갑에 속하지 않음",
|
||||
"validate_ownership": "소유자:",
|
||||
"validate_results": "결과:",
|
||||
"validate_results_placeholder": "결과가 여기에 표시됩니다",
|
||||
"validate_shielded_type": "차폐 (z 주소)",
|
||||
"validate_status": "상태:",
|
||||
"validate_title": "주소 검증",
|
||||
@@ -1750,6 +1819,7 @@
|
||||
"xmrig_loading_releases": "릴리스를 불러오는 중…",
|
||||
"xmrig_none": "없음",
|
||||
"xmrig_reinstall": "재설치",
|
||||
"xmrig_releases": "xmrig 릴리스",
|
||||
"xmrig_stop_mining_first": "채굴기를 업데이트하기 전에 채굴을 중지하세요.",
|
||||
"xmrig_unavailable_body": "이 플랫폼에서 사용 가능한 채굴기 빌드가 없습니다.",
|
||||
"xmrig_unavailable_title": "채굴기 업데이트를 사용할 수 없습니다",
|
||||
|
||||
+88
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "AVANÇADO",
|
||||
"advanced_effects": "Efeitos Avançados...",
|
||||
"ago": "atrás",
|
||||
"alerts_clear": "Limpar histórico de alertas",
|
||||
"alerts_history_tooltip": "Alertas recentes",
|
||||
"alerts_none": "Ainda não há alertas",
|
||||
"alerts_recent": "ALERTAS RECENTES",
|
||||
"all_filter": "Todos",
|
||||
"allow_custom_fees": "Permitir taxas personalizadas",
|
||||
"amount": "Valor",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender bloqueou o minerador",
|
||||
"available": "Disponível",
|
||||
"backup_backing_up": "Fazendo backup...",
|
||||
"backup_col_backup": "BACKUP",
|
||||
"backup_col_export": "EXPORTAR",
|
||||
"backup_col_import": "IMPORTAR E RESTAURAR",
|
||||
"backup_create": "Criar Backup",
|
||||
"backup_created": "Backup da carteira criado",
|
||||
"backup_data": "BACKUP & DADOS",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "Saldo",
|
||||
"balance_history_collecting": "Histórico de saldo — coletando dados...",
|
||||
"balance_layout": "Layout do Saldo",
|
||||
"balance_layout_switched": "Layout: %s",
|
||||
"balance_mining_rate": "Minerando %s",
|
||||
"balance_shielded_fmt": "Blindado: %.8f",
|
||||
"balance_syncing_pct": "Sincronizando %.1f%%",
|
||||
"balance_transparent_fmt": "Transparente: %.8f",
|
||||
"ban": "Banir",
|
||||
"banned_peers": "Pares Banidos",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "Verificando somas de verificação...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat está protegido)",
|
||||
"bootstrap_warning": "Os dados de blocos existentes (blocks, chainstate, notarizations) serão excluídos e substituídos. Seu wallet.dat NÃO será modificado ou excluído.",
|
||||
"byte_count_fmt": "%zu / %zu bytes",
|
||||
"cancel": "Cancelar",
|
||||
"change_pass_confirm": "Confirmar nova:",
|
||||
"change_pass_current": "Senha atual:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "Empacotado",
|
||||
"daemon_install_bundled": "Instalar incluído",
|
||||
"daemon_installed": "Instalado",
|
||||
"daemon_maintenance_label": "MANUTENÇÃO",
|
||||
"daemon_none_bundled": "nenhum nesta build",
|
||||
"daemon_not_installed": "não instalado",
|
||||
"daemon_status_differ": "O binário instalado difere da versão empacotada.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "Mais recente:",
|
||||
"daemon_update_loading": "Carregando versões…",
|
||||
"daemon_update_now": "Atualizar agora",
|
||||
"daemon_update_prompt_title": "Atualizar o daemon do node?",
|
||||
"daemon_update_reinstall": "Reinstalar",
|
||||
"daemon_update_restart_note": "Reinicie o daemon para começar a executar a nova versão.",
|
||||
"daemon_update_restart_now": "Reiniciar daemon agora",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "O download é verificado contra o SHA-256 publicado do lançamento e uma assinatura ed25519 fixada antes da instalação.",
|
||||
"daemon_update_verifying": "Verificando…",
|
||||
"daemon_update_version": "Versão:",
|
||||
"daemon_updates_label": "ATUALIZAÇÕES",
|
||||
"daemon_version": "Daemon",
|
||||
"dark": "Escuro",
|
||||
"data_stale_prefix": "Atualizado",
|
||||
"data_stale_tooltip": "O saldo pode estar desatualizado — a carteira não recebeu uma atualização recente. Verifique a conexão com o seu nó.",
|
||||
"date": "Data",
|
||||
"date_label": "Data:",
|
||||
"debug_logging": "REGISTRO DE DEPURAÇÃO",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "Baixar Bootstrap",
|
||||
"dragonx_green": "DragonX (Verde)",
|
||||
"edit": "Editar",
|
||||
"empty_wallet_keys_suffix": "chaves",
|
||||
"empty_wallet_open_manager": "Abrir gerenciador de carteiras",
|
||||
"empty_wallet_restore": "Restaurar minha carteira",
|
||||
"empty_wallet_salvage_body": "Esta carteira está vazia porque um reparo automático anterior colocou sua carteira original de lado como backup. Suas moedas quase certamente estão nesse backup, não perdidas. Restaure-o para carregar seus fundos novamente — nada é excluído; o arquivo atual é guardado primeiro.",
|
||||
"empty_wallet_salvage_headline": "Suas moedas estão seguras em um arquivo de backup.",
|
||||
"empty_wallet_salvage_title": "Sua carteira pode ter sido reparada",
|
||||
"empty_wallet_warning_body": "Esta carteira não tem endereços nem fundos, mas outro arquivo de carteira na sua pasta do DragonX contém chaves. Suas moedas provavelmente estão nele, não perdidas. Abra o gerenciador de carteiras para mudar para a carteira que contém seus fundos.",
|
||||
"empty_wallet_warning_dismiss": "Não avisar novamente para esta carteira",
|
||||
"empty_wallet_warning_dismiss_tip": "Interrompe este aviso apenas para o arquivo de carteira atual. Se você mudar para outra carteira vazia mais tarde, poderá avisar novamente.",
|
||||
"empty_wallet_warning_headline": "Você pode ter aberto a carteira errada.",
|
||||
"empty_wallet_warning_title": "Esta carteira está vazia",
|
||||
"enc_confirm": "Confirmar:",
|
||||
"enc_desc": "Criptografar sua carteira protege suas chaves privadas com uma senha. Após a criptografia, o daemon será reiniciado.",
|
||||
"enc_encrypting": "Criptografando a carteira...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "Claro",
|
||||
"lite_account_label": "Conta",
|
||||
"lite_action": "Ação",
|
||||
"lite_backend_unavailable": "Backend da carteira lite indisponível",
|
||||
"lite_backup_keys": "Backup e chaves",
|
||||
"lite_birthday_backup": "Aniversário: %llu (faça o backup disto também)",
|
||||
"lite_birthday_hint": "Altura do bloco a partir da qual começar a escanear. Deixe 0 se desconhecida (escaneamento completo mais lento).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "Comandos do backend:",
|
||||
"lite_console_help_passthrough": "Qualquer outra entrada é executada como um comando de console da carteira leve.",
|
||||
"lite_copy": "Copiar",
|
||||
"lite_could_not_start": "Não foi possível iniciar a operação",
|
||||
"lite_could_not_write": "Não foi possível gravar ",
|
||||
"lite_encrypt_wallet": "Criptografar carteira",
|
||||
"lite_encryption_removed": "Criptografia removida",
|
||||
"lite_enter_all_seed_words": "Informe todas as 24 palavras-semente para restaurar (obtidas %d)",
|
||||
"lite_enter_wallet_path": "Informe um caminho para a carteira",
|
||||
"lite_hide_wipe": "Ocultar e apagar",
|
||||
"lite_import": "Importar",
|
||||
"lite_import_key_label": "Importar chave",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "Processando…",
|
||||
"loading": "Carregando...",
|
||||
"loading_addresses": "Carregando endereços...",
|
||||
"loading_stall_body": "O daemon está inicializando há %.0f s. Isso pode ser normal após uma atualização ou no primeiro início (carregando o índice de blocos ou reescaneando) — ele se conectará automaticamente quando estiver pronto.",
|
||||
"loading_stall_hint": "Ainda travado? Abra as Configurações e use Reiniciar daemon, ou verifique o Console para mais detalhes.",
|
||||
"loading_stall_title": "Está demorando mais do que o esperado",
|
||||
"loading_transactions": "Carregando transações",
|
||||
"local_hashrate": "Hashrate Local",
|
||||
"low_spec_mode": "Modo econômico",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "Dificuldade copiada",
|
||||
"mining_est_block": "Bloco Est.",
|
||||
"mining_est_daily": "Est. Diário",
|
||||
"mining_est_daily_pool_sub": "equivalente solo aproximado, antes da taxa do pool",
|
||||
"mining_filter_all": "Todos",
|
||||
"mining_filter_tip_all": "Mostrar todos os ganhos",
|
||||
"mining_filter_tip_pool": "Mostrar apenas ganhos do pool",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "Abrir no explorador",
|
||||
"mining_payout_address": "Endereço de Pagamento",
|
||||
"mining_payout_foreign": "⚠ Este endereço de pagamento não está na sua carteira atual — as recompensas mineradas iriam para uma carteira diferente. Atualize-o se você trocou de carteira.",
|
||||
"mining_payout_invalid": "Endereço DragonX inválido — corrija antes de iniciar, ou as recompensas de mineração serão perdidas.",
|
||||
"mining_payout_tooltip": "Endereço para receber recompensas de mineração",
|
||||
"mining_pool": "Pool",
|
||||
"mining_pool_fee": "Taxa",
|
||||
"mining_pool_hashrate": "Hashrate do Pool",
|
||||
"mining_pool_needs_payout_tooltip": "Informe primeiro um endereço de pagamento (gere um endereço Z)",
|
||||
"mining_pool_url": "URL do Pool",
|
||||
"mining_pools_header": "POOLS",
|
||||
"mining_recent_blocks": "BLOCOS RECENTES",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "Blockchain está sincronizando...",
|
||||
"mining_tag": " · Mineração",
|
||||
"mining_threads": "Threads de Mineração",
|
||||
"mining_threads_input_tooltip": "Digite um número exato de threads (Enter para aplicar)",
|
||||
"mining_threads_minus_tooltip": "Menos threads",
|
||||
"mining_threads_plus_tooltip": "Mais threads",
|
||||
"mining_to_save": "para salvar",
|
||||
"mining_today": "Hoje",
|
||||
"mining_uptime": "Tempo Ativo",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "Nenhuma transação encontrada",
|
||||
"no_transactions_yet": "Nenhuma transação ainda",
|
||||
"node": "NÓ",
|
||||
"node_banner_crashed_title": "O nó parou inesperadamente",
|
||||
"node_banner_lite_open_failed": "Não foi possível abrir sua carteira",
|
||||
"node_banner_offline_title": "Não conectado ao nó DragonX",
|
||||
"node_banner_reconnect": "Reconectar",
|
||||
"node_banner_restart": "Reiniciar nó",
|
||||
"node_security": "NÓ & SEGURANÇA",
|
||||
"noise": "Ruído",
|
||||
"not_connected": "Não conectado ao daemon...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "Falha ao gerar código QR",
|
||||
"qr_title": "Código QR",
|
||||
"qr_unavailable": "QR indisponível",
|
||||
"quick_receive": "Recebimento rápido",
|
||||
"quick_send": "Envio rápido",
|
||||
"ram_daemon_gb": "Daemon: %.1f GB (%s)",
|
||||
"ram_daemon_mb": "Daemon: %.0f MB (%s)",
|
||||
"ram_system_gb": "Sistema: %.1f / %.0f GB",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "Conexão RPC...",
|
||||
"rpc_host": "Host RPC",
|
||||
"rpc_pass": "Senha",
|
||||
"rpc_plaintext_remote_warning": "O RPC remoto está usando HTTP em texto simples. Adicione rpctls=1 ao DRAGONX.conf se o seu daemon suportar TLS.",
|
||||
"rpc_port": "Porta",
|
||||
"rpc_user": "Usuário",
|
||||
"save": "Salvar",
|
||||
@@ -1154,6 +1202,8 @@
|
||||
"sb_connecting_external": "Conectando ao daemon externo...",
|
||||
"sb_connecting_generic": "Conectando ao daemon...",
|
||||
"sb_daemon_crashed": "O daemon travou %d vezes",
|
||||
"sb_daemon_extract_failed": "Falha ao gravar os arquivos do daemon — verifique o espaço livre em disco e as permissões.",
|
||||
"sb_daemon_files_failed": "Falha ao gravar os arquivos do daemon em %s — verifique o espaço livre em disco e as permissões.",
|
||||
"sb_daemon_not_found": "Daemon não encontrado",
|
||||
"sb_daemon_start_failed": "Não foi possível iniciar o dragonxd",
|
||||
"sb_dragonxd_running": "dragonxd em execução",
|
||||
@@ -1169,6 +1219,7 @@
|
||||
"sb_net_mhs": "Rede: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf não encontrado",
|
||||
"sb_peers": "Pares: %zu",
|
||||
"sb_plaintext_remote_blocked": "Recusando enviar credenciais RPC em texto simples para um host remoto. Adicione rpcallowplaintext=1 ao DRAGONX.conf para permitir, ou habilite TLS com rpctls=1.",
|
||||
"sb_rescanning": "Reescaneando",
|
||||
"sb_rescanning_pct": "Reescaneando %.0f%%",
|
||||
"sb_restarting_daemon": "Reiniciando daemon...",
|
||||
@@ -1182,6 +1233,7 @@
|
||||
"sb_waiting_daemon_err": "Aguardando dragonxd — %s",
|
||||
"sb_warming_up": "Aquecendo...",
|
||||
"sb_witness_cache": "Reconstruindo testemunhas",
|
||||
"scale_effects": "ESCALA E EFEITOS",
|
||||
"screenshot_open_dir": "Abrir local",
|
||||
"screenshot_sweep": "Executar varredura de capturas de tela",
|
||||
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
|
||||
@@ -1246,6 +1298,7 @@
|
||||
"send_tooltip_not_connected": "Não conectado ao daemon",
|
||||
"send_tooltip_select_source": "Selecione primeiro um endereço de origem",
|
||||
"send_tooltip_syncing": "Aguarde a sincronização da blockchain",
|
||||
"send_tooltip_view_only": "Endereço somente visualização — sem chave de gasto, não é possível enviar",
|
||||
"send_total": "Total",
|
||||
"send_transaction": "Enviar Transação",
|
||||
"send_tx_failed": "Transação falhou",
|
||||
@@ -1265,16 +1318,16 @@
|
||||
"sent_filter": "Enviado",
|
||||
"sent_type": "Enviado",
|
||||
"sent_upper": "ENVIADO",
|
||||
"set_label": "Definir Rótulo...",
|
||||
"set_label": "Definir Rótulo",
|
||||
"settings": "Ajustes",
|
||||
"settings_about_text": "Uma carteira de criptomoeda blindada para DragonX (DRGX), criada com Dear ImGui para uma experiência leve e portátil.",
|
||||
"settings_acrylic_level": "Nível acrílico:",
|
||||
"settings_address_book": "Livro de endereços...",
|
||||
"settings_address_book": "Livro de endereços…",
|
||||
"settings_auto_detected": "Detectado automaticamente de DRAGONX.conf",
|
||||
"settings_auto_lock": "BLOQUEIO AUTOMÁTICO",
|
||||
"settings_auto_shield_desc": "Mover automaticamente fundos transparentes para endereços blindados",
|
||||
"settings_auto_shield_funds": "Blindar fundos transparentes automaticamente",
|
||||
"settings_backup": "Backup...",
|
||||
"settings_backup": "Backup…",
|
||||
"settings_block_explorer_urls": "URLs do explorador de blocos",
|
||||
"settings_builtin": "Integrado",
|
||||
"settings_change_passphrase": "Alterar frase secreta",
|
||||
@@ -1285,60 +1338,71 @@
|
||||
"settings_configure_explorer": "Configurar links do explorador de blocos externo",
|
||||
"settings_configure_rpc": "Configurar conexão ao daemon dragonxd",
|
||||
"settings_connection": "Conexão",
|
||||
"settings_copy_diagnostics": "Copiar diagnósticos",
|
||||
"settings_copyright": "Copyright 2024-2026 Desenvolvedores DragonX | Licença GPLv3",
|
||||
"settings_custom": "Personalizado",
|
||||
"settings_data_dir": "Dir. de dados:",
|
||||
"settings_data_dir": "Dir. de dados",
|
||||
"settings_debug_changed": "Categorias de depuração alteradas — reinicie o daemon para aplicar",
|
||||
"settings_debug_restart_note": "As alterações entram em vigor após reiniciar o daemon.",
|
||||
"settings_debug_select": "Selecione categorias para ativar o registro de depuração do daemon (flags -debug=).",
|
||||
"settings_diagnostics_copied": "Diagnósticos copiados para a área de transferência",
|
||||
"settings_encrypt_first_pin": "Encripte a carteira primeiro para ativar o PIN",
|
||||
"settings_encrypt_wallet": "Encriptar carteira",
|
||||
"settings_explorer_hint": "As URLs devem incluir uma barra final. O txid/endereço será adicionado.",
|
||||
"settings_export_all": "Exportar tudo...",
|
||||
"settings_export_csv": "Exportar CSV...",
|
||||
"settings_export_key": "Exportar chave...",
|
||||
"settings_export_all": "Exportar tudo…",
|
||||
"settings_export_csv": "Exportar CSV…",
|
||||
"settings_export_key": "Exportar chave…",
|
||||
"settings_gradient_bg": "Fundo gradiente",
|
||||
"settings_gradient_desc": "Substituir fundos texturizados por gradientes suaves",
|
||||
"settings_idle_after": "após",
|
||||
"settings_import_key": "Importar Chave Privada...",
|
||||
"settings_import_viewkey": "Importar chave de visualização...",
|
||||
"settings_import_key": "Importar Chave Privada…",
|
||||
"settings_import_viewkey": "Importar chave de visualização…",
|
||||
"settings_language_note": "Nota: Alguns textos requerem reinício para atualizar",
|
||||
"settings_lock_now": "Bloquear agora",
|
||||
"settings_locked": "Bloqueado",
|
||||
"settings_merge_to_address": "Fundir para endereço...",
|
||||
"settings_merge_to_address": "Fundir para endereço…",
|
||||
"settings_noise_opacity": "Opacidade do ruído:",
|
||||
"settings_not_connected": "Não conectado ao daemon",
|
||||
"settings_not_encrypted": "Não encriptado",
|
||||
"settings_not_found": "Não encontrado",
|
||||
"settings_open_app_dir": "Abrir pasta do aplicativo",
|
||||
"settings_open_data_dir": "Abrir pasta de dados",
|
||||
"settings_open_log_folder": "Abrir pasta de logs",
|
||||
"settings_other": "Outros",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "Privacidade",
|
||||
"settings_quick_unlock_pin": "PIN de desbloqueio rápido",
|
||||
"settings_reduce_transparency": "Reduzir transparência",
|
||||
"settings_reloaded": "Configurações recarregadas do disco",
|
||||
"settings_remove_encryption": "Remover encriptação",
|
||||
"settings_remove_pin": "Remover PIN",
|
||||
"settings_request_payment": "Solicitar pagamento...",
|
||||
"settings_request_payment": "Solicitar pagamento…",
|
||||
"settings_rescan_desc": "Reescanear a blockchain em busca de transações ausentes",
|
||||
"settings_restart_daemon": "Reiniciar daemon",
|
||||
"settings_rpc_connection": "Conexão RPC",
|
||||
"settings_rpc_error_prefix": "Erro de RPC: ",
|
||||
"settings_rpc_note": "Nota: As configurações de conexão são normalmente detectadas automaticamente do DRAGONX.conf",
|
||||
"settings_rpc_ok": "Conexão RPC OK",
|
||||
"settings_save_shielded_desc": "Armazena transações z-addr em um arquivo local para visualização",
|
||||
"settings_save_shielded_local": "Salvar histórico de transações blindadas localmente",
|
||||
"settings_saved": "Configurações salvas",
|
||||
"settings_set_pin": "Definir PIN",
|
||||
"settings_shield_mining": "Blindar mineração...",
|
||||
"settings_shield_mining": "Blindar mineração…",
|
||||
"settings_solid_colors_desc": "Usar cores sólidas em vez de efeitos de desfoque (acessibilidade)",
|
||||
"settings_theme_refreshed": "Lista de temas atualizada",
|
||||
"settings_tor_desc": "Rotear todas as conexões através do Tor para maior privacidade",
|
||||
"settings_unlocked": "Desbloqueado",
|
||||
"settings_use_tor_network": "Usar Tor para conexões de rede",
|
||||
"settings_validate_address": "Validar endereço...",
|
||||
"settings_validate_address": "Validar endereço…",
|
||||
"settings_visual_effects": "Efeitos visuais",
|
||||
"settings_wallet_file_size": "Tamanho do arquivo da carteira: %s",
|
||||
"settings_wallet_info": "Informações da carteira",
|
||||
"settings_wallet_location": "Localização da carteira: %s",
|
||||
"settings_wallet_maintenance": "Manutenção da carteira",
|
||||
"settings_wallet_not_found": "Arquivo da carteira não encontrado",
|
||||
"settings_wallet_size_label": "Tamanho da carteira:",
|
||||
"settings_wallet_size_label": "Tamanho da carteira",
|
||||
"settings_ztx_cleared": "Histórico de transações Z limpo",
|
||||
"settings_ztx_not_found": "Nenhum arquivo de histórico encontrado",
|
||||
"setup_wizard": "Assistente de Configuração",
|
||||
"share": "Compartilhar",
|
||||
"shield_check_status": "Verificar Status",
|
||||
@@ -1421,6 +1485,7 @@
|
||||
"theme": "Tema",
|
||||
"theme_effects": "Efeitos de tema",
|
||||
"theme_language": "TEMA E IDIOMA",
|
||||
"tile_click_to_open": "Clique para abrir",
|
||||
"time_days_ago": "há %d dias",
|
||||
"time_hours_ago": "há %d horas",
|
||||
"time_minutes_ago": "há %d minutos",
|
||||
@@ -1435,7 +1500,9 @@
|
||||
"to_upper": "PARA",
|
||||
"tools": "FERRAMENTAS",
|
||||
"tools_actions": "Ferramentas e Ações...",
|
||||
"tools_actions_hdr": "FERRAMENTAS E AÇÕES",
|
||||
"total": "Total",
|
||||
"total_balance_label": "Saldo Total",
|
||||
"transaction_id": "ID DA TRANSAÇÃO",
|
||||
"transaction_sent": "Transação enviada com sucesso",
|
||||
"transaction_sent_msg": "Transação enviada!",
|
||||
@@ -1457,7 +1524,7 @@
|
||||
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
|
||||
"tt_backup": "Criar um backup do seu wallet.dat",
|
||||
"tt_block_explorer": "Abrir o explorador de blocos DragonX no seu navegador",
|
||||
"tt_blur": "Quantidade de desfoque (0%% = desligado, 100%% = máximo)",
|
||||
"tt_blur": "Quantidade de desfoque (0% = desligado, 100% = máximo)",
|
||||
"tt_change_pass": "Alterar a frase secreta de encriptação da carteira",
|
||||
"tt_change_pin": "Alterar seu PIN de desbloqueio",
|
||||
"tt_chat_bubble_accent": "Cor de destaque para seus balões de mensagem enviados (ou seguir o tema atual)",
|
||||
@@ -1470,6 +1537,7 @@
|
||||
"tt_chat_timestamp": "Formato de horário apenas para esta aba: seguir o relógio geral do aplicativo, ou forçar 24-hour ou 12-hour",
|
||||
"tt_clear_ztx": "Excluir histórico de z-transações em cache local",
|
||||
"tt_clock_format": "Relógio de 24 ou 12 horas, em todo o app. O chat pode substituí-lo.",
|
||||
"tt_copy_diagnostics": "Copia um resumo para suporte (versão, estado do daemon/carteira/logs — sem segredos) para a área de transferência",
|
||||
"tt_custom_fees": "Ativar entrada manual de taxas ao enviar transações",
|
||||
"tt_custom_theme": "Tema personalizado ativo",
|
||||
"tt_daemon_install_bundled": "Parar o nó, sobrescrever o dragonxd instalado com a versão incluída nesta compilação da carteira e reiniciar",
|
||||
@@ -1519,10 +1587,11 @@
|
||||
"tt_low_spec": "Desativar todos os efeitos visuais pesados\\nAtalho: Ctrl+Shift+Down",
|
||||
"tt_merge": "Consolidar múltiplos UTXOs em um endereço",
|
||||
"tt_mine_idle": "Iniciar mineração automaticamente quando o\\nsistema estiver ocioso (sem entrada de teclado/mouse)",
|
||||
"tt_noise": "Intensidade de textura granulada (0%% = desligado, 100%% = máximo)",
|
||||
"tt_noise": "Intensidade de textura granulada (0% = desligado, 100% = máximo)",
|
||||
"tt_open_app_dir": "Abrir a pasta ObsidianDragon (configurações, temas, logs) no gerenciador de arquivos",
|
||||
"tt_open_data_dir": "Abrir a pasta com os dados da sua carteira e da blockchain no gerenciador de arquivos",
|
||||
"tt_open_dir": "Clique para abrir no explorador de arquivos",
|
||||
"tt_open_log_folder": "Abre a pasta que contém os logs de depuração e de falhas",
|
||||
"tt_reduce_motion": "Desativar transições animadas e lerp de saldo para acessibilidade",
|
||||
"tt_remove_encrypt": "Remover encriptação e armazenar a carteira desprotegida",
|
||||
"tt_remove_pin": "Remover PIN e exigir frase secreta para desbloquear",
|
||||
@@ -1557,7 +1626,7 @@
|
||||
"tt_theme_hotkey": "Atalho: Ctrl+Esquerda/Direita para alternar temas",
|
||||
"tt_tor": "Rotear conexões do daemon através da rede Tor para anonimato",
|
||||
"tt_tx_url": "URL base para visualizar transações em um explorador de blocos",
|
||||
"tt_ui_opacity": "Opacidade de cartões e barra lateral (100%% = totalmente opaco, menor = mais transparente)",
|
||||
"tt_ui_opacity": "Opacidade de cartões e barra lateral (100% = totalmente opaco, menor = mais transparente)",
|
||||
"tt_validate": "Verificar se um endereço DragonX é válido",
|
||||
"tt_verbose": "Registrar diagnósticos detalhados de conexão,\\nestado do daemon e info de proprietário de porta\\nna aba Console",
|
||||
"tt_wallets_button": "Liste os arquivos de carteira e alterne entre eles",
|
||||
@@ -1610,6 +1679,7 @@
|
||||
"validate_not_mine": "Não pertence a esta carteira",
|
||||
"validate_ownership": "Propriedade:",
|
||||
"validate_results": "Resultados:",
|
||||
"validate_results_placeholder": "Os resultados aparecerão aqui",
|
||||
"validate_shielded_type": "Blindado (z-endereço)",
|
||||
"validate_status": "Status:",
|
||||
"validate_title": "Validar Endereço",
|
||||
@@ -1750,6 +1820,7 @@
|
||||
"xmrig_loading_releases": "Carregando versões…",
|
||||
"xmrig_none": "nenhum",
|
||||
"xmrig_reinstall": "Reinstalar",
|
||||
"xmrig_releases": "versões do xmrig",
|
||||
"xmrig_stop_mining_first": "Pare a mineração antes de atualizar o minerador.",
|
||||
"xmrig_unavailable_body": "Nenhuma versão do minerador está disponível para esta plataforma.",
|
||||
"xmrig_unavailable_title": "Atualizações do minerador indisponíveis",
|
||||
|
||||
+88
-17
@@ -48,6 +48,10 @@
|
||||
"advanced": "ПРОЧЕЕ",
|
||||
"advanced_effects": "Расширенные эффекты...",
|
||||
"ago": "назад",
|
||||
"alerts_clear": "Очистить историю оповещений",
|
||||
"alerts_history_tooltip": "Недавние оповещения",
|
||||
"alerts_none": "Пока нет оповещений",
|
||||
"alerts_recent": "НЕДАВНИЕ ОПОВЕЩЕНИЯ",
|
||||
"all_filter": "Все",
|
||||
"allow_custom_fees": "Разрешить пользовательские комиссии",
|
||||
"amount": "Сумма",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender заблокировал майнер",
|
||||
"available": "Доступно",
|
||||
"backup_backing_up": "Создание резервной копии...",
|
||||
"backup_col_backup": "РЕЗЕРВНАЯ КОПИЯ",
|
||||
"backup_col_export": "ЭКСПОРТ",
|
||||
"backup_col_import": "ИМПОРТ И ВОССТАНОВЛЕНИЕ",
|
||||
"backup_create": "Создать резервную копию",
|
||||
"backup_created": "Резервная копия кошелька создана",
|
||||
"backup_data": "РЕЗЕРВНОЕ КОПИРОВАНИЕ И ДАННЫЕ",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "Баланс",
|
||||
"balance_history_collecting": "История баланса — сбор данных...",
|
||||
"balance_layout": "Макет баланса",
|
||||
"balance_layout_switched": "Раскладка: %s",
|
||||
"balance_mining_rate": "Майнинг %s",
|
||||
"balance_shielded_fmt": "Экранировано: %.8f",
|
||||
"balance_syncing_pct": "Синхронизация %.1f%%",
|
||||
"balance_transparent_fmt": "Прозрачный: %.8f",
|
||||
"ban": "Заблокировать",
|
||||
"banned_peers": "Заблокированные узлы",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "Проверка контрольных сумм...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat защищён)",
|
||||
"bootstrap_warning": "Существующие данные блоков (blocks, chainstate, notarizations) будут удалены и заменены. Ваш wallet.dat НЕ будет изменён или удалён.",
|
||||
"byte_count_fmt": "%zu / %zu байт",
|
||||
"cancel": "Отмена",
|
||||
"change_pass_confirm": "Подтвердите новый:",
|
||||
"change_pass_current": "Текущий пароль:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "Встроенный",
|
||||
"daemon_install_bundled": "Установить встроенную",
|
||||
"daemon_installed": "Установлено",
|
||||
"daemon_maintenance_label": "ОБСЛУЖИВАНИЕ",
|
||||
"daemon_none_bundled": "нет в этой сборке",
|
||||
"daemon_not_installed": "не установлен",
|
||||
"daemon_status_differ": "Установленный бинарный файл отличается от встроенной версии.",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "Последняя:",
|
||||
"daemon_update_loading": "Загрузка релизов…",
|
||||
"daemon_update_now": "Обновить сейчас",
|
||||
"daemon_update_prompt_title": "Обновить демон узла?",
|
||||
"daemon_update_reinstall": "Переустановить",
|
||||
"daemon_update_restart_note": "Перезапустите daemon, чтобы начать работу с новой версией.",
|
||||
"daemon_update_restart_now": "Перезапустить демон сейчас",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "Перед установкой загрузка проверяется по опубликованному для релиза SHA-256 и закреплённой подписи ed25519.",
|
||||
"daemon_update_verifying": "Проверка…",
|
||||
"daemon_update_version": "Версия:",
|
||||
"daemon_updates_label": "ОБНОВЛЕНИЯ",
|
||||
"daemon_version": "Демон",
|
||||
"dark": "Тёмная",
|
||||
"data_stale_prefix": "Обновлено",
|
||||
"data_stale_tooltip": "Баланс может быть устаревшим — кошелёк давно не получал обновлений. Проверьте подключение к узлу.",
|
||||
"date": "Дата",
|
||||
"date_label": "Дата:",
|
||||
"debug_logging": "ЖУРНАЛ ОТЛАДКИ",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "Скачать бутстрап",
|
||||
"dragonx_green": "DragonX (Зелёная)",
|
||||
"edit": "Редактировать",
|
||||
"empty_wallet_keys_suffix": "ключей",
|
||||
"empty_wallet_open_manager": "Открыть менеджер кошельков",
|
||||
"empty_wallet_restore": "Восстановить мой кошелёк",
|
||||
"empty_wallet_salvage_body": "Этот кошелёк пуст, потому что предыдущее автоматическое восстановление отложило ваш исходный кошелёк в качестве резервной копии. Ваши монеты почти наверняка находятся в этой копии и не потеряны. Восстановите её, чтобы снова загрузить средства — ничего не удаляется; текущий файл сначала откладывается в сторону.",
|
||||
"empty_wallet_salvage_headline": "Ваши монеты в безопасности в файле резервной копии.",
|
||||
"empty_wallet_salvage_title": "Возможно, ваш кошелёк был восстановлен",
|
||||
"empty_wallet_warning_body": "В этом кошельке нет адресов и средств, но другой файл кошелька в вашей папке DragonX содержит ключи. Ваши монеты, скорее всего, находятся в нём и не потеряны. Откройте менеджер кошельков, чтобы переключиться на кошелёк с вашими средствами.",
|
||||
"empty_wallet_warning_dismiss": "Больше не предупреждать для этого кошелька",
|
||||
"empty_wallet_warning_dismiss_tip": "Останавливает это предупреждение только для текущего файла кошелька. Если позже вы переключитесь на другой пустой кошелёк, предупреждение может появиться снова.",
|
||||
"empty_wallet_warning_headline": "Возможно, вы открыли не тот кошелёк.",
|
||||
"empty_wallet_warning_title": "Этот кошелёк пуст",
|
||||
"enc_confirm": "Подтвердите:",
|
||||
"enc_desc": "Шифрование кошелька защищает ваши приватные ключи паролем. После шифрования демон перезапустится.",
|
||||
"enc_encrypting": "Шифрование кошелька...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "Светлая",
|
||||
"lite_account_label": "Аккаунт",
|
||||
"lite_action": "Действие",
|
||||
"lite_backend_unavailable": "Бэкенд облегчённого кошелька недоступен",
|
||||
"lite_backup_keys": "Резервная копия и ключи",
|
||||
"lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)",
|
||||
"lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "Команды бэкенда:",
|
||||
"lite_console_help_passthrough": "Любой другой ввод выполняется как команда консоли лайт-кошелька.",
|
||||
"lite_copy": "Копировать",
|
||||
"lite_could_not_start": "Не удалось запустить операцию",
|
||||
"lite_could_not_write": "Не удалось записать ",
|
||||
"lite_encrypt_wallet": "Зашифровать кошелёк",
|
||||
"lite_encryption_removed": "Шифрование удалено",
|
||||
"lite_enter_all_seed_words": "Введите все 24 слова seed-фразы для восстановления (введено %d)",
|
||||
"lite_enter_wallet_path": "Введите путь к кошельку",
|
||||
"lite_hide_wipe": "Скрыть и стереть",
|
||||
"lite_import": "Импорт",
|
||||
"lite_import_key_label": "Импортировать ключ",
|
||||
@@ -734,6 +765,9 @@
|
||||
"lite_working": "Обработка…",
|
||||
"loading": "Загрузка...",
|
||||
"loading_addresses": "Загрузка адресов...",
|
||||
"loading_stall_body": "Демон инициализируется уже %.0f с. Это может быть нормально после обновления или при первом запуске (загрузка индекса блоков или повторное сканирование) — соединение установится автоматически, когда он будет готов.",
|
||||
"loading_stall_hint": "Всё ещё не отвечает? Откройте Настройки и нажмите «Перезапустить демон» или посмотрите подробности в Консоли.",
|
||||
"loading_stall_title": "Занимает больше времени, чем ожидалось",
|
||||
"loading_transactions": "Загрузка транзакций",
|
||||
"local_hashrate": "Локальный хешрейт",
|
||||
"low_spec_mode": "Режим экономии",
|
||||
@@ -866,6 +900,7 @@
|
||||
"mining_difficulty_copied": "Сложность скопирована",
|
||||
"mining_est_block": "Расч. блок",
|
||||
"mining_est_daily": "Расч. за день",
|
||||
"mining_est_daily_pool_sub": "примерный соло-эквивалент, до комиссии пула",
|
||||
"mining_filter_all": "Все",
|
||||
"mining_filter_tip_all": "Показать все доходы",
|
||||
"mining_filter_tip_pool": "Показать только доходы пула",
|
||||
@@ -894,10 +929,12 @@
|
||||
"mining_open_in_explorer": "Открыть в обозревателе",
|
||||
"mining_payout_address": "Адрес выплат",
|
||||
"mining_payout_foreign": "⚠ Этот адрес выплат отсутствует в вашем текущем кошельке — намайненные вознаграждения будут отправлены в другой кошелёк. Обновите его, если вы сменили кошелёк.",
|
||||
"mining_payout_invalid": "Недействительный адрес DragonX — исправьте перед запуском, иначе награды за майнинг будут потеряны.",
|
||||
"mining_payout_tooltip": "Адрес для получения вознаграждений за майнинг",
|
||||
"mining_pool": "Пул",
|
||||
"mining_pool_fee": "Комиссия",
|
||||
"mining_pool_hashrate": "Хешрейт пула",
|
||||
"mining_pool_needs_payout_tooltip": "Сначала введите адрес для выплат (создайте Z-адрес)",
|
||||
"mining_pool_url": "URL пула",
|
||||
"mining_pools_header": "ПУЛЫ",
|
||||
"mining_recent_blocks": "ПОСЛЕДНИЕ БЛОКИ",
|
||||
@@ -927,6 +964,9 @@
|
||||
"mining_syncing_tooltip": "Блокчейн синхронизируется...",
|
||||
"mining_tag": " · Майнинг",
|
||||
"mining_threads": "Потоки майнинга",
|
||||
"mining_threads_input_tooltip": "Введите точное число потоков (Enter для применения)",
|
||||
"mining_threads_minus_tooltip": "Меньше потоков",
|
||||
"mining_threads_plus_tooltip": "Больше потоков",
|
||||
"mining_to_save": "для сохранения",
|
||||
"mining_today": "Сегодня",
|
||||
"mining_uptime": "Время работы",
|
||||
@@ -953,6 +993,11 @@
|
||||
"no_transactions": "Транзакции не найдены",
|
||||
"no_transactions_yet": "Транзакций пока нет",
|
||||
"node": "УЗЕЛ",
|
||||
"node_banner_crashed_title": "Узел неожиданно остановился",
|
||||
"node_banner_lite_open_failed": "Не удалось открыть кошелёк",
|
||||
"node_banner_offline_title": "Нет подключения к узлу DragonX",
|
||||
"node_banner_reconnect": "Переподключить",
|
||||
"node_banner_restart": "Перезапустить узел",
|
||||
"node_security": "УЗЕЛ И БЕЗОПАСНОСТЬ",
|
||||
"noise": "Шум",
|
||||
"not_connected": "Не подключено к daemon...",
|
||||
@@ -1091,6 +1136,8 @@
|
||||
"qr_failed": "Не удалось сгенерировать QR-код",
|
||||
"qr_title": "QR-код",
|
||||
"qr_unavailable": "QR недоступен",
|
||||
"quick_receive": "Быстрый приём",
|
||||
"quick_send": "Быстрая отправка",
|
||||
"ram_daemon_gb": "Демон: %.1f ГБ (%s)",
|
||||
"ram_daemon_mb": "Демон: %.0f МБ (%s)",
|
||||
"ram_system_gb": "Система: %.1f / %.0f ГБ",
|
||||
@@ -1140,6 +1187,7 @@
|
||||
"rpc_connection": "RPC-подключение...",
|
||||
"rpc_host": "RPC-хост",
|
||||
"rpc_pass": "Пароль",
|
||||
"rpc_plaintext_remote_warning": "Удалённый RPC использует незашифрованный HTTP. Добавьте rpctls=1 в DRAGONX.conf, если ваш демон поддерживает TLS.",
|
||||
"rpc_port": "Порт",
|
||||
"rpc_user": "Имя пользователя",
|
||||
"save": "Сохранить",
|
||||
@@ -1154,6 +1202,8 @@
|
||||
"sb_connecting_external": "Подключение к внешнему демону...",
|
||||
"sb_connecting_generic": "Подключение к демону...",
|
||||
"sb_daemon_crashed": "Демон упал %d раз",
|
||||
"sb_daemon_extract_failed": "Не удалось записать файлы демона — проверьте свободное место на диске и права доступа.",
|
||||
"sb_daemon_files_failed": "Не удалось записать файлы демона в %s — проверьте свободное место на диске и права доступа.",
|
||||
"sb_daemon_not_found": "Демон не найден",
|
||||
"sb_daemon_start_failed": "Не удалось запустить dragonxd",
|
||||
"sb_dragonxd_running": "dragonxd запущен",
|
||||
@@ -1169,6 +1219,7 @@
|
||||
"sb_net_mhs": "Сеть: %.2f MH/s",
|
||||
"sb_no_conf": "DRAGONX.conf не найден",
|
||||
"sb_peers": "Пиры: %zu",
|
||||
"sb_plaintext_remote_blocked": "Отправка учётных данных RPC открытым текстом на удалённый узел запрещена. Добавьте rpcallowplaintext=1 в DRAGONX.conf, чтобы разрешить, или включите TLS с помощью rpctls=1.",
|
||||
"sb_rescanning": "Пересканирование",
|
||||
"sb_rescanning_pct": "Пересканирование %.0f%%",
|
||||
"sb_restarting_daemon": "Перезапуск демона...",
|
||||
@@ -1182,6 +1233,7 @@
|
||||
"sb_waiting_daemon_err": "Ожидание dragonxd — %s",
|
||||
"sb_warming_up": "Прогрев...",
|
||||
"sb_witness_cache": "Перестроение свидетелей",
|
||||
"scale_effects": "МАСШТАБ И ЭФФЕКТЫ",
|
||||
"screenshot_open_dir": "Открыть расположение",
|
||||
"screenshot_sweep": "Запустить прогон скриншотов",
|
||||
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
|
||||
@@ -1246,6 +1298,7 @@
|
||||
"send_tooltip_not_connected": "Не подключено к daemon",
|
||||
"send_tooltip_select_source": "Сначала выберите адрес-источник",
|
||||
"send_tooltip_syncing": "Дождитесь синхронизации блокчейна",
|
||||
"send_tooltip_view_only": "Адрес только для просмотра — нет ключа расходования, отправка невозможна",
|
||||
"send_total": "Итого",
|
||||
"send_transaction": "Отправить транзакцию",
|
||||
"send_tx_failed": "Транзакция не удалась",
|
||||
@@ -1265,16 +1318,16 @@
|
||||
"sent_filter": "Отправлено",
|
||||
"sent_type": "Отправлено",
|
||||
"sent_upper": "ОТПРАВЛЕНО",
|
||||
"set_label": "Установить метку...",
|
||||
"set_label": "Установить метку",
|
||||
"settings": "Настройки",
|
||||
"settings_about_text": "Защищённый криптовалютный кошелёк для DragonX (DRGX), созданный на Dear ImGui для лёгкого и портативного использования.",
|
||||
"settings_acrylic_level": "Уровень акрила:",
|
||||
"settings_address_book": "Адресная книга...",
|
||||
"settings_address_book": "Адресная книга…",
|
||||
"settings_auto_detected": "Автоопределено из DRAGONX.conf",
|
||||
"settings_auto_lock": "АВТОБЛОКИРОВКА",
|
||||
"settings_auto_shield_desc": "Автоматически перемещать прозрачные средства на экранированные адреса",
|
||||
"settings_auto_shield_funds": "Автоматически экранировать прозрачные средства",
|
||||
"settings_backup": "Резервная копия...",
|
||||
"settings_backup": "Резервная копия…",
|
||||
"settings_block_explorer_urls": "URL-адреса обозревателя блоков",
|
||||
"settings_builtin": "Встроенные",
|
||||
"settings_change_passphrase": "Сменить пароль",
|
||||
@@ -1285,60 +1338,71 @@
|
||||
"settings_configure_explorer": "Настроить ссылки внешнего обозревателя блоков",
|
||||
"settings_configure_rpc": "Настроить подключение к демону dragonxd",
|
||||
"settings_connection": "Подключение",
|
||||
"settings_copy_diagnostics": "Копировать диагностику",
|
||||
"settings_copyright": "Copyright 2024-2026 Разработчики DragonX | Лицензия GPLv3",
|
||||
"settings_custom": "Пользовательские",
|
||||
"settings_data_dir": "Каталог данных:",
|
||||
"settings_data_dir": "Каталог данных",
|
||||
"settings_debug_changed": "Категории отладки изменены — перезапустите демон для применения",
|
||||
"settings_debug_restart_note": "Изменения вступают в силу после перезапуска демона.",
|
||||
"settings_debug_select": "Выберите категории для включения журнала отладки демона (флаги -debug=).",
|
||||
"settings_diagnostics_copied": "Диагностика скопирована в буфер обмена",
|
||||
"settings_encrypt_first_pin": "Сначала зашифруйте кошелёк, чтобы включить PIN",
|
||||
"settings_encrypt_wallet": "Зашифровать кошелёк",
|
||||
"settings_explorer_hint": "URL-адреса должны заканчиваться косой чертой. Txid/адрес будет добавлен.",
|
||||
"settings_export_all": "Экспортировать все...",
|
||||
"settings_export_csv": "Экспорт CSV...",
|
||||
"settings_export_key": "Экспортировать ключ...",
|
||||
"settings_export_all": "Экспортировать все…",
|
||||
"settings_export_csv": "Экспорт CSV…",
|
||||
"settings_export_key": "Экспортировать ключ…",
|
||||
"settings_gradient_bg": "Градиент фона",
|
||||
"settings_gradient_desc": "Заменить текстурные фоны плавными градиентами",
|
||||
"settings_idle_after": "через",
|
||||
"settings_import_key": "Импорт приватного ключа...",
|
||||
"settings_import_viewkey": "Импортировать ключ просмотра...",
|
||||
"settings_import_key": "Импорт приватного ключа…",
|
||||
"settings_import_viewkey": "Импортировать ключ просмотра…",
|
||||
"settings_language_note": "Примечание: Некоторый текст требует перезапуска для обновления",
|
||||
"settings_lock_now": "Заблокировать сейчас",
|
||||
"settings_locked": "Заблокирован",
|
||||
"settings_merge_to_address": "Объединить на адрес...",
|
||||
"settings_merge_to_address": "Объединить на адрес…",
|
||||
"settings_noise_opacity": "Непрозрачность шума:",
|
||||
"settings_not_connected": "Нет соединения с демоном",
|
||||
"settings_not_encrypted": "Не зашифрован",
|
||||
"settings_not_found": "Не найден",
|
||||
"settings_open_app_dir": "Открыть папку приложения",
|
||||
"settings_open_data_dir": "Открыть папку данных",
|
||||
"settings_open_log_folder": "Открыть папку журналов",
|
||||
"settings_other": "Прочее",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "Конфиденциальность",
|
||||
"settings_quick_unlock_pin": "Быстрый PIN-код разблокировки",
|
||||
"settings_reduce_transparency": "Уменьшить прозрачность",
|
||||
"settings_reloaded": "Настройки перезагружены с диска",
|
||||
"settings_remove_encryption": "Удалить шифрование",
|
||||
"settings_remove_pin": "Удалить PIN",
|
||||
"settings_request_payment": "Запросить платёж...",
|
||||
"settings_request_payment": "Запросить платёж…",
|
||||
"settings_rescan_desc": "Пересканировать блокчейн для поиска пропущенных транзакций",
|
||||
"settings_restart_daemon": "Перезапустить демон",
|
||||
"settings_rpc_connection": "RPC-соединение",
|
||||
"settings_rpc_error_prefix": "Ошибка RPC: ",
|
||||
"settings_rpc_note": "Примечание: Настройки подключения обычно определяются автоматически из DRAGONX.conf",
|
||||
"settings_rpc_ok": "RPC-соединение в порядке",
|
||||
"settings_save_shielded_desc": "Сохраняет z-addr транзакции в локальном файле для просмотра",
|
||||
"settings_save_shielded_local": "Сохранять историю защищённых транзакций локально",
|
||||
"settings_saved": "Настройки сохранены",
|
||||
"settings_set_pin": "Установить PIN",
|
||||
"settings_shield_mining": "Экранировать майнинг...",
|
||||
"settings_shield_mining": "Экранировать майнинг…",
|
||||
"settings_solid_colors_desc": "Использовать сплошные цвета вместо эффектов размытия (доступность)",
|
||||
"settings_theme_refreshed": "Список тем обновлён",
|
||||
"settings_tor_desc": "Маршрутизировать все соединения через Tor для повышения конфиденциальности",
|
||||
"settings_unlocked": "Разблокирован",
|
||||
"settings_use_tor_network": "Использовать Tor для сетевых подключений",
|
||||
"settings_validate_address": "Проверить адрес...",
|
||||
"settings_validate_address": "Проверить адрес…",
|
||||
"settings_visual_effects": "Визуальные эффекты",
|
||||
"settings_wallet_file_size": "Размер файла кошелька: %s",
|
||||
"settings_wallet_info": "Информация о кошельке",
|
||||
"settings_wallet_location": "Расположение кошелька: %s",
|
||||
"settings_wallet_maintenance": "Обслуживание кошелька",
|
||||
"settings_wallet_not_found": "Файл кошелька не найден",
|
||||
"settings_wallet_size_label": "Размер кошелька:",
|
||||
"settings_wallet_size_label": "Размер кошелька",
|
||||
"settings_ztx_cleared": "История Z-транзакций очищена",
|
||||
"settings_ztx_not_found": "Файл истории не найден",
|
||||
"setup_wizard": "Мастер настройки",
|
||||
"share": "Поделиться",
|
||||
"shield_check_status": "Проверить статус",
|
||||
@@ -1421,6 +1485,7 @@
|
||||
"theme": "Тема",
|
||||
"theme_effects": "Эффекты темы",
|
||||
"theme_language": "ТЕМА И ЯЗЫК",
|
||||
"tile_click_to_open": "Нажмите, чтобы открыть",
|
||||
"time_days_ago": "%d дней назад",
|
||||
"time_hours_ago": "%d часов назад",
|
||||
"time_minutes_ago": "%d минут назад",
|
||||
@@ -1435,7 +1500,9 @@
|
||||
"to_upper": "КОМУ",
|
||||
"tools": "УТИЛИТЫ",
|
||||
"tools_actions": "Инструменты и действия...",
|
||||
"tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ",
|
||||
"total": "Итого",
|
||||
"total_balance_label": "Общий баланс",
|
||||
"transaction_id": "ID ТРАНЗАКЦИИ",
|
||||
"transaction_sent": "Транзакция успешно отправлена",
|
||||
"transaction_sent_msg": "Транзакция отправлена!",
|
||||
@@ -1457,7 +1524,7 @@
|
||||
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
|
||||
"tt_backup": "Создать резервную копию вашего wallet.dat",
|
||||
"tt_block_explorer": "Открыть обозреватель блоков DragonX в браузере",
|
||||
"tt_blur": "Степень размытия (0%% = выкл., 100%% = максимум)",
|
||||
"tt_blur": "Степень размытия (0% = выкл., 100% = максимум)",
|
||||
"tt_change_pass": "Сменить пароль шифрования кошелька",
|
||||
"tt_change_pin": "Изменить PIN-код разблокировки",
|
||||
"tt_chat_bubble_accent": "Акцентный цвет для ваших исходящих пузырьков сообщений (или следовать текущей теме)",
|
||||
@@ -1470,6 +1537,7 @@
|
||||
"tt_chat_timestamp": "Формат времени только для этой вкладки: следовать общим настройкам часов приложения либо принудительно 24-hour или 12-hour",
|
||||
"tt_clear_ztx": "Удалить локально кешированную историю z-транзакций",
|
||||
"tt_clock_format": "24- или 12-часовой формат для всего приложения. Чат может переопределить.",
|
||||
"tt_copy_diagnostics": "Копирует сводку для поддержки (версия, состояние демона/кошелька/журналов — без секретов) в буфер обмена",
|
||||
"tt_custom_fees": "Включить ручной ввод комиссий при отправке транзакций",
|
||||
"tt_custom_theme": "Пользовательская тема активна",
|
||||
"tt_daemon_install_bundled": "Остановить узел, перезаписать установленный dragonxd версией, встроенной в эту сборку кошелька, затем перезапустить",
|
||||
@@ -1519,10 +1587,11 @@
|
||||
"tt_low_spec": "Отключить все тяжёлые визуальные эффекты\\nГорячая клавиша: Ctrl+Shift+Down",
|
||||
"tt_merge": "Объединить несколько UTXO в один адрес",
|
||||
"tt_mine_idle": "Автоматически начать майнинг при\\nпростое системы (нет ввода с клавиатуры/мыши)",
|
||||
"tt_noise": "Интенсивность зернистой текстуры (0%% = выкл., 100%% = максимум)",
|
||||
"tt_noise": "Интенсивность зернистой текстуры (0% = выкл., 100% = максимум)",
|
||||
"tt_open_app_dir": "Открыть папку ObsidianDragon (настройки, темы, логи) в файловом менеджере",
|
||||
"tt_open_data_dir": "Открыть в файловом менеджере папку с данными кошелька и блокчейна",
|
||||
"tt_open_dir": "Нажмите, чтобы открыть в проводнике",
|
||||
"tt_open_log_folder": "Открывает папку с журналами отладки и сбоев",
|
||||
"tt_reduce_motion": "Отключить анимированные переходы и плавное изменение баланса для доступности",
|
||||
"tt_remove_encrypt": "Удалить шифрование и хранить кошелёк без защиты",
|
||||
"tt_remove_pin": "Удалить PIN и требовать пароль для разблокировки",
|
||||
@@ -1557,7 +1626,7 @@
|
||||
"tt_theme_hotkey": "Горячая клавиша: Ctrl+Влево/Вправо для переключения тем",
|
||||
"tt_tor": "Маршрутизировать подключения демона через сеть Tor для анонимности",
|
||||
"tt_tx_url": "Базовый URL для просмотра транзакций в обозревателе блоков",
|
||||
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100%% = полностью непрозрачно, ниже = прозрачнее)",
|
||||
"tt_ui_opacity": "Непрозрачность карточек и боковой панели (100% = полностью непрозрачно, ниже = прозрачнее)",
|
||||
"tt_validate": "Проверить, действителен ли адрес DragonX",
|
||||
"tt_verbose": "Записывать подробную диагностику подключений,\\nсостояние демона и информацию о владельце порта\\nна вкладке Консоль",
|
||||
"tt_wallets_button": "Показать файлы кошельков и переключаться между ними",
|
||||
@@ -1610,6 +1679,7 @@
|
||||
"validate_not_mine": "Не принадлежит этому кошельку",
|
||||
"validate_ownership": "Принадлежность:",
|
||||
"validate_results": "Результаты:",
|
||||
"validate_results_placeholder": "Результаты появятся здесь",
|
||||
"validate_shielded_type": "Экранированный (z-адрес)",
|
||||
"validate_status": "Статус:",
|
||||
"validate_title": "Проверить адрес",
|
||||
@@ -1750,6 +1820,7 @@
|
||||
"xmrig_loading_releases": "Загрузка релизов…",
|
||||
"xmrig_none": "нет",
|
||||
"xmrig_reinstall": "Переустановить",
|
||||
"xmrig_releases": "релизы xmrig",
|
||||
"xmrig_stop_mining_first": "Остановите майнинг перед обновлением майнера.",
|
||||
"xmrig_unavailable_body": "Для этой платформы нет доступной сборки майнера.",
|
||||
"xmrig_unavailable_title": "Обновления майнера недоступны",
|
||||
|
||||
+84
-15
@@ -48,6 +48,10 @@
|
||||
"advanced": "高级",
|
||||
"advanced_effects": "高级特效...",
|
||||
"ago": "前",
|
||||
"alerts_clear": "清除通知历史",
|
||||
"alerts_history_tooltip": "最近通知",
|
||||
"alerts_none": "暂无通知",
|
||||
"alerts_recent": "最近通知",
|
||||
"all_filter": "全部",
|
||||
"allow_custom_fees": "允许自定义手续费",
|
||||
"amount": "金额",
|
||||
@@ -70,6 +74,9 @@
|
||||
"av_title": "Windows Defender 已阻止矿工程序",
|
||||
"available": "可用",
|
||||
"backup_backing_up": "正在备份...",
|
||||
"backup_col_backup": "备份",
|
||||
"backup_col_export": "导出",
|
||||
"backup_col_import": "导入与恢复",
|
||||
"backup_create": "创建备份",
|
||||
"backup_created": "钱包备份已创建",
|
||||
"backup_data": "备份与数据",
|
||||
@@ -88,7 +95,10 @@
|
||||
"balance": "余额",
|
||||
"balance_history_collecting": "余额历史——正在收集数据…",
|
||||
"balance_layout": "余额布局",
|
||||
"balance_layout_switched": "布局:%s",
|
||||
"balance_mining_rate": "挖矿中 %s",
|
||||
"balance_shielded_fmt": "屏蔽:%.8f",
|
||||
"balance_syncing_pct": "同步中 %.1f%%",
|
||||
"balance_transparent_fmt": "透明:%.8f",
|
||||
"ban": "封禁",
|
||||
"banned_peers": "已封禁节点",
|
||||
@@ -128,6 +138,7 @@
|
||||
"bootstrap_verifying": "正在验证校验和...",
|
||||
"bootstrap_wallet_protected": "(wallet.dat 已受保护)",
|
||||
"bootstrap_warning": "现有区块数据(blocks、chainstate、notarizations)将被删除并替换。您的 wallet.dat 不会被修改或删除。",
|
||||
"byte_count_fmt": "%zu / %zu 字节",
|
||||
"cancel": "取消",
|
||||
"change_pass_confirm": "确认新密码:",
|
||||
"change_pass_current": "当前密码短语:",
|
||||
@@ -415,6 +426,7 @@
|
||||
"daemon_bundled": "内置",
|
||||
"daemon_install_bundled": "安装内置版本",
|
||||
"daemon_installed": "已安装",
|
||||
"daemon_maintenance_label": "维护",
|
||||
"daemon_none_bundled": "此版本未内置",
|
||||
"daemon_not_installed": "未安装",
|
||||
"daemon_status_differ": "已安装的程序文件与内置版本不同。",
|
||||
@@ -437,6 +449,7 @@
|
||||
"daemon_update_latest": "最新:",
|
||||
"daemon_update_loading": "正在加载版本…",
|
||||
"daemon_update_now": "立即更新",
|
||||
"daemon_update_prompt_title": "是否更新节点守护进程?",
|
||||
"daemon_update_reinstall": "重新安装",
|
||||
"daemon_update_restart_note": "重启守护进程以开始运行新版本。",
|
||||
"daemon_update_restart_now": "立即重启守护进程",
|
||||
@@ -449,8 +462,11 @@
|
||||
"daemon_update_verify_note": "在安装前,会根据该版本发布的 SHA-256 和固定的 ed25519 签名对下载内容进行校验。",
|
||||
"daemon_update_verifying": "正在验证…",
|
||||
"daemon_update_version": "版本:",
|
||||
"daemon_updates_label": "更新",
|
||||
"daemon_version": "守护进程",
|
||||
"dark": "深色",
|
||||
"data_stale_prefix": "更新于",
|
||||
"data_stale_tooltip": "余额可能已过时 — 钱包最近未收到更新。请检查您的节点连接。",
|
||||
"date": "日期",
|
||||
"date_label": "日期:",
|
||||
"debug_logging": "调试日志",
|
||||
@@ -479,6 +495,17 @@
|
||||
"download_bootstrap": "下载引导程序",
|
||||
"dragonx_green": "DragonX(绿色)",
|
||||
"edit": "编辑",
|
||||
"empty_wallet_keys_suffix": "个密钥",
|
||||
"empty_wallet_open_manager": "打开钱包管理器",
|
||||
"empty_wallet_restore": "恢复我的钱包",
|
||||
"empty_wallet_salvage_body": "此钱包为空,因为先前的一次自动修复已将您的原始钱包作为备份保存到一旁。您的币几乎肯定在该备份中,并未丢失。恢复它即可重新加载您的资金——不会删除任何内容;当前文件会先被保存到一旁。",
|
||||
"empty_wallet_salvage_headline": "您的币安全地存放在备份文件中。",
|
||||
"empty_wallet_salvage_title": "您的钱包可能已被修复",
|
||||
"empty_wallet_warning_body": "此钱包没有地址也没有资金,但您的 DragonX 文件夹中的另一个钱包文件包含密钥。您的币很可能在其中,并未丢失。打开钱包管理器以切换到持有您资金的钱包。",
|
||||
"empty_wallet_warning_dismiss": "不再为此钱包提示",
|
||||
"empty_wallet_warning_dismiss_tip": "仅对当前钱包文件停止此提示。如果您以后切换到另一个空钱包,可能会再次提示。",
|
||||
"empty_wallet_warning_headline": "您可能打开了错误的钱包。",
|
||||
"empty_wallet_warning_title": "此钱包为空",
|
||||
"enc_confirm": "确认:",
|
||||
"enc_desc": "加密钱包会用密码短语保护您的私钥。加密后,守护进程将重新启动。",
|
||||
"enc_encrypting": "正在加密钱包...",
|
||||
@@ -640,6 +667,7 @@
|
||||
"light": "浅色",
|
||||
"lite_account_label": "账户",
|
||||
"lite_action": "操作",
|
||||
"lite_backend_unavailable": "轻量钱包后端不可用",
|
||||
"lite_backup_keys": "备份与密钥",
|
||||
"lite_birthday_backup": "生日区块:%llu (也请一并备份)",
|
||||
"lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。",
|
||||
@@ -647,9 +675,12 @@
|
||||
"lite_console_backend_commands": "后端命令:",
|
||||
"lite_console_help_passthrough": "其他任何输入都将作为轻钱包控制台命令运行。",
|
||||
"lite_copy": "复制",
|
||||
"lite_could_not_start": "无法启动该操作",
|
||||
"lite_could_not_write": "无法写入 ",
|
||||
"lite_encrypt_wallet": "加密钱包",
|
||||
"lite_encryption_removed": "已移除加密",
|
||||
"lite_enter_all_seed_words": "请输入全部 24 个助记词以恢复(已输入 %d 个)",
|
||||
"lite_enter_wallet_path": "请输入钱包路径",
|
||||
"lite_hide_wipe": "隐藏并清除",
|
||||
"lite_import": "导入",
|
||||
"lite_import_key_label": "导入密钥",
|
||||
@@ -734,6 +765,8 @@
|
||||
"lite_working": "处理中…",
|
||||
"loading": "加载中...",
|
||||
"loading_addresses": "正在加载地址...",
|
||||
"loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。",
|
||||
"loading_stall_title": "耗时超出预期",
|
||||
"loading_transactions": "正在加载交易",
|
||||
"local_hashrate": "本地算力",
|
||||
"low_spec_mode": "低配模式",
|
||||
@@ -866,6 +899,7 @@
|
||||
"mining_difficulty_copied": "难度已复制",
|
||||
"mining_est_block": "预计区块",
|
||||
"mining_est_daily": "预计日收益",
|
||||
"mining_est_daily_pool_sub": "粗略的单人挖矿等值,扣除矿池费用前",
|
||||
"mining_filter_all": "全部",
|
||||
"mining_filter_tip_all": "显示所有收益",
|
||||
"mining_filter_tip_pool": "仅显示矿池收益",
|
||||
@@ -894,10 +928,12 @@
|
||||
"mining_open_in_explorer": "在浏览器中打开",
|
||||
"mining_payout_address": "支付地址",
|
||||
"mining_payout_foreign": "⚠ 此支付地址不在您当前的钱包中——挖矿奖励将进入另一个钱包。如果您切换过钱包,请更新它。",
|
||||
"mining_payout_invalid": "不是有效的 DragonX 地址——启动前请更正,否则挖矿奖励将丢失。",
|
||||
"mining_payout_tooltip": "接收挖矿奖励的地址",
|
||||
"mining_pool": "矿池",
|
||||
"mining_pool_fee": "费用",
|
||||
"mining_pool_hashrate": "矿池算力",
|
||||
"mining_pool_needs_payout_tooltip": "请先输入收款地址(生成一个 Z 地址)",
|
||||
"mining_pool_url": "矿池 URL",
|
||||
"mining_pools_header": "矿池",
|
||||
"mining_recent_blocks": "最近区块",
|
||||
@@ -927,6 +963,9 @@
|
||||
"mining_syncing_tooltip": "区块链同步中...",
|
||||
"mining_tag": " · 挖矿",
|
||||
"mining_threads": "挖矿线程",
|
||||
"mining_threads_input_tooltip": "输入精确的线程数(按 Enter 应用)",
|
||||
"mining_threads_minus_tooltip": "减少线程",
|
||||
"mining_threads_plus_tooltip": "增加线程",
|
||||
"mining_to_save": "保存",
|
||||
"mining_today": "今天",
|
||||
"mining_uptime": "运行时间",
|
||||
@@ -953,6 +992,11 @@
|
||||
"no_transactions": "未找到交易",
|
||||
"no_transactions_yet": "尚无交易",
|
||||
"node": "节点",
|
||||
"node_banner_crashed_title": "节点意外停止",
|
||||
"node_banner_lite_open_failed": "无法打开您的钱包",
|
||||
"node_banner_offline_title": "未连接到 DragonX 节点",
|
||||
"node_banner_reconnect": "重新连接",
|
||||
"node_banner_restart": "重启节点",
|
||||
"node_security": "节点与安全",
|
||||
"noise": "噪点",
|
||||
"not_connected": "未连接到守护进程...",
|
||||
@@ -1091,6 +1135,8 @@
|
||||
"qr_failed": "无法生成二维码",
|
||||
"qr_title": "二维码",
|
||||
"qr_unavailable": "二维码不可用",
|
||||
"quick_receive": "快速接收",
|
||||
"quick_send": "快速发送",
|
||||
"ram_daemon_gb": "守护进程:%.1f GB (%s)",
|
||||
"ram_daemon_mb": "守护进程:%.0f MB (%s)",
|
||||
"ram_system_gb": "系统:%.1f / %.0f GB",
|
||||
@@ -1140,6 +1186,7 @@
|
||||
"rpc_connection": "RPC 连接...",
|
||||
"rpc_host": "RPC 主机",
|
||||
"rpc_pass": "密码",
|
||||
"rpc_plaintext_remote_warning": "远程 RPC 正在使用明文 HTTP。如果您的守护进程支持 TLS,请在 DRAGONX.conf 中添加 rpctls=1。",
|
||||
"rpc_port": "端口",
|
||||
"rpc_user": "用户名",
|
||||
"save": "保存",
|
||||
@@ -1154,6 +1201,8 @@
|
||||
"sb_connecting_external": "正在连接外部守护进程...",
|
||||
"sb_connecting_generic": "正在连接守护进程...",
|
||||
"sb_daemon_crashed": "守护进程崩溃 %d 次",
|
||||
"sb_daemon_extract_failed": "无法写入守护进程文件——请检查磁盘剩余空间和权限。",
|
||||
"sb_daemon_files_failed": "无法将守护进程文件写入 %s——请检查磁盘剩余空间和权限。",
|
||||
"sb_daemon_not_found": "未找到守护进程",
|
||||
"sb_daemon_start_failed": "无法启动 dragonxd",
|
||||
"sb_dragonxd_running": "dragonxd 运行中",
|
||||
@@ -1182,6 +1231,7 @@
|
||||
"sb_waiting_daemon_err": "等待 dragonxd — %s",
|
||||
"sb_warming_up": "正在预热...",
|
||||
"sb_witness_cache": "正在重建见证",
|
||||
"scale_effects": "缩放与效果",
|
||||
"screenshot_open_dir": "打开位置",
|
||||
"screenshot_sweep": "运行截图批处理",
|
||||
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
|
||||
@@ -1246,6 +1296,7 @@
|
||||
"send_tooltip_not_connected": "未连接到守护进程",
|
||||
"send_tooltip_select_source": "请先选择来源地址",
|
||||
"send_tooltip_syncing": "请等待区块链同步",
|
||||
"send_tooltip_view_only": "仅查看地址 — 无花费密钥,无法发送",
|
||||
"send_total": "合计",
|
||||
"send_transaction": "发送交易",
|
||||
"send_tx_failed": "交易失败",
|
||||
@@ -1265,16 +1316,16 @@
|
||||
"sent_filter": "已发送",
|
||||
"sent_type": "已发送",
|
||||
"sent_upper": "已发送",
|
||||
"set_label": "设置标签...",
|
||||
"set_label": "设置标签",
|
||||
"settings": "设置",
|
||||
"settings_about_text": "DragonX (DRGX) 屏蔽加密货币钱包,使用 Dear ImGui 构建,提供轻量、便携的体验。",
|
||||
"settings_acrylic_level": "亚克力级别:",
|
||||
"settings_address_book": "地址簿...",
|
||||
"settings_address_book": "地址簿…",
|
||||
"settings_auto_detected": "从 DRAGONX.conf 自动检测",
|
||||
"settings_auto_lock": "自动锁定",
|
||||
"settings_auto_shield_desc": "自动将透明资金转移到屏蔽地址",
|
||||
"settings_auto_shield_funds": "自动屏蔽透明资金",
|
||||
"settings_backup": "备份...",
|
||||
"settings_backup": "备份…",
|
||||
"settings_block_explorer_urls": "区块浏览器网址",
|
||||
"settings_builtin": "内置",
|
||||
"settings_change_passphrase": "更改密码",
|
||||
@@ -1285,53 +1336,62 @@
|
||||
"settings_configure_explorer": "配置外部区块浏览器链接",
|
||||
"settings_configure_rpc": "配置 dragonxd 守护进程连接",
|
||||
"settings_connection": "连接",
|
||||
"settings_copy_diagnostics": "复制诊断信息",
|
||||
"settings_copyright": "版权所有 2024-2026 DragonX 开发者 | GPLv3 许可证",
|
||||
"settings_custom": "自定义",
|
||||
"settings_data_dir": "数据目录:",
|
||||
"settings_debug_changed": "调试类别已更改——重启守护进程以应用",
|
||||
"settings_debug_restart_note": "更改将在重启守护进程后生效。",
|
||||
"settings_debug_select": "选择要启用的守护进程调试日志类别(-debug= 标志)。",
|
||||
"settings_diagnostics_copied": "诊断信息已复制到剪贴板",
|
||||
"settings_encrypt_first_pin": "请先加密钱包以启用 PIN",
|
||||
"settings_encrypt_wallet": "加密钱包",
|
||||
"settings_explorer_hint": "URL 应包含尾部斜杠。将自动附加 txid/地址。",
|
||||
"settings_export_all": "全部导出...",
|
||||
"settings_export_csv": "导出 CSV...",
|
||||
"settings_export_key": "导出密钥...",
|
||||
"settings_export_all": "全部导出…",
|
||||
"settings_export_csv": "导出 CSV…",
|
||||
"settings_export_key": "导出密钥…",
|
||||
"settings_gradient_bg": "渐变背景",
|
||||
"settings_gradient_desc": "用平滑渐变替换纹理背景",
|
||||
"settings_idle_after": "之后",
|
||||
"settings_import_key": "导入私钥...",
|
||||
"settings_import_viewkey": "导入查看密钥...",
|
||||
"settings_import_key": "导入私钥…",
|
||||
"settings_import_viewkey": "导入查看密钥…",
|
||||
"settings_language_note": "注意:部分文本需要重启才能更新",
|
||||
"settings_lock_now": "立即锁定",
|
||||
"settings_locked": "已锁定",
|
||||
"settings_merge_to_address": "合并到地址...",
|
||||
"settings_merge_to_address": "合并到地址…",
|
||||
"settings_noise_opacity": "噪点不透明度:",
|
||||
"settings_not_connected": "未连接到守护进程",
|
||||
"settings_not_encrypted": "未加密",
|
||||
"settings_not_found": "未找到",
|
||||
"settings_open_app_dir": "打开应用文件夹",
|
||||
"settings_open_data_dir": "打开数据文件夹",
|
||||
"settings_open_log_folder": "打开日志文件夹",
|
||||
"settings_other": "其他",
|
||||
"settings_pin_active": "PIN",
|
||||
"settings_privacy": "隐私",
|
||||
"settings_quick_unlock_pin": "快速解锁 PIN",
|
||||
"settings_reduce_transparency": "降低透明度",
|
||||
"settings_reloaded": "已从磁盘重新加载设置",
|
||||
"settings_remove_encryption": "移除加密",
|
||||
"settings_remove_pin": "移除 PIN",
|
||||
"settings_request_payment": "请求付款...",
|
||||
"settings_request_payment": "请求付款…",
|
||||
"settings_rescan_desc": "重新扫描区块链以查找丢失的交易",
|
||||
"settings_restart_daemon": "重启守护进程",
|
||||
"settings_rpc_connection": "RPC 连接",
|
||||
"settings_rpc_error_prefix": "RPC 错误:",
|
||||
"settings_rpc_note": "注意:连接设置通常从 DRAGONX.conf 自动检测",
|
||||
"settings_rpc_ok": "RPC 连接正常",
|
||||
"settings_save_shielded_desc": "将 z-addr 交易存储在本地文件中以供查看",
|
||||
"settings_save_shielded_local": "将屏蔽交易历史保存到本地",
|
||||
"settings_saved": "设置已保存",
|
||||
"settings_set_pin": "设置 PIN",
|
||||
"settings_shield_mining": "屏蔽挖矿...",
|
||||
"settings_shield_mining": "屏蔽挖矿…",
|
||||
"settings_solid_colors_desc": "使用纯色代替模糊效果(无障碍功能)",
|
||||
"settings_theme_refreshed": "主题列表已刷新",
|
||||
"settings_tor_desc": "通过 Tor 路由所有连接以增强隐私",
|
||||
"settings_unlocked": "已解锁",
|
||||
"settings_use_tor_network": "使用 Tor 进行网络连接",
|
||||
"settings_validate_address": "验证地址...",
|
||||
"settings_validate_address": "验证地址…",
|
||||
"settings_visual_effects": "视觉效果",
|
||||
"settings_wallet_file_size": "钱包文件大小:%s",
|
||||
"settings_wallet_info": "钱包信息",
|
||||
@@ -1339,6 +1399,8 @@
|
||||
"settings_wallet_maintenance": "钱包维护",
|
||||
"settings_wallet_not_found": "未找到钱包文件",
|
||||
"settings_wallet_size_label": "钱包大小:",
|
||||
"settings_ztx_cleared": "Z 交易历史记录已清除",
|
||||
"settings_ztx_not_found": "未找到历史记录文件",
|
||||
"setup_wizard": "设置向导",
|
||||
"share": "分享",
|
||||
"shield_check_status": "检查状态",
|
||||
@@ -1421,6 +1483,7 @@
|
||||
"theme": "主题",
|
||||
"theme_effects": "主题效果",
|
||||
"theme_language": "主题与语言",
|
||||
"tile_click_to_open": "点击打开",
|
||||
"time_days_ago": "%d 天前",
|
||||
"time_hours_ago": "%d 小时前",
|
||||
"time_minutes_ago": "%d 分钟前",
|
||||
@@ -1435,7 +1498,9 @@
|
||||
"to_upper": "至",
|
||||
"tools": "工具",
|
||||
"tools_actions": "工具与操作...",
|
||||
"tools_actions_hdr": "工具与操作",
|
||||
"total": "合计",
|
||||
"total_balance_label": "总余额",
|
||||
"transaction_id": "交易 ID",
|
||||
"transaction_sent": "交易发送成功",
|
||||
"transaction_sent_msg": "交易已发送!",
|
||||
@@ -1457,7 +1522,7 @@
|
||||
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
|
||||
"tt_backup": "创建 wallet.dat 的备份",
|
||||
"tt_block_explorer": "在浏览器中打开 DragonX 区块浏览器",
|
||||
"tt_blur": "模糊程度(0%% = 关闭,100%% = 最大)",
|
||||
"tt_blur": "模糊程度(0% = 关闭,100% = 最大)",
|
||||
"tt_change_pass": "更改钱包加密密码",
|
||||
"tt_change_pin": "更改您的解锁 PIN",
|
||||
"tt_chat_bubble_accent": "你发出的消息气泡的强调色(或跟随当前主题)",
|
||||
@@ -1470,6 +1535,7 @@
|
||||
"tt_chat_timestamp": "仅此标签页的时间戳格式:跟随全应用时钟,或强制使用 24-hour 或 12-hour",
|
||||
"tt_clear_ztx": "删除本地缓存的 z-交易历史",
|
||||
"tt_clock_format": "24 或 12 小时制,应用全局。聊天可覆盖。",
|
||||
"tt_copy_diagnostics": "将支持诊断摘要(版本、守护进程/钱包/日志状态 — 不含机密)复制到剪贴板",
|
||||
"tt_custom_fees": "发送交易时启用手动费用输入",
|
||||
"tt_custom_theme": "自定义主题已激活",
|
||||
"tt_daemon_install_bundled": "停止节点,用此钱包版本内置的 dragonxd 覆盖已安装的版本,然后重启",
|
||||
@@ -1519,10 +1585,11 @@
|
||||
"tt_low_spec": "禁用所有重度视觉效果\\n快捷键:Ctrl+Shift+Down",
|
||||
"tt_merge": "将多个 UTXO 合并到一个地址",
|
||||
"tt_mine_idle": "系统空闲时自动开始挖矿\\n(无键盘/鼠标输入)",
|
||||
"tt_noise": "颗粒纹理强度(0%% = 关闭,100%% = 最大)",
|
||||
"tt_noise": "颗粒纹理强度(0% = 关闭,100% = 最大)",
|
||||
"tt_open_app_dir": "在文件管理器中打开 ObsidianDragon 文件夹(设置、主题、日志)",
|
||||
"tt_open_data_dir": "在文件管理器中打开包含您钱包和区块链数据的文件夹",
|
||||
"tt_open_dir": "点击在文件管理器中打开",
|
||||
"tt_open_log_folder": "打开包含调试和崩溃日志的文件夹",
|
||||
"tt_reduce_motion": "禁用动画过渡和余额渐变以提高无障碍性",
|
||||
"tt_remove_encrypt": "移除加密并以未受保护状态存储钱包",
|
||||
"tt_remove_pin": "移除 PIN 并要求密码解锁",
|
||||
@@ -1557,7 +1624,7 @@
|
||||
"tt_theme_hotkey": "快捷键:Ctrl+左/右箭头切换主题",
|
||||
"tt_tor": "通过 Tor 网络路由守护进程连接以实现匿名",
|
||||
"tt_tx_url": "在区块浏览器中查看交易的基础 URL",
|
||||
"tt_ui_opacity": "卡片和侧边栏不透明度(100%% = 完全不透明,越低越透明)",
|
||||
"tt_ui_opacity": "卡片和侧边栏不透明度(100% = 完全不透明,越低越透明)",
|
||||
"tt_validate": "检查 DragonX 地址是否有效",
|
||||
"tt_verbose": "将详细连接诊断、守护进程状态\\n和端口所有者信息记录到控制台选项卡",
|
||||
"tt_wallets_button": "列出您的钱包文件并在它们之间切换",
|
||||
@@ -1610,6 +1677,7 @@
|
||||
"validate_not_mine": "不属于此钱包",
|
||||
"validate_ownership": "所有权:",
|
||||
"validate_results": "结果:",
|
||||
"validate_results_placeholder": "结果将显示在此处",
|
||||
"validate_shielded_type": "屏蔽(z 地址)",
|
||||
"validate_status": "状态:",
|
||||
"validate_title": "验证地址",
|
||||
@@ -1750,6 +1818,7 @@
|
||||
"xmrig_loading_releases": "正在加载发行版…",
|
||||
"xmrig_none": "无",
|
||||
"xmrig_reinstall": "重新安装",
|
||||
"xmrig_releases": "xmrig 版本",
|
||||
"xmrig_stop_mining_first": "更新矿工程序前请先停止挖矿。",
|
||||
"xmrig_unavailable_body": "此平台没有可用的矿工构建版本。",
|
||||
"xmrig_unavailable_title": "矿工更新不可用",
|
||||
|
||||
@@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 }
|
||||
status-pill-y-offset = { size = 1 }
|
||||
confirmed-threshold = { size = 10 }
|
||||
|
||||
# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner).
|
||||
# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action.
|
||||
[banners.node-status]
|
||||
min-height = { size = 26.0 }
|
||||
height = { size = 30.0 }
|
||||
|
||||
[tabs.transactions]
|
||||
search-max-width = 300.0
|
||||
search-width-ratio = 0.3
|
||||
@@ -1503,6 +1509,7 @@ progress-bar = { height = 6.0, radius = 3.0 }
|
||||
progress-width = { size = 260.0 }
|
||||
backdrop-alpha = { opacity = 0.80 }
|
||||
vertical-gap = { size = 8.0 }
|
||||
stall-timeout-sec = { size = 45.0 }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# First-Run Wizard Screens
|
||||
|
||||
+1485
-114
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <deque>
|
||||
#include <condition_variable>
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
@@ -71,14 +72,28 @@ enum class EncryptDialogPhase {
|
||||
Done // Finished — close dialog
|
||||
};
|
||||
|
||||
// A status string written by a background/worker thread and read every frame by the UI thread. Its
|
||||
// operator= locks, so all the plain `x = "..."` assignment sites stay unchanged; readers call get()
|
||||
// for a consistent per-frame snapshot instead of racing a non-atomic std::string. (M-05, L-06)
|
||||
class GuardedStatus {
|
||||
public:
|
||||
GuardedStatus() = default;
|
||||
GuardedStatus& operator=(std::string v) { std::lock_guard<std::mutex> lk(m_); v_ = std::move(v); return *this; }
|
||||
std::string get() const { std::lock_guard<std::mutex> lk(m_); return v_; }
|
||||
private:
|
||||
mutable std::mutex m_;
|
||||
std::string v_;
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Main application class
|
||||
*
|
||||
*
|
||||
* Manages application state, RPC connection, and coordinates UI rendering.
|
||||
*/
|
||||
class App {
|
||||
public:
|
||||
App();
|
||||
void wipeSecrets(); // scrub all resident secret buffers; called from ~App() AND the forced-exit path (L-05)
|
||||
~App();
|
||||
|
||||
// Non-copyable
|
||||
@@ -147,6 +162,17 @@ public:
|
||||
bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); }
|
||||
bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); }
|
||||
bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); }
|
||||
|
||||
// Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on
|
||||
// pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself".
|
||||
bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; }
|
||||
bool daemonAutoShieldActive() const { return daemon_autoshield_active_; }
|
||||
const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; }
|
||||
const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; }
|
||||
|
||||
// W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state)
|
||||
// for the "Copy diagnostics" action. Contains no secrets.
|
||||
std::string buildDiagnosticsReport();
|
||||
bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); }
|
||||
bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); }
|
||||
bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); }
|
||||
@@ -156,6 +182,21 @@ public:
|
||||
*/
|
||||
void renderShutdownScreen();
|
||||
|
||||
/**
|
||||
* @brief Tail the last N lines of the daemon's debug.log (best-effort, reads only the file tail).
|
||||
* Fallback for the shutdown screen when we have no captured stdout — e.g. an external daemon we
|
||||
* attached to rather than spawned — so the user can still see the node flushing/exiting.
|
||||
*/
|
||||
std::vector<std::string> tailDaemonDebugLog(int maxLines) const;
|
||||
|
||||
// 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
|
||||
* @param contentH Height of the content area child window
|
||||
@@ -389,6 +430,9 @@ public:
|
||||
// each under every skin. Output: <config>/screenshots-full/<surface>/<skin>.png + an index.
|
||||
void startFullUiSweep();
|
||||
std::string screenshotFullDir() const;
|
||||
// Debug option: restrict either sweep to just the currently-active theme instead of cycling all.
|
||||
bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; }
|
||||
void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; }
|
||||
bool isScreenshotSweeping() const { return screenshot_sweep_active_; }
|
||||
bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; }
|
||||
const std::string& screenshotSweepPath() const { return sweep_current_path_; }
|
||||
@@ -794,12 +838,21 @@ private:
|
||||
// One-time nudge: on a full-node wallet that has a mnemonic, remind the user (once per
|
||||
// install) to back up their seed phrase. Cheap early-outs keep it idle until it can act.
|
||||
void maybeRemindSeedBackup();
|
||||
void maybeWarnEmptyWalletWithFundedSiblings(); // full-node: empty active wallet + a funded sibling → warn once
|
||||
void maybeWarnLargeWallet(); // full-node: wallet.dat past bloat threshold → one-time toast + clickable alert
|
||||
void scanFundedSiblingsAsync(); // off-UI-thread probe of sibling wallet files
|
||||
static void scrubAndRemoveExport(const std::string& path); // zero + delete a plaintext key export (H-02)
|
||||
void sweepStaleDecryptExports(); // startup net: purge stale obsidiandecryptexport* files (H-02)
|
||||
|
||||
// Seed-wallet migration (Phase 1: create a new mnemonic wallet in isolation, no funds moved).
|
||||
void beginCreateSeedWallet(); // starts the isolated create on a background thread
|
||||
void pumpSeedMigration(); // main thread: pick up background progress/result each frame
|
||||
// Phase 2: sweep all legacy funds into the new wallet, then adopt it as the primary wallet.
|
||||
void refreshSeedMigrationBalance(); // query the legacy total (shown on the Sweep step)
|
||||
// W3-3: the terminal callback for the sweep opid, shared by the initial submit and a resume
|
||||
// re-track. `resumed` selects the failure behaviour: a fresh sweep that fails -> Error; a resumed
|
||||
// opid the daemon no longer knows (stale) -> back to the dismissable Sweep gate (re-check balance).
|
||||
std::function<void(bool, const std::string&)> makeSweepCompletionCallback(bool resumed);
|
||||
void beginSweepToSeedWallet(); // z_mergetoaddress ["ANY_TADDR","ANY_ZADDR"] -> dest
|
||||
void pollSweepStatus(); // Confirming step: poll sweep confirmations + legacy balance
|
||||
void beginAdoptSeedWallet(); // stop daemon -> swap wallet.dat -> restart with -rescan
|
||||
@@ -829,6 +882,16 @@ private:
|
||||
bool lite_startup_lock_checked_ = false;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
// Serialized async mining-control queue: xmrig start/stop (SIGTERM->SIGKILL->join, up to ~3s) run on
|
||||
// this dedicated FIFO thread instead of the render thread, so the UI never blocks and stop/start
|
||||
// ordering is preserved across the ~13 call sites. (M-03/L-06/L-08/L-09/L-13)
|
||||
std::thread mining_ctl_thread_;
|
||||
std::mutex mining_ctl_mutex_;
|
||||
std::condition_variable mining_ctl_cv_;
|
||||
std::deque<std::function<void()>> mining_ctl_queue_;
|
||||
bool mining_ctl_stop_ = false;
|
||||
void postMiningControl(std::function<void()> job); // enqueue a blocking xmrig op onto the FIFO thread
|
||||
void stopMiningControlThread(); // signal + join the control thread (shutdown)
|
||||
// Auto-balance runtime state (pool mining, full-node only). The service fetches
|
||||
// pool hashrates off-thread; the RNG drives the weighted-random pick.
|
||||
util::PoolStatsService pool_stats_service_;
|
||||
@@ -861,8 +924,13 @@ private:
|
||||
std::atomic<bool> shutting_down_{false};
|
||||
std::atomic<bool> shutdown_complete_{false};
|
||||
bool address_list_dirty_ = false; // P8: dedup rebuildAddressList
|
||||
std::string shutdown_status_;
|
||||
GuardedStatus shutdown_status_; // thread-safe: written by the shutdown thread, read by the UI (M-05)
|
||||
std::thread shutdown_thread_;
|
||||
// Confirm-before-stopping-daemon-mid-witness-rebuild guard (see beginShutdown / renderDaemonStopConfirm)
|
||||
bool pending_shutdown_confirm_ = false; // a quit is deferred, waiting to open the confirm modal
|
||||
bool daemon_stop_confirm_open_ = false; // the confirm modal is currently showing
|
||||
bool shutdown_confirmed_ = false; // user chose to proceed — bypass the guard on re-entry
|
||||
bool shutdown_keep_daemon_override_ = false; // user chose "keep node running" for this shutdown only
|
||||
float shutdown_timer_ = 0.0f;
|
||||
bool force_quit_confirm_ = false;
|
||||
std::chrono::steady_clock::time_point shutdown_start_time_;
|
||||
@@ -887,6 +955,49 @@ private:
|
||||
// sets these and defers to renderSwitchStopDaemonDialog; confirming re-calls switchToWallet(w, true).
|
||||
bool show_switch_stop_daemon_confirm_ = false;
|
||||
std::string pending_switch_wallet_file_;
|
||||
// Block-database recovery: set when the embedded node aborts because its block DB is unreadable
|
||||
// (a daemon-vs-chaindata format mismatch after an update, or a corrupt index). While set, the
|
||||
// connect loop STOPS crash-restarting into the same abort and offers a one-click reindex instead.
|
||||
bool block_db_reindex_available_ = false; // node needs its block DB rebuilt (gates restart loop)
|
||||
bool show_block_db_reindex_confirm_ = false; // auto-shown offer dialog
|
||||
// Wallet auto-recovery: the daemon moved wallet.dat to wallet.<ts>.bak and loaded a salvaged copy
|
||||
// (BDB-verify failure — often a false positive from stale/cross-platform env state). We warn loudly
|
||||
// so a possibly-incomplete salvaged wallet isn't mistaken for fund loss. Warned once per session.
|
||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||
bool wallet_degraded_ = false; // v1.3.0+ opened the wallet in DEGRADED mode (no new HD keys)
|
||||
bool wallet_degraded_warned_ = false; // guard: surface the degraded-mode notice once per session
|
||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||
// Complementary on-disk safety net for a salvage we DIDN'T witness this launch (happened on a prior
|
||||
// run, or under an external daemon whose startup output we never captured): if the active wallet loads
|
||||
// empty while a sibling wallet file in the datadir still holds keys, warn once so the user's funds
|
||||
// (likely in a wallet.<ts>.bak) aren't mistaken for loss. See maybeWarnEmptyWalletWithFundedSiblings().
|
||||
struct FundedSibling { std::string fileName; int transparentKeys = 0; int shieldedKeys = 0; };
|
||||
bool show_empty_wallet_warning_ = false; // auto-shown warning modal
|
||||
bool empty_wallet_warn_checked_ = false; // evaluated this wallet-open already (reset in onConnected)
|
||||
bool empty_wallet_scan_in_flight_ = false; // a sibling scan is running (main-thread only)
|
||||
bool empty_wallet_has_salvage_bak_ = false; // modal variant: a funded salvage .bak → offer Restore
|
||||
std::vector<FundedSibling> empty_wallet_funded_siblings_; // scan result (main-thread only)
|
||||
// The recovery dialog is the ONE authoritative surface: it stays open through the async rebuild/
|
||||
// restore, driven Offer → Working → Done/Failed (pumpWalletRestore sets the outcome). Presentation
|
||||
// only — the fund-safety file ops in rebuildWalletDatabase()/restoreOriginalWallet() are unchanged.
|
||||
enum class RecoveryPhase { Offer, Working, Done, Failed };
|
||||
RecoveryPhase recovery_phase_ = RecoveryPhase::Offer;
|
||||
int recovery_outcome_sev_ = 0; // 0 ok / 1 warn / 2 error, set at Done/Failed
|
||||
std::string recovery_outcome_msg_; // honest result string for the Done/Failed body
|
||||
bool recovery_last_action_rebuild_ = false; // which handler ran (for "try the other option")
|
||||
// After a successful repair the daemon restarts with a full rescan — minutes long, and it won't
|
||||
// answer RPC yet. This makes the loading overlay show a calm "finishing your wallet repair" screen
|
||||
// (instead of the generic "daemon stuck / RPC timeout / restart daemon" text) and suppresses the
|
||||
// daemon-crash toast. Set on repair success; cleared on connect (onConnected).
|
||||
bool post_recovery_rescan_ = false;
|
||||
double post_recovery_rescan_since_ = 0.0; // stamped on first overlay frame (ImGui::GetTime)
|
||||
// "Restore original wallet" background op: worker sets these under the mutex, pumpWalletRestore()
|
||||
// (main thread) shows the result. 0 = success, 1 = warning, 2 = error.
|
||||
std::mutex wallet_restore_mutex_;
|
||||
bool wallet_restore_done_ = false;
|
||||
int wallet_restore_severity_ = 0;
|
||||
std::string wallet_restore_msg_;
|
||||
// Live progress for the switch modal: it stays open from confirm through stop → wait-for-exit → start →
|
||||
// reconnect and auto-closes when the new node connects (onConnected). Phase is worker-updated;
|
||||
// dialog_open_ gates rendering — both atomic since onConnected/the worker may run off the main thread.
|
||||
@@ -926,6 +1037,7 @@ private:
|
||||
bool seed_backup_loading_ = false;
|
||||
bool seed_backup_no_mnemonic_ = false;
|
||||
bool seed_backup_reminder_in_flight_ = false; // guards the one-time backup nudge probe
|
||||
bool large_wallet_checked_ = false; // gate: stat wallet.dat for the bloat nudge once per launch
|
||||
|
||||
// Cached mnemonic status of the current wallet, driving the Migrate-to-seed button glow. Probed
|
||||
// once per connect (probeWalletSeedStatus, via exportSeedPhrase); NoMnemonic = a legacy wallet a
|
||||
@@ -1019,10 +1131,22 @@ private:
|
||||
bool daemon_start_error_shown_ = false;
|
||||
int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once
|
||||
bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active
|
||||
// Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is
|
||||
// O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded
|
||||
// wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll
|
||||
// off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue().
|
||||
bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge
|
||||
std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling)
|
||||
double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan
|
||||
bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle
|
||||
// Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept.
|
||||
std::uint64_t clipboard_secret_hash_ = 0;
|
||||
double clipboard_clear_deadline_ = 0.0;
|
||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||
double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h)
|
||||
bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning
|
||||
bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry
|
||||
std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot
|
||||
|
||||
// Current page (sidebar navigation)
|
||||
ui::NavPage current_page_ = ui::NavPage::Overview;
|
||||
@@ -1032,6 +1156,7 @@ private:
|
||||
|
||||
// Debug screenshot sweep state.
|
||||
bool screenshot_sweep_active_ = false;
|
||||
bool sweep_current_theme_only_ = false; // Debug Options: sweep only the active theme
|
||||
bool sweep_capture_this_frame_ = false;
|
||||
int sweep_skin_idx_ = 0;
|
||||
int sweep_settle_frames_ = 0; // frames to let a new skin/surface settle before capture
|
||||
@@ -1125,6 +1250,16 @@ private:
|
||||
|
||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||
std::atomic<bool> auto_shield_pending_{false};
|
||||
// v1.3.0+ daemons auto-shield coinbase themselves; probe z_autoshieldstatus once per connection and
|
||||
// defer the wallet's own client-side auto-shield when the daemon is doing it (otherwise both race for
|
||||
// the same coinbase UTXOs and split funds across different z-addresses). Fail-closed: a pre-1.3.0
|
||||
// daemon lacks the RPC → active stays false → the wallet keeps shielding client-side (no regression).
|
||||
bool daemon_autoshield_probed_ = false;
|
||||
bool daemon_autoshield_active_ = false;
|
||||
std::atomic<bool> daemon_autoshield_probe_inflight_{false};
|
||||
std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons
|
||||
std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable)
|
||||
bool daemon_autoshield_seed_recoverable_ = false;
|
||||
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
@@ -1221,8 +1356,8 @@ private:
|
||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||
|
||||
// Wizard: stopping an external daemon before bootstrap
|
||||
bool wizard_stopping_external_ = false;
|
||||
std::string wizard_stop_status_;
|
||||
std::atomic<bool> wizard_stopping_external_{false}; // written by the stop worker, read by the UI (L-06)
|
||||
GuardedStatus wizard_stop_status_; // thread-safe: written by the stop worker, read by the UI (L-06)
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
@@ -1288,6 +1423,13 @@ private:
|
||||
|
||||
// Private methods - rendering
|
||||
void renderStatusBar();
|
||||
// Persistent node/RPC error strip at the top of the content column when the wallet can't
|
||||
// reach its node (or the embedded daemon gave up crashing). Decision logic is the pure
|
||||
// evaluateNodeStatusBanner() in ui/node_status_banner.h; this draws it and wires the action.
|
||||
void renderNodeStatusBanner();
|
||||
// Body of the status-bar alert-history popup: recent alerts (incl. ones whose toast faded),
|
||||
// newest first, with severity icon + relative age + a Clear action. See src/ui/notifications.h.
|
||||
void renderAlertHistoryPanel();
|
||||
void renderLiteFirstRunPrompt(); // lite-only welcome modal when no wallet exists yet
|
||||
void renderLiteUnlockPrompt(); // lite-only send-time unlock modal
|
||||
void renderImportKeyDialog();
|
||||
@@ -1305,6 +1447,16 @@ private:
|
||||
void renderPinDialogs();
|
||||
void renderAntivirusHelpDialog();
|
||||
void renderSwitchStopDaemonDialog(); // confirm before stopping an adopted node to switch wallets
|
||||
void renderBlockDbReindexDialog(); // offer to rebuild an unreadable block database (-reindex)
|
||||
void reindexBlockDatabase(); // restart the daemon with -reindex to rebuild the block DB
|
||||
void renderWalletRecoveredDialog(); // warn that the node auto-recovered/salvaged wallet.dat
|
||||
void renderEmptyWalletWarningDialog();// warn that the active wallet is empty while a sibling holds funds
|
||||
void detectWalletAutoRecovery(); // scan daemon output for a salvage; fire the warning once/session
|
||||
void detectWalletDegraded(); // scan daemon output for a DEGRADED-mode open; warn once/session
|
||||
void restoreOriginalWallet(); // swap the wallet.<ts>.bak back over the salvaged copy + restart
|
||||
void pumpWalletRestore(); // main-thread: surface the restore/rebuild op's result
|
||||
void rebuildWalletDatabase(); // rebuild a BDB-inconsistent wallet into a loadable one (helper)
|
||||
bool walletRebuildAvailable() const; // the dragonx-wallet-rebuild helper is present
|
||||
void processDeferredEncryption();
|
||||
|
||||
// Private methods - connection
|
||||
@@ -1335,6 +1487,8 @@ private:
|
||||
void refreshPrice();
|
||||
void refreshWalletEncryptionState();
|
||||
void applyRefreshPolicy(ui::NavPage page);
|
||||
bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis)
|
||||
bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost?
|
||||
bool currentPageNeedsWalletDataRefresh() const;
|
||||
bool shouldRunWalletTransactionRefresh() const;
|
||||
bool shouldRefreshTransactions() const;
|
||||
|
||||
+917
-118
File diff suppressed because it is too large
Load Diff
+122
-8
@@ -33,6 +33,10 @@
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
#include <sodium.h>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
@@ -234,6 +238,41 @@ private:
|
||||
// daemon off the main thread (to avoid stalling the UI), or ask the user to
|
||||
// restart an external daemon. Shared by encryptWalletWithPassphrase() and
|
||||
// processDeferredEncryption(); must be called on the main thread.
|
||||
// Zero (overwrite) then delete a plaintext key export so a full cleartext dump of every private key is
|
||||
// never left readable on disk. Idempotent + error-tolerant (safe on a missing/locked file). (H-02)
|
||||
void App::scrubAndRemoveExport(const std::string& path)
|
||||
{
|
||||
if (path.empty()) return;
|
||||
std::error_code ec;
|
||||
const auto sz = std::filesystem::file_size(path, ec);
|
||||
if (!ec && sz > 0) {
|
||||
std::fstream scrub(path, std::ios::binary | std::ios::in | std::ios::out);
|
||||
if (scrub) {
|
||||
const std::vector<char> zeros(static_cast<size_t>(sz), 0);
|
||||
scrub.write(zeros.data(), static_cast<std::streamsize>(sz));
|
||||
scrub.flush();
|
||||
}
|
||||
}
|
||||
std::filesystem::remove(path, ec);
|
||||
}
|
||||
|
||||
// Startup net for H-02: a crash/kill/early-return between exporting the cleartext keys and scrubbing them
|
||||
// could leave an obsidiandecryptexport* file behind. Purge any found in the data dir on launch.
|
||||
void App::sweepStaleDecryptExports()
|
||||
{
|
||||
std::error_code ec;
|
||||
const std::string dir = util::Platform::getDragonXDataDir();
|
||||
std::filesystem::directory_iterator it(dir, ec), end;
|
||||
for (; it != end; it.increment(ec)) {
|
||||
if (ec) break;
|
||||
const std::string name = it->path().filename().string();
|
||||
if (name.rfind("obsidiandecryptexport", 0) == 0) {
|
||||
scrubAndRemoveExport(it->path().string());
|
||||
DEBUG_LOGF("[decrypt] swept stale plaintext key export: %s\n", name.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestartStatus) {
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
if (announceRestartStatus) {
|
||||
@@ -483,7 +522,17 @@ void App::lockWallet() {
|
||||
state_.locked = true;
|
||||
state_.unlocked_until = 0;
|
||||
resetTransactionHistoryCacheSession();
|
||||
lock_failure_warned_ = false;
|
||||
DEBUG_LOGF("[App] Wallet locked\n");
|
||||
} else {
|
||||
// The walletlock RPC failed — the wallet is still UNLOCKED. Surface it (once) rather
|
||||
// than silently leaving an auto-lock unfulfilled and the wallet exposed (W2-4).
|
||||
DEBUG_LOGF("[App] walletlock failed — wallet remains unlocked\n");
|
||||
if (!lock_failure_warned_) {
|
||||
lock_failure_warned_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
"Couldn't lock the wallet — it is still unlocked. Check the daemon connection.", 12.0f);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -560,6 +609,12 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.unlocked_until = until;
|
||||
state_.locked = (until == 0);
|
||||
state_.encryption_state_known = true;
|
||||
// Wallet is encrypted — any pending deferred-encryption request has now been
|
||||
// satisfied (however it completed). Clear the persisted flag (W2-2).
|
||||
if (settings_ && settings_->getEncryptionPending()) {
|
||||
settings_->setEncryptionPending(false);
|
||||
settings_->save();
|
||||
}
|
||||
if (state_.locked) {
|
||||
resetTransactionHistoryCacheSession();
|
||||
} else if (state_.transactions.empty()) {
|
||||
@@ -572,6 +627,19 @@ void App::refreshWalletEncryptionState() {
|
||||
state_.locked = false;
|
||||
state_.unlocked_until = 0;
|
||||
state_.encryption_state_known = true;
|
||||
// W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted,
|
||||
// and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a
|
||||
// failed connect before it applied. Warn (once/session) instead of silently leaving
|
||||
// an unencrypted wallet the user believes is protected. The flag stays set until the
|
||||
// wallet is actually encrypted, so the warning recurs each launch until resolved.
|
||||
if (settings_ && settings_->getEncryptionPending() &&
|
||||
!wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ &&
|
||||
!encryption_incomplete_warned_) {
|
||||
encryption_incomplete_warned_ = true;
|
||||
ui::Notifications::instance().warning(
|
||||
"Wallet encryption did not complete — your wallet is NOT encrypted. "
|
||||
"Open Settings to finish encrypting it.", 30.0f);
|
||||
}
|
||||
if (state_.transactions.empty()) {
|
||||
loadTransactionHistoryCacheIfAvailable();
|
||||
} else {
|
||||
@@ -691,6 +759,10 @@ void App::checkIdleMining() {
|
||||
// Resolve auto values: active defaults to half, idle defaults to all
|
||||
if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2);
|
||||
if (idleThreads <= 0) idleThreads = maxThreads;
|
||||
// Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field
|
||||
// could otherwise carry an arbitrary count straight past every bound. (M-06)
|
||||
activeThreads = std::clamp(activeThreads, 1, maxThreads);
|
||||
idleThreads = std::clamp(idleThreads, 1, maxThreads);
|
||||
|
||||
if (systemIdle) {
|
||||
// System is idle — scale up to idle thread count
|
||||
@@ -1193,7 +1265,7 @@ void App::renderEncryptWalletDialog() {
|
||||
else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
|
||||
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 4.0f;
|
||||
float barH = 4.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1243,7 +1315,7 @@ void App::renderEncryptWalletDialog() {
|
||||
// Indeterminate progress bar
|
||||
{
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 6.0f;
|
||||
float barH = 6.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1311,9 +1383,13 @@ void App::renderEncryptWalletDialog() {
|
||||
enc_dlg_pin_status_.clear();
|
||||
std::string savedPass = enc_dlg_saved_passphrase_;
|
||||
if (worker_ && vault_) {
|
||||
worker_->post([this, pinStr, savedPass]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, pinStr, savedPass]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Argon2id runs here (worker thread)
|
||||
bool ok = vault_->store(pinStr, savedPass);
|
||||
// Scrub the captured PIN + passphrase copies (they live in the worker's task
|
||||
// queue until this runs); the source member is scrubbed in the MainCb. (L-03)
|
||||
if (!savedPass.empty()) util::SecureVault::secureZero(&savedPass[0], savedPass.size());
|
||||
if (!pinStr.empty()) util::SecureVault::secureZero(&pinStr[0], pinStr.size());
|
||||
return [this, ok]() {
|
||||
if (ok) {
|
||||
settings_->setPinEnabled(true);
|
||||
@@ -1374,6 +1450,11 @@ void App::renderEncryptWalletDialog() {
|
||||
ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
|
||||
if (BeginOverlayDialog(ov)) {
|
||||
|
||||
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
|
||||
// passphrase is lost — reuse their warning string/header for consistency.
|
||||
DialogWarningHeader(TR("wiz_encrypt_warning"));
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::TextUnformatted(TR("change_pass_current"));
|
||||
ImGui::PushItemWidth(-1);
|
||||
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),
|
||||
@@ -1400,12 +1481,22 @@ void App::renderEncryptWalletDialog() {
|
||||
bool valid = strlen(change_old_pass_buf_) > 0 &&
|
||||
strlen(change_new_pass_buf_) >= 8 &&
|
||||
strcmp(change_new_pass_buf_, change_confirm_buf_) == 0;
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || encrypt_in_progress_);
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("change_pass_title"), ImVec2(btnW, 40))) {
|
||||
changePassphrase(std::string(change_old_pass_buf_),
|
||||
std::string(change_new_pass_buf_));
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_change_passphrase_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
show_change_passphrase_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
}
|
||||
|
||||
@@ -1478,12 +1569,14 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Run entire decrypt flow on worker thread
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, passphrase = std::move(passphrase)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||
// Scrub the passphrase — unlock is its only use in this flow.
|
||||
if (!passphrase.empty()) sodium_memzero(&passphrase[0], passphrase.size());
|
||||
if (!unlock.ok) {
|
||||
return [this]() {
|
||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||
@@ -1553,6 +1646,11 @@ void App::renderDecryptWalletDialog() {
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||
// Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path —
|
||||
// success, a restart-failure early return, or an exception. A full cleartext dump of all
|
||||
// private keys must never outlive this step. The startup sweep is a further net for a
|
||||
// crash/kill mid-flight. (H-02)
|
||||
struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath};
|
||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
@@ -1606,6 +1704,8 @@ void App::renderDecryptWalletDialog() {
|
||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||
importAdapter, exportPath);
|
||||
// (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02)
|
||||
|
||||
if (!importResult.ok) {
|
||||
std::string err = importResult.error;
|
||||
if (worker_) {
|
||||
@@ -1730,7 +1830,7 @@ void App::renderDecryptWalletDialog() {
|
||||
// Indeterminate progress bar
|
||||
{
|
||||
float barW = ImGui::GetContentRegionAvail().x;
|
||||
float barH = 6.0f;
|
||||
float barH = 6.0f * ui::Layout::dpiScale();
|
||||
ImVec2 p = ImGui::GetCursorScreenPos();
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH),
|
||||
@@ -1860,8 +1960,10 @@ void App::renderPinDialogs() {
|
||||
util::SecureVault::isValidPin(pinStr) &&
|
||||
strcmp(pin_buf_, pin_confirm_buf_) == 0;
|
||||
|
||||
// Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings.
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || pin_in_progress_);
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) {
|
||||
if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) {
|
||||
pin_in_progress_ = true;
|
||||
pin_status_ = "Verifying passphrase...";
|
||||
|
||||
@@ -1874,12 +1976,14 @@ void App::renderPinDialogs() {
|
||||
memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_));
|
||||
|
||||
if (rpc_ && rpc_->isConnected() && worker_) {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb {
|
||||
// Verify passphrase via RPC (worker thread)
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Security / PIN setup");
|
||||
rpc_->call("walletpassphrase", {passphrase, 5});
|
||||
} catch (const std::exception& e) {
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
return [this]() {
|
||||
pin_status_ = "Incorrect passphrase";
|
||||
pin_in_progress_ = false;
|
||||
@@ -1888,6 +1992,9 @@ void App::renderPinDialogs() {
|
||||
|
||||
// Passphrase correct — store in vault (Argon2id, worker thread)
|
||||
bool storeOk = vault_ && vault_->store(pin, passphrase);
|
||||
// Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01)
|
||||
if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size());
|
||||
if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size());
|
||||
|
||||
// Lock wallet back
|
||||
try {
|
||||
@@ -1915,6 +2022,13 @@ void App::renderPinDialogs() {
|
||||
}
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
// Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by
|
||||
// the !show_pin_setup_ cleanup block below.
|
||||
if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) {
|
||||
show_pin_setup_ = false;
|
||||
}
|
||||
EndOverlayDialog();
|
||||
}
|
||||
// Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc /
|
||||
|
||||
+6
-2
@@ -170,10 +170,10 @@ void App::installDemoWalletData()
|
||||
}
|
||||
|
||||
auto zaddr = [](const char* a, double bal, const char* label) {
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "shielded"; i.label = label; return i;
|
||||
};
|
||||
auto taddr = [](const char* a, double bal, const char* label) {
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
|
||||
AddressInfo i; i.address = a; i.balance = bal; i.spendableBalance = bal; i.type = "transparent"; i.label = label; return i;
|
||||
};
|
||||
state_.z_addresses = {
|
||||
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
|
||||
@@ -704,6 +704,10 @@ void App::startSweepImpl(bool full)
|
||||
if (sk.valid) sweep_skins_.push_back(sk.id);
|
||||
if (sweep_skins_.empty()) return;
|
||||
|
||||
// Debug Options "Current theme only": sweep just the active skin instead of cycling every theme.
|
||||
if (sweep_current_theme_only_)
|
||||
sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId());
|
||||
|
||||
sweep_full_ = full;
|
||||
if (full) { capture_mode_ = true; installDemoWalletData(); }
|
||||
buildSweepCatalog();
|
||||
|
||||
+109
-24
@@ -177,8 +177,36 @@ void App::renderFirstRunWizard() {
|
||||
// DPI scale factor — multiply all pixel constants by dp
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
|
||||
// Vertical scroll: the wizard cards are hand-drawn at absolute Y offsets and grow ~1.5x with the
|
||||
// font-scale setting, so at high scale the focused card's primary button (Continue / Encrypt & Continue
|
||||
// / Skip) can fall below the fixed window. Offset the whole layout by a wheel-driven scroll, clamped to
|
||||
// last frame's measured content height, so every control stays reachable. The window keeps
|
||||
// NoScrollWithMouse, so ImGui doesn't consume the wheel — we read the raw delta and apply our own offset.
|
||||
static float s_wizScroll = 0.0f, s_wizContentH = 0.0f;
|
||||
if (ImGui::IsWindowAppearing()) s_wizScroll = 0.0f;
|
||||
const float wizMaxScroll = std::max(0.0f, s_wizContentH - winSize.y);
|
||||
// Don't steal the wheel from an open combo popup (e.g. the 9-item Language dropdown, which is a
|
||||
// scrollable popup): NoPopupHierarchy stops the popup counting as hovering the wizard, and the
|
||||
// IsPopupOpen guard ensures no wheel is consumed for the whole wizard while any popup is showing.
|
||||
const bool wizPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
|
||||
if (wizMaxScroll > 0.0f && !wizPopupOpen &&
|
||||
ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows | ImGuiHoveredFlags_NoPopupHierarchy)) {
|
||||
float wheel = ImGui::GetIO().MouseWheel;
|
||||
if (wheel != 0.0f) s_wizScroll -= wheel * 60.0f * dp;
|
||||
}
|
||||
s_wizScroll = std::max(0.0f, std::min(s_wizScroll, wizMaxScroll));
|
||||
const float scrollY = s_wizScroll;
|
||||
|
||||
// --- Header: Logo + Welcome ---
|
||||
float headerCy = winPos.y + 20.0f * dp;
|
||||
// Vertically center the content when it fits (mirrors the horizontal centering below): on a tall
|
||||
// monitor top-anchoring leaves a large void under the cards. Using last frame's measured block
|
||||
// height, when the content fits inside the window (and we're NOT overflowing, so this doesn't
|
||||
// fight the scroll), push everything down by half the leftover space. No-op once content
|
||||
// fills/exceeds the window (s_wizContentH >= winSize.y ⇒ wizMaxScroll > 0 ⇒ vCenter skipped).
|
||||
float vCenter = 0.0f;
|
||||
if (wizMaxScroll == 0.0f && s_wizContentH > 0.0f && s_wizContentH < winSize.y)
|
||||
vCenter = std::max(0.0f, (winSize.y - s_wizContentH) * 0.5f);
|
||||
float headerCy = winPos.y - scrollY + 20.0f * dp + vCenter;
|
||||
float logoSize = S.drawElement("screens.first-run", "logo").sizeOr(56.0f);
|
||||
if (logo_tex_ != 0) {
|
||||
float aspect = (logo_h_ > 0) ? (float)logo_w_ / (float)logo_h_ : 1.0f;
|
||||
@@ -266,29 +294,43 @@ void App::renderFirstRunWizard() {
|
||||
{
|
||||
int state = cardState(0);
|
||||
bool isFocused = (state == 1);
|
||||
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
|
||||
float cx = leftX + cardPad;
|
||||
float cy = card0Top + cardPad;
|
||||
float contentW = colW - 2 * cardPad;
|
||||
|
||||
// Step indicator
|
||||
{
|
||||
// Step indicator + title (inline when collapsed)
|
||||
if (isCollapsed) {
|
||||
// Compact single-line: check icon + "Step 1" + "Appearance"
|
||||
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
||||
cy += captionFont->LegacySize + 6.0f * dp;
|
||||
}
|
||||
float labelX = cx + iconW + 4.0f * dp;
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
|
||||
float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
|
||||
float titleX = labelX + step1W + 12.0f * dp;
|
||||
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
|
||||
cy += captionFont->LegacySize + 4.0f * dp;
|
||||
} else {
|
||||
// Step indicator
|
||||
{
|
||||
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
|
||||
cy += captionFont->LegacySize + 6.0f * dp;
|
||||
}
|
||||
|
||||
// Title
|
||||
{
|
||||
const char* t = TR("wiz_appearance");
|
||||
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
||||
cy += titleFont->LegacySize + 10.0f * dp;
|
||||
}
|
||||
// Title
|
||||
{
|
||||
const char* t = TR("wiz_appearance");
|
||||
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
|
||||
cy += titleFont->LegacySize + 10.0f * dp;
|
||||
}
|
||||
|
||||
// Separator
|
||||
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
// Separator
|
||||
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
}
|
||||
|
||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||
@@ -324,6 +366,9 @@ void App::renderFirstRunWizard() {
|
||||
wiz_appearance_init = true;
|
||||
}
|
||||
|
||||
// Controls: rendered for the focused and upcoming states so content is visible under
|
||||
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
|
||||
if (!isCollapsed) {
|
||||
// Render controls always so content is visible under the dim
|
||||
// overlay when not focused; disable interaction when not active.
|
||||
ImGui::BeginDisabled(!isFocused);
|
||||
@@ -640,13 +685,21 @@ void App::renderFirstRunWizard() {
|
||||
cy += btnH;
|
||||
}
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
} // if (!isCollapsed)
|
||||
|
||||
// Card 0 finalization deferred until after cards 1+2 are sized
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
if (isCollapsed) {
|
||||
// Completed: finalize immediately as a compact pill (do not stretch to the
|
||||
// right column height, and skip the deferred stretch below).
|
||||
card0Bot = card0Top + (cy - card0Top);
|
||||
finalizeCard(leftX, colW, card0Top, card0Bot, state);
|
||||
} else {
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
// Card 0 finalization deferred until after cards 1+2 are sized
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -889,8 +942,9 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
|
||||
if (wizard_stopping_external_) {
|
||||
const std::string ws = wizard_stop_status_.get();
|
||||
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx, cy), dimCol,
|
||||
wizard_stop_status_.c_str());
|
||||
ws.c_str());
|
||||
cy += captionFont->LegacySize + 8.0f * dp;
|
||||
} else {
|
||||
float stopW = 150.0f * dp;
|
||||
@@ -1338,6 +1392,10 @@ void App::renderFirstRunWizard() {
|
||||
wallet_security_.beginDeferredEncryption(
|
||||
std::string(encrypt_pass_buf_),
|
||||
(pinEntered && pinOk) ? pinStr : std::string());
|
||||
// Persist that encryption was requested (never the passphrase) so a quit/crash or
|
||||
// failed daemon connect before it applies isn't silent — reconciled on the next
|
||||
// connect in refreshWalletEncryptionState (W2-2). Saved with the wizard state below.
|
||||
settings_->setEncryptionPending(true);
|
||||
|
||||
// Clear sensitive buffers
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
@@ -1372,6 +1430,13 @@ void App::renderFirstRunWizard() {
|
||||
encrypt_status_ = TR("wiz_skip_confirm");
|
||||
} else {
|
||||
s_skipEncConfirm = false;
|
||||
// Skipping leaves the wallet UNENCRYPTED — wipe the passphrase/PIN the user may have
|
||||
// typed so it doesn't linger in these process-lifetime buffers (only the Encrypt
|
||||
// path cleared them before). (L-07)
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
memset(encrypt_confirm_buf_, 0, sizeof(encrypt_confirm_buf_));
|
||||
memset(wizard_pin_buf_, 0, sizeof(wizard_pin_buf_));
|
||||
memset(wizard_pin_confirm_buf_, 0, sizeof(wizard_pin_confirm_buf_));
|
||||
wizard_phase_ = WizardPhase::Done;
|
||||
settings_->setWizardCompleted(true);
|
||||
settings_->save();
|
||||
@@ -1407,7 +1472,9 @@ void App::renderFirstRunWizard() {
|
||||
}
|
||||
|
||||
// --- Deferred Card 0 finalization: match right column total height ---
|
||||
{
|
||||
// Only for the focused/upcoming Appearance card; a completed one was already finalized
|
||||
// above as a compact pill and must not be re-stretched.
|
||||
if (cardState(0) != 2) {
|
||||
float rightColBot = card2Bot;
|
||||
if (rightColBot > card0Bot) card0Bot = rightColBot;
|
||||
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));
|
||||
@@ -1416,6 +1483,24 @@ void App::renderFirstRunWizard() {
|
||||
// Merge channels: backgrounds → content → overlays
|
||||
dl->ChannelsMerge();
|
||||
|
||||
// Measure this frame's content height (feeds next frame's scroll clamp) and, when it overflows the
|
||||
// window, draw a slim scroll indicator so the off-screen content is discoverable.
|
||||
{
|
||||
float contentBottom = std::max(card0Bot, std::max(card1Bot, card2Bot));
|
||||
// Subtract vCenter back out: everything below the header was shifted down by it, so the raw
|
||||
// span includes it. We want s_wizContentH to be the true (un-centered) content height, or the
|
||||
// vertical-centering above would feed on itself and oscillate frame-to-frame.
|
||||
s_wizContentH = (contentBottom - winPos.y + scrollY - vCenter) + 24.0f * dp;
|
||||
if (wizMaxScroll > 0.0f && s_wizContentH > 0.0f) {
|
||||
float trackH = winSize.y - 8.0f * dp;
|
||||
float thumbH = std::min(trackH, std::max(32.0f * dp, trackH * (winSize.y / s_wizContentH)));
|
||||
float thumbY = winPos.y + 4.0f * dp + (trackH - thumbH) * (scrollY / wizMaxScroll);
|
||||
float barX = winPos.x + winSize.x - 6.0f * dp;
|
||||
dl->AddRectFilled(ImVec2(barX, thumbY), ImVec2(barX + 3.0f * dp, thumbY + thumbH),
|
||||
ui::material::WithAlpha(ui::material::OnSurface(), 55), 1.5f * dp);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::End();
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "console_text_color", console_text_color_);
|
||||
loadScalar(j, "console_zoom", console_zoom_);
|
||||
if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN
|
||||
loadScalar(j, "console_auto_focus", console_auto_focus_);
|
||||
if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) {
|
||||
hidden_addresses_.clear();
|
||||
for (const auto& a : j["hidden_addresses"])
|
||||
@@ -231,12 +232,20 @@ bool Settings::load(const std::string& path)
|
||||
}
|
||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||
loadScalar(j, "large_wallet_warned", large_wallet_warned_);
|
||||
if (j.contains("empty_wallet_warning_acked") && j["empty_wallet_warning_acked"].is_array()) {
|
||||
empty_wallet_warning_acked_.clear();
|
||||
for (const auto& w : j["empty_wallet_warning_acked"])
|
||||
if (w.is_string()) empty_wallet_warning_acked_.insert(w.get<std::string>());
|
||||
}
|
||||
loadScalar(j, "encryption_pending", encryption_pending_);
|
||||
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
||||
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
loadScalar(j, "seed_migration_sweep_txid", seed_migration_sweep_txid_);
|
||||
loadScalar(j, "seed_migration_sweep_opid", seed_migration_sweep_opid_);
|
||||
loadScalar(j, "auto_lock_timeout", auto_lock_timeout_);
|
||||
loadScalar(j, "unlock_duration", unlock_duration_);
|
||||
loadScalar(j, "pin_enabled", pin_enabled_);
|
||||
@@ -476,6 +485,7 @@ bool Settings::save(const std::string& path)
|
||||
j["console_line_accents"] = console_line_accents_;
|
||||
j["console_text_color"] = console_text_color_;
|
||||
j["console_zoom"] = console_zoom_;
|
||||
j["console_auto_focus"] = console_auto_focus_;
|
||||
j["hidden_addresses"] = json::array();
|
||||
for (const auto& addr : hidden_addresses_)
|
||||
j["hidden_addresses"].push_back(addr);
|
||||
@@ -497,12 +507,18 @@ bool Settings::save(const std::string& path)
|
||||
}
|
||||
j["wizard_completed"] = wizard_completed_;
|
||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||
j["large_wallet_warned"] = large_wallet_warned_;
|
||||
j["empty_wallet_warning_acked"] = json::array();
|
||||
for (const auto& w : empty_wallet_warning_acked_)
|
||||
j["empty_wallet_warning_acked"].push_back(w);
|
||||
j["encryption_pending"] = encryption_pending_;
|
||||
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
||||
j["active_wallet_file"] = active_wallet_file_;
|
||||
j["seed_migration_pending"] = seed_migration_pending_;
|
||||
j["seed_migration_dest"] = seed_migration_dest_;
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
j["seed_migration_sweep_txid"] = seed_migration_sweep_txid_;
|
||||
j["seed_migration_sweep_opid"] = seed_migration_sweep_opid_;
|
||||
j["auto_lock_timeout"] = auto_lock_timeout_;
|
||||
j["unlock_duration"] = unlock_duration_;
|
||||
j["pin_enabled"] = pin_enabled_;
|
||||
|
||||
@@ -256,6 +256,9 @@ public:
|
||||
void setConsoleTextColor(bool v) { console_text_color_ = v; }
|
||||
float getConsoleZoom() const { return console_zoom_; }
|
||||
void setConsoleZoom(float v) { console_zoom_ = v; }
|
||||
// Auto-place the text cursor in the command box when the Console tab is opened.
|
||||
bool getConsoleAutoFocus() const { return console_auto_focus_; }
|
||||
void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; }
|
||||
|
||||
// Hidden addresses (addresses hidden from the UI by the user)
|
||||
const std::set<std::string>& getHiddenAddresses() const { return hidden_addresses_; }
|
||||
@@ -327,6 +330,24 @@ public:
|
||||
bool getSeedBackupReminded() const { return seed_backup_reminded_; }
|
||||
void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; }
|
||||
|
||||
// One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back).
|
||||
bool getLargeWalletWarned() const { return large_wallet_warned_; }
|
||||
void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; }
|
||||
|
||||
// Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds"
|
||||
// warning has been dismissed. Keyed per active wallet file so switching to a different empty
|
||||
// wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings).
|
||||
bool isEmptyWalletWarnAcked(const std::string& walletFile) const {
|
||||
return empty_wallet_warning_acked_.count(walletFile) > 0;
|
||||
}
|
||||
void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); }
|
||||
|
||||
// Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is
|
||||
// observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected
|
||||
// and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested.
|
||||
bool getEncryptionPending() const { return encryption_pending_; }
|
||||
void setEncryptionPending(bool v) { encryption_pending_ = v; }
|
||||
|
||||
// Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the
|
||||
// "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging.
|
||||
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
||||
@@ -350,6 +371,11 @@ public:
|
||||
// migration is past the sweep, so a resume goes to the confirm/adopt stage (not sweep again).
|
||||
std::string getSeedMigrationSweepTxid() const { return seed_migration_sweep_txid_; }
|
||||
void setSeedMigrationSweepTxid(const std::string& v) { seed_migration_sweep_txid_ = v; }
|
||||
// W3-3: the async sweep operation id, persisted while the sweep is in flight (before it resolves
|
||||
// to a txid). Lets a resume re-poll a mid-sweep interruption instead of dropping the txid. Cleared
|
||||
// in the same write that persists the txid, so the txid always outranks it (see [[decideSeedMigrationResume]]).
|
||||
std::string getSeedMigrationSweepOpid() const { return seed_migration_sweep_opid_; }
|
||||
void setSeedMigrationSweepOpid(const std::string& v) { seed_migration_sweep_opid_ = v; }
|
||||
|
||||
// Security — auto-lock timeout (seconds; 0 = disabled)
|
||||
int getAutoLockTimeout() const { return auto_lock_timeout_; }
|
||||
@@ -569,17 +595,22 @@ private:
|
||||
bool console_line_accents_ = true; // left color accent bars in console output
|
||||
bool console_text_color_ = true; // per-channel text coloring in console output
|
||||
float console_zoom_ = 1.0f; // console output font zoom factor
|
||||
bool console_auto_focus_ = false; // focus the command input when the Console tab is opened (opt-in)
|
||||
std::set<std::string> hidden_addresses_;
|
||||
std::set<std::string> favorite_addresses_;
|
||||
std::map<std::string, AddressMeta> address_meta_;
|
||||
bool wizard_completed_ = false;
|
||||
bool seed_backup_reminded_ = false;
|
||||
bool large_wallet_warned_ = false;
|
||||
std::set<std::string> empty_wallet_warning_acked_; // wallet files whose empty-wallet warning was dismissed
|
||||
bool encryption_pending_ = false;
|
||||
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
||||
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
||||
bool seed_migration_pending_ = false;
|
||||
std::string seed_migration_dest_;
|
||||
std::string seed_migration_temp_dir_;
|
||||
std::string seed_migration_sweep_txid_;
|
||||
std::string seed_migration_sweep_opid_;
|
||||
int auto_lock_timeout_ = 900; // 15 minutes
|
||||
int unlock_duration_ = 600; // 10 minutes
|
||||
bool pin_enabled_ = false;
|
||||
|
||||
@@ -71,9 +71,11 @@ DaemonController::State DaemonController::state() const
|
||||
return daemon_->getState();
|
||||
}
|
||||
|
||||
const std::string& DaemonController::lastError() const
|
||||
std::string DaemonController::lastError() const
|
||||
{
|
||||
return daemon_->getLastError();
|
||||
// By value — getLastError() now returns a mutex-locked COPY, so forwarding it by reference would
|
||||
// dangle (bind a reference to that temporary). (M-04 follow-through)
|
||||
return daemon_ ? daemon_->getLastError() : std::string();
|
||||
}
|
||||
|
||||
int DaemonController::crashCount() const
|
||||
@@ -126,6 +128,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled)
|
||||
daemon_->setSalvageOnNextStart(enabled);
|
||||
}
|
||||
|
||||
void DaemonController::setReindexOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setReindexOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::zapOnNextStart() const
|
||||
{
|
||||
return daemon_->zapOnNextStart();
|
||||
|
||||
@@ -95,7 +95,7 @@ public:
|
||||
bool externalDaemonDetected() const;
|
||||
void clearExternalDaemonDetected();
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
std::string lastError() const; // by value: EmbeddedDaemon::getLastError() returns a locked copy (M-04)
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
@@ -108,6 +108,7 @@ public:
|
||||
void setZapOnNextStart(bool enabled);
|
||||
bool zapOnNextStart() const;
|
||||
void setSalvageOnNextStart(bool enabled);
|
||||
void setReindexOnNextStart(bool enabled); // -reindex: rebuild the block DB from raw blocks on next start
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
|
||||
@@ -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
|
||||
+160
-35
@@ -205,11 +205,13 @@ std::vector<std::string> EmbeddedDaemon::getChainParams()
|
||||
"-ac_reward=300000000",
|
||||
"-ac_blocktime=36",
|
||||
"-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=node2.dragonx.is",
|
||||
"-addnode=node3.dragonx.is",
|
||||
"-addnode=node4.dragonx.is",
|
||||
"-addnode=node5.dragonx.is",
|
||||
"-experimentalfeatures",
|
||||
"-developerencryptwallet",
|
||||
// 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)
|
||||
{
|
||||
state_ = s;
|
||||
if (!message.empty()) {
|
||||
if (s == State::Error) {
|
||||
last_error_ = message;
|
||||
}
|
||||
if (!message.empty() && s == State::Error) {
|
||||
std::lock_guard<std::mutex> lk(error_mutex_); // dedicated mutex — never taken with output_mutex_ held
|
||||
last_error_ = message;
|
||||
}
|
||||
|
||||
|
||||
if (state_callback_) {
|
||||
state_callback_(s, message);
|
||||
}
|
||||
@@ -488,6 +489,34 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
return 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...");
|
||||
|
||||
@@ -543,6 +572,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
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
|
||||
// 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)
|
||||
@@ -557,8 +594,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
override_extra_args_.clear();
|
||||
|
||||
if (!startProcess(daemon_path, args)) {
|
||||
DEBUG_LOGF("[ERROR] Failed to start dragonxd process: %s\\n", last_error_.c_str());
|
||||
setState(State::Error, "Failed to start dragonxd process");
|
||||
// startProcess() sets a precise last_error_ (e.g. "dragonxd could not be executed:
|
||||
// ... 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;
|
||||
}
|
||||
|
||||
@@ -579,12 +622,28 @@ bool EmbeddedDaemon::start(const std::string& binary_path)
|
||||
// Forward declaration — defined after startProcess
|
||||
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)
|
||||
{
|
||||
// Build command line
|
||||
// Build command line (binary path always quoted; each arg quoted/escaped per Windows rules — L-02)
|
||||
std::string cmd = "\"" + binary_path + "\"";
|
||||
for (const auto& arg : args) {
|
||||
cmd += " " + arg;
|
||||
cmd += " " + quoteWinArg(arg);
|
||||
}
|
||||
DEBUG_LOGF("[INFO] Starting daemon: %s\n", cmd.c_str());
|
||||
|
||||
@@ -632,7 +691,10 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
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)
|
||||
// — it must be in <exe_dir>/dragonx/ to avoid conflicts with lock files and data.
|
||||
STARTUPINFOA si;
|
||||
@@ -642,7 +704,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
si.dwFlags = STARTF_USESHOWWINDOW;
|
||||
si.wShowWindow = SW_HIDE;
|
||||
ZeroMemory(&pi, sizeof(pi));
|
||||
|
||||
|
||||
char* cmd_line = _strdup(cmd.c_str());
|
||||
BOOL success = CreateProcessA(
|
||||
NULL,
|
||||
@@ -650,7 +712,7 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE,
|
||||
CREATE_NEW_CONSOLE,
|
||||
CREATE_NO_WINDOW,
|
||||
NULL,
|
||||
work_dir.c_str(),
|
||||
&si,
|
||||
@@ -962,18 +1024,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
last_error_ = "Failed to create pipe: " + std::string(strerror(errno));
|
||||
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();
|
||||
if (pid == -1) {
|
||||
last_error_ = "Fork failed: " + std::string(strerror(errno));
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
close(execpipe[0]);
|
||||
close(execpipe[1]);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
// 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
|
||||
// group later (including dragonxd spawned by a wrapper script).
|
||||
@@ -1040,22 +1122,61 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec
|
||||
execv(binary_path.c_str(), argv.data());
|
||||
}
|
||||
|
||||
// If we get here, exec failed
|
||||
fprintf(stderr, "execv failed: %s\n", strerror(errno));
|
||||
// If we get here, execv() failed — the child never became dragonxd.
|
||||
// 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);
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
// Also set process group from parent side (race with child's setpgid)
|
||||
setpgid(pid, pid);
|
||||
|
||||
|
||||
// Best-effort: the child already calls setpgid(0, 0); this parent-side call
|
||||
// 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
|
||||
int flags = fcntl(stdout_fd_, F_GETFL, 0);
|
||||
fcntl(stdout_fd_, F_SETFL, flags | O_NONBLOCK);
|
||||
|
||||
|
||||
process_pid_ = pid;
|
||||
return true;
|
||||
}
|
||||
@@ -1135,17 +1256,21 @@ double EmbeddedDaemon::getMemoryUsageMB() 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;
|
||||
|
||||
int status;
|
||||
pid_t result = waitpid(process_pid_, &status, WNOHANG);
|
||||
|
||||
if (result == 0) {
|
||||
// Still running
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
const State s = state_.load(std::memory_order_relaxed);
|
||||
// State::Stopping is included: stop()'s graceful/SIGTERM wait loops poll
|
||||
// isRunning() while state_ == Stopping — before the process has actually
|
||||
// terminated — and must keep seeing "alive" to wait/escalate correctly.
|
||||
return (s == State::Running || s == State::Stopping);
|
||||
}
|
||||
|
||||
void EmbeddedDaemon::drainOutput()
|
||||
|
||||
@@ -79,7 +79,9 @@ public:
|
||||
/**
|
||||
* @brief Get last error message
|
||||
*/
|
||||
const std::string& getLastError() const { return last_error_; }
|
||||
// Copy under lock: last_error_ is written from the monitor thread (setState on an unexpected exit)
|
||||
// while the UI thread reads it — a reference would be a torn-read / use-after-free race (M-04).
|
||||
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||
|
||||
/**
|
||||
* @brief Get dragonxd process output (thread-safe copy)
|
||||
@@ -206,6 +208,13 @@ public:
|
||||
void setSalvageOnNextStart(bool v) { salvage_on_next_start_ = v; }
|
||||
bool salvageOnNextStart() const { return salvage_on_next_start_.load(); }
|
||||
|
||||
// -reindex: rebuild the block index + chainstate from the raw blocks (blk*.dat) on startup. One-shot,
|
||||
// consumed on the next start. Offered when the node aborts on an unreadable block database (a
|
||||
// daemon-vs-chaindata format mismatch after an update, or a corrupt index). It implies a wallet
|
||||
// rescan, so it's the block-DB analogue of -salvagewallet and coexists with the wallet-repair flags.
|
||||
void setReindexOnNextStart(bool v) { reindex_on_next_start_ = v; }
|
||||
bool reindexOnNextStart() const { return reindex_on_next_start_.load(); }
|
||||
|
||||
/**
|
||||
* @brief One-shot isolated-datadir override for the NEXT start(): run the daemon against a
|
||||
* different datadir (with its own DRAGONX.conf) plus the given extra args. Used by the
|
||||
@@ -235,6 +244,32 @@ public:
|
||||
*/
|
||||
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) */
|
||||
static bool tcpPortInUse(int port);
|
||||
|
||||
@@ -253,6 +288,7 @@ private:
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
std::atomic<bool> external_daemon_detected_{false};
|
||||
std::string last_error_;
|
||||
mutable std::mutex error_mutex_; // protects last_error_ (written by main + monitor threads)
|
||||
mutable std::mutex output_mutex_; // protects process_output_
|
||||
std::string process_output_;
|
||||
StateCallback state_callback_;
|
||||
@@ -280,6 +316,7 @@ private:
|
||||
std::atomic<bool> rescan_on_next_start_{false}; // -rescan flag for next start
|
||||
std::atomic<bool> zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start
|
||||
std::atomic<bool> salvage_on_next_start_{false}; // -salvagewallet flag for next start
|
||||
std::atomic<bool> reindex_on_next_start_{false}; // -reindex flag for next start (rebuild block DB)
|
||||
std::string override_datadir_; // one-shot: -datadir for the next start
|
||||
std::vector<std::string> override_extra_args_; // one-shot: extra args for the next start
|
||||
bool skip_port_check_ = false; // isolated instance on a non-default port
|
||||
|
||||
@@ -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.
|
||||
const std::string base = util::Platform::getConfigDir() + "/seed-migrate";
|
||||
const std::string dataDir = base + "/DRAGONX";
|
||||
// W3-2: never blindly wipe a pre-existing temp seed wallet. A prior migration that swept funds into
|
||||
// it but was abandoned or crashed before adopting would otherwise have its (fund-bearing) wallet
|
||||
// destroyed here. A completed migration removes this dir on adopt, so a leftover means an unfinished
|
||||
// one — refuse and point the user at it rather than silently destroying it.
|
||||
if (fs::exists(dataDir + "/wallet.dat")) {
|
||||
r.error = "A previous seed migration looks unfinished — its temporary wallet is still at\n" + base +
|
||||
"\nResume or cancel it first. If you are certain its funds are already in your main "
|
||||
"wallet, delete that folder and try again.";
|
||||
return r;
|
||||
}
|
||||
fs::remove_all(base, ec);
|
||||
fs::create_directories(dataDir, ec);
|
||||
if (ec) { r.error = "Could not create the temporary wallet directory."; return r; }
|
||||
|
||||
+107
-66
@@ -23,6 +23,7 @@
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/pool_registry.h"
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -89,8 +90,32 @@ static std::string getConfigDir() {
|
||||
// libcurl write callback
|
||||
static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
auto* s = static_cast<std::string*>(userdata);
|
||||
s->append(static_cast<char*>(ptr), sz * n);
|
||||
return sz * n;
|
||||
const size_t add = sz * n;
|
||||
// Stats JSON (local xmrig HTTP API + pool API) is tiny; refuse an unbounded body from a hostile or
|
||||
// MITM'd endpoint so it can't grow this string until OOM. Returning < add aborts the transfer. (L-02)
|
||||
constexpr size_t kMaxStatsBytes = 1u << 20; // 1 MiB
|
||||
if (s->size() + add > kMaxStatsBytes) return 0;
|
||||
s->append(static_cast<char*>(ptr), add);
|
||||
return add;
|
||||
}
|
||||
|
||||
// True if `host` (already stripped of scheme+port) is a loopback/private/link-local/single-label target
|
||||
// that a public mining pool would never be — used to refuse a background stats GET to it (M-09).
|
||||
static bool hostLooksInternal(const std::string& host) {
|
||||
if (host.empty() || host == "localhost") return true;
|
||||
if (host.rfind("127.", 0) == 0 || host.rfind("10.", 0) == 0 ||
|
||||
host.rfind("192.168.", 0) == 0 || host.rfind("169.254.", 0) == 0) return true;
|
||||
if (host.rfind("172.", 0) == 0) { // 172.16.0.0 - 172.31.255.255
|
||||
const int second = std::atoi(host.c_str() + 4);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
if (host.find(':') != std::string::npos) { // IPv6 literal: loopback / ULA / link-local
|
||||
if (host == "::1" || host.rfind("fc", 0) == 0 || host.rfind("fd", 0) == 0 ||
|
||||
host.rfind("fe80", 0) == 0) return true;
|
||||
}
|
||||
if (host.size() >= 6 && host.compare(host.size() - 6, 6, ".local") == 0) return true;
|
||||
if (host.find('.') == std::string::npos) return true; // bare single-label name = LAN/hosts, not a pool
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -100,9 +125,14 @@ static size_t curlWriteCb(void* ptr, size_t sz, size_t n, void* userdata) {
|
||||
XmrigManager::XmrigManager() = default;
|
||||
|
||||
XmrigManager::~XmrigManager() {
|
||||
should_stop_ = true;
|
||||
if (isRunning()) {
|
||||
stop(3000);
|
||||
}
|
||||
// Join a monitor thread left joinable by an unexpected xmrig exit (State::Error, so isRunning() is
|
||||
// false and stop() above was skipped) — std::thread's destructor would otherwise std::terminate(). (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -116,32 +146,18 @@ std::string XmrigManager::findXmrigBinary() {
|
||||
return path;
|
||||
}
|
||||
|
||||
// Fallback: system PATH
|
||||
// Fallback: system PATH — windowless so it never flashes a console.
|
||||
#ifdef _WIN32
|
||||
FILE* f = _popen("where xmrig.exe 2>nul", "r");
|
||||
std::string out = util::Platform::runHiddenCapture("where xmrig.exe");
|
||||
#else
|
||||
FILE* f = popen("which xmrig 2>/dev/null", "r");
|
||||
#endif
|
||||
if (f) {
|
||||
char line[512];
|
||||
if (fgets(line, sizeof(line), f)) {
|
||||
std::string s(line);
|
||||
while (!s.empty() && (s.back() == '\n' || s.back() == '\r'))
|
||||
s.pop_back();
|
||||
if (!s.empty() && fs::exists(s)) {
|
||||
#ifdef _WIN32
|
||||
_pclose(f);
|
||||
#else
|
||||
pclose(f);
|
||||
#endif
|
||||
return s;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
_pclose(f);
|
||||
#else
|
||||
pclose(f);
|
||||
std::string out = util::Platform::runHiddenCapture("which xmrig");
|
||||
#endif
|
||||
{
|
||||
std::string s = out;
|
||||
const auto nl = s.find_first_of("\r\n"); // first line only
|
||||
if (nl != std::string::npos) s.erase(nl);
|
||||
while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.pop_back();
|
||||
if (!s.empty() && fs::exists(s)) return s;
|
||||
}
|
||||
|
||||
return {};
|
||||
@@ -208,23 +224,43 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
try {
|
||||
fs::create_directories(fs::path(outPath).parent_path());
|
||||
std::ofstream ofs(outPath, std::ios::trunc);
|
||||
if (!ofs.is_open()) {
|
||||
last_error_ = "Cannot write xmrig config: " + outPath;
|
||||
const std::string dumped = j.dump(4);
|
||||
#ifndef _WIN32
|
||||
// Create the config 0600 AT CREATION (open with mode) so the API token + wallet address are never
|
||||
// in a world/group-readable file — even for a local attacker who opened it in the old
|
||||
// create-then-chmod window and held the fd open across the chmod. (L-01)
|
||||
int fd = ::open(outPath.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
||||
if (fd < 0) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
// Restrict to owner (0600) BEFORE writing any secret material (API token, wallet
|
||||
// address, worker name). The file is still empty here, so the config is never
|
||||
// world-readable — closing the window between creation and the previous post-write chmod.
|
||||
chmod(outPath.c_str(), 0600);
|
||||
#endif
|
||||
ofs << j.dump(4);
|
||||
size_t off = 0;
|
||||
bool wrote = true;
|
||||
while (off < dumped.size()) {
|
||||
ssize_t nw = ::write(fd, dumped.data() + off, dumped.size() - off);
|
||||
if (nw <= 0) { wrote = false; break; }
|
||||
off += static_cast<size_t>(nw);
|
||||
}
|
||||
::close(fd);
|
||||
if (!wrote) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
std::ofstream ofs(outPath, std::ios::trunc);
|
||||
if (!ofs.is_open()) {
|
||||
setLastError("Cannot write xmrig config: " + outPath);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
ofs << dumped;
|
||||
ofs.close();
|
||||
return true;
|
||||
#endif
|
||||
} catch (const std::exception& e) {
|
||||
last_error_ = std::string("Config write error: ") + e.what();
|
||||
setLastError(std::string("Config write error: ") + e.what());
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -236,19 +272,22 @@ bool XmrigManager::generateConfig(const Config& cfg, const std::string& outPath)
|
||||
|
||||
bool XmrigManager::start(const Config& cfg) {
|
||||
if (state_ == State::Running || state_ == State::Starting) {
|
||||
last_error_ = "Already running";
|
||||
setLastError("Already running");
|
||||
DEBUG_LOGF("[WARN] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
state_ = State::Starting;
|
||||
should_stop_ = false;
|
||||
last_error_.clear();
|
||||
setLastError(std::string());
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(output_mutex_);
|
||||
process_output_.clear();
|
||||
}
|
||||
stats_ = PoolStats{};
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(stats_mutex_);
|
||||
stats_ = PoolStats{};
|
||||
}
|
||||
|
||||
// Extract pool hostname for stats API queries
|
||||
{
|
||||
@@ -265,7 +304,7 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
// Find binary
|
||||
std::string binary = findXmrigBinary();
|
||||
if (binary.empty()) {
|
||||
last_error_ = "xmrig binary not found";
|
||||
setLastError("xmrig binary not found");
|
||||
state_ = State::Error;
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: xmrig binary not found\n");
|
||||
return false;
|
||||
@@ -293,7 +332,11 @@ bool XmrigManager::start(const Config& cfg) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Start monitor thread
|
||||
// Join a prior monitor thread before move-assigning: if xmrig exited unexpectedly, monitorProcess set
|
||||
// State::Error and returned, leaving monitor_thread_ joinable — move-assigning over a joinable
|
||||
// std::thread calls std::terminate() and aborts the whole wallet. (M-04)
|
||||
if (monitor_thread_.joinable())
|
||||
monitor_thread_.join();
|
||||
monitor_thread_ = std::thread(&XmrigManager::monitorProcess, this);
|
||||
state_ = State::Running;
|
||||
DEBUG_LOGF("[INFO] XmrigManager: started\n");
|
||||
@@ -368,7 +411,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
||||
|
||||
HANDLE hRead = nullptr, hWrite = nullptr;
|
||||
if (!CreatePipe(&hRead, &hWrite, &sa, 0)) {
|
||||
last_error_ = "CreatePipe failed";
|
||||
setLastError("CreatePipe failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -400,7 +443,7 @@ bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string&
|
||||
char errBuf[256];
|
||||
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
NULL, err, 0, errBuf, sizeof(errBuf), NULL);
|
||||
last_error_ = "CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf;
|
||||
setLastError("CreateProcess failed for xmrig (error " + std::to_string(err) + "): " + errBuf);
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\nCommand: %s\n", last_error_.c_str(), cmdLine.c_str());
|
||||
return false;
|
||||
}
|
||||
@@ -451,14 +494,14 @@ void XmrigManager::drainOutput() {
|
||||
bool XmrigManager::startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads) {
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) != 0) {
|
||||
last_error_ = "pipe() failed";
|
||||
setLastError("pipe() failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid < 0) {
|
||||
last_error_ = "fork() failed";
|
||||
setLastError("fork() failed");
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: %s\n", last_error_.c_str());
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
@@ -629,7 +672,7 @@ void XmrigManager::monitorProcess() {
|
||||
if (GetExitCodeProcess(process_handle_, &exitCode) && exitCode != STILL_ACTIVE) {
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (code %lu)\n", exitCode);
|
||||
state_ = State::Error;
|
||||
last_error_ = "xmrig process exited unexpectedly";
|
||||
setLastError("xmrig process exited unexpectedly");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -640,7 +683,7 @@ void XmrigManager::monitorProcess() {
|
||||
if (ret == process_pid_ || ret < 0) {
|
||||
DEBUG_LOGF("[ERROR] XmrigManager: process exited (waitpid=%d)\n", ret);
|
||||
state_ = State::Error;
|
||||
last_error_ = "xmrig process exited unexpectedly";
|
||||
setLastError("xmrig process exited unexpectedly");
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -780,6 +823,11 @@ void XmrigManager::fetchPoolApiStats() {
|
||||
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
|
||||
// /api/pools); unknown/custom hosts fall back to the .is convention.
|
||||
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
|
||||
// SSRF guard: for an UNKNOWN (user-typed) pool host, don't let the wallet issue a background GET to a
|
||||
// loopback/private/link-local/single-label target — those aren't public mining pools, and a
|
||||
// paste-a-pool-config lure could otherwise point us at an internal host. Known pools use their trusted
|
||||
// registry statsUrl and are exempt. (M-09)
|
||||
if (!known && hostLooksInternal(pool_host_)) return;
|
||||
const std::string url = known ? known->statsUrl
|
||||
: ("https://" + pool_host_ + "/api/stats");
|
||||
|
||||
@@ -858,25 +906,18 @@ void XmrigManager::startVersionDetection()
|
||||
std::thread([]() {
|
||||
const std::string bin = findXmrigBinary();
|
||||
std::string ver;
|
||||
if (!bin.empty()) {
|
||||
const std::string cmd = "\"" + bin + "\" --version 2>&1";
|
||||
#ifdef _WIN32
|
||||
FILE* fp = _popen(cmd.c_str(), "r");
|
||||
#else
|
||||
FILE* fp = popen(cmd.c_str(), "r");
|
||||
#endif
|
||||
if (fp) {
|
||||
std::string out;
|
||||
char buf[256];
|
||||
size_t n;
|
||||
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
|
||||
#ifdef _WIN32
|
||||
_pclose(fp);
|
||||
#else
|
||||
pclose(fp);
|
||||
#endif
|
||||
ver = parseMinerVersion(out);
|
||||
}
|
||||
// Don't hand a path containing shell/cmd metacharacters to popen()'s shell — bin is normally an
|
||||
// app-controlled path, but this closes command injection if it ever isn't. (M-10)
|
||||
// Reject only chars that stay shell-special INSIDE the double-quotes we wrap bin in ("\"" + bin + "\"")
|
||||
// on cmd.exe or /bin/sh. Parens are inert when quoted, so they're excluded — otherwise common Windows
|
||||
// paths like "C:\Program Files (x86)\..." would be rejected and version detection would silently fail. (M-10)
|
||||
const bool binShellSafe =
|
||||
!bin.empty() && bin.find_first_of("\"'`$;&|<>^%\n\r") == std::string::npos;
|
||||
if (binShellSafe) {
|
||||
// Windowless capture (mergeStderr: xmrig may print --version to stderr) — never flashes.
|
||||
const std::string cmd = "\"" + bin + "\" --version";
|
||||
const std::string out = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/true);
|
||||
if (!out.empty()) ver = parseMinerVersion(out);
|
||||
}
|
||||
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
|
||||
g_installed_ver = ver;
|
||||
|
||||
@@ -86,8 +86,10 @@ public:
|
||||
bool isRunning() const;
|
||||
State getState() const { return state_.load(std::memory_order_relaxed); }
|
||||
|
||||
const PoolStats& getStats() const { return stats_; }
|
||||
const std::string& getLastError() const { return last_error_; }
|
||||
// Return COPIES under lock: stats_ and last_error_ are mutated by the monitor thread while the UI
|
||||
// thread reads them, so handing out a reference is a torn-read / use-after-free race (M-03, M-04).
|
||||
PoolStats getStats() const { std::lock_guard<std::mutex> lk(stats_mutex_); return stats_; }
|
||||
std::string getLastError() const { std::lock_guard<std::mutex> lk(error_mutex_); return last_error_; }
|
||||
|
||||
/// Thread count requested at start() — available immediately, unlike
|
||||
/// PoolStats::threads_active which requires an API response.
|
||||
@@ -156,11 +158,14 @@ private:
|
||||
void monitorProcess();
|
||||
void drainOutput();
|
||||
void appendOutput(const char* data, size_t len);
|
||||
// Set last_error_ under error_mutex_ (writers run on both the main thread and the monitor thread).
|
||||
void setLastError(std::string e) { std::lock_guard<std::mutex> lk(error_mutex_); last_error_ = std::move(e); }
|
||||
void fetchStatsHttp(); // Blocking HTTP call — runs on monitor thread only
|
||||
void fetchPoolApiStats(); // Fetch pool-side stats (hashrate) from pool HTTP API
|
||||
|
||||
std::atomic<State> state_{State::Stopped};
|
||||
std::string last_error_;
|
||||
mutable std::mutex error_mutex_; // guards last_error_ (written by main + monitor threads)
|
||||
|
||||
mutable std::mutex output_mutex_;
|
||||
std::string process_output_;
|
||||
|
||||
+17
-12
@@ -46,20 +46,25 @@ bool AddressBook::load()
|
||||
entries_.clear();
|
||||
|
||||
if (j.contains("entries") && j["entries"].is_array()) {
|
||||
size_t skipped = 0;
|
||||
for (const auto& entry : j["entries"]) {
|
||||
AddressBookEntry e;
|
||||
e.label = entry.value("label", "");
|
||||
e.address = entry.value("address", "");
|
||||
e.notes = entry.value("notes", "");
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
e.avatar = entry.value("avatar", "");
|
||||
|
||||
if (!e.address.empty()) {
|
||||
entries_.push_back(e);
|
||||
}
|
||||
// W6-3: skip (and count) a malformed element rather than letting one bad entry throw and
|
||||
// abort the whole load — which would discard EVERY contact (entries_ was already cleared).
|
||||
if (!entry.is_object()) { ++skipped; continue; }
|
||||
try {
|
||||
AddressBookEntry e;
|
||||
e.label = entry.value("label", "");
|
||||
e.address = entry.value("address", "");
|
||||
e.notes = entry.value("notes", "");
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
e.avatar = entry.value("avatar", "");
|
||||
if (!e.address.empty()) entries_.push_back(e);
|
||||
} catch (const std::exception&) { ++skipped; }
|
||||
}
|
||||
if (skipped > 0)
|
||||
DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies");
|
||||
}
|
||||
|
||||
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
||||
|
||||
@@ -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) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
|
||||
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
|
||||
bestBalance = addresses[i].spendableBalance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
+27
-6
@@ -21,13 +21,17 @@ namespace dragonx {
|
||||
*/
|
||||
struct AddressInfo {
|
||||
std::string address;
|
||||
double balance = 0.0;
|
||||
double balance = 0.0; // DISPLAY total incl. pending 0-conf change (minconf=0)
|
||||
std::string type; // "shielded" or "transparent"
|
||||
bool has_spending_key = true; // false for view-only (imported via z_importviewingkey)
|
||||
|
||||
// For display
|
||||
std::string label;
|
||||
|
||||
|
||||
// CONFIRMED balance (minconf>=1) — what z_sendmany can actually spend now. Kept last so positional
|
||||
// brace-init of the leading fields (used in tests) still compiles.
|
||||
double spendableBalance = 0.0;
|
||||
|
||||
// Derived
|
||||
bool isZAddr() const { return !address.empty() && address[0] == 'z'; }
|
||||
bool isShielded() const { return type == "shielded"; }
|
||||
@@ -252,12 +256,18 @@ struct WalletState {
|
||||
// Sync status
|
||||
SyncInfo sync;
|
||||
|
||||
// Balances (named to match UI usage)
|
||||
double privateBalance = 0.0; // shielded balance
|
||||
// Balances (named to match UI usage). These are the DISPLAY totals — minconf=0, so they include the
|
||||
// user's own pending change and don't crater during an unconfirmed send.
|
||||
double privateBalance = 0.0; // shielded balance (display, incl. pending change)
|
||||
double transparentBalance = 0.0;
|
||||
double totalBalance = 0.0;
|
||||
double unconfirmedBalance = 0.0;
|
||||
|
||||
double unconfirmedBalance = 0.0; // = totalBalance - spendableTotalBalance (the pending portion)
|
||||
// CONFIRMED / spendable totals (minconf>=1) — what can actually be sent right now. z_sendmany runs at
|
||||
// minconf=1, so the Send form / Max / spend validation must size off these, never the display totals.
|
||||
double spendablePrivateBalance = 0.0;
|
||||
double spendableTransparentBalance = 0.0;
|
||||
double spendableTotalBalance = 0.0;
|
||||
|
||||
// Aliases for backward compatibility
|
||||
double& shielded_balance = privateBalance;
|
||||
double& transparent_balance = transparentBalance;
|
||||
@@ -302,6 +312,7 @@ struct WalletState {
|
||||
|
||||
// Timestamps for refresh logic
|
||||
int64_t last_balance_update = 0;
|
||||
int64_t last_address_update = 0; // set when an address-list refresh applies; 0 = never loaded yet
|
||||
int64_t last_tx_update = 0;
|
||||
int64_t last_peer_update = 0;
|
||||
int64_t last_mining_update = 0;
|
||||
@@ -325,6 +336,7 @@ struct WalletState {
|
||||
sync = SyncInfo{};
|
||||
privateBalance = transparentBalance = totalBalance = 0.0;
|
||||
unconfirmedBalance = 0.0;
|
||||
spendablePrivateBalance = spendableTransparentBalance = spendableTotalBalance = 0.0;
|
||||
encrypted = false;
|
||||
locked = false;
|
||||
unlocked_until = 0;
|
||||
@@ -335,6 +347,15 @@ struct WalletState {
|
||||
transactions.clear();
|
||||
peers.clear();
|
||||
bannedPeers.clear();
|
||||
// W6-1: reset node-level mining state too — the daemon restarts on a wallet switch (mining
|
||||
// stops), so leaving the previous wallet's hashrate/blocks would show stale mining stats.
|
||||
mining = MiningInfo{};
|
||||
pool_mining = PoolMiningState{};
|
||||
// After a disconnect / wallet switch nothing is freshly known, so drop the "last successful
|
||||
// refresh" stamps. Otherwise the pre-teardown time survives and, on reconnect, the staleness
|
||||
// badge (and any "updated X ago" reader) briefly reports it as current until the first refresh
|
||||
// re-stamps it. All readers treat 0 as "never" (formatTimeAgoShort/timeAgo return "").
|
||||
last_balance_update = last_address_update = last_tx_update = last_peer_update = last_mining_update = 0;
|
||||
}
|
||||
|
||||
// Rebuild combined addresses list from z/t lists
|
||||
|
||||
+92
-6
@@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale,
|
||||
}
|
||||
}
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <csignal>
|
||||
#include <cstring>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#if defined(__has_include)
|
||||
# if __has_include(<execinfo.h>)
|
||||
# include <execinfo.h>
|
||||
# define DRAGONX_HAVE_BACKTRACE 1
|
||||
# endif
|
||||
#endif
|
||||
|
||||
// Absolute path to the crash log, filled at install time so the async-signal handler needs no
|
||||
// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.)
|
||||
static char g_crashLogPath[1024] = {0};
|
||||
|
||||
// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used —
|
||||
// no stdio, std::filesystem or malloc (all unsafe inside a signal handler).
|
||||
static void PosixCrashHandler(int sig)
|
||||
{
|
||||
int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1;
|
||||
if (fd >= 0) {
|
||||
auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; };
|
||||
put("\n=== CRASH: signal ");
|
||||
char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio
|
||||
if (v == 0) { num[i++] = '0'; }
|
||||
else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; }
|
||||
while (t > 0) num[i++] = tmp[--t]; }
|
||||
num[i] = '\n';
|
||||
ssize_t nn = write(fd, num, i + 1); (void)nn;
|
||||
#ifdef DRAGONX_HAVE_BACKTRACE
|
||||
void* frames[64];
|
||||
int nframes = backtrace(frames, 64);
|
||||
backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe
|
||||
#endif
|
||||
put("=== END CRASH ===\n");
|
||||
close(fd);
|
||||
}
|
||||
// Restore the default disposition and re-raise so we still get a core dump / normal termination.
|
||||
signal(sig, SIG_DFL);
|
||||
raise(sig);
|
||||
}
|
||||
|
||||
static void installPosixCrashHandler(const std::string& crashLogPath)
|
||||
{
|
||||
std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str());
|
||||
struct sigaction sa;
|
||||
std::memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = PosixCrashHandler;
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_flags = 0;
|
||||
for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) {
|
||||
sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
}
|
||||
#endif // !_WIN32
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Ensure ObsidianDragon config directory exists early (before any file I/O)
|
||||
{
|
||||
std::string odDir = dragonx::util::Platform::getObsidianDragonDir();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(odDir, ec);
|
||||
std::string odErr;
|
||||
if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) {
|
||||
// Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the
|
||||
// Windows log redirect below isn't set up yet — report loudly before any setup.
|
||||
std::fprintf(stderr, "%s\n", odErr.c_str());
|
||||
#ifdef _WIN32
|
||||
MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR);
|
||||
#endif
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Redirect stdout/stderr to a log file so diagnostic output is visible
|
||||
// even when built as a GUI app (WIN32_EXECUTABLE hides the console).
|
||||
// W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are
|
||||
// actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the
|
||||
// file never existed at all (the Windows-only stdout freopen below is a separate mechanism).
|
||||
{
|
||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
||||
const std::string logPath =
|
||||
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string();
|
||||
dragonx::util::Logger::instance().init(logPath);
|
||||
}
|
||||
|
||||
#if !defined(_WIN32)
|
||||
// W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or
|
||||
// abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS.
|
||||
{
|
||||
const std::string crashPath =
|
||||
(std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string();
|
||||
installPosixCrashHandler(crashPath);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef _WIN32
|
||||
// Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when
|
||||
// built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger
|
||||
// above so the two writers don't interleave/contend on one file.
|
||||
{
|
||||
std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string();
|
||||
freopen(logPath.c_str(), "w", stdout);
|
||||
freopen(logPath.c_str(), "a", stderr);
|
||||
}
|
||||
@@ -2042,6 +2127,7 @@ int main(int argc, char* argv[])
|
||||
// deadlocks waiting for detached pthreads. On Linux, static
|
||||
// destructors and atexit handlers can also block. _Exit() bypasses
|
||||
// all of that.
|
||||
app.wipeSecrets(); // _Exit() below bypasses ~App(), so scrub secret buffers here (L-05)
|
||||
fflush(stdout);
|
||||
fflush(stderr);
|
||||
_Exit(0);
|
||||
|
||||
@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
|
||||
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
|
||||
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
|
||||
#endif
|
||||
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
|
||||
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
|
||||
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (rebuildRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
|
||||
if (!extractResource(rebuildRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
|
||||
return success;
|
||||
}
|
||||
|
||||
std::string ensureWalletRebuildHelperExtracted()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (!res || res->size == 0) return {};
|
||||
#ifdef _WIN32
|
||||
const char sep = '\\';
|
||||
#else
|
||||
const char sep = '/';
|
||||
#endif
|
||||
const std::string dir = getDaemonDirectory();
|
||||
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (!extractResource(res, dest)) return {};
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||
return dest;
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string getDaemonDirectory()
|
||||
{
|
||||
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
|
||||
|
||||
@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
|
||||
// caller should stop the daemon first. Returns true if all present resources were written.
|
||||
bool reextractBundledDaemon();
|
||||
|
||||
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
|
||||
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
|
||||
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
|
||||
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
|
||||
std::string ensureWalletRebuildHelperExtracted();
|
||||
|
||||
// Resource names
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
|
||||
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
|
||||
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
|
||||
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
|
||||
|
||||
+161
-29
@@ -14,8 +14,12 @@
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <vector>
|
||||
#include <chrono>
|
||||
|
||||
#include "../util/logger.h"
|
||||
#include "../util/platform.h"
|
||||
#include "../util/xmrig_updater.h" // util::sha256Hex
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <shlobj.h>
|
||||
@@ -120,30 +124,121 @@ std::string Connection::getSaplingParamsDir()
|
||||
return resources::getDaemonDirectory();
|
||||
}
|
||||
|
||||
bool Connection::verifySaplingParams()
|
||||
namespace {
|
||||
|
||||
std::string joinParamPath(const std::string& dir, const std::string& file) {
|
||||
#ifdef _WIN32
|
||||
return dir + "\\" + file;
|
||||
#else
|
||||
return dir + "/" + file;
|
||||
#endif
|
||||
}
|
||||
|
||||
// "<size>:<mtime>" fingerprint used to skip re-hashing an unchanged file. Empty on error.
|
||||
std::string paramStatLine(const std::string& path) {
|
||||
std::error_code ec;
|
||||
auto sz = fs::file_size(path, ec);
|
||||
if (ec) return {};
|
||||
auto mtime = fs::last_write_time(path, ec);
|
||||
long long ticks = ec ? 0 :
|
||||
std::chrono::duration_cast<std::chrono::seconds>(mtime.time_since_epoch()).count();
|
||||
return std::to_string(static_cast<unsigned long long>(sz)) + ":" + std::to_string(ticks);
|
||||
}
|
||||
|
||||
bool paramHashMatches(const std::string& path, const std::string& expectedHex) {
|
||||
std::ifstream f(path, std::ios::binary | std::ios::ate);
|
||||
if (!f) return false;
|
||||
std::streamsize sz = f.tellg();
|
||||
if (sz <= 0) return false;
|
||||
f.seekg(0, std::ios::beg);
|
||||
std::vector<char> buf(static_cast<size_t>(sz));
|
||||
if (!f.read(buf.data(), sz)) return false;
|
||||
std::string got = util::sha256Hex(buf.data(), buf.size());
|
||||
return !got.empty() && got == expectedHex;
|
||||
}
|
||||
|
||||
// The verification cache: <params_dir>/.sapling_verified holds one paramStatLine per param,
|
||||
// in list order, from the last successful hash check.
|
||||
bool saplingMarkerMatches(const std::string& markerPath, const std::vector<std::string>& expected) {
|
||||
for (const auto& s : expected) if (s.empty()) return false; // couldn't stat -> don't trust
|
||||
std::ifstream f(markerPath);
|
||||
if (!f) return false;
|
||||
std::vector<std::string> lines;
|
||||
std::string l;
|
||||
while (std::getline(f, l)) lines.push_back(l);
|
||||
return lines == expected;
|
||||
}
|
||||
|
||||
void writeSaplingMarker(const std::string& markerPath, const std::vector<std::string>& lines) {
|
||||
std::ofstream f(markerPath, std::ios::trunc);
|
||||
if (!f) return;
|
||||
for (const auto& l : lines) f << l << "\n";
|
||||
}
|
||||
|
||||
// Canonical Zcash-family Sapling trusted-setup param digests — identical bytes across every
|
||||
// fork/platform. Source of truth: scripts/build-lite-backend-artifact.sh ensure_sapling_params().
|
||||
// Keep in sync if the params are ever rotated.
|
||||
const std::pair<std::string, std::string> kSaplingParamDigests[] = {
|
||||
{ "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" },
|
||||
{ "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" },
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Connection::verifySaplingParamsIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests)
|
||||
{
|
||||
std::string params_dir = getSaplingParamsDir();
|
||||
if (params_dir.empty()) {
|
||||
if (dir.empty()) {
|
||||
DEBUG_LOGF("verifySaplingParams: params dir is empty\n");
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
std::string spend_path = params_dir + "\\sapling-spend.params";
|
||||
std::string output_path = params_dir + "\\sapling-output.params";
|
||||
#else
|
||||
std::string spend_path = params_dir + "/sapling-spend.params";
|
||||
std::string output_path = params_dir + "/sapling-output.params";
|
||||
#endif
|
||||
|
||||
bool spend_exists = fs::exists(spend_path);
|
||||
bool output_exists = fs::exists(output_path);
|
||||
|
||||
DEBUG_LOGF("verifySaplingParams: dir=%s\n", params_dir.c_str());
|
||||
DEBUG_LOGF(" spend: %s -> %s\n", spend_path.c_str(), spend_exists ? "found" : "MISSING");
|
||||
DEBUG_LOGF(" output: %s -> %s\n", output_path.c_str(), output_exists ? "found" : "MISSING");
|
||||
|
||||
return spend_exists && output_exists;
|
||||
if (digests.empty()) return false;
|
||||
|
||||
// 1) Every param must exist.
|
||||
std::vector<std::string> paths;
|
||||
paths.reserve(digests.size());
|
||||
for (const auto& d : digests) {
|
||||
std::string p = joinParamPath(dir, d.first);
|
||||
if (!fs::exists(p)) {
|
||||
DEBUG_LOGF("verifySaplingParams: %s MISSING\n", p.c_str());
|
||||
return false;
|
||||
}
|
||||
paths.push_back(std::move(p));
|
||||
}
|
||||
|
||||
// 2) Fast path: if the cached marker matches the current size:mtime of every param, trust
|
||||
// the previous successful hash instead of re-hashing ~48MB on every startup.
|
||||
const std::string markerPath = joinParamPath(dir, ".sapling_verified");
|
||||
std::vector<std::string> current;
|
||||
current.reserve(paths.size());
|
||||
for (const auto& p : paths) current.push_back(paramStatLine(p));
|
||||
if (saplingMarkerMatches(markerPath, current)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3) Integrity-check each param against its pinned SHA-256. A truncated or corrupt param
|
||||
// (a partial extraction, or a Linux bundle where the file merely *exists*) is rejected
|
||||
// here instead of being handed to the daemon and failing later on a shielded operation.
|
||||
for (size_t i = 0; i < paths.size(); ++i) {
|
||||
if (!paramHashMatches(paths[i], digests[i].second)) {
|
||||
DEBUG_LOGF("verifySaplingParams: %s FAILED integrity check (truncated or corrupt)\n",
|
||||
paths[i].c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Record the verified state so later startups take the fast path.
|
||||
writeSaplingMarker(markerPath, current);
|
||||
DEBUG_LOGF("verifySaplingParams: %zu params verified (sha256)\n", paths.size());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Connection::verifySaplingParams()
|
||||
{
|
||||
std::vector<std::pair<std::string, std::string>> digests;
|
||||
for (const auto& d : kSaplingParamDigests) digests.emplace_back(d.first, d.second);
|
||||
return verifySaplingParamsIn(getSaplingParamsDir(), digests);
|
||||
}
|
||||
|
||||
ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
@@ -195,6 +290,8 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
} else if (key == "rpcallowplaintext") {
|
||||
config.allow_plaintext_remote = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,11 +306,14 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
{
|
||||
ConnectionConfig config;
|
||||
|
||||
// Ensure data directory exists
|
||||
// Ensure the data directory exists. Use the non-throwing helper and report any failure
|
||||
// via config.dir_error so callers can surface it — the old throwing create_directories()
|
||||
// overload could raise an uncaught filesystem_error straight through autoDetectConfig()'s
|
||||
// callers (read-only home, permission denied, etc.).
|
||||
std::string data_dir = getDefaultDataDir();
|
||||
if (!fs::exists(data_dir)) {
|
||||
DEBUG_LOGF("Creating data directory: %s\n", data_dir.c_str());
|
||||
fs::create_directories(data_dir);
|
||||
if (!util::Platform::ensureDirectory(data_dir, &config.dir_error)) {
|
||||
DEBUG_LOGF("[ERROR] autoDetectConfig: %s\n", config.dir_error.c_str());
|
||||
return config; // data dir unusable — bail early with dir_error set
|
||||
}
|
||||
|
||||
// Try to find DRAGONX.conf
|
||||
@@ -268,6 +368,31 @@ bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionC
|
||||
return true;
|
||||
}
|
||||
|
||||
// True only for a well-formed IPv4 loopback literal (127.0.0.0/8): exactly four dot-separated
|
||||
// 0-255 octets with the first == 127. Rejects "127.evil.com", "127.0.0.1.attacker",
|
||||
// "127.300.0.1", "1270.0.0.1", etc. — the old rfind("127.",0)==0 prefix matched all of those.
|
||||
static bool isExactIPv4Loopback(const std::string& host)
|
||||
{
|
||||
int octets = 0, value = 0, digits = 0;
|
||||
bool firstIs127 = false;
|
||||
for (size_t i = 0; i <= host.size(); ++i) {
|
||||
const char c = (i < host.size()) ? host[i] : '.'; // trailing sentinel flushes the last octet
|
||||
if (c == '.') {
|
||||
if (digits == 0 || digits > 3 || value > 255) return false;
|
||||
if (octets == 0) firstIs127 = (value == 127);
|
||||
++octets;
|
||||
value = 0;
|
||||
digits = 0;
|
||||
} else if (c >= '0' && c <= '9') {
|
||||
value = value * 10 + (c - '0');
|
||||
++digits;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return octets == 4 && firstIs127;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
@@ -277,7 +402,7 @@ bool Connection::isLocalHost(const std::string& host)
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
isExactIPv4Loopback(lowered);
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
@@ -285,6 +410,13 @@ bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
bool Connection::allowsPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
// Explicit opt-in (DRAGONX.conf: rpcallowplaintext=1) to send credentials over a plaintext
|
||||
// link to a remote host. Off by default — see usesPlaintextRemote().
|
||||
return config.allow_plaintext_remote;
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
@@ -324,11 +456,11 @@ bool Connection::createDefaultConfig(const std::string& path)
|
||||
file << "exportdir=" << dataDir << "\n";
|
||||
file << "experimentalfeatures=1\n";
|
||||
file << "developerencryptwallet=1\n";
|
||||
file << "addnode=node.dragonx.is\n";
|
||||
// Round-robin DNS seed (self-updating) + static fallbacks; mirrors the daemon's vSeeds and keeps
|
||||
// pre-1.3.0 daemons (broken peer discovery) able to find peers. Works on every daemon version.
|
||||
file << "addnode=seed.dragonx.is\n";
|
||||
file << "addnode=node1.dragonx.is\n";
|
||||
file << "addnode=node2.dragonx.is\n";
|
||||
file << "addnode=node3.dragonx.is\n";
|
||||
file << "addnode=node4.dragonx.is\n";
|
||||
file << "addnode=node5.dragonx.is\n";
|
||||
|
||||
file.close();
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
@@ -27,7 +29,11 @@ struct ConnectionConfig {
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
bool allow_plaintext_remote = false; // rpcallowplaintext=1 — opt in to plaintext creds to a remote host
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
// Non-empty when autoDetectConfig() could not create the data directory; callers
|
||||
// should surface it and abort the connect rather than proceeding blindly.
|
||||
std::string dir_error;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +75,14 @@ public:
|
||||
*/
|
||||
static bool verifySaplingParams();
|
||||
|
||||
// Verify the Sapling params in `dir` against a { filename, expected-sha256-hex } list.
|
||||
// Exposed with an injectable dir + digest list so the integrity + marker-cache logic is
|
||||
// unit-testable without the real ~48MB params; verifySaplingParams() calls it with the
|
||||
// pinned production digests and getSaplingParamsDir().
|
||||
static bool verifySaplingParamsIn(
|
||||
const std::string& dir,
|
||||
const std::vector<std::pair<std::string, std::string>>& digests);
|
||||
|
||||
/**
|
||||
* @brief Get the Sapling params directory
|
||||
*/
|
||||
@@ -119,6 +133,11 @@ public:
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
// Whether plaintext credentials to a remote host are explicitly allowed (opt-in via the
|
||||
// DRAGONX.conf rpcallowplaintext key). Off by default: usesPlaintextRemote() && !this
|
||||
// means the connect is refused.
|
||||
static bool allowsPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
|
||||
@@ -146,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique<Impl>())
|
||||
{
|
||||
}
|
||||
|
||||
RPCClient::~RPCClient() = default;
|
||||
RPCClient::~RPCClient() {
|
||||
// Scrub the persistent Basic-auth secret on destruction (disconnect() may not have run). impl_ is
|
||||
// still destroyed normally afterward (curl cleanup unchanged). (L-04)
|
||||
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size());
|
||||
}
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password)
|
||||
@@ -166,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
|
||||
// "user:password" temporary (std::string does not zero its buffer on destruction).
|
||||
std::string credentials = user + ":" + password;
|
||||
if (!auth_.empty()) sodium_memzero(auth_.data(), auth_.size()); // wipe any prior secret before overwrite (L-04)
|
||||
auth_ = util::base64_encode(credentials);
|
||||
if (!credentials.empty()) sodium_memzero(credentials.data(), credentials.size());
|
||||
|
||||
@@ -193,6 +198,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
impl_->headers = curl_slist_append(nullptr, "Content-Type: text/plain");
|
||||
std::string auth_header = "Authorization: Basic " + auth_;
|
||||
impl_->headers = curl_slist_append(impl_->headers, auth_header.c_str());
|
||||
if (!auth_header.empty()) sodium_memzero(auth_header.data(), auth_header.size()); // curl copied it (L-04)
|
||||
|
||||
// Configure curl
|
||||
curl_easy_setopt(impl_->curl, CURLOPT_URL, impl_->url.c_str());
|
||||
@@ -299,6 +305,7 @@ void RPCClient::disconnect()
|
||||
curl_slist_free_all(impl_->headers);
|
||||
impl_->headers = nullptr;
|
||||
}
|
||||
if (!auth_.empty()) { sodium_memzero(auth_.data(), auth_.size()); auth_.clear(); } // scrub Basic-auth secret (L-04)
|
||||
}
|
||||
|
||||
json RPCClient::makePayload(const std::string& method, const json& params)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
@@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
||||
{
|
||||
if (!unspent.is_array()) return;
|
||||
|
||||
std::map<std::string, double> balances;
|
||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||
std::map<std::string, double> total;
|
||||
std::map<std::string, double> spendable;
|
||||
for (const auto& output : unspent) {
|
||||
auto address = readOptional<std::string>(output, "address");
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (address && amount) balances[*address] += *amount;
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (!address || !amount) continue;
|
||||
total[*address] += *amount;
|
||||
auto conf = readOptional<int>(output, "confirmations");
|
||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
||||
}
|
||||
|
||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
|
||||
for (auto& info : addresses) {
|
||||
auto balance = balances.find(info.address);
|
||||
if (balance != balances.end()) info.balance = balance->second;
|
||||
auto t = total.find(info.address);
|
||||
auto s = spendable.find(info.address);
|
||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +261,7 @@ NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnec
|
||||
}
|
||||
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
|
||||
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||
const json& totalBalance, const json& spendableBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||
{
|
||||
CoreRefreshResult result;
|
||||
result.balanceOk = balanceOk && totalBalance.is_object();
|
||||
@@ -258,6 +270,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||
}
|
||||
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
|
||||
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
|
||||
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
|
||||
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
|
||||
}
|
||||
|
||||
result.blockchainOk = blockOk && blockInfo.is_object();
|
||||
if (result.blockchainOk) {
|
||||
@@ -274,17 +291,28 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
||||
{
|
||||
json totalBalance;
|
||||
json spendableBalance;
|
||||
json blockInfo;
|
||||
bool balanceOk = false;
|
||||
bool blockOk = false;
|
||||
double balanceScanMs = 0.0;
|
||||
|
||||
if (includeBalance) {
|
||||
try {
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array());
|
||||
// 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();
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -294,7 +322,9 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
|
||||
auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||
result.balanceScanMs = balanceScanMs;
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
@@ -426,12 +456,21 @@ std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::
|
||||
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
||||
|
||||
const auto& data = parsed["dragonx-2"];
|
||||
// CoinGecko emits JSON null (not an omitted key) for fields it can't currently compute —
|
||||
// commonly usd_24h_change on illiquid/newly-listed tokens — while still returning a valid
|
||||
// spot price in the same object. .value(key, default) throws type_error on a PRESENT null,
|
||||
// which the outer catch turns into "no price update at all", so read null-tolerantly and
|
||||
// keep the valid usd/btc rather than discarding the whole refresh.
|
||||
auto num = [&data](const char* key, double def) {
|
||||
auto it = data.find(key);
|
||||
return (it != data.end() && it->is_number()) ? it->get<double>() : def;
|
||||
};
|
||||
PriceRefreshResult result;
|
||||
result.market.price_usd = data.value("usd", 0.0);
|
||||
result.market.price_btc = data.value("btc", 0.0);
|
||||
result.market.change_24h = data.value("usd_24h_change", 0.0);
|
||||
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
|
||||
result.market.market_cap = data.value("usd_market_cap", 0.0);
|
||||
result.market.price_usd = num("usd", 0.0);
|
||||
result.market.price_btc = num("btc", 0.0);
|
||||
result.market.change_24h = num("usd_24h_change", 0.0);
|
||||
result.market.volume_24h = num("usd_24h_vol", 0.0);
|
||||
result.market.market_cap = num("usd_market_cap", 0.0);
|
||||
|
||||
char buf[64];
|
||||
// Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the
|
||||
@@ -599,18 +638,23 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the shielded list may be falsely short
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("z_listunspent", json::array());
|
||||
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
|
||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try {
|
||||
json balance = rpc.call("z_getbalance", json::array({info.address}));
|
||||
if (!balance.is_null()) info.balance = balance.get<double>();
|
||||
try { // display total (minconf=0, includes pending change)
|
||||
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||
if (!total.is_null()) info.balance = total.get<double>();
|
||||
} catch (...) {}
|
||||
try { // spendable (minconf=1); degrade to the display value on old daemons
|
||||
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
|
||||
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||
} catch (...) { info.spendableBalance = info.balance; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -619,10 +663,11 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
result.addressListOk = false; // enumeration failed → the transparent list may be falsely short
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("listunspent", json::array());
|
||||
json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change
|
||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||
@@ -1101,12 +1146,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe
|
||||
std::set<std::string> reported;
|
||||
for (const auto& op : result) {
|
||||
if (!op.is_object()) continue;
|
||||
std::string opid = op.value("id", std::string());
|
||||
// Type-checked reads: .value(key, default) throws if the key is PRESENT with a non-string
|
||||
// type, which would abort the whole poll (and wedge it for the session — see the call site).
|
||||
if (!op.contains("id") || !op["id"].is_string()) continue;
|
||||
std::string opid = op["id"].get<std::string>();
|
||||
if (opid.empty()) continue;
|
||||
if (requested.find(opid) == requested.end()) continue; // not one of ours — ignore
|
||||
reported.insert(opid);
|
||||
|
||||
std::string status = op.value("status", std::string());
|
||||
std::string status = (op.contains("status") && op["status"].is_string())
|
||||
? op["status"].get<std::string>() : std::string();
|
||||
if (status == "success") {
|
||||
parsed.doneOpids.push_back(opid);
|
||||
parsed.anySuccess = true;
|
||||
@@ -1184,6 +1233,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
||||
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
||||
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
||||
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
|
||||
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
|
||||
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
|
||||
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
|
||||
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
|
||||
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
|
||||
state.last_balance_update = updatedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,12 @@ public:
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
|
||||
std::optional<double> spendableTransparentBalance;
|
||||
std::optional<double> spendableTotalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
@@ -108,6 +111,7 @@ public:
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped)
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
@@ -146,6 +150,10 @@ public:
|
||||
struct AddressRefreshResult {
|
||||
std::vector<AddressInfo> shieldedAddresses;
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
// False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the
|
||||
// lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the
|
||||
// empty-wallet warning) must not trust a 0 count unless this is true.
|
||||
bool addressListOk = true;
|
||||
};
|
||||
|
||||
struct AddressRefreshSnapshot {
|
||||
@@ -227,6 +235,7 @@ public:
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
const nlohmann::json& spendableBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
#include "wallet_security_controller.h"
|
||||
#include "../util/secure_vault.h"
|
||||
#include "../util/address_validation.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
@@ -108,18 +111,35 @@ WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(c
|
||||
|
||||
bool WalletSecurityController::isViewingKey(const std::string& key)
|
||||
{
|
||||
// Sapling extended full viewing key (mainnet HRP "zxviews"; "zxview" also matches the prefix the
|
||||
// lite backend recognizes). Watch-only: reveals the address's funds but cannot spend them.
|
||||
return key.rfind("zxview", 0) == 0;
|
||||
// DragonX's z_exportviewingkey returns a Sapling *incoming* viewing key (mainnet HRP "zivks");
|
||||
// z_importviewingkey only decodes that form. Recognize it structurally — a valid Bech32 checksum
|
||||
// plus a known HRP — instead of a bare prefix, and cover testnet/regtest too. (The old check
|
||||
// looked for Zcash's "zxview" extended-FVK HRP, which DragonX never emits, so every real viewing
|
||||
// key was rejected client-side.) Watch-only: reveals the address's funds but cannot spend them.
|
||||
const std::string hrp = util::bech32Hrp(key);
|
||||
return hrp == "zivks" // mainnet
|
||||
|| hrp == "zivktestsapling" // testnet
|
||||
|| hrp == "zivkregtestsapling"; // regtest
|
||||
}
|
||||
|
||||
bool WalletSecurityController::isRecognizedPrivateKey(const std::string& key)
|
||||
{
|
||||
// Sapling z spending key (HRP "secret-extended-key-{main,test,regtest}"). These run ~300 chars,
|
||||
// past the Bech32 length cap, so match by HRP prefix and let the daemon vet the payload.
|
||||
if (key.rfind("secret-extended-key-", 0) == 0) return true; // Sapling z spending key
|
||||
if (key.size() >= 2 && key[0] == 'S' && key[1] == 'K') return true; // Sprout z spending key
|
||||
// Transparent WIF: base58, ~51-52 chars, common version prefixes.
|
||||
if (key.size() >= 51 && key.size() <= 52 &&
|
||||
(key[0] == '5' || key[0] == 'K' || key[0] == 'L' || key[0] == 'U')) return true;
|
||||
// Transparent WIF: decode Base58Check and confirm it is actually a secret key — version byte plus
|
||||
// a 32-byte key, optionally a compression flag (payload 33 or 34 bytes). This accepts BOTH the
|
||||
// compressed ("U…") and uncompressed ("7…") mainnet forms and the testnet form, and rejects
|
||||
// addresses / typos via the real checksum — the old length+first-char heuristic dropped the
|
||||
// uncompressed mainnet key (which starts with '7', not one of 5/K/L/U).
|
||||
std::vector<std::uint8_t> payload;
|
||||
if (util::decodeBase58Check(key, payload) &&
|
||||
(payload.size() == 33 || payload.size() == 34) &&
|
||||
(payload[0] == 188 /* DragonX main/regtest SECRET_KEY */ ||
|
||||
payload[0] == 128 /* DragonX testnet SECRET_KEY */)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,7 @@ public:
|
||||
std::size_t minLength = 4);
|
||||
static KeyKind classifyAddress(const std::string& address);
|
||||
static KeyKind classifyPrivateKey(const std::string& key);
|
||||
// True if `key` is a shielded viewing key (extended full viewing key, "zxview…" — watch-only).
|
||||
// True if `key` is a shielded viewing key (Sapling incoming viewing key, "zivks…" — watch-only).
|
||||
static bool isViewingKey(const std::string& key);
|
||||
// True if `key` looks like a recognized Z (Sapling/Sprout spending) or T (WIF) private key.
|
||||
static bool isRecognizedPrivateKey(const std::string& key);
|
||||
|
||||
@@ -173,6 +173,24 @@ inline float kSidePanelMinWidth() { return schema::UI().drawElement("panels",
|
||||
inline float kSidePanelMaxWidth() { return schema::UI().drawElement("panels", "side-panel").getFloat("max-width", 450.0f) * dpiScale(); }
|
||||
inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels", "side-panel").getFloat("width-ratio", 0.4f); }
|
||||
|
||||
// Overall content-column cap: the max width a tab's content occupies before it is centered in wider
|
||||
// windows. <= 0 disables the cap so tab content fills ALL available horizontal width (the default —
|
||||
// requested so large windows don't leave a big empty gutter on the right). Set a positive
|
||||
// ui.toml [layout] content-max-width to re-enable a centered readable column.
|
||||
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(0.0f) * dpiScale(); }
|
||||
|
||||
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
|
||||
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
|
||||
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
|
||||
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
|
||||
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
|
||||
struct CardBox { float width; float offsetX; };
|
||||
inline CardBox mainComposeCardBox(float availW) {
|
||||
float cap = kContentMaxWidth();
|
||||
float w = (cap > 0.0f) ? std::min(availW, cap) : availW; // cap <= 0 -> fill full width
|
||||
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
|
||||
}
|
||||
|
||||
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
|
||||
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }
|
||||
|
||||
|
||||
@@ -116,6 +116,26 @@ inline ImVec4 WarningVec4() { return ImGui::ColorConvertU32ToFloat4(Warni
|
||||
// Convenience Functions for Common Patterns
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* @brief Theme-aware translucent overlay for tracks / hover fills / dividers.
|
||||
*
|
||||
* A raw white overlay (IM_COL32(255,255,255,a)) reads on dark skins but vanishes
|
||||
* on light/pastel skins (white-on-white). This picks a dark overlay on light
|
||||
* themes and a white overlay on dark themes so the alpha reads either way.
|
||||
* (Self-contained luminance check so colors.h stays free of draw_helpers.h.)
|
||||
*
|
||||
* @param alpha 0-255 opacity of the overlay
|
||||
*/
|
||||
inline ImU32 SurfaceOverlay(int alpha)
|
||||
{
|
||||
ImU32 bg = Background();
|
||||
float r = ((bg >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
|
||||
float g = ((bg >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
|
||||
float b = ((bg >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
|
||||
bool light = (0.299f * r + 0.587f * g + 0.114f * b) > 0.5f;
|
||||
return light ? IM_COL32(0, 0, 0, alpha) : IM_COL32(255, 255, 255, alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Get color with applied state overlay
|
||||
*
|
||||
|
||||
+103
-15
@@ -79,6 +79,63 @@ inline const char* LoadingDots() {
|
||||
return kDots[n];
|
||||
}
|
||||
|
||||
// ── Centered empty state ─────────────────────────────────────────────────
|
||||
// A big muted icon + title + optional wrapped hint, centered on BOTH axes within
|
||||
// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states
|
||||
// read the same across tabs. Call at the start of the region you want it centered in
|
||||
// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font
|
||||
// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is
|
||||
// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics.
|
||||
inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr)
|
||||
{
|
||||
auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; };
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const ImVec2 origin = ImGui::GetCursorPos();
|
||||
ImFont* iconF = Type().iconXL();
|
||||
ImFont* titleF = Type().subtitle1();
|
||||
ImFont* hintF = Type().body2();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float gap = 8.0f * dp;
|
||||
const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp);
|
||||
const float iconSz = iconF ? scaled(iconF) : 40.0f;
|
||||
const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y;
|
||||
const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f;
|
||||
const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f);
|
||||
float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f);
|
||||
|
||||
if (iconF && iconGlyph && iconGlyph[0]) {
|
||||
const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y));
|
||||
ImGui::PushFont(iconF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70));
|
||||
ImGui::TextUnformatted(iconGlyph);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
y += iconH + gap;
|
||||
}
|
||||
{
|
||||
const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x;
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y));
|
||||
ImGui::PushFont(titleF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium());
|
||||
ImGui::TextUnformatted(title);
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
y += titleH + gap;
|
||||
}
|
||||
if (hint) {
|
||||
ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y));
|
||||
ImGui::PushFont(hintF);
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap);
|
||||
ImGui::TextUnformatted(hint);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
ImGui::PopFont();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Text Drop Shadow
|
||||
// ============================================================================
|
||||
@@ -487,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
|
||||
ImVec2 bMin = ImGui::GetItemRectMin();
|
||||
ImVec2 bMax = ImGui::GetItemRectMax();
|
||||
|
||||
// For icon fonts, manually draw centered icon after getting button rect
|
||||
// For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
|
||||
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
|
||||
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
|
||||
// shove the glyph left off-center (and try to draw the notdef id chars).
|
||||
if (isIconFont && size.x > 0 && size.y > 0) {
|
||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label);
|
||||
const char* labelEnd = label;
|
||||
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
|
||||
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
|
||||
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
|
||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label);
|
||||
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
|
||||
}
|
||||
|
||||
float rounding = ImGui::GetStyle().FrameRounding;
|
||||
@@ -1403,6 +1465,7 @@ struct OverlayCardState {
|
||||
int stableCount = 0; // consecutive frames the height held steady (within 1px)
|
||||
int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap
|
||||
bool shown = false; // revealed (centered) at least once this open; don't re-hide after
|
||||
bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open)
|
||||
};
|
||||
inline std::unordered_map<std::string, OverlayCardState> g_overlayCardHeights;
|
||||
inline std::string g_overlayCurrentKey;
|
||||
@@ -1528,6 +1591,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f;
|
||||
float cardY, cardBottomY;
|
||||
bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame
|
||||
bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll
|
||||
const bool fixedHeight = (spec.cardHeight > 0.0f);
|
||||
if (fixedHeight) {
|
||||
float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f);
|
||||
@@ -1537,9 +1601,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
} else {
|
||||
g_overlayCurrentKey = childId;
|
||||
OverlayCardState& cs = g_overlayCardHeights[childId];
|
||||
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; }
|
||||
if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; }
|
||||
if (!cs.shown) cs.appearFrames++;
|
||||
const float measuredH = cs.height;
|
||||
const float maxCardH = vp_size.y - 32.0f;
|
||||
// Once the measured content is taller than the viewport, lock the card to the viewport height and
|
||||
// let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this
|
||||
// open: clamping makes next frame's measured height the clamped value, so re-deciding from it would
|
||||
// oscillate — decide once and hold until the dialog re-opens.
|
||||
if (measuredH > maxCardH) cs.overflow = true;
|
||||
autoOverflow = cs.overflow;
|
||||
// Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's
|
||||
// already been shown this open (don't re-hide on a mid-dialog content change); a frame cap
|
||||
// guarantees a pathological ever-changing height can't hide the dialog forever.
|
||||
@@ -1547,11 +1618,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
(cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8);
|
||||
if (ready) {
|
||||
cs.shown = true;
|
||||
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
||||
cardY = (measuredH < vp_size.y - 32.0f)
|
||||
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
||||
: vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + measuredH;
|
||||
if (autoOverflow) {
|
||||
// Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the
|
||||
// content child (below) becomes the scroll region so the footer/actions stay reachable.
|
||||
cardY = vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + maxCardH;
|
||||
} else {
|
||||
// Center the measured content; if it's taller than the window, anchor at the top margin.
|
||||
cardY = (measuredH < maxCardH)
|
||||
? vp_pos.y + (vp_size.y - measuredH) * 0.5f
|
||||
: vp_pos.y + 16.0f;
|
||||
cardBottomY = cardY + measuredH;
|
||||
}
|
||||
} else {
|
||||
// Still settling: lay the content out (so the auto-height child gets measured) but keep
|
||||
// the card hidden (hideForMeasure below) so it never flashes off-center — it appears,
|
||||
@@ -1567,7 +1645,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
|
||||
if (!floating && !hideForMeasure) {
|
||||
GlassPanelSpec cardGlass;
|
||||
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f;
|
||||
// Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
|
||||
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
|
||||
// tiles, chat) while staying translucent rather than opaque.
|
||||
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
|
||||
DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
|
||||
}
|
||||
|
||||
@@ -1584,14 +1665,21 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind)
|
||||
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY);
|
||||
// A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose
|
||||
// content overflowed the viewport); otherwise the child auto-resizes to its content.
|
||||
const bool clampedCard = fixedHeight || autoOverflow;
|
||||
ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY);
|
||||
// NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift
|
||||
// the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll
|
||||
// the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on
|
||||
// their own; auto-height cards resize to content so they never overflow anyway.
|
||||
// their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the
|
||||
// content is taller than the viewport (autoOverflow), where the card itself IS the scroll region.
|
||||
ImGuiWindowFlags childScroll = autoOverflow
|
||||
? ImGuiWindowFlags_None
|
||||
: (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||
ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f),
|
||||
cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f),
|
||||
cflags, childScroll);
|
||||
// Floating (portfolio-style) cards: the padding applies to this content child only, so pop it
|
||||
// now (nested children mustn't inherit it), and center button labels. Net style-var count stays
|
||||
// at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged.
|
||||
@@ -1714,7 +1802,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa
|
||||
inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel,
|
||||
bool danger, bool& outCancel, bool& outConfirm)
|
||||
{
|
||||
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f);
|
||||
float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) {
|
||||
outCancel = true;
|
||||
|
||||
@@ -107,7 +107,7 @@ inline float ActionButtonWidth(const char* label, const char* icon, float minWid
|
||||
ImFont* lf = Type().button();
|
||||
ImFont* icf = Type().iconSmall();
|
||||
const float dp = Layout::dpiScale();
|
||||
const float padX = 12.0f * dp, gap = 6.0f * dp;
|
||||
const float padX = 9.0f * dp, gap = 6.0f * dp; // mockup .btn padding: 9px horizontal
|
||||
const float labelW = lf->CalcTextSizeA(lf->LegacySize, FLT_MAX, 0, label).x;
|
||||
const float iconW = (icon && icon[0] && icf) ? icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x : 0.0f;
|
||||
const float w = padX * 2.0f + iconW + (iconW > 0.0f ? gap : 0.0f) + labelW;
|
||||
@@ -133,19 +133,21 @@ inline bool ActionButton(const char* id, const char* label, const char* icon, Ac
|
||||
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMax(pos.x + w, pos.y + h);
|
||||
const float round = ImGui::GetStyle().FrameRounding;
|
||||
const float round = 7.0f * dp; // mockup .btn radius: 7px (softer than the global 4px frame)
|
||||
const float a = ImGui::GetStyle().Alpha; // BeginDisabled() lowers this
|
||||
|
||||
ImU32 bg = 0, border = 0, fg = OnSurface();
|
||||
bool glass = false;
|
||||
switch (tier) {
|
||||
case ActionTier::Primary:
|
||||
bg = WithAlpha(Primary(), act ? 255 : (hov ? 245 : 220));
|
||||
fg = IM_COL32(255, 255, 255, 240);
|
||||
// Mockup .btn.acc: a dark accent-tinted chip with accent TEXT — not a bright filled button.
|
||||
bg = WithAlpha(Primary(), hov ? 52 : 38);
|
||||
border = WithAlpha(Primary(), hov ? 150 : 110);
|
||||
fg = Primary();
|
||||
break;
|
||||
case ActionTier::Secondary:
|
||||
bg = WithAlpha(OnSurface(), hov ? 26 : 16);
|
||||
border = WithAlpha(OnSurface(), 40);
|
||||
bg = WithAlpha(OnSurface(), hov ? 30 : 20);
|
||||
border = WithAlpha(OnSurface(), 48);
|
||||
glass = true;
|
||||
fg = OnSurface();
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
};
|
||||
|
||||
// Status bar geometry
|
||||
float sbHeight = S.window("components.status-bar").height;
|
||||
if (sbHeight <= 0.0f) sbHeight = 30.0f;
|
||||
// Status bar geometry. These are logical-px schema values; the icon/text drawn into the pill
|
||||
// are DPI-baked, so scale the box by dpiScale to match the (also DPI-scaled) rendered status bar
|
||||
// and keep the icon/text inside the pill at HiDPI.
|
||||
const float dp = Layout::dpiScale();
|
||||
float sbHeight = S.window("components.status-bar").height * dp;
|
||||
if (sbHeight <= 0.0f) sbHeight = 30.0f * dp;
|
||||
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
float viewBottom = viewport->WorkPos.y + viewport->WorkSize.y;
|
||||
float viewCenterX = viewport->WorkPos.x + viewport->WorkSize.x * 0.5f;
|
||||
|
||||
// Toast pill sizing — fit inside status bar with margin
|
||||
float pillMarginY = nde("pill-margin-y", 3.0f);
|
||||
float pillMarginY = nde("pill-margin-y", 3.0f) * dp;
|
||||
float pillHeight = sbHeight - pillMarginY * 2.0f;
|
||||
float pillPadX = nde("padding-x", 12.0f);
|
||||
float pillRounding = nde("pill-rounding", 12.0f);
|
||||
float pillPadX = nde("padding-x", 12.0f) * dp;
|
||||
float pillRounding = nde("pill-rounding", 12.0f) * dp;
|
||||
|
||||
// Get accent color based on type — resolved from theme palette
|
||||
ImVec4 accent_color, text_color;
|
||||
@@ -89,7 +92,7 @@ void Notifications::render()
|
||||
ImFont* textFont = material::Type().caption();
|
||||
ImFont* iconFont = material::Type().iconSmall();
|
||||
float iconW = iconFont ? iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0.0f, icon).x : 0.0f;
|
||||
float iconGap = 4.0f;
|
||||
float iconGap = 4.0f * dp;
|
||||
float msgW = textFont ? textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, notif.message.c_str()).x : 100.0f;
|
||||
float pillWidth = pillPadX + iconW + iconGap + msgW + pillPadX;
|
||||
// Clamp to reasonable bounds
|
||||
@@ -122,7 +125,7 @@ void Notifications::render()
|
||||
// Progress bar at bottom of pill (accent-colored), clipped to pill rounded
|
||||
// corners. Draw a full-pill-size rounded rect and clip it to just the
|
||||
// bottom-left progress strip so both bottom corners are respected.
|
||||
float progH = nde("progress-bar-height", 2.0f);
|
||||
float progH = nde("progress-bar-height", 2.0f) * dp;
|
||||
float progW = pillWidth * (1.0f - progress);
|
||||
if (progW > 0.0f) {
|
||||
ImVec2 clipMin(pillX, pMax.y - progH);
|
||||
|
||||
+53
-7
@@ -9,6 +9,8 @@
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include "../util/logger.h"
|
||||
#include "schema/ui_schema.h"
|
||||
|
||||
@@ -22,6 +24,17 @@ enum class NotificationType {
|
||||
Error
|
||||
};
|
||||
|
||||
// A retained alert for the persistent history panel. Unlike a live Notification (which fades and is
|
||||
// erased within seconds), this keeps a wall-clock epoch so its age can be shown as "3m ago" long
|
||||
// after the toast is gone. See App::renderAlertHistoryPanel.
|
||||
struct AlertRecord {
|
||||
std::string message;
|
||||
NotificationType type;
|
||||
std::int64_t epoch; // std::time(nullptr) at push — wall-clock, for relative-age display
|
||||
std::function<void()> onClick; // optional: makes this bell-panel entry actionable
|
||||
std::string actionHint; // optional: accent link label rendered for the action
|
||||
};
|
||||
|
||||
struct Notification {
|
||||
std::string message;
|
||||
NotificationType type;
|
||||
@@ -81,23 +94,43 @@ public:
|
||||
if (duration < 0.0f) duration = schemaDuration("duration-error", 4.0f);
|
||||
push(message, NotificationType::Error, duration);
|
||||
}
|
||||
|
||||
// An actionable alert: a normal toast PLUS a clickable entry in the bell/alert-history panel.
|
||||
// onClick fires when the user clicks the accent `actionHint` link in that panel.
|
||||
void action(const std::string& message, NotificationType type, std::function<void()> onClick,
|
||||
const std::string& actionHint, float duration = -1.0f) {
|
||||
if (duration < 0.0f) duration = schemaDuration("duration-warning", 3.5f);
|
||||
push(message, type, duration, std::move(onClick), actionHint);
|
||||
}
|
||||
|
||||
void push(const std::string& message, NotificationType type, float duration = 5.0f) {
|
||||
void push(const std::string& message, NotificationType type, float duration = 5.0f,
|
||||
std::function<void()> onClick = nullptr, const std::string& actionHint = "") {
|
||||
notifications_.emplace_back(message, type, duration);
|
||||
|
||||
|
||||
// Retain a copy in the persistent history (the toast above will fade in seconds; this
|
||||
// survives so the user can review what happened). Thread note: every push is on the UI
|
||||
// thread (RPC results run as main-thread MainCb callbacks), so this container needs no lock,
|
||||
// consistent with the rest of this class. Do NOT push from a raw worker thread.
|
||||
history_.push_back(AlertRecord{message, type, static_cast<std::int64_t>(std::time(nullptr)),
|
||||
std::move(onClick), actionHint});
|
||||
++total_pushed_;
|
||||
while (history_.size() > kMaxHistory) {
|
||||
history_.pop_front();
|
||||
}
|
||||
|
||||
// Log errors and warnings (debug-only output)
|
||||
if (type == NotificationType::Error) {
|
||||
DEBUG_LOGF("[ERROR] Notification: %s\n", message.c_str());
|
||||
} else if (type == NotificationType::Warning) {
|
||||
DEBUG_LOGF("[WARN] Notification: %s\n", message.c_str());
|
||||
}
|
||||
|
||||
|
||||
// Forward errors and warnings to console callback
|
||||
if (console_callback_ && (type == NotificationType::Error || type == NotificationType::Warning)) {
|
||||
const char* prefix = (type == NotificationType::Error) ? "[ERROR] " : "[WARN] ";
|
||||
console_callback_(prefix + message, type == NotificationType::Error);
|
||||
}
|
||||
|
||||
|
||||
// Limit max notifications
|
||||
while (notifications_.size() > max_notifications_) {
|
||||
notifications_.pop_front();
|
||||
@@ -122,21 +155,34 @@ public:
|
||||
void clear() {
|
||||
notifications_.clear();
|
||||
}
|
||||
|
||||
|
||||
void setMaxNotifications(size_t max) {
|
||||
max_notifications_ = max;
|
||||
}
|
||||
|
||||
|
||||
// ── Persistent alert history (for the status-bar bell panel) ──
|
||||
/// Retained alerts, oldest first (capped at kMaxHistory; the toast deque is separate).
|
||||
const std::deque<AlertRecord>& history() const { return history_; }
|
||||
bool hasHistory() const { return !history_.empty(); }
|
||||
void clearHistory() { history_.clear(); }
|
||||
/// Monotonic count of every alert ever pushed this session — survives capping/clearing, so it is
|
||||
/// the correct basis for an "unseen since last opened" count (deque size is not).
|
||||
std::uint64_t totalPushed() const { return total_pushed_; }
|
||||
|
||||
private:
|
||||
Notifications() = default;
|
||||
~Notifications() = default;
|
||||
Notifications(const Notifications&) = delete;
|
||||
Notifications& operator=(const Notifications&) = delete;
|
||||
|
||||
|
||||
std::deque<Notification> notifications_;
|
||||
size_t max_notifications_ = 5;
|
||||
std::function<void(const std::string&, bool)> console_callback_;
|
||||
|
||||
std::deque<AlertRecord> history_;
|
||||
std::uint64_t total_pushed_ = 0;
|
||||
static constexpr size_t kMaxHistory = 100;
|
||||
|
||||
static float schemaDuration(const char* key, float fallback) {
|
||||
float v = schema::UI().drawElement("components.notifications", key).size;
|
||||
return v > 0.0f ? v : fallback;
|
||||
|
||||
+849
-788
File diff suppressed because it is too large
Load Diff
+29
-9
@@ -538,11 +538,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
float exitRelY = curY + bottomPadding;
|
||||
float panelH = exitRelY + stripH;
|
||||
|
||||
// Vertical centering — offset so panel is centered in the child window
|
||||
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
||||
if (centerOffset + panelH > contentHeight)
|
||||
centerOffset = std::max(0.0f, contentHeight - panelH);
|
||||
|
||||
// ===================================================================
|
||||
// PASS 2: Render using computed positions
|
||||
// ===================================================================
|
||||
@@ -552,6 +547,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImVec2 wp = ImGui::GetWindowPos();
|
||||
|
||||
// Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight)
|
||||
// to the visible area (child top -> status-bar top) using window-local geometry, so this yields
|
||||
// equal top/bottom gaps at any height on every platform, no viewport dependency.
|
||||
float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f);
|
||||
if (centerOffset + panelH > contentHeight)
|
||||
centerOffset = std::max(0.0f, contentHeight - panelH);
|
||||
|
||||
float panelLeft = wp.x + glassMarginL;
|
||||
float panelRight = wp.x + sidebarWidth - glassMarginR;
|
||||
float panelTopY = wp.y + centerOffset;
|
||||
@@ -676,17 +678,33 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium());
|
||||
|
||||
if (showLabels) {
|
||||
// Reserve room for a badge (if this item will draw one) so the
|
||||
// label centers in the space to the left of it instead of
|
||||
// running underneath the badge circle.
|
||||
bool itemHasBadge =
|
||||
(item.page == NavPage::History && status.unconfirmedTxCount > 0) ||
|
||||
(item.page == NavPage::Mining && status.miningActive) ||
|
||||
(item.page == NavPage::Peers && status.peerCount > 0) ||
|
||||
(item.page == NavPage::Chat && status.chatUnreadCount > 0);
|
||||
float badgeReserve = 0.0f;
|
||||
if (itemHasBadge) {
|
||||
bool dotOnlyReserve = (item.page == NavPage::Mining);
|
||||
float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber;
|
||||
float badgeInsetXReserve = sde("badge-inset-x", 6.0f);
|
||||
badgeReserve = badgeRReserve * 2.0f + badgeInsetXReserve;
|
||||
}
|
||||
|
||||
ImFont* font = selected ? Type().subtitle2() : Type().body2();
|
||||
float lblFsz = ScaledFontSize(font);
|
||||
float btnW = indMax.x - indMin.x;
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2;
|
||||
float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve;
|
||||
ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||
if (labelSz.x > maxLabelW && maxLabelW > 0) {
|
||||
lblFsz *= maxLabelW / labelSz.x;
|
||||
labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item));
|
||||
}
|
||||
float totalW = iconS * 2.0f + iconLabelGap + labelSz.x;
|
||||
float btnCX = (indMin.x + indMax.x) * 0.5f;
|
||||
float btnCX = (indMin.x + indMax.x - badgeReserve) * 0.5f;
|
||||
float startX = btnCX - totalW * 0.5f;
|
||||
|
||||
DrawNavIcon(dl, item.page, startX + iconS, iconCY, iconS, textCol);
|
||||
@@ -722,8 +740,10 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei
|
||||
|
||||
if (badgeCount > 0 || dotOnly) {
|
||||
float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber;
|
||||
float bx = indMax.x - badgeR - 6.0f;
|
||||
float by = indMin.y + badgeR + 5.0f;
|
||||
float badgeInsetX = sde("badge-inset-x", 6.0f);
|
||||
float badgeInsetY = sde("badge-inset-y", 5.0f);
|
||||
float bx = indMax.x - badgeR - badgeInsetX;
|
||||
float by = indMin.y + badgeR + badgeInsetY;
|
||||
dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol);
|
||||
if (!dotOnly && showLabels) {
|
||||
char buf[16];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
|
||||
// Refresh-staleness badge (finding W6-2). The wallet stamps WalletState::last_balance_update only on
|
||||
// a *successful* balance fetch (see services/network_refresh_service.cpp), so a busy daemon that fails
|
||||
// z_gettotalbalance without dropping the whole connection leaves the old balance on screen with a
|
||||
// frozen timestamp — and the node-status banner (which only fires on a full disconnect) stays hidden.
|
||||
// This badge is the surface that reflects that "connected but the number may be out of date" state.
|
||||
//
|
||||
// The decision is a pure function of (last-success timestamp, now, connected) so it is unit-testable;
|
||||
// balance_tab.cpp draws the pill. Both use the same std::time(nullptr) wall-clock the refresh path
|
||||
// stamps with, so age = now - last_update is consistent.
|
||||
namespace dragonx::ui {
|
||||
|
||||
enum class StalenessSeverity {
|
||||
Warning, // amber — noticeably behind
|
||||
Error, // red — very stale, something is likely wrong
|
||||
};
|
||||
|
||||
struct StalenessBadge {
|
||||
bool show = false;
|
||||
StalenessSeverity severity = StalenessSeverity::Warning;
|
||||
std::int64_t seconds_old = 0;
|
||||
};
|
||||
|
||||
// Balance refreshes every ~2s on the Overview profile (and ~10s while syncing), so tens of seconds
|
||||
// with no successful update means refreshes are failing, not merely slow.
|
||||
inline constexpr std::int64_t kStaleAfterSeconds = 45;
|
||||
inline constexpr std::int64_t kVeryStaleAfterSeconds = 180;
|
||||
|
||||
inline StalenessBadge evaluateStalenessBadge(std::int64_t last_update, std::int64_t now, bool connected) {
|
||||
StalenessBadge b;
|
||||
// Offline is the node-status banner's job; don't double up. A zero stamp means "never updated
|
||||
// this session" (fresh start) or "reset on disconnect" — nothing to be stale about yet.
|
||||
if (!connected || last_update <= 0) return b;
|
||||
|
||||
std::int64_t age = now - last_update;
|
||||
if (age < 0) age = 0; // clock skew guard
|
||||
if (age < kStaleAfterSeconds) return b;
|
||||
|
||||
b.show = true;
|
||||
b.seconds_old = age;
|
||||
b.severity = (age >= kVeryStaleAfterSeconds) ? StalenessSeverity::Error : StalenessSeverity::Warning;
|
||||
return b;
|
||||
}
|
||||
|
||||
} // namespace dragonx::ui
|
||||
@@ -125,29 +125,26 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", TR("about_license_text"));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Links
|
||||
if (material::StyledButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
// Links — 3-button action row, centered via the shared footer helper (draws its own
|
||||
// Spacing/Separator/Spacing above the row, replacing the hand-rolled divider block).
|
||||
const float linksTotalW = linkW * 3.0f + ImGui::GetStyle().ItemSpacing.x * 2.0f;
|
||||
material::BeginOverlayDialogFooter(linksTotalW);
|
||||
if (material::TactileButton(TR("about_website"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://dragonx.is");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
if (material::TactileButton(TR("about_github"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
if (material::TactileButton(TR("about_block_explorer"), ImVec2(linkW, 0), S.resolveFont(linkBtn.font))) {
|
||||
util::Platform::openUrl("https://explorer.dragonx.is");
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Close button
|
||||
float button_width = closeW;
|
||||
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - button_width) * 0.5f);
|
||||
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
||||
|
||||
// Close button — lone dismiss action, centered via the shared footer helper (no extra
|
||||
// divider above it, so it sits directly under the links row).
|
||||
material::BeginOverlayDialogFooter(closeW, false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(closeW, 0), S.resolveFont(closeBtn.font))) {
|
||||
*p_open = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -138,8 +138,13 @@ public:
|
||||
const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve);
|
||||
const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY);
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
|
||||
// Scrollbar visible (not NoScrollbar) — the icon set exceeds the fixed-height
|
||||
// grid, so a real scrollbar is the discoverable way to reach the rest.
|
||||
ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ApplySmoothScroll();
|
||||
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
|
||||
@@ -185,6 +190,7 @@ public:
|
||||
}
|
||||
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
|
||||
ImGui::PopStyleColor();
|
||||
|
||||
if (ImGui::GetCursorPosY() < controlsTopY) {
|
||||
|
||||
@@ -169,9 +169,9 @@ public:
|
||||
}
|
||||
if (amountValid && newFromBal < 1e-9) {
|
||||
ImGui::Spacing();
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
|
||||
ImGui::TextWrapped("%s", TR("sends_full_balance_warning"));
|
||||
ImGui::PopStyleColor();
|
||||
// Full-balance send: same warning-icon treatment as the de-shielding header above,
|
||||
// so this stakes-bearing line reads as distinct from the neutral preview text.
|
||||
DialogWarningHeader(TR("sends_full_balance_warning"));
|
||||
}
|
||||
|
||||
// Buttons
|
||||
@@ -180,16 +180,14 @@ public:
|
||||
const char* sendingLabel = TR("sending");
|
||||
ImFont* buttonFont = Type().button();
|
||||
float buttonFontSize = ScaledFontSize(buttonFont);
|
||||
float minBtnW = 120.0f * dp;
|
||||
float confirmMinW = 160.0f * dp;
|
||||
float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
|
||||
float cancelW = std::max(minBtnW,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW);
|
||||
// Both footer buttons share one width (equal-width primary/Close pair), sized to fit the
|
||||
// widest label — the "Sending…" swap label included — so nothing clips.
|
||||
float confirmTextW = std::max(
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x,
|
||||
buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x);
|
||||
float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||
float totalW = cancelW + confirmW + Layout::spacingMd();
|
||||
float btnW = std::max(confirmMinW, confirmTextW + buttonPadW);
|
||||
float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed
|
||||
ImGuiViewport* vp = ImGui::GetMainViewport();
|
||||
float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f;
|
||||
@@ -201,19 +199,18 @@ public:
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
// Standardized primary + Close footer (centered, no divider). The primary is the
|
||||
// Confirm/"Sending…" action; the Close button dismisses the dialog.
|
||||
bool outConfirm = false;
|
||||
bool outClose = false;
|
||||
DialogActionFooter(s_sending ? sendingLabel : confirmLabel,
|
||||
amountValid && !s_sending,
|
||||
cancelLabel, outConfirm, outClose, btnW);
|
||||
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f));
|
||||
|
||||
if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) {
|
||||
if (outClose) {
|
||||
s_open = false;
|
||||
}
|
||||
ImGui::SameLine(0, Layout::spacingMd());
|
||||
|
||||
ImGui::BeginDisabled(!amountValid || s_sending);
|
||||
if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) {
|
||||
if (outConfirm) {
|
||||
s_sending = true;
|
||||
s_app->sendTransaction(s_info.fromAddr, s_info.toAddr,
|
||||
amount, s_fee, "",
|
||||
@@ -231,7 +228,6 @@ public:
|
||||
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
|
||||
// (with its own Close button). Previously closing here made that result screen dead code.
|
||||
}
|
||||
ImGui::EndDisabled();
|
||||
|
||||
EndOverlayDialog();
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@ void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float v
|
||||
|
||||
// Render the shared address list section (used by all layouts)
|
||||
void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
float glassRound, float hs, float vs) {
|
||||
float glassRound, float hs, float vs, float reserveBelow) {
|
||||
using namespace material;
|
||||
const auto& S = schema::UISchema::instance();
|
||||
const float dp = Layout::dpiScale();
|
||||
@@ -188,8 +188,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
}
|
||||
}
|
||||
|
||||
float buttonWidth = (addrBtn.width > 0) ? addrBtn.width : 140.0f;
|
||||
float spacing = (addrBtn.gap > 0) ? addrBtn.gap : 8.0f;
|
||||
float buttonWidth = ((addrBtn.width > 0) ? addrBtn.width : 140.0f) * dp;
|
||||
float spacing = ((addrBtn.gap > 0) ? addrBtn.gap : 8.0f) * dp;
|
||||
float totalButtonsWidth = buttonWidth * 2 + spacing;
|
||||
float kMinButtonsPosition = std::max(S.drawElement("tabs.balance", "min-buttons-position").size,
|
||||
S.drawElement("tabs.balance", "buttons-position").size * hs);
|
||||
@@ -225,6 +225,14 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
|
||||
// ---- Glass panel container ----
|
||||
float addrListH = listH;
|
||||
// Cap the card to the space that actually remains here (measured AFTER the title + toolbar are laid
|
||||
// out, so no chrome modelling is needed) minus what the caller reserves for the section below it
|
||||
// (recent-tx). Without this, a fixed dp-scaled listH grows ~1.5x at high font scale and evicts the
|
||||
// Recent Transactions list off the bottom of the fixed, non-scrolling tab host.
|
||||
if (reserveBelow > 0.0f) {
|
||||
float maxH = ImGui::GetContentRegionAvail().y - reserveBelow;
|
||||
if (maxH < addrListH) addrListH = maxH;
|
||||
}
|
||||
if (addrListH < 40.0f * dp) addrListH = 40.0f * dp;
|
||||
|
||||
ImDrawList* dlPanel = ImGui::GetWindowDrawList();
|
||||
@@ -324,12 +332,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
s_dragIdx < (int)rows.size()) {
|
||||
const auto& srcRow = rows[s_dragIdx];
|
||||
const auto& dstRow = rows[s_dropTargetIdx];
|
||||
if (srcRow.info->balance > 1e-9) {
|
||||
if (srcRow.info->spendableBalance > 1e-9) { // only offer a transfer of CONFIRMED funds
|
||||
AddressTransferDialog::TransferInfo ti;
|
||||
ti.fromAddr = srcRow.info->address;
|
||||
ti.toAddr = dstRow.info->address;
|
||||
ti.fromBalance = srcRow.info->balance;
|
||||
ti.toBalance = dstRow.info->balance;
|
||||
ti.fromBalance = srcRow.info->spendableBalance; // spend cap — z_sendmany runs at minconf=1
|
||||
ti.toBalance = dstRow.info->balance; // destination display only
|
||||
ti.fromIsZ = srcRow.isZ;
|
||||
ti.toIsZ = dstRow.isZ;
|
||||
AddressTransferDialog::show(app, ti);
|
||||
@@ -466,7 +474,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
{
|
||||
const auto& starRect = rowLayout.favoriteButton;
|
||||
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
|
||||
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -492,7 +501,8 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
if (showEye) {
|
||||
const auto& eyeRect = rowLayout.visibilityButton;
|
||||
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
// material::IsRectHovered so the button inherits overlay/popup input blocking
|
||||
bool bHov = material::IsRectHovered(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
||||
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
@@ -539,12 +549,24 @@ void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, miningTag);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf);
|
||||
|
||||
// User label next to type
|
||||
// User label next to type — clip to the gap before the right-aligned
|
||||
// balance so a long custom label can't overrun the balance on this line
|
||||
// (mirrors the address-line width guard below).
|
||||
if (!row.label.empty()) {
|
||||
float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy),
|
||||
OnSurfaceMedium(), row.label.c_str());
|
||||
float userLabelX = labelX + typeLabelW + Layout::spacingLg();
|
||||
// Balance is drawn right-aligned at contentRight; recompute its left edge here.
|
||||
char balBufPeek[32];
|
||||
snprintf(balBufPeek, sizeof(balBufPeek), "%.8f", addr.balance);
|
||||
float balW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBufPeek).x;
|
||||
float userLabelAvailW = (contentRight - balW - Layout::spacingMd()) - userLabelX;
|
||||
std::string userLabel = material::TruncateToWidth(
|
||||
row.label, capFont, capFont->LegacySize, userLabelAvailW);
|
||||
if (userLabelAvailW > 0.0f) {
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(userLabelX, cy),
|
||||
OnSurfaceMedium(), userLabel.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,6 +805,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
const float kRecentTxRowHeight = S.drawElement("tabs.balance", "recent-tx-row-height").sizeOr(22.0f);
|
||||
const auto& state = app->state();
|
||||
|
||||
float headerStartY = ImGui::GetCursorPosY();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("recent_transactions"));
|
||||
ImGui::SameLine();
|
||||
@@ -790,6 +813,7 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
app->setCurrentPage(NavPage::History);
|
||||
}
|
||||
ImGui::Spacing();
|
||||
float headerHeight = ImGui::GetCursorPosY() - headerStartY;
|
||||
|
||||
float scaledRowH = std::max(S.drawElement("tabs.balance", "recent-tx-row-min-height").size, kRecentTxRowHeight * vs);
|
||||
float availableListH = ImGui::GetContentRegionAvail().y;
|
||||
@@ -798,14 +822,17 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
|
||||
const auto& txs = state.transactions;
|
||||
int count = std::min(4, (int)txs.size()); // show only the 4 most recent (state.transactions is newest-first)
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
// Only draw as many rows as fully fit within the reserved section height (header + rows);
|
||||
// dropping the overflow row is fine since "View All" already links to full History.
|
||||
int maxRows = std::max(1, (int)((recentH - headerHeight) / rowH));
|
||||
int count = std::min({4, maxRows, (int)txs.size()}); // show only the most recent (state.transactions is newest-first)
|
||||
|
||||
if (count == 0) {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions_yet"));
|
||||
} else {
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
ImFont* capFont = Type().caption();
|
||||
float rowH = std::max(18.0f * dp, kRecentTxRowHeight * vs);
|
||||
float iconSz = std::max(S.drawElement("tabs.balance", "recent-tx-icon-min-size").size,
|
||||
S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs);
|
||||
|
||||
@@ -820,7 +847,11 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
// Start the address column past the MEASURED type-label width (plus a fixed gap) so it can
|
||||
// never overlap the label — a fixed schema offset shrinks below the label width at narrow
|
||||
// widths (hs < 1) and collides. Mirrors how amtX/agoSz measure their own text below.
|
||||
ImVec2 typeSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.typeText.c_str());
|
||||
float addrX = tx_x + typeSz.x + Layout::spacingMd();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
@@ -835,13 +866,13 @@ void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float
|
||||
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs, rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
if (material::IsRectHovered(rowPos, rowEnd)) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
dl->AddRectFilled(rowPos, rowEnd, SurfaceOverlay(15), S.drawElement("tabs.balance", "row-hover-rounding").sizeOr(4.0f));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(0))
|
||||
app->setCurrentPage(NavPage::History);
|
||||
@@ -868,7 +899,7 @@ void RenderSyncBar(App* app, ImDrawList* dl, float vs) {
|
||||
ImVec2 barPos = ImGui::GetCursorScreenPos();
|
||||
dl->AddRectFilled(barPos,
|
||||
ImVec2(barPos.x + barW, barPos.y + barH),
|
||||
IM_COL32(255, 255, 255, 15), 1.0f * dp);
|
||||
SurfaceOverlay(15), 1.0f * dp);
|
||||
dl->AddRectFilled(barPos,
|
||||
ImVec2(barPos.x + barW * prog, barPos.y + barH),
|
||||
WithAlpha(Warning(), 200), 1.0f * dp);
|
||||
|
||||
@@ -26,7 +26,8 @@ extern bool s_generating_z_address;
|
||||
void UpdateBalanceLerp(App* app);
|
||||
void RenderCompactHero(App* app, ImDrawList* dl, float availW, float hs, float vs,
|
||||
float heroHeightOverride = -1.0f);
|
||||
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs);
|
||||
void RenderSharedAddressList(App* app, float listH, float availW, float glassRound, float hs, float vs,
|
||||
float reserveBelow = 0.0f);
|
||||
void RenderSharedRecentTx(App* app, float recentH, float availW, float hs, float vs);
|
||||
void RenderSyncBar(App* app, ImDrawList* dl, float vs);
|
||||
|
||||
|
||||
+106
-42
@@ -26,10 +26,12 @@
|
||||
#include "../effects/imgui_acrylic.h"
|
||||
#include "../sidebar.h"
|
||||
#include "../notifications.h"
|
||||
#include "../staleness_badge.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
#include <toml++/toml.hpp>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
@@ -195,7 +197,9 @@ void RenderBalanceTab(App* app)
|
||||
for (const auto& l : allLayouts) {
|
||||
if (l.id == layoutId) { displayName = l.name; break; }
|
||||
}
|
||||
Notifications::instance().info("Layout: " + displayName);
|
||||
char layoutToast[128];
|
||||
snprintf(layoutToast, sizeof(layoutToast), TR("balance_layout_switched"), displayName.c_str());
|
||||
Notifications::instance().info(layoutToast);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -358,8 +362,11 @@ static void RenderBalanceClassic(App* app)
|
||||
IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.classic", "logo-opacity").sizeOr(180.0f)));
|
||||
}
|
||||
|
||||
std::string totalLabelUpper = TR("total_balance_label");
|
||||
std::transform(totalLabelUpper.begin(), totalLabelUpper.end(), totalLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
OnSurfaceMedium(), totalLabelUpper.c_str());
|
||||
cy += ovFont->LegacySize + ovGap;
|
||||
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
@@ -386,7 +393,7 @@ static void RenderBalanceClassic(App* app)
|
||||
// Sync progress or mining indicator (whichever fits)
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
||||
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy),
|
||||
Warning(), buf);
|
||||
|
||||
@@ -400,7 +407,7 @@ static void RenderBalanceClassic(App* app)
|
||||
dl->PushClipRect(ImVec2(cMin.x, barTop), cMax, true);
|
||||
// Background track
|
||||
dl->AddRectFilled(cMin, cMax,
|
||||
IM_COL32(255, 255, 255, 15), cardSpec.rounding);
|
||||
SurfaceOverlay(15), cardSpec.rounding);
|
||||
// Progress fill — additional horizontal clip
|
||||
float progRight = cMin.x + (cMax.x - cMin.x) * prog;
|
||||
dl->PushClipRect(ImVec2(cMin.x, barTop), ImVec2(progRight, cMax.y), true);
|
||||
@@ -417,10 +424,33 @@ static void RenderBalanceClassic(App* app)
|
||||
dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f),
|
||||
S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol);
|
||||
double hr = state.mining.localHashrate;
|
||||
snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str());
|
||||
// Leading indent clears the mining dot drawn at cx+4dp; the text
|
||||
// itself starts at cx+12dp so keep the two spaces for spacing parity.
|
||||
char mineFmt[64];
|
||||
snprintf(mineFmt, sizeof(mineFmt), " %s", TR("balance_mining_rate"));
|
||||
snprintf(buf, sizeof(buf), mineFmt, FormatHashrate(hr).c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx + 12 * dp, cy),
|
||||
WithAlpha(Success(), 200), buf);
|
||||
} else {
|
||||
// Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance
|
||||
// hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without
|
||||
// dropping the whole connection). The node banner only covers full disconnects, so
|
||||
// this pill is the sole signal that the shown number may be out of date.
|
||||
StalenessBadge badge = evaluateStalenessBadge(
|
||||
state.last_balance_update, std::time(nullptr), state.connected);
|
||||
if (badge.show) {
|
||||
const bool err = (badge.severity == StalenessSeverity::Error);
|
||||
ImU32 fg = err ? Error() : Warning();
|
||||
ImU32 bg = WithAlpha(fg, 38);
|
||||
ImU32 bd = WithAlpha(fg, 90);
|
||||
snprintf(buf, sizeof(buf), "%s %s",
|
||||
TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str());
|
||||
ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd);
|
||||
// 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
|
||||
@@ -554,15 +584,18 @@ static void RenderBalanceClassic(App* app)
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, "USD");
|
||||
|
||||
// Measure widest text line to determine sparkline left edge
|
||||
std::string marketLabel = TR("market");
|
||||
std::transform(marketLabel.begin(), marketLabel.end(), marketLabel.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
float textW = std::max(pSz.x + tickGap + usdSz.x,
|
||||
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, "MARKET").x);
|
||||
ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, marketLabel.c_str()).x);
|
||||
float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f);
|
||||
float sparkLeft = cx + textW + sparkGap;
|
||||
float sparkRight = cMax.x - cardPadLg;
|
||||
|
||||
// Left side: label + price + 24h change
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy),
|
||||
OnSurfaceMedium(), "MARKET");
|
||||
OnSurfaceMedium(), marketLabel.c_str());
|
||||
cy += ovFont->LegacySize + ovGap;
|
||||
|
||||
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy),
|
||||
@@ -628,7 +661,7 @@ static void RenderBalanceClassic(App* app)
|
||||
float addrH = (classicAddrH >= 0.0f) ? classicAddrH * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, contentAvail.x, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, contentAvail.x, hs, vs);
|
||||
}
|
||||
}
|
||||
@@ -662,7 +695,7 @@ static void RenderBalanceDonut(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.donut", "hero-pad-ratio").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -802,7 +835,7 @@ static void RenderBalanceDonut(App* app) {
|
||||
float addrH = (donutAddrOverride >= 0.0f) ? donutAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -927,10 +960,13 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
|
||||
// Shielded bar
|
||||
float shieldX = cardMin.x + pad;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), "SHIELDED");
|
||||
std::string shieldLabelUpper = TR("shielded");
|
||||
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(shieldX, barY), Success(), shieldLabelUpper.c_str());
|
||||
barY += ovFont->LegacySize + 4 * dp;
|
||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(shieldX, barY), ImVec2(shieldX + halfW * shieldRatio, barY + barH),
|
||||
WithAlpha(Success(), 180), barH * 0.5f);
|
||||
barY += barH + 2 * dp;
|
||||
@@ -940,10 +976,13 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
// Transparent bar
|
||||
float transX = cardMin.x + pad * 2 + halfW;
|
||||
barY = divY + Layout::spacingSm();
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), "TRANSPARENT");
|
||||
std::string transLabelUpper = TR("transparent");
|
||||
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(transX, barY), Warning(), transLabelUpper.c_str());
|
||||
barY += ovFont->LegacySize + 4 * dp;
|
||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(transX, barY), ImVec2(transX + halfW * transRatio, barY + barH),
|
||||
WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
barY += barH + 2 * dp;
|
||||
@@ -962,7 +1001,7 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), cardMax, true);
|
||||
// Background track
|
||||
dl->AddRectFilled(cardMin, cardMax,
|
||||
IM_COL32(255, 255, 255, 15), glassRound);
|
||||
SurfaceOverlay(15), glassRound);
|
||||
// Progress fill — additional horizontal clip
|
||||
float progRight = cardMin.x + (cardMax.x - cardMin.x) * prog;
|
||||
dl->PushClipRect(ImVec2(cardMin.x, syncBarTop), ImVec2(progRight, cardMax.y), true);
|
||||
@@ -979,7 +1018,7 @@ static void RenderBalanceConsolidated(App* app) {
|
||||
float addrH = (consAddrOverride >= 0.0f) ? consAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1055,11 +1094,20 @@ static void RenderBalanceDashboard(App* app) {
|
||||
snprintf(shBuf, sizeof(shBuf), "%.8f", s_dispShielded);
|
||||
snprintf(trBuf, sizeof(trBuf), "%.8f", s_dispTransparent);
|
||||
|
||||
// Localized captions — raw dl->AddText (no auto-uppercase), so uppercase to preserve the caption look.
|
||||
auto upperTR = [](const char* key) {
|
||||
std::string s = TR(key);
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||
return s;
|
||||
};
|
||||
std::string lblShielded = upperTR("shielded"), lblTransparent = upperTR("transparent");
|
||||
std::string lblQuickSend = upperTR("quick_send"), lblQuickReceive = upperTR("quick_receive");
|
||||
|
||||
TileInfo tiles[4] = {
|
||||
{"SHIELDED", shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
||||
{"TRANSPARENT", trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
||||
{"QUICK SEND", "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
||||
{"QUICK RECEIVE", "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
||||
{lblShielded.c_str(), shBuf, S.resolveColor("var(--accent-shielded)", Success()), ICON_MD_SHIELD, NavPage::Receive, false},
|
||||
{lblTransparent.c_str(), trBuf, S.resolveColor("var(--accent-transparent)", Warning()), ICON_MD_CIRCLE, NavPage::Receive, false},
|
||||
{lblQuickSend.c_str(), "Send", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_MADE, NavPage::Send, true},
|
||||
{lblQuickReceive.c_str(), "Receive", S.resolveColor("var(--accent-action)", Primary()), ICON_MD_CALL_RECEIVED, NavPage::Receive, true},
|
||||
};
|
||||
|
||||
for (int i = 0; i < 4; i++) {
|
||||
@@ -1105,7 +1153,7 @@ static void RenderBalanceDashboard(App* app) {
|
||||
tiles[i].accent, tiles[i].value);
|
||||
} else {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py),
|
||||
OnSurfaceMedium(), "Click to open");
|
||||
OnSurfaceMedium(), TR("tile_click_to_open"));
|
||||
}
|
||||
|
||||
// Click
|
||||
@@ -1127,7 +1175,7 @@ static void RenderBalanceDashboard(App* app) {
|
||||
float addrH = (dashAddrOverride >= 0.0f) ? dashAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1259,7 +1307,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
float barW = barRight - barLeft;
|
||||
float barY = rowPos.y + (rowH - barH) * 0.5f;
|
||||
dl->AddRectFilled(ImVec2(barLeft, barY), ImVec2(barRight, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(barLeft, barY),
|
||||
ImVec2(barLeft + barW * rowInfos[i].ratio, barY + barH),
|
||||
WithAlpha(rowInfos[i].accent, 180), barH * 0.5f);
|
||||
@@ -1306,7 +1354,7 @@ static void RenderBalanceVerticalStack(App* app) {
|
||||
float addrH = (vstackAddrOverride >= 0.0f) ? vstackAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1453,7 +1501,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm();
|
||||
float barY = cellMin.y + (rowH - barH) * 0.5f;
|
||||
dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH),
|
||||
IM_COL32(255, 255, 255, 15), barH * 0.5f);
|
||||
SurfaceOverlay(15), barH * 0.5f);
|
||||
dl->AddRectFilled(ImVec2(barX, barY),
|
||||
ImVec2(barX + barW * cell.ratio, barY + barH),
|
||||
WithAlpha(cell.accent, 180), barH * 0.5f);
|
||||
@@ -1501,7 +1549,7 @@ static void RenderBalanceVertical2x2(App* app) {
|
||||
float addrH = (addrOverride >= 0.0f) ? addrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1531,7 +1579,7 @@ static void RenderBalanceShield(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -1645,13 +1693,19 @@ static void RenderBalanceShield(App* app) {
|
||||
float infoY = panelMin.y + shieldPad;
|
||||
ImFont* ovFont = Type().overline();
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), "SHIELDED");
|
||||
std::string shieldLabelUpper = TR("shielded");
|
||||
std::transform(shieldLabelUpper.begin(), shieldLabelUpper.end(), shieldLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
std::string transLabelUpper = TR("transparent");
|
||||
std::transform(transLabelUpper.begin(), transLabelUpper.end(), transLabelUpper.begin(),
|
||||
[](unsigned char c){ return (char)std::toupper(c); });
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Success(), shieldLabelUpper.c_str());
|
||||
infoY += ovFont->LegacySize + 2 * dp;
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispShielded);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Success(), buf);
|
||||
infoY += capFont->LegacySize + 6 * dp;
|
||||
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), "TRANSPARENT");
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(infoX, infoY), Warning(), transLabelUpper.c_str());
|
||||
infoY += ovFont->LegacySize + 2 * dp;
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTransparent);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(infoX, infoY), Warning(), buf);
|
||||
@@ -1677,7 +1731,7 @@ static void RenderBalanceShield(App* app) {
|
||||
float addrH = (shieldAddrOverride >= 0.0f) ? shieldAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1707,7 +1761,7 @@ static void RenderBalanceTimeline(App* app) {
|
||||
else
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance", "compact-hero-pad").sizeOr(8.0f) * vs));
|
||||
{
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "TOTAL BALANCE");
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("total_balance_label"));
|
||||
ImGui::Dummy(ImVec2(0, 2 * dp));
|
||||
snprintf(buf, sizeof(buf), "%.8f", s_dispTotal);
|
||||
ImFont* heroFont = Type().h2();
|
||||
@@ -1796,10 +1850,16 @@ static void RenderBalanceTimeline(App* app) {
|
||||
spec.rounding = glassRound;
|
||||
|
||||
struct SumCard { const char* label; ImU32 col; double val; bool isMoney; };
|
||||
auto upperTR = [](const char* key) {
|
||||
std::string s = TR(key);
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c){ return (char)std::toupper(c); });
|
||||
return s;
|
||||
};
|
||||
std::string cShielded = upperTR("shielded"), cTransparent = upperTR("transparent"), cMarket = upperTR("market");
|
||||
SumCard cards[3] = {
|
||||
{"SHIELDED", Success(), s_dispShielded, false},
|
||||
{"TRANSPARENT", Warning(), s_dispTransparent, false},
|
||||
{"MARKET", Primary(), state.market.price_usd, true},
|
||||
{cShielded.c_str(), Success(), s_dispShielded, false},
|
||||
{cTransparent.c_str(), Warning(), s_dispTransparent, false},
|
||||
{cMarket.c_str(), Primary(), state.market.price_usd, true},
|
||||
};
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ImVec2 cMin(origin.x + i * (cardW + cGap), origin.y);
|
||||
@@ -1830,7 +1890,7 @@ static void RenderBalanceTimeline(App* app) {
|
||||
float addrH = (tlAddrOverride >= 0.0f) ? tlAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -1867,26 +1927,30 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
ImFont* capFont = Type().caption();
|
||||
if (state.sync.syncing && state.sync.headers > 0) {
|
||||
float pct = static_cast<float>(state.sync.verification_progress) * 100.0f;
|
||||
snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct);
|
||||
snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct);
|
||||
Type().textColored(TypeStyle::Caption, Warning(), buf);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
if (state.mining.generate) {
|
||||
double hr = state.mining.localHashrate;
|
||||
snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str());
|
||||
snprintf(buf, sizeof(buf), TR("balance_mining_rate"), FormatHashrate(hr).c_str());
|
||||
Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf);
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Action buttons right-aligned
|
||||
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f);
|
||||
float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f) * dp;
|
||||
float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg();
|
||||
ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm());
|
||||
if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
// Stable ## ids keep the button identity fixed across translations.
|
||||
char sendBtn[64], recvBtn[64];
|
||||
snprintf(sendBtn, sizeof(sendBtn), "%s##tworow-send", TR("send"));
|
||||
snprintf(recvBtn, sizeof(recvBtn), "%s##tworow-receive", TR("receive"));
|
||||
if (TactileButton(sendBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
app->setCurrentPage(NavPage::Send);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
if (TactileButton(recvBtn, ImVec2(btnW, 0), S.resolveFont("button"))) {
|
||||
app->setCurrentPage(NavPage::Receive);
|
||||
}
|
||||
}
|
||||
@@ -2017,7 +2081,7 @@ static void RenderBalanceTwoRow(App* app) {
|
||||
float addrH = (twoRowAddrOverride >= 0.0f) ? twoRowAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
@@ -2110,7 +2174,7 @@ static void RenderBalanceMinimal(App* app) {
|
||||
float addrH = (minAddrOverride >= 0.0f) ? minAddrOverride * dp
|
||||
: ImGui::GetContentRegionAvail().y - recentReserve
|
||||
- Layout::spacingXl() - Type().h6()->LegacySize - Layout::spacingMd();
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs);
|
||||
RenderSharedAddressList(app, addrH, availW, glassRound, hs, vs, recentReserve);
|
||||
RenderSharedRecentTx(app, recentReserve, availW, hs, vs);
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ void BlockInfoDialog::render(App* app)
|
||||
|
||||
// Height input
|
||||
ImGui::Text("%s", TR("block_height"));
|
||||
ImGui::SetNextItemWidth(heightInput.width);
|
||||
ImGui::SetNextItemWidth(heightInput.width * Layout::dpiScale());
|
||||
ImGui::InputInt("##Height", &s_height);
|
||||
if (s_height < 1) s_height = 1;
|
||||
// Clamp to the chain tip so navigation/typing can't request a height
|
||||
@@ -135,7 +135,7 @@ void BlockInfoDialog::render(App* app)
|
||||
ImGui::BeginDisabled();
|
||||
}
|
||||
|
||||
if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (rpc && rpc->isConnected() && app->worker()) {
|
||||
s_loading = true;
|
||||
s_error.clear();
|
||||
@@ -303,7 +303,7 @@ void BlockInfoDialog::render(App* app)
|
||||
// Navigation buttons
|
||||
if (s_has_data) {
|
||||
if (s_height > 1) {
|
||||
if (material::StyledButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_nav_prev"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
s_height--;
|
||||
s_has_data = false;
|
||||
s_error.clear();
|
||||
@@ -315,7 +315,7 @@ void BlockInfoDialog::render(App* app)
|
||||
// nextblockhash, so this stays hidden there).
|
||||
if (!s_next_hash.empty() &&
|
||||
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
|
||||
if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
if (material::TactileButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
|
||||
s_height++;
|
||||
s_has_data = false;
|
||||
s_error.clear();
|
||||
@@ -323,9 +323,10 @@ void BlockInfoDialog::render(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// Close button at bottom
|
||||
ImGui::SetCursorPosY(ImGui::GetWindowHeight() - 40);
|
||||
if (material::StyledButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
||||
// Close button at bottom — centered via the shared footer helper (no separator, matching
|
||||
// the prior hand-rolled placement).
|
||||
material::BeginOverlayDialogFooter(closeBtn.width, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
|
||||
s_open = false;
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
|
||||
+106
-23
@@ -10,6 +10,7 @@
|
||||
#include "../../data/address_book.h"
|
||||
#include "../../chat/chat_service.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient
|
||||
#include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11)
|
||||
#include "../../config/settings.h" // per-conversation mute (Q10)
|
||||
#include "../material/colors.h"
|
||||
@@ -59,6 +60,10 @@ bool s_msgsel_dragging = false;
|
||||
// Composer + new-conversation UI state.
|
||||
char s_compose[512] = "";
|
||||
std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes
|
||||
// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer
|
||||
// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor
|
||||
// instead of always appending. -1 = unknown/never-focused => append at the end.
|
||||
int s_composeCursor = -1;
|
||||
// On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp).
|
||||
// The composer hard-caps input to this; the emoji picker respects it too.
|
||||
constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236
|
||||
@@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block
|
||||
// neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the
|
||||
// newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap.
|
||||
int composeInputCallback(ImGuiInputTextCallbackData* data) {
|
||||
// Track the live caret so the emoji picker can insert at the cursor. This callback runs under
|
||||
// CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses
|
||||
// focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position.
|
||||
s_composeCursor = data->CursorPos;
|
||||
ImGuiIO& io = ImGui::GetIO();
|
||||
if (io.KeyShift
|
||||
&& (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter))
|
||||
&& data->BufTextLen < kChatBodyMaxBytes) {
|
||||
data->InsertChars(data->CursorPos, "\n");
|
||||
s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -441,7 +451,7 @@ void RenderChatSettingsPreview(App* app, float width) {
|
||||
totalH += padIn;
|
||||
|
||||
const ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 16; g.borderAlpha = 36;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36;
|
||||
material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g);
|
||||
|
||||
const float leftX = origin.x + padIn;
|
||||
@@ -537,7 +547,7 @@ void centeredEmptyState(const char* icon, const char* title, const char* hint) {
|
||||
ImFont* titleF = material::Type().subtitle1();
|
||||
ImFont* hintF = material::Type().body2();
|
||||
const float gap = 8.0f * Layout::dpiScale();
|
||||
const float wrap = std::min(avail.x - 40.0f, 360.0f);
|
||||
const float wrap = std::min(avail.x - 40.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
|
||||
const float iconSz = iconF ? scaledSize(iconF) : 40.0f;
|
||||
const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f;
|
||||
const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y;
|
||||
@@ -625,16 +635,22 @@ static const EmojiEntry kEmoji[] = {
|
||||
// Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a
|
||||
// grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer.
|
||||
void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) {
|
||||
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space
|
||||
// when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap.
|
||||
// Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor,
|
||||
// kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the
|
||||
// caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap.
|
||||
// The composer is inactive whenever the picker is open, so it renders straight from buf — splicing
|
||||
// here shows immediately.
|
||||
auto insertToken = [&](const char* tok) {
|
||||
const std::size_t cur = std::strlen(buf), add = std::strlen(tok);
|
||||
const bool needsSpace = cur > 0 && static_cast<unsigned char>(buf[cur - 1]) > ' ';
|
||||
const std::size_t pos = (s_composeCursor < 0)
|
||||
? cur : std::min(static_cast<std::size_t>(s_composeCursor), cur);
|
||||
const bool needsSpace = pos > 0 && static_cast<unsigned char>(buf[pos - 1]) > ' ';
|
||||
const std::size_t pad = needsSpace ? 1 : 0;
|
||||
if (cur + pad + add <= static_cast<std::size_t>(kChatBodyMaxBytes) && cur + pad + add < bufSize) {
|
||||
if (needsSpace) buf[cur] = ' ';
|
||||
std::memcpy(buf + cur + pad, tok, add);
|
||||
buf[cur + pad + add] = '\0';
|
||||
std::memmove(buf + pos + pad + add, buf + pos, (cur - pos) + 1); // shift tail right (incl NUL)
|
||||
if (needsSpace) buf[pos] = ' ';
|
||||
std::memcpy(buf + pos + pad, tok, add);
|
||||
s_composeCursor = static_cast<int>(pos + pad + add); // keep the caret after the inserted token
|
||||
}
|
||||
};
|
||||
if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; }
|
||||
@@ -747,6 +763,7 @@ void RenderChatTab(App* app)
|
||||
// for one contact can't be sent to another (B5).
|
||||
if (s_selected_cid != s_compose_cid) {
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again
|
||||
s_compose_cid = s_selected_cid;
|
||||
s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch
|
||||
}
|
||||
@@ -759,7 +776,7 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
const ImVec2 avail = ImGui::GetContentRegionAvail();
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f);
|
||||
const float listW = std::clamp(avail.x * 0.32f, 220.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale());
|
||||
// Row geometry is logical px — scale by dpiScale() so rows/padding grow with the (DPI-scaled)
|
||||
// fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's
|
||||
// right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word.
|
||||
@@ -778,7 +795,7 @@ void RenderChatTab(App* app)
|
||||
{
|
||||
ImDrawList* paneDL = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * Layout::dpiScale(); g.fillAlpha = 20; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 20; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + listW, pMin.y + avail.y), g);
|
||||
}
|
||||
// Inner padding so the list content (buttons, search, conversation cards) doesn't hug the glass
|
||||
@@ -878,16 +895,22 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
const float textX = avC.x + avR + pad;
|
||||
|
||||
// Name (top). Hidden conversations (shown via "Show hidden") render dimmed.
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||
// Time (top-right, muted) — compact relative form (Q5).
|
||||
// Time (top-right, muted) — compact relative form (Q5). Measure/draw it FIRST so the name can be
|
||||
// clipped to the column left of it — otherwise a long peer name overruns the timestamp (worse at
|
||||
// HiDPI, where the fixed-length name grows ~1.5x).
|
||||
const std::string when = relativeTime(c.lastTs);
|
||||
float nameRight = mx.x - pad;
|
||||
if (!when.empty()) {
|
||||
const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str());
|
||||
dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f),
|
||||
material::OnSurfaceMedium(), when.c_str());
|
||||
nameRight = mx.x - pad - wsz.x - pad; // reserve the timestamp column + a gap
|
||||
}
|
||||
// Name (top), clipped to the space left of the timestamp. Hidden conversations render dimmed.
|
||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, nameRight), mx.y), true);
|
||||
dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad),
|
||||
c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str());
|
||||
dl->PopClipRect();
|
||||
// Preview (bottom, clipped to the text column, muted).
|
||||
const std::string preview = previewOf(c.lastBody);
|
||||
dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true);
|
||||
@@ -950,7 +973,7 @@ void RenderChatTab(App* app)
|
||||
ImDrawList* paneDL = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
const float pW = ImGui::GetContentRegionAvail().x;
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * tdp; g.fillAlpha = 12; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 12; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + pW, pMin.y + (avail.y - composerAreaH)), g);
|
||||
}
|
||||
// Inner padding so the header + messages don't hug the glass card's edges (the message child
|
||||
@@ -1290,7 +1313,7 @@ void RenderChatTab(App* app)
|
||||
}
|
||||
|
||||
const float availW = ImGui::GetContentRegionAvail().x;
|
||||
const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f);
|
||||
const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp);
|
||||
const float innerW = maxBubbleW - 2.0f * bpad;
|
||||
|
||||
// ── Date separator (once per calendar day): a centered pill.
|
||||
@@ -1576,7 +1599,8 @@ void RenderChatTab(App* app)
|
||||
const float ringR = std::max(7.0f, lineH * 0.42f);
|
||||
const float ringPad = 9.0f * tdp;
|
||||
const float ringSlot = 2.0f * ringR + ringPad * 1.6f;
|
||||
const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW);
|
||||
const float inputW = std::min(720.0f * tdp,
|
||||
std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW));
|
||||
const float sendX = inputX + inputW + inGap;
|
||||
const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring
|
||||
const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR);
|
||||
@@ -1598,7 +1622,7 @@ void RenderChatTab(App* app)
|
||||
// FrameBg); a flat FrameBg showed the sharp texture.
|
||||
{
|
||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||
material::GlassPanelSpec g; g.rounding = 8.0f * tdp; g.fillAlpha = 16; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(cdl, ImVec2(inputX, rowY),
|
||||
ImVec2(inputX + inputW, rowY + composerBoxH), g);
|
||||
}
|
||||
@@ -1705,6 +1729,7 @@ void RenderChatTab(App* app)
|
||||
if (submit && s_compose[0] != '\0' && !overCap) {
|
||||
app->sendChatMessage(sel->cid, s_compose);
|
||||
sodium_memzero(s_compose, sizeof(s_compose));
|
||||
s_composeCursor = -1;
|
||||
s_scroll_to_cid = sel->cid;
|
||||
s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused
|
||||
// Sending closes the emoji picker (it takes over the conversation-list pane) so the list
|
||||
@@ -1726,6 +1751,17 @@ void RenderChatTab(App* app)
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
const float fieldW = ImGui::GetContentRegionAvail().x;
|
||||
material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW);
|
||||
// Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t)
|
||||
// address can't receive one. Contacts can hold t-addresses, so guard the manual field too:
|
||||
// warn when the entry isn't a valid z-address and keep Send disabled below.
|
||||
const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr);
|
||||
if (s_new_zaddr[0] != '\0' && !newAddrIsZ) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, material::Warning());
|
||||
ImGui::PushTextWrapPos(0.0f);
|
||||
ImGui::TextUnformatted(TR("chat_new_needs_zaddr"));
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::PopStyleColor();
|
||||
}
|
||||
// Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed.
|
||||
// Selecting one fills the field above (manual paste still works).
|
||||
ImGui::SetNextItemWidth(fieldW);
|
||||
@@ -1757,7 +1793,7 @@ void RenderChatTab(App* app)
|
||||
material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW);
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
|
||||
|
||||
const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0';
|
||||
const bool canSend = newAddrIsZ && s_new_msg[0] != '\0';
|
||||
const float actionW = std::max(130.0f * dp,
|
||||
ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp);
|
||||
const float actionGap = Layout::spacingSm();
|
||||
@@ -1849,20 +1885,37 @@ void ResetChatTab()
|
||||
s_show_chat_settings = false;
|
||||
}
|
||||
|
||||
void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards)
|
||||
{
|
||||
auto* st = app ? app->settings() : nullptr;
|
||||
if (!st) return;
|
||||
const float dp = Layout::dpiScale();
|
||||
const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably)
|
||||
const float rowGap = 5.0f * dp;
|
||||
const float rowGap = 10.0f * dp;
|
||||
|
||||
// Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the
|
||||
// Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false
|
||||
// and keeps its plain single-surface layout — the controls themselves are identical either way.
|
||||
ImDrawList* cardDL = ImGui::GetWindowDrawList();
|
||||
const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f;
|
||||
material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding();
|
||||
float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f;
|
||||
if (drawCards) {
|
||||
cardTopScr = ImGui::GetCursorScreenPos().y;
|
||||
cardBaseXScr = ImGui::GetCursorScreenPos().x;
|
||||
cardDL->ChannelsSplit(2);
|
||||
cardDL->ChannelsSetCurrent(1);
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad));
|
||||
ImGui::Indent(cardPad);
|
||||
}
|
||||
// Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard
|
||||
// whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth;
|
||||
// the chat modal's dialog content region is correct, so it passes 0 (auto).
|
||||
// leftX/rowW define the current column the rows lay out in; retargeted below
|
||||
// to split Appearance | Messaging into two columns when the card is wide.
|
||||
float leftX = ImGui::GetCursorPosX();
|
||||
float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x;
|
||||
float rowW = drawCards ? (contentWidth - 2.0f * cardPad)
|
||||
: ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x);
|
||||
|
||||
// Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin.
|
||||
auto beginRow = [&](const char* label) {
|
||||
@@ -1930,7 +1983,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
// Two internal columns when the card is wide enough: Appearance on the left,
|
||||
// Messaging on the right — fills the width and roughly halves the height.
|
||||
// (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column.
|
||||
const float chatColGap = 24.0f * dp;
|
||||
const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp);
|
||||
const bool chatTwoCol = rowW > 760.0f * dp;
|
||||
const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW;
|
||||
const float chatBaseLeftX = leftX;
|
||||
@@ -1987,6 +2040,7 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
// line-start holds the column; retarget leftX so controls right-align in it).
|
||||
if (chatTwoCol) {
|
||||
chatLeftBottomY = ImGui::GetCursorPosY();
|
||||
cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel
|
||||
ImGui::SetCursorPosY(chatTopY);
|
||||
ImGui::Indent(chatColW + chatColGap);
|
||||
leftX = chatBaseLeftX + chatColW + chatColGap;
|
||||
@@ -2020,12 +2074,41 @@ void RenderChatSettingsControls(App* app, float contentWidth)
|
||||
}
|
||||
|
||||
// Close the two-column band: un-indent and drop below the taller column.
|
||||
const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen
|
||||
if (chatTwoCol) {
|
||||
ImGui::Unindent(chatColW + chatColGap);
|
||||
const float chatRightBottomY = ImGui::GetCursorPosY();
|
||||
ImGui::SetCursorPosX(chatBaseLeftX);
|
||||
ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY));
|
||||
}
|
||||
|
||||
// Paint the glass card(s) behind the content, then merge the channels.
|
||||
if (drawCards) {
|
||||
ImGui::Unindent(cardPad);
|
||||
cardDL->ChannelsSetCurrent(0);
|
||||
const float cardW = (contentWidth - Layout::cardGap()) * 0.5f;
|
||||
if (chatTwoCol) {
|
||||
const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch)
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
|
||||
ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec);
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr),
|
||||
ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec);
|
||||
} else {
|
||||
material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr),
|
||||
ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec);
|
||||
}
|
||||
cardDL->ChannelsMerge();
|
||||
const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr;
|
||||
// Reserve the card footprint with a Dummy so the parent scroll region grows to include it
|
||||
// (a bare SetCursorScreenPos past content warns in ImGui).
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr));
|
||||
ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad));
|
||||
|
||||
// Live conversation preview below the two cards (Settings tab only — the chat modal renders
|
||||
// its own preview column beside these controls, so it passes drawCards=false and skips this).
|
||||
ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd()));
|
||||
RenderChatSettingsPreview(app, contentWidth);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -34,7 +34,7 @@ void RenderChatTab(App* app);
|
||||
* width from the Settings tab (whose GlassCard doesn't narrow the content region). 0 = auto
|
||||
* (use the current content region, correct inside the chat modal's dialog).
|
||||
*/
|
||||
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f);
|
||||
void RenderChatSettingsControls(App* app, float contentWidth = 0.0f, bool drawCards = false);
|
||||
|
||||
/**
|
||||
* @brief Securely wipe the Chat tab's UI-local state (composer / new-conversation
|
||||
|
||||
@@ -160,10 +160,10 @@ const ConsoleCommandEntry kWalletCommands[] = {
|
||||
"z_sendmany \"RfromAddr\" [{\"address\":\"zs1toAddr\",\"amount\":1.0}]", "send pay private shielded transfer money", true},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]",
|
||||
"Moves newly mined (coinbase) transparent funds into a private shielded z-address, since mined rewards must be shielded before they can be spent normally. Runs in the background and returns an operation id.",
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded"},
|
||||
"z_shieldcoinbase \"RyourMiningAddr\" \"zs1yourShieldedAddr\"", "shield mining rewards coinbase private hide mined funds move to shielded", true},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]",
|
||||
"Combines many small balances (from transparent and/or shielded addresses) into a single destination address in one transaction, to consolidate funds. Runs in the background and returns an operation id.",
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address"},
|
||||
"z_mergetoaddress [\"RyourAddr\",\"zs1yourShieldedAddr\"] \"zs1destShieldedAddr\"", "merge combine consolidate funds sweep small balances into one address", true},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]",
|
||||
"Your most recent wallet transactions, newest first \xE2\x80\x94 amounts, addresses and confirmations.",
|
||||
"listtransactions", "transactions history recent payments received sent"},
|
||||
|
||||
@@ -564,12 +564,27 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec)
|
||||
ImGui::SameLine();
|
||||
}
|
||||
|
||||
// Line count
|
||||
ImGui::TextDisabled(TR("console_line_count"), model_.size());
|
||||
// Line count — the least-critical trailing element. When the row is too narrow to fit the
|
||||
// filter box (at its placeholder-sized minimum) AND its trailing controls, drop the line count
|
||||
// rather than starve/hide the filter (worst at 1024px). Mirror the reservation formula in
|
||||
// drawFilterInput(): trailing = 4 frame-height buttons + the group spacers, and the filter's
|
||||
// hard floor = its placeholder width + frame padding.
|
||||
{
|
||||
char lineCountBuf[64];
|
||||
snprintf(lineCountBuf, sizeof(lineCountBuf), TR("console_line_count"), model_.size());
|
||||
float lineCountW = ImGui::CalcTextSize(lineCountBuf).x + Layout::spacingSm() * 2.0f; // text + its trailing spacer
|
||||
float trailingW = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
|
||||
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
|
||||
bool showLineCount = ImGui::GetContentRegionAvail().x >= lineCountW + trailingW + filterMinW;
|
||||
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
if (showLineCount) {
|
||||
ImGui::TextDisabled(TR("console_line_count"), model_.size());
|
||||
ImGui::SameLine();
|
||||
ImGui::Spacing();
|
||||
ImGui::SameLine();
|
||||
}
|
||||
}
|
||||
|
||||
// Output filter input
|
||||
drawFilterInput();
|
||||
@@ -617,7 +632,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec)
|
||||
ConsoleStatusLine st = exec.toolbarStatus();
|
||||
if (!st.text.empty()) {
|
||||
ImVec2 cp = ImGui::GetCursorScreenPos();
|
||||
float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale();
|
||||
float dotR = (schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size) * Layout::hScale();
|
||||
float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f;
|
||||
float dotX = cp.x + dotR + 2.0f * Layout::dpiScale();
|
||||
|
||||
@@ -687,9 +702,21 @@ void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps)
|
||||
void ConsoleTab::drawFilterInput()
|
||||
{
|
||||
using namespace material;
|
||||
float zoomBtnSpace = ImGui::GetFrameHeight() * 2.0f + Layout::spacingSm() * 3.0f;
|
||||
float filterAvail = ImGui::GetContentRegionAvail().x - zoomBtnSpace;
|
||||
float filterW = std::min(schema::UI().drawElement("tabs.console", "filter-max-width").size, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
|
||||
// Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar
|
||||
// row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the
|
||||
// group spacers between them. Otherwise the filter eats the row and the trailing controls
|
||||
// run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide.
|
||||
float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f;
|
||||
float filterAvail = ImGui::GetContentRegionAvail().x - trailingBtnSpace;
|
||||
float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale();
|
||||
float filterW = std::min(filterMaxW, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size);
|
||||
// Never shrink below the placeholder — otherwise the hint clips to "Filter outp" (or the box
|
||||
// vanishes) at narrow widths. Floor = placeholder text + frame padding + a little breathing room.
|
||||
// (drawToolbar() drops the "NNN lines" count when even this floor won't fit alongside the row's
|
||||
// trailing controls, so this max() doesn't push the zoom/color buttons off-window.)
|
||||
float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale();
|
||||
filterW = std::max(filterMinW, filterW);
|
||||
ImGui::SetNextItemWidth(filterW);
|
||||
ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_));
|
||||
if (filter_text_[0] != '\0') {
|
||||
@@ -768,7 +795,10 @@ void ConsoleTab::renderOutput()
|
||||
// height. The inter-line gap is added explicitly to layout_.heights
|
||||
// so that layout_.cumulativeY stays perfectly in sync with actual
|
||||
// cursor positions (avoiding selection-offset drift).
|
||||
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f);
|
||||
// Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5
|
||||
// (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the
|
||||
// gap only, never the line height).
|
||||
float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale();
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
|
||||
|
||||
// Inner padding for glass panel
|
||||
@@ -1050,8 +1080,11 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt
|
||||
ImVec2(cx, cy + sz * 0.7f), triCol); // ▼ expanded
|
||||
}
|
||||
// Click anywhere in the gutter cell for this line's first row toggles the fold.
|
||||
// Guard against a click that is actually dismissing the ConsoleContextMenu (or any
|
||||
// popup) — the same popup guard the text-selection path uses (see mouse_in_output).
|
||||
ImVec2 mp = ImGui::GetIO().MousePos;
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
!ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) &&
|
||||
mp.x >= output_origin_.x - padX && mp.x < output_origin_.x &&
|
||||
mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) {
|
||||
pendingFoldToggle = i;
|
||||
@@ -1406,18 +1439,24 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec)
|
||||
ImGui::PopItemWidth();
|
||||
ImGui::PopFont();
|
||||
|
||||
// Auto-focus on input
|
||||
if (reclaim_focus) {
|
||||
// Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened
|
||||
// (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting).
|
||||
// Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway.
|
||||
if ((reclaim_focus || focus_input_pending_) && !busy) {
|
||||
ImGui::SetKeyboardFocusHere(-1);
|
||||
}
|
||||
focus_input_pending_ = false;
|
||||
}
|
||||
|
||||
bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd)
|
||||
{
|
||||
if (cmd.empty()) return false;
|
||||
|
||||
addLine("> " + cmd, ConsoleChannel::Command);
|
||||
AppendConsoleHistory(command_history_, cmd, 100);
|
||||
// Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible
|
||||
// log and the recall history. The real `cmd` below is still executed unredacted.
|
||||
const std::string display = RedactConsoleCommand(cmd);
|
||||
addLine("> " + display, ConsoleChannel::Command);
|
||||
AppendConsoleHistory(command_history_, display, 100);
|
||||
history_index_ = -1;
|
||||
|
||||
// First token, lowercased, for built-in interception.
|
||||
@@ -1885,7 +1924,7 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec)
|
||||
|
||||
// Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding.
|
||||
GlassPanelSpec paneGlass;
|
||||
paneGlass.rounding = 14.0f;
|
||||
paneGlass.rounding = 14.0f * dp;
|
||||
paneGlass.fillAlpha = 30;
|
||||
paneGlass.borderAlpha = 30;
|
||||
|
||||
|
||||
@@ -65,6 +65,10 @@ public:
|
||||
*/
|
||||
void clear();
|
||||
|
||||
// Ask the console to place the keyboard focus in the command input on the next render (consumed once).
|
||||
// Called when the user switches to the Console tab, gated by the console_auto_focus setting.
|
||||
void requestInputFocus() { focus_input_pending_ = true; }
|
||||
|
||||
// Scanline effect toggle (set from settings)
|
||||
static bool s_scanline_enabled;
|
||||
|
||||
@@ -156,6 +160,7 @@ private:
|
||||
int history_index_ = -1;
|
||||
char input_buffer_[4096] = {0};
|
||||
bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop'
|
||||
bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus)
|
||||
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
|
||||
|
||||
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
#include "console_tab_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
// First tokens (lowercase) of console/RPC commands that carry a secret argument on the command line.
|
||||
// Output-secret commands (dumpprivkey / z_exportkey / z_exportmnemonic) are deliberately absent —
|
||||
// their secret is in the RESULT, which is a separate redaction concern.
|
||||
const char* const kSecretConsoleCommands[] = {
|
||||
"walletpassphrase", "walletpassphrasechange", "encryptwallet",
|
||||
"importprivkey", "importwallet", "importmulti",
|
||||
"z_importkey", "z_importviewingkey", "z_importwallet",
|
||||
"signrawtransaction", "magicrecoverkey", "sethdseed", "importmnemonic",
|
||||
};
|
||||
|
||||
std::string firstConsoleTokenLower(const std::string& cmd, size_t& tokenEnd) {
|
||||
size_t b = cmd.find_first_not_of(" \t");
|
||||
if (b == std::string::npos) { tokenEnd = cmd.size(); return {}; }
|
||||
size_t e = cmd.find_first_of(" \t", b);
|
||||
tokenEnd = (e == std::string::npos) ? cmd.size() : e;
|
||||
std::string t = cmd.substr(b, tokenEnd - b);
|
||||
std::transform(t.begin(), t.end(), t.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return t;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
@@ -27,5 +51,27 @@ float ClampConsoleWrapWidth(float contentWidth, float paddingX)
|
||||
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
||||
}
|
||||
|
||||
bool ConsoleCommandCarriesSecret(const std::string& cmd)
|
||||
{
|
||||
size_t end = 0;
|
||||
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||
if (name.empty()) return false;
|
||||
for (const char* s : kSecretConsoleCommands) if (name == s) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string RedactConsoleCommand(const std::string& cmd)
|
||||
{
|
||||
size_t end = 0;
|
||||
const std::string name = firstConsoleTokenLower(cmd, end);
|
||||
if (name.empty()) return cmd;
|
||||
bool secret = false;
|
||||
for (const char* s : kSecretConsoleCommands) if (name == s) { secret = true; break; }
|
||||
if (!secret) return cmd;
|
||||
// Only redact if there are actually arguments after the command name.
|
||||
if (cmd.find_first_not_of(" \t", end) == std::string::npos) return cmd;
|
||||
return cmd.substr(0, end) + " ****";
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
@@ -14,5 +16,14 @@ float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float minHeightRatio);
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
||||
|
||||
// True if `cmd`'s first token names a console/RPC command that carries a SECRET on its command line
|
||||
// (passphrase, private/spending/viewing key, mnemonic). Output-secret commands (dumpprivkey,
|
||||
// z_exportkey, z_exportmnemonic) are NOT covered — their secret is in the result, a separate concern.
|
||||
bool ConsoleCommandCarriesSecret(const std::string& cmd);
|
||||
|
||||
// A display/history-safe copy of `cmd`: the command name with its arguments replaced by "****" when
|
||||
// it carries a secret, else `cmd` unchanged. The real command is still executed unredacted.
|
||||
std::string RedactConsoleCommand(const std::string& cmd);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -40,6 +40,7 @@ static bool s_show_edit_dialog = false;
|
||||
static bool s_show_contacts_settings = false; // Contacts customization modal (gear button)
|
||||
static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens
|
||||
static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms
|
||||
static int s_confirm_avatar_del_idx = -1; // armed avatar-library index; a 2nd badge click confirms the (irreversible) file delete
|
||||
static char s_edit_label[128] = "";
|
||||
static char s_edit_address[512] = "";
|
||||
static char s_edit_notes[512] = "";
|
||||
@@ -206,6 +207,28 @@ static bool isShieldedAddr(const std::string& a) {
|
||||
return !a.empty() && a[0] == 'z';
|
||||
}
|
||||
|
||||
// Width-aware middle-ellipsis truncation (mirrors the add/edit dialog's local fitMiddle lambda, but
|
||||
// file-scope so the Cards/List rows can share it too): keeps head + tail, shrinking symmetrically
|
||||
// until the rendered width fits maxW. minFront/minBack are the schema-configured floor — below that
|
||||
// the fixed-length util::truncateMiddle result is used instead, so very cramped rows still read as
|
||||
// "front...back" rather than collapsing to a near-empty stub. Addresses are ASCII, so byte-wise
|
||||
// trimming is safe.
|
||||
static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW,
|
||||
int minFront, int minBack) {
|
||||
const std::string floor = util::truncateMiddle(s, minFront, minBack);
|
||||
auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; };
|
||||
if (w(s) <= maxW) return s; // fits in full — no truncation needed at all
|
||||
if (maxW <= 0.0f) return floor; // no room to measure against — fall back to the floor
|
||||
const std::string ell = "\xE2\x80\xA6";
|
||||
size_t head = s.size() / 2, tail = s.size() - head;
|
||||
while (head + tail > static_cast<size_t>(minFront + minBack)) {
|
||||
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
|
||||
if (w(cand) <= maxW) return cand;
|
||||
if (head >= tail) --head; else --tail;
|
||||
}
|
||||
return floor; // couldn't fit even at the configured floor — use the fixed-length result
|
||||
}
|
||||
|
||||
// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
|
||||
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
|
||||
static ImU32 contactTypeColor(bool shielded, bool light) {
|
||||
@@ -555,7 +578,7 @@ void RenderContactsTab(App* app)
|
||||
sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
|
||||
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
||||
material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
|
||||
ImU32 fg = active ? IM_COL32(255, 255, 255, 255) : (hov ? material::OnSurface() : material::OnSurfaceMedium());
|
||||
ImU32 fg = active ? material::OnPrimary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
|
||||
float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
|
||||
float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
|
||||
float gapI = 5.0f * dp;
|
||||
@@ -747,12 +770,16 @@ void RenderContactsTab(App* app)
|
||||
float dr = std::max(7.0f * dp, cell * 0.15f);
|
||||
ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
|
||||
bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr));
|
||||
const int libIdx = n - 1;
|
||||
const bool delArmed = (s_confirm_avatar_del_idx == libIdx);
|
||||
ImGui::PushID(n);
|
||||
bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell));
|
||||
bool thumbHov = ImGui::IsItemHovered();
|
||||
ImGui::PopID();
|
||||
if (thumbHov || sel || hov) { // draw the delete badge
|
||||
gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
|
||||
if (thumbHov || sel || hov || delArmed) { // draw the delete badge (persist while armed)
|
||||
gdl->AddCircleFilled(dcc, dr, (dhov || delArmed) ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
|
||||
// While armed, ring the badge so the "click again to delete" state is unmistakable.
|
||||
if (delArmed) gdl->AddCircle(dcc, dr + 1.5f * dp, material::ReadableError(), 0, 1.5f * dp);
|
||||
ImFont* xf = material::Type().iconSmall();
|
||||
float xsz = dr * 1.35f;
|
||||
ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
|
||||
@@ -760,11 +787,18 @@ void RenderContactsTab(App* app)
|
||||
}
|
||||
if (dhov) { // badge takes priority over selecting the thumbnail
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("delete"));
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) pendingDelete = n - 1;
|
||||
// Two-stage confirm: the first click arms this badge; a second click on the SAME
|
||||
// badge deletes the image file (fs::remove is irreversible — no undo).
|
||||
material::Tooltip("%s", delArmed ? TR("address_book_confirm_delete") : TR("delete"));
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
if (delArmed) { pendingDelete = libIdx; s_confirm_avatar_del_idx = -1; }
|
||||
else s_confirm_avatar_del_idx = libIdx; // arm; requires a 2nd deliberate click
|
||||
}
|
||||
} else {
|
||||
if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (thumbClicked) s_edit_avatar = "img:" + path;
|
||||
// Any click that lands off this badge (selecting the thumbnail or elsewhere)
|
||||
// disarms it, so a stale armed badge can't be confirmed by an unrelated click.
|
||||
if (thumbClicked) { s_edit_avatar = "img:" + path; s_confirm_avatar_del_idx = -1; }
|
||||
}
|
||||
}
|
||||
col = (col + 1) % cols;
|
||||
@@ -948,7 +982,7 @@ void RenderContactsTab(App* app)
|
||||
}
|
||||
|
||||
// Search / filter (tight against the toolbar row above — no extra spacer)
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
ImGui::SetNextItemWidth(std::min(ImGui::GetContentRegionAvail().x, 700.0f * dp));
|
||||
ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
|
||||
s_search, sizeof(s_search));
|
||||
bool searchActive = ImGui::IsItemActive();
|
||||
@@ -1014,7 +1048,7 @@ void RenderContactsTab(App* app)
|
||||
const float tpW = ImGui::GetContentRegionAvail().x;
|
||||
const float tIpad = 12.0f * dp, tVpad = 10.0f * dp;
|
||||
{
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(tpdl, tpMin, ImVec2(tpMin.x + tpW, tpMin.y + listH), g);
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(tpMin.x + tIpad, tpMin.y + tVpad));
|
||||
@@ -1117,7 +1151,7 @@ void RenderContactsTab(App* app)
|
||||
ImDrawList* pdl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
const float pW = ImGui::GetContentRegionAvail().x;
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(pdl, pMin, ImVec2(pMin.x + pW, pMin.y + listH), g);
|
||||
}
|
||||
// AlwaysUseWindowPadding: a borderless child ignores WindowPadding without it, so the rows
|
||||
@@ -1207,10 +1241,13 @@ void RenderContactsTab(App* app)
|
||||
dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
|
||||
dl->PopClipRect();
|
||||
// Un-collapse to the full address on hover (clipped to the text column so it never
|
||||
// runs under the trailing actions); middle-truncated otherwise.
|
||||
// runs under the trailing actions); otherwise middle-truncated to FIT the actual text
|
||||
// column width (tx..textMaxX) rather than a fixed char count — wide rows show more of
|
||||
// the address instead of leaving a dead gap before the action-icon cluster.
|
||||
std::string addr = rowHovered
|
||||
? entry.address
|
||||
: util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
: truncateAddressToWidth(entry.address, adrF, adrSz, textMaxX - tx,
|
||||
addrFrontLbl.truncate, addrBackLbl.truncate);
|
||||
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
|
||||
dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp),
|
||||
material::OnSurfaceMedium(), addr.c_str());
|
||||
@@ -1378,7 +1415,7 @@ void RenderContactsTab(App* app)
|
||||
const float rowH = avR * 2.0f + 16.0f * dp;
|
||||
const float panelH = 2.0f * rowH + gap + 2.0f * padIn;
|
||||
const ImVec2 pOrigin = ImGui::GetCursorScreenPos();
|
||||
material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 14; g.borderAlpha = 34;
|
||||
material::DrawGlassPanel(pdl, pOrigin, ImVec2(pOrigin.x + w, pOrigin.y + panelH), g);
|
||||
const ImVec2 rmn(pOrigin.x + padIn, pOrigin.y + padIn);
|
||||
const float rowW = w - 2.0f * padIn;
|
||||
|
||||
@@ -408,7 +408,9 @@ private:
|
||||
|
||||
// ---- Below the info card (outside the surface): verify note + install button ----
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
|
||||
Type().textColored(TypeStyle::Caption, downgrade ? Warning() : OnSurfaceMedium(), noteStr);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Spacing();
|
||||
// Install button sized to its text and centered in the pane.
|
||||
const float bw = ImGui::CalcTextSize(label).x + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
||||
|
||||
@@ -446,10 +446,10 @@ static void renderSearchBar(App* app, float availWidth) {
|
||||
float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f;
|
||||
|
||||
float inputW = std::min(
|
||||
S.drawElement("tabs.explorer", "search-input-width").size,
|
||||
S.drawElement("tabs.explorer", "search-input-width").size * Layout::dpiScale(),
|
||||
availWidth * 0.65f);
|
||||
float btnW = S.drawElement("tabs.explorer", "search-button-width").size;
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size;
|
||||
float btnW = S.drawElement("tabs.explorer", "search-button-width").size * Layout::dpiScale();
|
||||
float barH = S.drawElement("tabs.explorer", "search-bar-height").size * Layout::dpiScale();
|
||||
|
||||
// Clamp so search bar never overflows
|
||||
float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize;
|
||||
@@ -620,7 +620,16 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
|
||||
drawStatusPill(cardMin, cardW);
|
||||
|
||||
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg();
|
||||
// Distribute the two stat blocks (Height, Best Block) evenly through the
|
||||
// content region so the card matches the density of the sibling 2x2 metric
|
||||
// grid instead of pinning Height to the top and Best Block to the bottom
|
||||
// edge with a large empty gap between them.
|
||||
float contentTop = cardMin.y + pad * 0.5f + headerH;
|
||||
float contentBottom = cardMax.y - pad;
|
||||
float blockGap = std::max(Layout::spacingMd(),
|
||||
(contentBottom - contentTop - heroLineH - hashLineH) / 3.0f);
|
||||
|
||||
float labelY = contentTop + blockGap;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
|
||||
|
||||
@@ -640,7 +649,7 @@ static void renderChainStats(App* app, float availWidth) {
|
||||
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
|
||||
}
|
||||
|
||||
float hashLabelY = cardMax.y - pad - hashLineH;
|
||||
float hashLabelY = labelY + heroLineH + blockGap;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));
|
||||
|
||||
@@ -755,8 +764,8 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
ImFont* body2 = Type().body2();
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size;
|
||||
float baseRowH = S.drawElement("tabs.explorer", "row-height").size * dp;
|
||||
float rowRound = S.drawElement("tabs.explorer", "row-rounding").size * dp;
|
||||
float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f;
|
||||
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
@@ -989,7 +998,7 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
|
||||
ImGui::EndChild();
|
||||
|
||||
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size;
|
||||
float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size * dp;
|
||||
ApplyScrollEdgeMask(dl, parentVtx, childDL, childVtx,
|
||||
rowAreaTop, rowAreaTop + rowAreaH, fadeZone, scrollY, scrollMaxY);
|
||||
|
||||
@@ -1116,8 +1125,9 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
// ── Info grid ──
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
float dp = Layout::dpiScale();
|
||||
float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize;
|
||||
float labelW = S.drawElement("tabs.explorer", "label-column").size;
|
||||
float labelW = S.drawElement("tabs.explorer", "label-column").size * dp;
|
||||
{
|
||||
ImVec2 gridPos = ImGui::GetCursorScreenPos();
|
||||
float gx = gridPos.x;
|
||||
@@ -1178,7 +1188,7 @@ static void renderBlockDetailModal(App* app) {
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size;
|
||||
float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size * dp;
|
||||
ImU32 linkCol = schema::UI().resolveColor("var(--secondary-light)");
|
||||
|
||||
for (int i = 0; i < (int)s_detail_txids.size(); i++) {
|
||||
|
||||
@@ -309,12 +309,10 @@ void KeyExportDialog::render(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Close button
|
||||
// Close button — centered via the shared footer helper (no divider, matching the
|
||||
// dialog's prior leading-Spacing placement) instead of hand-computing the offset.
|
||||
float button_width = closeBtn.width;
|
||||
float avail_width = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (avail_width - button_width) / 2.0f);
|
||||
material::BeginOverlayDialogFooter(button_width, /*drawSeparator=*/false);
|
||||
|
||||
if (material::TactileButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
||||
s_open = false;
|
||||
|
||||
@@ -137,6 +137,12 @@ struct PfEditState {
|
||||
};
|
||||
static PfEditState s_pfEdit;
|
||||
|
||||
// Two-stage delete arm/confirm for the master-list per-row delete icon (mirrors the
|
||||
// contacts tab's s_confirm_delete_idx): first click on a row's trash arms it (icon turns
|
||||
// red); a second click on the SAME row within the loop confirms the erase. Hovering/clicking
|
||||
// a different row, or clicking off the icons, disarms. -1 = nothing armed.
|
||||
static int s_pfConfirmDelIdx = -1;
|
||||
|
||||
// The pure price-series math lives in data/market_series.h; the selected chart range is
|
||||
// s_mkt.chartInterval (0=Live 1=1H 2=1D 3=1W 4=1M, session-scoped, default 1M).
|
||||
|
||||
@@ -837,6 +843,7 @@ static void RenderPortfolioEditor(App* app)
|
||||
float rowH = 46.0f * dp;
|
||||
float delSlot = 36.0f * dp; // reserved trailing space for the delete icon + margin
|
||||
int clickedSel = -999, delRow = -1; // deferred: don't mutate `entries` mid-loop
|
||||
bool armedThisFrame = false; // an arm-click also fires the row Selectable; don't let it disarm
|
||||
for (int vi = 0; vi < (int)vis.size(); vi++) {
|
||||
int i = vis[vi];
|
||||
ImGui::PushID(i);
|
||||
@@ -877,6 +884,7 @@ static void RenderPortfolioEditor(App* app)
|
||||
// Per-row delete icon (larger, inset from the edge). Hit-tested manually so it is
|
||||
// NOT an overlapping ImGui item over the row Selectable (which asserted on hover).
|
||||
if (rowHov || selRow) {
|
||||
bool armed = (s_pfConfirmDelIdx == i); // this row is arm-confirmed
|
||||
ImFont* delFont = Type().iconMed();
|
||||
float ds = delFont->LegacySize;
|
||||
ImVec2 dc(rmx.x - Layout::spacingMd() - ds * 0.5f, rmn.y + rowH * 0.5f);
|
||||
@@ -884,11 +892,20 @@ static void RenderPortfolioEditor(App* app)
|
||||
bool dhov = ImGui::IsMouseHoveringRect(dmn, dmx);
|
||||
if (dhov) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) delRow = i;
|
||||
// Hovering a *different* row's trash disarms the previously armed one.
|
||||
if (!armed && s_pfConfirmDelIdx >= 0) s_pfConfirmDelIdx = -1;
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
if (armed) delRow = i; // 2nd click on the armed row erases
|
||||
else { s_pfConfirmDelIdx = i; // 1st click arms this row...
|
||||
armedThisFrame = true; } // ...and must survive the row-select this same click triggers
|
||||
}
|
||||
}
|
||||
ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, ICON_MD_DELETE_OUTLINE);
|
||||
// A filled trash glyph while armed (vs outline) reinforces the recolor.
|
||||
const char* delGlyph = armed ? ICON_MD_DELETE : ICON_MD_DELETE_OUTLINE;
|
||||
ImU32 delCol = armed ? ReadableError() : (dhov ? Error() : OnSurfaceMedium());
|
||||
ImVec2 isz = delFont->CalcTextSizeA(ds, FLT_MAX, 0, delGlyph);
|
||||
mdl->AddText(delFont, ds, ImVec2(dc.x - isz.x * 0.5f, dc.y - isz.y * 0.5f),
|
||||
dhov ? Error() : OnSurfaceMedium(), ICON_MD_DELETE_OUTLINE);
|
||||
delCol, delGlyph);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
@@ -905,10 +922,16 @@ static void RenderPortfolioEditor(App* app)
|
||||
else if (delRow == s_pfEdit.sel) s_pfEdit.sel = std::min(delRow, (int)es.size() - 1);
|
||||
PortfolioBeginEdit(app, s_pfEdit.sel);
|
||||
}
|
||||
} else if (clickedSel != -999) {
|
||||
s_pfConfirmDelIdx = -1; // erase done — storage indices shifted, drop the arm
|
||||
} else if (clickedSel != -999 && !armedThisFrame) {
|
||||
pfCommitIfNeeded(app); // auto-save the current group before switching
|
||||
PortfolioBeginEdit(app, clickedSel);
|
||||
s_pfConfirmDelIdx = -1; // switching rows disarms any pending delete
|
||||
}
|
||||
// A left-click that hit no row/action (empty list space) disarms a pending delete.
|
||||
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) &&
|
||||
!ImGui::IsAnyItemHovered())
|
||||
s_pfConfirmDelIdx = -1;
|
||||
}
|
||||
ImGui::EndChild();
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
@@ -1324,9 +1347,10 @@ static void mktDrawPairSelector(App* app, const std::vector<data::ExchangeInfo>&
|
||||
|
||||
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
|
||||
{
|
||||
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
|
||||
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
|
||||
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
|
||||
float dp = Layout::dpiScale();
|
||||
float chipH = S.drawElement("tabs.market", "pair-chip-height").height * dp;
|
||||
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius * dp;
|
||||
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size * dp;
|
||||
float innerGap = Layout::spacingSm();
|
||||
float sidePad = Layout::spacingMd();
|
||||
|
||||
@@ -2112,11 +2136,29 @@ static void mktDrawPortfolio(const MktCtx& cx)
|
||||
float rowGap = pfRowGapFor(style, mktDp);
|
||||
float rowsH = std::max(rowH, portfolioH - pfSummaryH);
|
||||
|
||||
// Anchor the bottom of the portfolio: the group list otherwise ends at its content height,
|
||||
// leaving un-anchored dead space between the last row and the content-area bottom. Grow the
|
||||
// rows region to fill the remaining vertical space (down to the scroll area's bottom, less a
|
||||
// small margin) and wrap it in a contained glass panel — so it reads as one framed table with
|
||||
// a real bottom edge (like Explorer's block-list card) instead of trailing off into nothing.
|
||||
// The empty state then centres in this full-height panel rather than in a one-row band.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
|
||||
float floorH = ImGui::GetContentRegionAvail().y - gap; // to the scroll bottom, keep a margin
|
||||
float panelH = std::max(rowsH, floorH);
|
||||
{
|
||||
ImVec2 pMin(cardMin.x, cardMin.y + pfSummaryH);
|
||||
ImVec2 pMax(rightEdge, pMin.y + panelH);
|
||||
GlassPanelSpec pg;
|
||||
pg.rounding = Layout::glassRounding();
|
||||
pg.fillAlpha = 12; pg.borderAlpha = 24; // faint container: frames without competing with the per-row cards
|
||||
DrawGlassPanel(dl, pMin, pMax, pg);
|
||||
}
|
||||
// Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
|
||||
// only when the visible groups overflow the bounded height.
|
||||
// only when the visible groups overflow the bounded height. NoBackground so the contained
|
||||
// glass panel drawn above is the sole surface — otherwise the opaque ChildBg (non-acrylic
|
||||
// mode) would paint over the panel fill + border.
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
ImGui::BeginChild("##pfRows", ImVec2(availWidth, rowsH), false);
|
||||
ImGui::BeginChild("##pfRows", ImVec2(availWidth, panelH), false, ImGuiWindowFlags_NoBackground);
|
||||
ImGui::PopStyleVar();
|
||||
// Zero item-spacing INSIDE the child: each row emits an InvisibleButton + a gap Dummy, and
|
||||
// ImGui's default ItemSpacing.y between those items would inflate the content past rowsH and
|
||||
@@ -2125,8 +2167,7 @@ static void mktDrawPortfolio(const MktCtx& cx)
|
||||
ImDrawList* rdl = ImGui::GetWindowDrawList();
|
||||
float rowW = ImGui::GetContentRegionAvail().x; // narrows automatically if a scrollbar shows
|
||||
if (vis.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
|
||||
material::DrawEmptyState(ICON_MD_PIE_CHART, TR("portfolio_no_entries"));
|
||||
} else {
|
||||
// TABLE column-header strip (style 0): a thin static header labelling the numeric columns —
|
||||
// a signature the card styles don't have. Uses the shared pfTableCols so it aligns with rows.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01)
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -27,6 +28,7 @@
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <cstdlib> // std::atoi (xmrig version compare)
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -55,6 +57,25 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
float miningBtnGap = gap;
|
||||
float miningBtnMaxW = availWidth * schema::UI().drawElement("tabs.mining", "btn-max-width-ratio").size;
|
||||
|
||||
// Thread-count tiles at an adaptive step (multiples of 1/2/4/8, chosen by core count) plus 1 and the
|
||||
// max, so the tile row stays bounded (~<=24 tiles) instead of one tile per thread — a 192-thread
|
||||
// EPYC would otherwise overflow the card. Exact in-between counts are settable via the input box.
|
||||
int tileStep = 1;
|
||||
if (max_threads > 96) tileStep = 8;
|
||||
else if (max_threads > 48) tileStep = 4;
|
||||
else if (max_threads > 24) tileStep = 2;
|
||||
std::vector<int> threadOptions;
|
||||
threadOptions.push_back(1);
|
||||
for (int v = tileStep; v < max_threads; v += tileStep)
|
||||
if (v > 1) threadOptions.push_back(v);
|
||||
if (max_threads > 1 && threadOptions.back() != max_threads) threadOptions.push_back(max_threads);
|
||||
const int nOpts = (int)threadOptions.size();
|
||||
|
||||
// Custom drawlist hit-tests (the thread tiles + the Mine button) bypass ImGui's popup input capture,
|
||||
// so a click on an open dropdown (saved pools / payout addresses) would ALSO fire them. Gate on this
|
||||
// so clicking the dropdown's X (or a row) doesn't bleed through to the tiles/Mine button.
|
||||
const bool anyPopupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel);
|
||||
|
||||
// --- Compute thread grid layout based on controls card width ---
|
||||
// Estimate controlsW first to compute cols correctly
|
||||
// The Mine button is square (= card height, which scales with DPI), so the width we
|
||||
@@ -62,10 +83,10 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
// over-estimates the grid width and lets the thread cells overflow the card (e.g. at 150%).
|
||||
float estControlsW = availWidth - std::min(schema::UI().drawElement("tabs.mining", "button-max-width-clamp").size * dp, miningBtnMaxW) - miningBtnGap;
|
||||
float innerW = estControlsW - pad * 2;
|
||||
float cellSz = std::clamp(schema::UI().drawElement("tabs.mining", "cell-size").size * vs, schema::UI().drawElement("tabs.mining", "cell-min-size").size, schema::UI().drawElement("tabs.mining", "cell-max-size").sizeOr(42.0f));
|
||||
float cellSz = std::clamp(schema::UI().drawElement("tabs.mining", "cell-size").size * vs, schema::UI().drawElement("tabs.mining", "cell-min-size").size * dp, schema::UI().drawElement("tabs.mining", "cell-max-size").sizeOr(42.0f) * dp);
|
||||
float cellGap = std::max(schema::UI().drawElement("tabs.mining", "cell-gap-min").size, cellSz * schema::UI().drawElement("tabs.mining", "cell-gap-ratio").size);
|
||||
int cols = std::max(1, std::min(max_threads, (int)(innerW / (cellSz + cellGap))));
|
||||
int rows = (max_threads + cols - 1) / cols;
|
||||
int cols = std::max(1, std::min(nOpts, (int)(innerW / (cellSz + cellGap))));
|
||||
int rows = (nOpts + cols - 1) / cols;
|
||||
float gridW = cols * cellSz + (cols - 1) * cellGap;
|
||||
float gridH = rows * cellSz + (rows - 1) * cellGap;
|
||||
|
||||
@@ -115,6 +136,87 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
// Centered, top-aligned thread-count stepper: [-] centered editable number [+]. Lets a
|
||||
// high-core CPU pick an EXACT count that isn't one of the tiles. The input commits on Enter
|
||||
// (not per keystroke) so it doesn't restart the miner while the user is still typing.
|
||||
{
|
||||
auto applyThreads = [&](int tc) {
|
||||
tc = std::clamp(tc, 1, max_threads);
|
||||
if (tc == s_selected_threads) return;
|
||||
s_selected_threads = tc;
|
||||
app->settings()->setPoolThreads(tc);
|
||||
app->settings()->save();
|
||||
if (mining.generate) app->startMining(tc);
|
||||
if (s_pool_mode && state.pool_mining.xmrig_running) {
|
||||
app->stopPoolMining();
|
||||
app->startPoolMining(tc);
|
||||
}
|
||||
};
|
||||
ImFont* stepFont = Type().iconSmall();
|
||||
const float fieldH = capFont->LegacySize + 6.0f * dp;
|
||||
const float sideW = fieldH; // square -/+ buttons
|
||||
const float fieldW = 46.0f * dp;
|
||||
const float g = 2.0f * dp;
|
||||
const float totalW = sideW + g + fieldW + g + sideW;
|
||||
float sx = cardMin.x + (controlsW - totalW) * 0.5f;
|
||||
const float sy = curY;
|
||||
ImVec2 savedCur = ImGui::GetCursorScreenPos();
|
||||
|
||||
// -/+ buttons drawn in the same style as the thread tiles (rounded rect + border + centered
|
||||
// glyph, same fill/border/rounding). Hit-tested with a real InvisibleButton so they stay
|
||||
// popup-safe.
|
||||
const float stepRound = schema::UI().drawElement("tabs.mining", "cell-rounding").size;
|
||||
// atBound: the stepper can't move further (already at 1 for "-" or max for "+"). When
|
||||
// at the bound the control is a no-op, so render it disabled: dim glyph/border, no hand
|
||||
// cursor, no tooltip — and swallow the click so it reads as inert.
|
||||
auto rectStepBtn = [&](const char* id, const char* glyph, float x, const char* tip, bool atBound) -> bool {
|
||||
ImGui::SetCursorScreenPos(ImVec2(x, sy));
|
||||
ImGui::InvisibleButton(id, ImVec2(sideW, fieldH));
|
||||
const bool hov = ImGui::IsItemHovered() && !atBound;
|
||||
const bool clk = ImGui::IsItemClicked() && !atBound;
|
||||
const ImVec2 mn(x, sy), mx(x + sideW, sy + fieldH);
|
||||
dl->AddRectFilled(mn, mx, hov ? WithAlpha(OnSurface(), 25) : WithAlpha(OnSurface(), 8), stepRound);
|
||||
dl->AddRect(mn, mx, WithAlpha(OnSurface(), atBound ? 15 : (hov ? 80 : 35)), stepRound);
|
||||
const ImVec2 gsz = stepFont->CalcTextSizeA(stepFont->LegacySize, FLT_MAX, 0, glyph);
|
||||
dl->AddText(stepFont, stepFont->LegacySize,
|
||||
ImVec2(x + (sideW - gsz.x) * 0.5f, sy + (fieldH - gsz.y) * 0.5f),
|
||||
WithAlpha(OnSurface(), atBound ? 30 : (hov ? 160 : 80)), glyph);
|
||||
if (hov) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
if (tip && tip[0]) material::Tooltip("%s", tip);
|
||||
}
|
||||
return clk;
|
||||
};
|
||||
|
||||
// "-" button (disabled at 1 thread)
|
||||
if (rectStepBtn("##ThreadMinus", ICON_MD_REMOVE, sx, TR("mining_threads_minus_tooltip"),
|
||||
s_selected_threads <= 1))
|
||||
applyThreads(s_selected_threads - 1);
|
||||
sx += sideW + g;
|
||||
|
||||
// Centered editable number: symmetric FramePadding pushes the digits to the field centre.
|
||||
char tb[8]; snprintf(tb, sizeof(tb), "%d", s_selected_threads);
|
||||
float basePad = ImGui::GetStyle().FramePadding.x;
|
||||
float txtW = ImGui::CalcTextSize(tb).x;
|
||||
float centerPad = std::max(basePad, (fieldW - txtW) * 0.5f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(centerPad, ImGui::GetStyle().FramePadding.y));
|
||||
ImGui::SetCursorScreenPos(ImVec2(sx, sy));
|
||||
ImGui::SetNextItemWidth(fieldW);
|
||||
int tc = s_selected_threads;
|
||||
if (ImGui::InputInt("##ThreadCountInput", &tc, 0, 0, ImGuiInputTextFlags_EnterReturnsTrue))
|
||||
applyThreads(tc);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("mining_threads_input_tooltip"));
|
||||
ImGui::PopStyleVar();
|
||||
sx += fieldW + g;
|
||||
|
||||
// "+" button (disabled at max_threads)
|
||||
if (rectStepBtn("##ThreadPlus", ICON_MD_ADD, sx, TR("mining_threads_plus_tooltip"),
|
||||
s_selected_threads >= max_threads))
|
||||
applyThreads(s_selected_threads + 1);
|
||||
|
||||
ImGui::SetCursorScreenPos(savedCur);
|
||||
}
|
||||
|
||||
// Idle mining toggle (top-right corner of card)
|
||||
float idleRightEdge = cardMax.x - pad;
|
||||
{
|
||||
@@ -191,7 +293,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
for (const auto& d : delays) {
|
||||
if (d.seconds == curDelay) { previewLabel = d.label; break; }
|
||||
}
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f);
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f) * dp;
|
||||
float comboX = idleRightEdge - comboW;
|
||||
float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
|
||||
@@ -230,7 +332,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
if (curVal <= 0) curVal = hwThreads;
|
||||
char previewBuf[16];
|
||||
snprintf(previewBuf, sizeof(previewBuf), "%d", curVal);
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f);
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f) * dp;
|
||||
float comboX = idleRightEdge - comboW;
|
||||
float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
|
||||
@@ -269,7 +371,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
if (curVal <= 0) curVal = std::max(1, hwThreads / 2);
|
||||
char previewBuf[16];
|
||||
snprintf(previewBuf, sizeof(previewBuf), "%d", curVal);
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f);
|
||||
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f) * dp;
|
||||
float comboX = idleRightEdge - comboW;
|
||||
float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f;
|
||||
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
|
||||
@@ -474,8 +576,39 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
std::string curVer = s_live_miner_ver;
|
||||
if (curVer.empty()) curVer = app->poolMiningInstalledVersion();
|
||||
if (curVer.empty()) curVer = app->settings()->getXmrigVersion();
|
||||
// Version state: subtle GREEN + "xmrig releases" when up to date, subtle ORANGE +
|
||||
// "Update <latest>" when an update is available, neutral when either version is unknown.
|
||||
auto verNum = [](std::string s) -> std::vector<int> {
|
||||
if (!s.empty() && (s[0] == 'v' || s[0] == 'V')) s.erase(0, 1);
|
||||
s = s.substr(0, s.find('-')); // drop any -build suffix
|
||||
std::vector<int> v; size_t p = 0;
|
||||
while (p <= s.size()) {
|
||||
size_t q = s.find('.', p);
|
||||
v.push_back(std::atoi(s.substr(p, q == std::string::npos ? std::string::npos : q - p).c_str()));
|
||||
if (q == std::string::npos) break;
|
||||
p = q + 1;
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const bool verKnown = !curVer.empty() && !s_xmrig_latest_tag.empty();
|
||||
bool upToDate = false;
|
||||
if (verKnown) {
|
||||
auto a = verNum(curVer), b = verNum(s_xmrig_latest_tag);
|
||||
int cmp = 0;
|
||||
for (size_t i = 0; i < std::max(a.size(), b.size()) && cmp == 0; ++i) {
|
||||
int x = i < a.size() ? a[i] : 0, y = i < b.size() ? b[i] : 0;
|
||||
if (x != y) cmp = x < y ? -1 : 1;
|
||||
}
|
||||
upToDate = (cmp >= 0); // installed >= latest
|
||||
}
|
||||
const bool outdated = verKnown && !upToDate;
|
||||
const ImU32 subtleGreen = IM_COL32(120, 190, 130, 255);
|
||||
const ImU32 subtleOrange = IM_COL32(214, 158, 74, 255);
|
||||
|
||||
char xbtn[64];
|
||||
if (!s_xmrig_latest_tag.empty())
|
||||
if (upToDate)
|
||||
snprintf(xbtn, sizeof(xbtn), "%s", TR("xmrig_releases"));
|
||||
else if (!s_xmrig_latest_tag.empty())
|
||||
snprintf(xbtn, sizeof(xbtn), "%s %s", TR("xmrig_update_short"), s_xmrig_latest_tag.c_str());
|
||||
else
|
||||
snprintf(xbtn, sizeof(xbtn), "%s", TR("xmrig_update_button"));
|
||||
@@ -498,7 +631,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
xpill, xbtnH * 0.3f);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(xbtnX + xpadX, xbtnY + (xbtnH - xlblSz.y) * 0.5f),
|
||||
minerBusy ? OnSurfaceDisabled() : OnSurfaceMedium(), xbtn);
|
||||
minerBusy ? OnSurfaceDisabled()
|
||||
: (upToDate ? subtleGreen : outdated ? subtleOrange : OnSurfaceMedium()),
|
||||
xbtn);
|
||||
if (xhov) {
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", minerBusy ? TR("xmrig_stop_mining_first")
|
||||
@@ -514,7 +649,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
float xcurX = xbtnX - 6.0f * dp - xcurSz.x;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(xcurX, curY + (headerH - xcurSz.y) * 0.5f),
|
||||
OnSurfaceDisabled(), xcur);
|
||||
upToDate ? subtleGreen : outdated ? subtleOrange : OnSurfaceDisabled(), xcur);
|
||||
idleRightEdge = xcurX - 8.0f * dp;
|
||||
}
|
||||
|
||||
@@ -547,14 +682,14 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
// Track which thread the mouse is currently over (-1 = none)
|
||||
int hovered_thread = -1;
|
||||
|
||||
// First pass: hit-test all cells to find hovered thread
|
||||
for (int i = 0; i < max_threads; i++) {
|
||||
// First pass: hit-test all cells to find the hovered thread-count option (its value, not index)
|
||||
for (int i = 0; i < nOpts; i++) {
|
||||
int row = i / cols;
|
||||
int col = i % cols;
|
||||
float cx = gridX + col * (cellSz + cellGap);
|
||||
float cy = gridY + row * (cellSz + cellGap);
|
||||
if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + cellSz, cy + cellSz))) {
|
||||
hovered_thread = i + 1;
|
||||
hovered_thread = threadOptions[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -565,8 +700,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
if (hovered_thread > 0 && !benchActive)
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
|
||||
// Drag-to-select logic (disabled during benchmark)
|
||||
if (!benchActive && ImGui::IsMouseClicked(0) && hovered_thread > 0) {
|
||||
// Drag-to-select logic (disabled during benchmark; ignored while a dropdown popup is open)
|
||||
if (!benchActive && !anyPopupOpen && ImGui::IsMouseClicked(0) && hovered_thread > 0) {
|
||||
// Begin drag
|
||||
s_drag_active = true;
|
||||
s_drag_anchor_thread = hovered_thread;
|
||||
@@ -586,8 +721,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
}
|
||||
}
|
||||
|
||||
// Render cells
|
||||
for (int i = 0; i < max_threads; i++) {
|
||||
// Render cells (one per discrete thread-count option)
|
||||
for (int i = 0; i < nOpts; i++) {
|
||||
int row = i / cols;
|
||||
int col = i % cols;
|
||||
float cx = gridX + col * (cellSz + cellGap);
|
||||
@@ -595,8 +730,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
ImVec2 cMin(cx, cy);
|
||||
ImVec2 cMax(cx + cellSz, cy + cellSz);
|
||||
|
||||
int threadNum = i + 1;
|
||||
bool active = threadNum <= s_selected_threads;
|
||||
int threadNum = threadOptions[i];
|
||||
bool active = threadNum <= s_selected_threads; // fill-up-to-selected heat metaphor
|
||||
bool hovered = (threadNum == hovered_thread);
|
||||
|
||||
// Determine visual state
|
||||
@@ -623,15 +758,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
dl->AddRect(cMin, cMax, WithAlpha(Primary(), (int)(160 + 60 * glow)), rounding, 0, schema::UI().drawElement("tabs.mining", "active-cell-border-thickness").size);
|
||||
} else if (active) {
|
||||
// Active but not mining: solid primary fill
|
||||
ImU32 pri = Primary();
|
||||
int priR = (pri >> 0) & 0xFF;
|
||||
int priG = (pri >> 8) & 0xFF;
|
||||
int priB = (pri >> 16) & 0xFF;
|
||||
ImU32 fillCol = hovered
|
||||
? IM_COL32(priR, priG, priB, 220)
|
||||
: IM_COL32(priR, priG, priB, 180);
|
||||
ImU32 fillCol = material::WithAlpha(Primary(), hovered ? 220 : 180);
|
||||
dl->AddRectFilled(cMin, cMax, fillCol, rounding);
|
||||
dl->AddRect(cMin, cMax, IM_COL32(priR, priG, priB, 255), rounding, 0, schema::UI().drawElement("tabs.mining", "cell-border-thickness").size);
|
||||
dl->AddRect(cMin, cMax, material::WithAlpha(Primary(), 255), rounding, 0, schema::UI().drawElement("tabs.mining", "cell-border-thickness").size);
|
||||
} else {
|
||||
// Inactive: dim outline
|
||||
ImU32 fillCol = hovered
|
||||
@@ -673,7 +802,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
ImVec2 bMin(btnX, btnY);
|
||||
ImVec2 bMax(btnX + miningBtnSz, btnY + cardH);
|
||||
|
||||
bool btnHovered = material::IsRectHovered(bMin, bMax);
|
||||
bool btnHovered = !anyPopupOpen && material::IsRectHovered(bMin, bMax); // don't bleed through an open dropdown
|
||||
bool btnClicked = btnHovered && ImGui::IsMouseClicked(0);
|
||||
bool isSyncing = state.sync.syncing;
|
||||
bool poolBlockedBySolo = s_pool_mode && mining.generate && !state.pool_mining.xmrig_running;
|
||||
@@ -685,8 +814,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running;
|
||||
// Can't start pool mining without a payout address (blank for a new wallet with no z-address);
|
||||
// only blocks starting — stopping a running miner stays enabled.
|
||||
// Block start when the payout address is empty OR not a valid DragonX address — mining to a
|
||||
// malformed / wrong-chain address silently loses the rewards. (M-01)
|
||||
const std::string poolPayoutStr(s_pool_worker);
|
||||
bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running &&
|
||||
std::string(s_pool_worker).empty();
|
||||
(poolPayoutStr.empty() ||
|
||||
(poolPayoutStr != "x" && !util::isValidRecipientAddress(poolPayoutStr)));
|
||||
bool disabled = s_pool_mode
|
||||
? (isToggling || poolBlockedBySolo || poolNeedsPayout)
|
||||
: (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing));
|
||||
@@ -819,7 +952,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
|
||||
else if (poolBlockedBySolo)
|
||||
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
|
||||
else if (poolNeedsPayout)
|
||||
material::Tooltip("%s", "Enter a payout address first (generate a Z address)");
|
||||
material::Tooltip("%s", TR("mining_pool_needs_payout_tooltip"));
|
||||
else
|
||||
material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
|
||||
}
|
||||
|
||||
@@ -104,7 +104,10 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
||||
}
|
||||
}
|
||||
|
||||
// Use pool hashrate for EST. DAILY when in pool mode
|
||||
// Est. Daily = expected reward for YOUR own hashrate share of the network. In pool mode this uses
|
||||
// your local miner rate (pool_mining.hashrate_10s), NOT the pool's aggregate rate: pool payouts are
|
||||
// share-proportional, so your expected daily is the same solo-equivalent value (a rough estimate,
|
||||
// before the pool fee). Using the pool's total rate here would show the POOL's earnings, not yours. (M-05)
|
||||
double estHashrate = s_pool_mode ? state.pool_mining.hashrate_10s : mining.localHashrate;
|
||||
double est_hours_2 = EstimateHoursToBlock(estHashrate, mining.networkHashrate, mining.difficulty);
|
||||
double estDailyBlocks = (est_hours_2 > 0) ? (24.0 / est_hours_2) : 0.0;
|
||||
@@ -218,12 +221,15 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
||||
snprintf(estVal, sizeof(estVal), "~%.4f", estDaily);
|
||||
else
|
||||
snprintf(estVal, sizeof(estVal), "N/A");
|
||||
// Disclose in pool mode that Est. Daily is a rough solo-equivalent (before the pool fee), so the
|
||||
// number isn't silently mismatched to its plain "Est. Daily" label. (M-05)
|
||||
const char* estSub = (s_pool_mode && estActive) ? TR("mining_est_daily_pool_sub") : nullptr;
|
||||
|
||||
EarningsEntry entries[] = {
|
||||
{ TR("mining_today"), todayVal, todaySub, greenCol2 },
|
||||
{ TR("mining_yesterday"), yesterdayVal, yesterdaySub, OnSurface() },
|
||||
{ TR("mining_all_time"), allVal, allSub, OnSurface() },
|
||||
{ TR("mining_est_daily"), estVal, nullptr, estActive ? greenCol2 : OnSurfaceDisabled() },
|
||||
{ TR("mining_est_daily"), estVal, estSub, estActive ? greenCol2 : OnSurfaceDisabled() },
|
||||
};
|
||||
|
||||
for (int ei = 0; ei < numCols; ei++) {
|
||||
@@ -527,7 +533,18 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo&
|
||||
float recentAvailH = ImGui::GetContentRegionAvail().y - sHdr - gapOver;
|
||||
float minRows = recentMined.empty() ? 2.0f : (float)recentMined.size();
|
||||
float contentH_blocks = rowH_blocks * minRows + pad * 2.5f;
|
||||
float recentH = std::clamp(contentH_blocks, 30.0f * dp, std::max(30.0f * dp, recentAvailH));
|
||||
// Lower bound for the panel height. When the list is empty the centred empty-state
|
||||
// (top pad + icon + gap + caption + bottom pad) needs more vertical room than the bare
|
||||
// 30*dp floor, otherwise the caption is clipped once the thread-tile grid wraps to two
|
||||
// rows and recentAvailH shrinks (font metrics/Layout helpers are already DPI-scaled — do
|
||||
// not multiply them by dp; pad is a scaled param).
|
||||
float recentMinH = 30.0f * dp;
|
||||
if (recentMined.empty()) {
|
||||
recentMinH = std::max(recentMinH,
|
||||
pad * 0.5f + Type().iconMed()->LegacySize + Layout::spacingXs()
|
||||
+ capFont->LegacySize + pad * 0.5f);
|
||||
}
|
||||
float recentH = std::clamp(contentH_blocks, recentMinH, std::max(recentMinH, recentAvailH));
|
||||
|
||||
// Glass panel wrapping the list + scroll-edge mask state
|
||||
ImVec2 recentPanelMin = ImGui::GetCursorScreenPos();
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "mining_mode_toggle.h"
|
||||
#include "mining_tab_helpers.h"
|
||||
#include "mining_tab.h" // CancelMiningBenchmark (L-11)
|
||||
#include "mining_pool_panel.h"
|
||||
|
||||
#include "../../app.h"
|
||||
@@ -16,6 +17,7 @@
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../../util/address_validation.h" // isValidRecipientAddress — payout validation (M-01)
|
||||
#include "../layout.h"
|
||||
#include "../notifications.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
@@ -40,10 +42,24 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
bool& s_pool_mode, char (&s_pool_url)[256], char (&s_pool_worker)[256],
|
||||
bool& s_pool_settings_dirty)
|
||||
{
|
||||
// Arm/confirm for the destructive X (delete) on saved pools & workers: a first click on a
|
||||
// row's X arms it (the X turns red + relabels via the trash glyph) and a second click within
|
||||
// a short window actually removes it; moving to another row or letting the window lapse disarms.
|
||||
// Same idiom as contacts_tab.cpp's s_confirm_delete_idx, keyed by row index within each list.
|
||||
static int s_armed_pool_idx = -1; // armed saved-pool row (-1 = none)
|
||||
static int s_armed_worker_idx = -1; // armed saved-worker row (-1 = none)
|
||||
static double s_armed_pool_time = 0.0;
|
||||
static double s_armed_worker_time = 0.0;
|
||||
const double kArmWindowSecs = 3.0; // second click must land within this window
|
||||
const double nowTime = ImGui::GetTime();
|
||||
// Lapse the arm if the confirm window elapsed.
|
||||
if (s_armed_pool_idx >= 0 && nowTime - s_armed_pool_time > kArmWindowSecs) s_armed_pool_idx = -1;
|
||||
if (s_armed_worker_idx >= 0 && nowTime - s_armed_worker_time > kArmWindowSecs) s_armed_worker_idx = -1;
|
||||
|
||||
const bool soloMiningAvailable = app->supportsSoloMining();
|
||||
float toggleW = schema::UI().drawElement("tabs.mining", "mode-toggle-width").size * hs;
|
||||
float toggleH = schema::UI().drawElement("tabs.mining", "mode-toggle-height").size;
|
||||
float toggleRnd = schema::UI().drawElement("tabs.mining", "mode-toggle-rounding").size;
|
||||
float toggleH = schema::UI().drawElement("tabs.mining", "mode-toggle-height").size * hs;
|
||||
float toggleRnd = schema::UI().drawElement("tabs.mining", "mode-toggle-rounding").size * hs;
|
||||
float totalW = soloMiningAvailable ? (toggleW * 2) : toggleW;
|
||||
|
||||
ImVec2 tMin = ImGui::GetCursorScreenPos();
|
||||
@@ -101,6 +117,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
s_pool_mode = false;
|
||||
app->settings()->setPoolMode(false);
|
||||
app->settings()->save();
|
||||
CancelMiningBenchmark(app); // don't leave a pool benchmark running after switching to solo (L-11)
|
||||
app->stopPoolMining();
|
||||
}
|
||||
if (soloHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
@@ -172,8 +189,11 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
float perGroupExtra = iconBtnW * 2; // dropdown + bookmark
|
||||
float remainW = contentEndX - inputsStartX - Layout::spacingSm() - resetBtnW
|
||||
- Layout::spacingSm() - perGroupExtra * 2;
|
||||
float urlW = std::max(60.0f, remainW * 0.30f);
|
||||
float wrkW = std::max(40.0f, remainW * 0.70f);
|
||||
// Floor keeps the inputs usable when cramped; the ceiling stops a
|
||||
// single input sprawling absurdly wide on a large window (leftover
|
||||
// space becomes right-side margin). Caps are logical px * dp.
|
||||
float urlW = std::min(std::max(60.0f, remainW * 0.30f), 420.0f * dp);
|
||||
float wrkW = std::min(std::max(40.0f, remainW * 0.70f), 560.0f * dp);
|
||||
|
||||
// Track positions for popup alignment
|
||||
float urlGroupStartX = ImGui::GetCursorScreenPos().x;
|
||||
@@ -293,9 +313,11 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
ImFont* rowFont = ImGui::GetFont();
|
||||
float rowFontSz = ImGui::GetFontSize();
|
||||
float rowH = ImGui::GetFrameHeight();
|
||||
int rowIdx = 0;
|
||||
for (const auto& url : savedUrls) {
|
||||
ImGui::PushID(url.c_str());
|
||||
bool isCurrent = (std::string(s_pool_url) == url);
|
||||
bool armed = (s_armed_pool_idx == rowIdx); // this row's X is arm-confirmed
|
||||
ImVec2 rowMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rowMax(rowMin.x + popupInnerW, rowMin.y + rowH);
|
||||
ImGui::InvisibleButton("##row", ImVec2(popupInnerW, rowH));
|
||||
@@ -316,40 +338,46 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
pdl->AddText(rowFont, rowFontSz,
|
||||
ImVec2(rowMin.x + textPadX, textY),
|
||||
isCurrent ? Primary() : OnSurface(), urlDisp.c_str());
|
||||
// X button — flush with right edge, icon centered
|
||||
// X (delete) button — flush with right edge, icon centered. First click on the X
|
||||
// arms it (filled trash glyph + readable-error tint); a second click within the
|
||||
// arm window actually removes. Armed row stays lit even when not hovered so the
|
||||
// pending-delete state is visible.
|
||||
{
|
||||
ImVec2 xMin(rowMax.x - xZoneW, rowMin.y);
|
||||
ImVec2 xMax(rowMax.x, rowMax.y);
|
||||
if (inXZone) {
|
||||
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("mining_remove"));
|
||||
} else if (rowHov) {
|
||||
// Show faint X when row is hovered
|
||||
ImFont* icoF = Type().iconSmall();
|
||||
const char* xIcon = ICON_MD_CLOSE;
|
||||
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
|
||||
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
|
||||
pdl->AddText(icoF, icoF->LegacySize,
|
||||
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
|
||||
OnSurfaceDisabled(), xIcon);
|
||||
material::Tooltip("%s", armed ? TR("address_book_confirm_delete")
|
||||
: TR("mining_remove"));
|
||||
}
|
||||
// Always draw icon when hovering X zone
|
||||
if (inXZone) {
|
||||
if (armed || inXZone || rowHov) {
|
||||
ImFont* icoF = Type().iconSmall();
|
||||
const char* xIcon = ICON_MD_CLOSE;
|
||||
// Filled trash while armed reinforces the recolor; otherwise a plain X.
|
||||
const char* xIcon = armed ? ICON_MD_DELETE : ICON_MD_CLOSE;
|
||||
ImU32 xCol = armed ? ReadableError()
|
||||
: (inXZone ? Error() : OnSurfaceDisabled());
|
||||
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
|
||||
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
|
||||
pdl->AddText(icoF, icoF->LegacySize,
|
||||
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
|
||||
Error(), xIcon);
|
||||
xCol, xIcon);
|
||||
}
|
||||
}
|
||||
// Hovering a *different* row's X disarms the previously armed one.
|
||||
if (inXZone && !armed && s_armed_pool_idx >= 0)
|
||||
s_armed_pool_idx = -1;
|
||||
// Click handling
|
||||
if (rowClk) {
|
||||
if (inXZone) {
|
||||
urlToRemove = url;
|
||||
if (armed) {
|
||||
urlToRemove = url; // 2nd click on the armed row removes
|
||||
} else {
|
||||
s_armed_pool_idx = rowIdx; // 1st click arms this row
|
||||
s_armed_pool_time = nowTime;
|
||||
}
|
||||
} else {
|
||||
s_armed_pool_idx = -1; // selecting a row disarms
|
||||
strncpy(s_pool_url, url.c_str(), sizeof(s_pool_url) - 1);
|
||||
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
@@ -359,10 +387,12 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
if (rowHov && !inXZone)
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImGui::PopID();
|
||||
rowIdx++;
|
||||
}
|
||||
if (!urlToRemove.empty()) {
|
||||
app->settings()->removeSavedPoolUrl(urlToRemove);
|
||||
app->settings()->save();
|
||||
s_armed_pool_idx = -1; // removal shifts indices — drop the arm
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
@@ -382,6 +412,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
std::string currentWorkerStr(s_pool_worker);
|
||||
if (currentWorkerStr.empty()) {
|
||||
material::Tooltip("%s", TR("mining_generate_z_address_hint"));
|
||||
} else if (currentWorkerStr != "x" && !util::isValidRecipientAddress(currentWorkerStr)) {
|
||||
material::Tooltip("%s", TR("mining_payout_invalid")); // block start below too (M-01)
|
||||
} else {
|
||||
material::Tooltip("%s", TR("mining_payout_tooltip"));
|
||||
}
|
||||
@@ -422,7 +454,9 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
// --- Worker: Popup positioned below the input group ---
|
||||
// Popup sized to fit full z-addresses without truncation;
|
||||
// zero horizontal padding so item highlights are flush with edges.
|
||||
float addrPopupW = std::max(wrkGroupW, availWidth * 0.55f);
|
||||
// Wide enough for full z-addresses, but ceiling it so it doesn't span
|
||||
// the whole window on a large display (leftover -> unused margin).
|
||||
float addrPopupW = std::min(std::max(wrkGroupW, availWidth * 0.55f), 640.0f * dp);
|
||||
ImGui::SetNextWindowPos(ImVec2(wrkGroupStartX, wrkGroupStartY + inputFrameH2));
|
||||
ImGui::SetNextWindowSize(ImVec2(addrPopupW, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f * dp);
|
||||
@@ -455,9 +489,11 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
ImFont* wRowFont = ImGui::GetFont();
|
||||
float wRowFontSz = ImGui::GetFontSize();
|
||||
float wRowH = ImGui::GetFrameHeight();
|
||||
int wRowIdx = 0;
|
||||
for (const auto& addr : savedWorkers) {
|
||||
ImGui::PushID(addr.c_str());
|
||||
bool isCurrent = (std::string(s_pool_worker) == addr);
|
||||
bool armed = (s_armed_worker_idx == wRowIdx); // this row's X is arm-confirmed
|
||||
ImVec2 rowMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 rowMax(rowMin.x + wPopupInnerW, rowMin.y + wRowH);
|
||||
ImGui::InvisibleButton("##row", ImVec2(wPopupInnerW, wRowH));
|
||||
@@ -481,38 +517,46 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
// Tooltip for long addresses
|
||||
if (rowHov && !inXZone)
|
||||
material::Tooltip("%s", addr.c_str());
|
||||
// X button — flush with right edge, icon centered
|
||||
// X (delete) button — flush with right edge, icon centered. First click on the X
|
||||
// arms it (filled trash glyph + readable-error tint); a second click within the
|
||||
// arm window actually removes. Armed row stays lit even when not hovered so the
|
||||
// pending-delete state is visible.
|
||||
{
|
||||
ImVec2 xMin(rowMax.x - wXZoneW, rowMin.y);
|
||||
ImVec2 xMax(rowMax.x, rowMax.y);
|
||||
if (inXZone) {
|
||||
pdl->AddRectFilled(xMin, xMax, IM_COL32(255, 80, 80, 30));
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
material::Tooltip("%s", TR("mining_remove"));
|
||||
} else if (rowHov) {
|
||||
ImFont* icoF = Type().iconSmall();
|
||||
const char* xIcon = ICON_MD_CLOSE;
|
||||
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
|
||||
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
|
||||
pdl->AddText(icoF, icoF->LegacySize,
|
||||
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
|
||||
OnSurfaceDisabled(), xIcon);
|
||||
material::Tooltip("%s", armed ? TR("address_book_confirm_delete")
|
||||
: TR("mining_remove"));
|
||||
}
|
||||
if (inXZone) {
|
||||
if (armed || inXZone || rowHov) {
|
||||
ImFont* icoF = Type().iconSmall();
|
||||
const char* xIcon = ICON_MD_CLOSE;
|
||||
// Filled trash while armed reinforces the recolor; otherwise a plain X.
|
||||
const char* xIcon = armed ? ICON_MD_DELETE : ICON_MD_CLOSE;
|
||||
ImU32 xCol = armed ? ReadableError()
|
||||
: (inXZone ? Error() : OnSurfaceDisabled());
|
||||
ImVec2 iSz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, xIcon);
|
||||
ImVec2 xCenter((xMin.x + xMax.x) * 0.5f, (xMin.y + xMax.y) * 0.5f);
|
||||
pdl->AddText(icoF, icoF->LegacySize,
|
||||
ImVec2(xCenter.x - iSz.x * 0.5f, xCenter.y - iSz.y * 0.5f),
|
||||
Error(), xIcon);
|
||||
xCol, xIcon);
|
||||
}
|
||||
}
|
||||
// Hovering a *different* row's X disarms the previously armed one.
|
||||
if (inXZone && !armed && s_armed_worker_idx >= 0)
|
||||
s_armed_worker_idx = -1;
|
||||
// Click handling
|
||||
if (rowClk) {
|
||||
if (inXZone) {
|
||||
addrToRemove = addr;
|
||||
if (armed) {
|
||||
addrToRemove = addr; // 2nd click on the armed row removes
|
||||
} else {
|
||||
s_armed_worker_idx = wRowIdx; // 1st click arms this row
|
||||
s_armed_worker_time = nowTime;
|
||||
}
|
||||
} else {
|
||||
s_armed_worker_idx = -1; // selecting a row disarms
|
||||
strncpy(s_pool_worker, addr.c_str(), sizeof(s_pool_worker) - 1);
|
||||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
@@ -522,10 +566,12 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
|
||||
if (rowHov && !inXZone)
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImGui::PopID();
|
||||
wRowIdx++;
|
||||
}
|
||||
if (!addrToRemove.empty()) {
|
||||
app->settings()->removeSavedPoolWorker(addrToRemove);
|
||||
app->settings()->save();
|
||||
s_armed_worker_idx = -1; // removal shifts indices — drop the arm
|
||||
}
|
||||
}
|
||||
ImGui::EndPopup();
|
||||
|
||||
@@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||
const std::string& firstShieldedAddress,
|
||||
const std::string& firstTransparentAddress)
|
||||
{
|
||||
// The configured payout address is the pool login rewards go to, so it wins over
|
||||
// the wallet's own addresses. "x" is the placeholder for an unset field.
|
||||
if (!payoutAddress.empty() && payoutAddress != "x") return payoutAddress;
|
||||
if (!firstShieldedAddress.empty()) return firstShieldedAddress;
|
||||
return firstTransparentAddress; // may be empty -> caller reports "no address"
|
||||
}
|
||||
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,14 @@ namespace ui {
|
||||
|
||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
||||
|
||||
// The xmrig "user" — the pool login block rewards are credited to. The user-entered
|
||||
// payout address wins; otherwise fall back to the wallet's own first shielded, then
|
||||
// transparent, address. "x" is the empty-field placeholder and counts as unset. The
|
||||
// result may be empty (no address anywhere), which the caller treats as an error.
|
||||
std::string resolveMiningUserAddress(const std::string& payoutAddress,
|
||||
const std::string& firstShieldedAddress,
|
||||
const std::string& firstTransparentAddress);
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value);
|
||||
const char* defaultPoolUrl();
|
||||
|
||||
@@ -251,11 +251,15 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
||||
}
|
||||
y += gap * 0.5f;
|
||||
|
||||
// The pool list = official pools ∪ user-saved favorites ∪ the current custom pool.
|
||||
const auto effective = util::effectivePools(app->settings()->getPoolUrl(),
|
||||
app->settings()->getSavedPoolUrls());
|
||||
|
||||
// --- POOLS (N) header + Refresh ---
|
||||
{
|
||||
char hdr[48];
|
||||
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
|
||||
(int)util::knownPools().size());
|
||||
(int)effective.size());
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
|
||||
|
||||
float btnS = ovFont->LegacySize + 6 * dp;
|
||||
@@ -278,11 +282,11 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
||||
{
|
||||
ImDrawList* cdl = ImGui::GetWindowDrawList();
|
||||
const auto snap = app->poolStatsSnapshot();
|
||||
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl());
|
||||
const util::KnownPool* current = util::findPoolByUrl(effective, app->settings()->getPoolUrl());
|
||||
const float childW = ImGui::GetContentRegionAvail().x;
|
||||
const float listRowH = capFont->LegacySize + 10 * dp;
|
||||
|
||||
for (const auto& kp : util::knownPools()) {
|
||||
for (const auto& kp : effective) {
|
||||
ImGui::PushID(kp.id.c_str());
|
||||
const bool isCurrent = current && current->id == kp.id;
|
||||
const auto it = snap.byId.find(kp.id);
|
||||
@@ -308,15 +312,35 @@ static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* d
|
||||
isCurrent ? Success() : OnSurfaceDisabled());
|
||||
|
||||
const float textY = rMin.y + (listRowH - capFont->LegacySize) * 0.5f;
|
||||
// Show the short host label (the narrow card can't fit host:port); the
|
||||
// full stratum URL is in the hover tooltip.
|
||||
cdl->AddText(capFont, capFont->LegacySize, ImVec2(rMin.x + 16 * dp, textY),
|
||||
isCurrent ? Primary() : OnSurface(), kp.label.c_str());
|
||||
|
||||
// Build the right-side "<hashrate> N% fee" run first so we know its width
|
||||
// and can bound (and ellipsis-truncate) the left host label to avoid a
|
||||
// collision — both text runs grow ~1.5x at font_scale 1.5 while the card
|
||||
// width barely does.
|
||||
char right[64];
|
||||
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("—");
|
||||
snprintf(right, sizeof(right), "%s %.0f%% fee", hrStr.c_str(), kp.feePercent);
|
||||
// Prefer the live fee the pool reports; fall back to the compile-time
|
||||
// KnownPool.feePercent. A synthetic user pool has an unknown (<0) fee, so
|
||||
// we show just its hashrate placeholder for it.
|
||||
double feePct = (it != snap.byId.end() && it->second.feePercent >= 0.0)
|
||||
? it->second.feePercent
|
||||
: kp.feePercent;
|
||||
if (feePct >= 0.0)
|
||||
snprintf(right, sizeof(right), "%s %s%% fee", hrStr.c_str(),
|
||||
FormatFeePercent(feePct).c_str());
|
||||
else
|
||||
snprintf(right, sizeof(right), "%s", hrStr.c_str());
|
||||
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
|
||||
|
||||
// Show the short host label (the narrow card can't fit host:port); the
|
||||
// full stratum URL is in the hover tooltip. Truncate it to the gap left
|
||||
// of the right-side run so a long hostname can't overlap the hashrate.
|
||||
const float labelX = rMin.x + 16 * dp;
|
||||
const float labelMaxW = (rMax.x - rSz.x - 6 * dp - gap) - labelX;
|
||||
std::string label = TruncateToWidth(kp.label, capFont, capFont->LegacySize, labelMaxW);
|
||||
cdl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, textY),
|
||||
isCurrent ? Primary() : OnSurface(), label.c_str());
|
||||
|
||||
cdl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
|
||||
|
||||
@@ -375,7 +399,9 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
// A bit wider than the old 25%/180dp so the pool rows fit the "<hashrate> N% fee" text.
|
||||
float leftW = std::clamp(availWidth * 0.30f, 210.0f * dp, availWidth * 0.45f);
|
||||
// Ratio ceiling (0.45) never binds on a wide window, so give leftW an
|
||||
// absolute cap too — the pool card doesn't need to be enormous.
|
||||
float leftW = std::clamp(availWidth * 0.30f, 210.0f * dp, 440.0f * dp);
|
||||
float colGap = gap;
|
||||
float rightW = availWidth - leftW - colGap;
|
||||
|
||||
@@ -390,6 +416,15 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
RenderLeftPoolCard(app, state, dl, capFont, sub1, ovFont, dp, gap, pad,
|
||||
leftMin, leftMax, s_pool_url, s_pool_settings_dirty);
|
||||
|
||||
// rightW is uncapped, so the chart/hint band it draws would sprawl the
|
||||
// full panel on a wide window. Constrain just the drawn content to a
|
||||
// centered ~1000dp band inside the panel's padded content region; the
|
||||
// glass panel itself still fills rightW, leftover -> side margin.
|
||||
const float rightContentW = rightW - pad * 2.0f;
|
||||
const float chartBandW = std::min(rightContentW, 1000.0f * dp);
|
||||
const float chartBandX = rightMin.x + pad
|
||||
+ std::max(0.0f, (rightContentW - chartBandW) * 0.5f);
|
||||
|
||||
// Right panel: live log (if toggled + available) else the sparkline.
|
||||
if (showLogView) {
|
||||
float logPad = pad * 0.5f;
|
||||
@@ -398,14 +433,14 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
|
||||
state.pool_mining.log_lines, "##PoolLogText");
|
||||
} else if (hasChartContent) {
|
||||
DrawHashrateSparkline(dl,
|
||||
ImVec2(rightMin.x + pad, rightMin.y + statRowH * 0.5f),
|
||||
ImVec2(rightMax.x - pad, rightMax.y),
|
||||
ImVec2(chartBandX, rightMin.y + statRowH * 0.5f),
|
||||
ImVec2(chartBandX + chartBandW, rightMax.y),
|
||||
chartHistory, capFont, dp);
|
||||
} else {
|
||||
const char* hint = TR("mining_chart_start");
|
||||
ImVec2 hs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, hint);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2((rightMin.x + rightMax.x - hs.x) * 0.5f,
|
||||
ImVec2(chartBandX + (chartBandW - hs.x) * 0.5f,
|
||||
(rightMin.y + rightMax.y - hs.y) * 0.5f),
|
||||
OnSurfaceDisabled(), hint);
|
||||
}
|
||||
|
||||
@@ -55,6 +55,19 @@ bool IsMiningBenchmarkActive() {
|
||||
return s_benchmark.active();
|
||||
}
|
||||
|
||||
void CancelMiningBenchmark(App* app) {
|
||||
if (!s_benchmark.active()) return;
|
||||
const int restoreThreads = s_benchmark.prev_threads;
|
||||
s_benchmark.reset();
|
||||
// Restore the miner to its pre-benchmark thread count. A benchmark runs in pool mode, so restore
|
||||
// regardless of the instantaneous running state — the sweep may be mid inter-candidate stop, where an
|
||||
// isPoolMinerRunning() check would be transiently false and silently drop the restart. (L-04, L-11)
|
||||
if (app && restoreThreads > 0) {
|
||||
app->stopPoolMining();
|
||||
app->startPoolMining(restoreThreads);
|
||||
}
|
||||
}
|
||||
|
||||
// Miner-update version check (one shot per session): fetches the latest DRG-XMRig release tag in
|
||||
// the background so the "Update" button can show it. Network call to the project Gitea, started
|
||||
// the first time the pool section is shown.
|
||||
|
||||
@@ -21,5 +21,12 @@ void RenderMiningTab(App* app);
|
||||
*/
|
||||
bool IsMiningBenchmarkActive();
|
||||
|
||||
/**
|
||||
* @brief Cancel a running thread benchmark and restore the miner to its pre-benchmark thread count.
|
||||
* Safe to call when no benchmark is active (no-op). Used when leaving the Mining tab or switching to
|
||||
* solo mode so the miner isn't left stuck at a benchmark step. (L-04, L-11)
|
||||
*/
|
||||
void CancelMiningBenchmark(App* app);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -41,11 +41,26 @@ std::string FormatHashrate(double hashrate)
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
std::string FormatFeePercent(double feePercent)
|
||||
{
|
||||
// Whole fees read "1"; fractional ones keep only their significant decimals
|
||||
// ("1.5", "0.9", "1.25") with no trailing zeros. Capped at 2 dp — finer than
|
||||
// any pool advertises, and the caller appends the "%".
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%.2f", feePercent);
|
||||
std::string s(buffer);
|
||||
if (s.find('.') != std::string::npos) {
|
||||
s.erase(s.find_last_not_of('0') + 1);
|
||||
if (!s.empty() && s.back() == '.') s.pop_back();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||
{
|
||||
(void)difficulty;
|
||||
if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0;
|
||||
double blocksPerHour = 3600.0 / 75.0;
|
||||
double blocksPerHour = 3600.0 / 150.0; // DragonX mainnet target spacing is 150s (chainparams) (L-05)
|
||||
double share = localHashrate / networkHashrate;
|
||||
if (share <= 0.0) return 0.0;
|
||||
return 1.0 / (blocksPerHour * share);
|
||||
|
||||
@@ -9,6 +9,7 @@ int GetMaxMiningThreads();
|
||||
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
||||
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
||||
std::string FormatHashrate(double hashrate);
|
||||
std::string FormatFeePercent(double feePercent);
|
||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
||||
std::string FormatEstTime(double estimatedHours);
|
||||
|
||||
|
||||
@@ -93,9 +93,12 @@ void RenderLiteNetworkTab(App* app)
|
||||
const bool connected = ws.connected;
|
||||
ImDrawList* sdl = ImGui::GetWindowDrawList();
|
||||
const float panelH = 56.0f * dp;
|
||||
const float panelW = ImGui::GetContentRegionAvail().x;
|
||||
const float availPanelW = ImGui::GetContentRegionAvail().x;
|
||||
const float panelW = std::min(1000.0f * dp, availPanelW);
|
||||
const float panelOffsetX = std::max(0.0f, (availPanelW - panelW) * 0.5f);
|
||||
const float pad2 = 12.0f * dp;
|
||||
ImVec2 pMin = ImGui::GetCursorScreenPos();
|
||||
pMin.x += panelOffsetX;
|
||||
ImVec2 pMax(pMin.x + panelW, pMin.y + panelH);
|
||||
GlassPanelSpec sspec; sspec.rounding = 8.0f * dp;
|
||||
DrawGlassPanel(sdl, pMin, pMax, sspec);
|
||||
@@ -150,7 +153,7 @@ void RenderLiteNetworkTab(App* app)
|
||||
Primary(), barH * 0.5f);
|
||||
}
|
||||
|
||||
ImGui::Dummy(ImVec2(panelW, panelH)); // reserve the panel (proper boundary growth)
|
||||
ImGui::Dummy(ImVec2(availPanelW, panelH)); // reserve the full row (proper boundary growth)
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
@@ -174,8 +177,8 @@ void RenderLiteNetworkTab(App* app)
|
||||
{
|
||||
const float availW = ImGui::GetContentRegionAvail().x;
|
||||
const float addBtnW = 80.0f * dp;
|
||||
const float urlW = (availW - addBtnW - 16.0f * dp) * 0.6f;
|
||||
const float lblW = (availW - addBtnW - 16.0f * dp) * 0.4f;
|
||||
const float urlW = std::min(550.0f * dp, (availW - addBtnW - 16.0f * dp) * 0.6f);
|
||||
const float lblW = std::min(300.0f * dp, (availW - addBtnW - 16.0f * dp) * 0.4f);
|
||||
ImGui::SetNextItemWidth(urlW);
|
||||
ImGui::InputTextWithHint("##LiteAddUrl", TR("lite_net_add_url_hint"), s_addUrl, sizeof(s_addUrl));
|
||||
ImGui::SameLine();
|
||||
@@ -236,8 +239,11 @@ void RenderLiteNetworkTab(App* app)
|
||||
auto it = probe.find(sv.url);
|
||||
if (it != probe.end()) pr = it->second;
|
||||
|
||||
const float cardW = ImGui::GetContentRegionAvail().x;
|
||||
const float availCardW = ImGui::GetContentRegionAvail().x;
|
||||
const float cardW = std::min(1000.0f * dp, availCardW);
|
||||
const float cardOffsetX = std::max(0.0f, (availCardW - cardW) * 0.5f);
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
cardMin.x += cardOffsetX;
|
||||
ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH);
|
||||
const float rnd = 8.0f * dp;
|
||||
const float cardBtnW = cardW - hideW;
|
||||
@@ -326,8 +332,10 @@ void RenderLiteNetworkTab(App* app)
|
||||
|
||||
// Advance to the next card via a real item (Dummy) below the card, so the scroll region's
|
||||
// content height actually grows — ImGui won't extend bounds from a bare SetCursorScreenPos.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(cardW, gap));
|
||||
// Reset X to the (unshifted) region origin so next card's GetContentRegionAvail() isn't
|
||||
// cumulatively narrowed by this card's centering offset.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x - cardOffsetX, cardMax.y));
|
||||
ImGui::Dummy(ImVec2(availCardW, gap));
|
||||
};
|
||||
|
||||
// ── Visible servers ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -621,12 +621,11 @@ void RenderPeersTab(App* app)
|
||||
if (!s_show_banned) {
|
||||
// ---- Connected Peers ----
|
||||
if (!app->isConnected()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("not_connected"));
|
||||
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
|
||||
} else if (state.peers.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("peers_no_connected"));
|
||||
material::DrawEmptyState(ICON_MD_WIFI_TETHERING, TR("peers_no_connected"));
|
||||
} else {
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg();
|
||||
float rowInset = Layout::spacingLg();
|
||||
float innerW = ImGui::GetContentRegionAvail().x - rowInset * 2;
|
||||
@@ -643,13 +642,13 @@ void RenderPeersTab(App* app)
|
||||
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
|
||||
|
||||
if (is_selected) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "row-selection-rounding").size);
|
||||
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size, rowEnd.y), Primary(), S.drawElement("tabs.peers", "row-accent-rounding").size);
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "row-selection-rounding").size * dp);
|
||||
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size * dp, rowEnd.y), Primary(), S.drawElement("tabs.peers", "row-accent-rounding").size * dp);
|
||||
}
|
||||
|
||||
bool hovered = material::IsRectHovered(rowPos, rowEnd);
|
||||
if (hovered && !is_selected) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "row-selection-rounding").size);
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "row-selection-rounding").size * dp);
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
@@ -662,17 +661,29 @@ void RenderPeersTab(App* app)
|
||||
else if (ping_ms < 500) dotCol = Warning();
|
||||
else dotCol = Error();
|
||||
float pingDotR = S.drawElement("tabs.peers", "ping-dot-radius-base").size + S.drawElement("tabs.peers", "ping-dot-radius-scale").size * hs;
|
||||
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ping-dot-x-offset").size, cy + body2->LegacySize * 0.5f), pingDotR, dotCol);
|
||||
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ping-dot-x-offset").size * dp, cy + body2->LegacySize * 0.5f), pingDotR, dotCol);
|
||||
|
||||
float addrX = cx + S.drawElement("tabs.peers", "address-x-offset").size;
|
||||
float addrX = cx + S.drawElement("tabs.peers", "address-x-offset").size * dp;
|
||||
// Reserve the line-1 trailing zone (ping + direction pill live on the far right,
|
||||
// the nearest being the ping at innerW - pingW - spacingXl*3). A long IPv6+port
|
||||
// addr must clip before it so it can't run under those. Mirror the ping formula
|
||||
// conservatively and leave a gap.
|
||||
float line1RightLimit = rowPos.x + innerW - Layout::spacingXl() * 3 -
|
||||
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, "0000ms").x -
|
||||
Layout::spacingSm();
|
||||
dl->PushClipRect(ImVec2(addrX, rowPos.y), ImVec2(line1RightLimit, rowEnd.y), true);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(addrX, cy), OnSurface(), peer.addr.c_str());
|
||||
dl->PopClipRect();
|
||||
|
||||
// Seed node icon — rendered right after the IP address
|
||||
// Seed node icon — rendered right after the IP address, but never past the
|
||||
// reserved trailing zone (a long addr would otherwise push it into the ping text).
|
||||
if (IsSeedNode(peer.addr)) {
|
||||
ImVec2 addrSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, peer.addr.c_str());
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
float iconY = cy + (body2->LegacySize - iconFont->LegacySize) * 0.5f;
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(addrX + addrSz.x + Layout::spacingSm(), iconY), WithAlpha(Success(), 200), ICON_MD_GRASS);
|
||||
float seedIconX = std::min(addrX + addrSz.x + Layout::spacingSm(),
|
||||
line1RightLimit - iconFont->LegacySize);
|
||||
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(seedIconX, iconY), WithAlpha(Success(), 200), ICON_MD_GRASS);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -681,9 +692,9 @@ void RenderPeersTab(App* app)
|
||||
ImU32 dirFg = peer.inbound ? WithAlpha(Success(), 200) : WithAlpha(Secondary(), 200);
|
||||
ImVec2 dirSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, dirLabel);
|
||||
float dirX = rowPos.x + innerW - dirSz.x - Layout::spacingXl();
|
||||
ImVec2 pillMin(dirX - S.drawElement("tabs.peers", "dir-pill-padding").size, cy + S.drawElement("tabs.peers", "dir-pill-y-offset").size);
|
||||
ImVec2 pillMax(dirX + dirSz.x + S.drawElement("tabs.peers", "dir-pill-padding").size, cy + capFont->LegacySize + S.drawElement("tabs.peers", "dir-pill-y-bottom").size);
|
||||
dl->AddRectFilled(pillMin, pillMax, dirBg, S.drawElement("tabs.peers", "dir-pill-rounding").size);
|
||||
ImVec2 pillMin(dirX - S.drawElement("tabs.peers", "dir-pill-padding").size * dp, cy + S.drawElement("tabs.peers", "dir-pill-y-offset").size * dp);
|
||||
ImVec2 pillMax(dirX + dirSz.x + S.drawElement("tabs.peers", "dir-pill-padding").size * dp, cy + capFont->LegacySize + S.drawElement("tabs.peers", "dir-pill-y-bottom").size * dp);
|
||||
dl->AddRectFilled(pillMin, pillMax, dirBg, S.drawElement("tabs.peers", "dir-pill-rounding").size * dp);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(dirX, cy + 2), dirFg, dirLabel);
|
||||
}
|
||||
|
||||
@@ -696,30 +707,55 @@ void RenderPeersTab(App* app)
|
||||
}
|
||||
|
||||
float cy2 = cy + body2->LegacySize + Layout::spacingXs();
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + S.drawElement("tabs.peers", "address-x-offset").size, cy2),
|
||||
OnSurfaceDisabled(), peer.subver.c_str());
|
||||
float subverX = cx + S.drawElement("tabs.peers", "address-x-offset").size * dp;
|
||||
|
||||
// Reserve the line-2 trailing widths up front so an untrusted, arbitrarily long
|
||||
// remote subver cannot push the TLS badge / ban-score off the row. Order right→left:
|
||||
// ban-score pinned far right, then the TLS/no-TLS badge, then the clipped subver text.
|
||||
float line2RightLimit = rowPos.x + innerW - Layout::spacingLg();
|
||||
float banScoreLeftX = line2RightLimit;
|
||||
char banBuf[16];
|
||||
ImU32 banCol = 0;
|
||||
bool haveBanScore = peer.banscore > 0;
|
||||
if (haveBanScore) {
|
||||
snprintf(banBuf, sizeof(banBuf), TR("peers_ban_score"), peer.banscore);
|
||||
banCol = peer.banscore > 50 ? Error() : Warning();
|
||||
float banW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, banBuf).x;
|
||||
banScoreLeftX = line2RightLimit - banW;
|
||||
// The TLS/no-TLS badge sits to the LEFT of the ban score with a gap.
|
||||
line2RightLimit = banScoreLeftX - Layout::spacingLg();
|
||||
}
|
||||
|
||||
float tlsBadgeW = std::max(S.drawElement("tabs.peers", "tls-badge-min-width").size, S.drawElement("tabs.peers", "tls-badge-width").size * hs);
|
||||
float noTlsW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, TR("peers_no_tls")).x;
|
||||
float badgeW = peer.tls_cipher.empty() ? noTlsW : tlsBadgeW;
|
||||
// Hard right boundary for the subver text column, leaving room for the badge.
|
||||
float subverMaxX = line2RightLimit - badgeW - Layout::spacingSm();
|
||||
if (subverMaxX < subverX) subverMaxX = subverX;
|
||||
|
||||
float verW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, peer.subver.c_str()).x;
|
||||
float tlsBadgeW = std::max(S.drawElement("tabs.peers", "tls-badge-min-width").size, S.drawElement("tabs.peers", "tls-badge-width").size * hs);
|
||||
dl->PushClipRect(ImVec2(subverX, rowPos.y), ImVec2(subverMaxX, rowEnd.y), true);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(subverX, cy2),
|
||||
OnSurfaceDisabled(), peer.subver.c_str());
|
||||
dl->PopClipRect();
|
||||
|
||||
// Pin the badge just after the (clipped) subver, but never past the reserved zone.
|
||||
float badgeX = std::min(subverX + verW + Layout::spacingSm(), subverMaxX + Layout::spacingSm());
|
||||
if (!peer.tls_cipher.empty()) {
|
||||
ImU32 tlsBg = WithAlpha(Success(), 25);
|
||||
ImU32 tlsFg = WithAlpha(Success(), 200);
|
||||
ImVec2 tlsMin(cx + S.drawElement("tabs.peers", "address-x-offset").size + verW + Layout::spacingSm(), cy2);
|
||||
ImVec2 tlsMin(badgeX, cy2);
|
||||
ImVec2 tlsMax(tlsMin.x + tlsBadgeW, tlsMin.y + capFont->LegacySize + 2);
|
||||
dl->AddRectFilled(tlsMin, tlsMax, tlsBg, S.drawElement("tabs.peers", "tls-badge-rounding").size);
|
||||
dl->AddRectFilled(tlsMin, tlsMax, tlsBg, S.drawElement("tabs.peers", "tls-badge-rounding").size * dp);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(tlsMin.x + 4, cy2 + 1), tlsFg, "TLS");
|
||||
} else {
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + S.drawElement("tabs.peers", "address-x-offset").size + verW + Layout::spacingSm(), cy2),
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(badgeX, cy2),
|
||||
WithAlpha(Error(), 140), TR("peers_no_tls"));
|
||||
}
|
||||
|
||||
if (peer.banscore > 0) {
|
||||
char banBuf[16];
|
||||
snprintf(banBuf, sizeof(banBuf), TR("peers_ban_score"), peer.banscore);
|
||||
ImU32 banCol = peer.banscore > 50 ? Error() : Warning();
|
||||
ImVec2 banSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, banBuf);
|
||||
if (haveBanScore) {
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rowPos.x + innerW - banSz.x - Layout::spacingLg(), cy2), banCol, banBuf);
|
||||
ImVec2(banScoreLeftX, cy2), banCol, banBuf);
|
||||
}
|
||||
|
||||
ImGui::InvisibleButton("##peerRow", ImVec2(innerW, rowH));
|
||||
@@ -778,7 +814,7 @@ void RenderPeersTab(App* app)
|
||||
|
||||
if (i < state.peers.size() - 1) {
|
||||
ImVec2 divStart = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(divStart.x + pad + 18, divStart.y),
|
||||
dl->AddLine(ImVec2(divStart.x + pad + 18 * dp, divStart.y),
|
||||
ImVec2(divStart.x + innerW - pad, divStart.y),
|
||||
IM_COL32(255, 255, 255, 15));
|
||||
}
|
||||
@@ -789,13 +825,12 @@ void RenderPeersTab(App* app)
|
||||
} else {
|
||||
// ---- Banned Peers ----
|
||||
if (!app->isConnected()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("not_connected"));
|
||||
material::DrawEmptyState(ICON_MD_CLOUD_OFF, TR("not_connected"));
|
||||
} else if (state.bannedPeers.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("peers_no_banned"));
|
||||
material::DrawEmptyState(ICON_MD_BLOCK, TR("peers_no_banned"));
|
||||
} else {
|
||||
float rowH = capFont->LegacySize + S.drawElement("tabs.peers", "banned-row-height-padding").size;
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
float rowH = capFont->LegacySize + S.drawElement("tabs.peers", "banned-row-height-padding").size * dp;
|
||||
float rowInsetB = pad;
|
||||
float innerW = ImGui::GetContentRegionAvail().x - rowInsetB * 2;
|
||||
listScrollY = ImGui::GetScrollY();
|
||||
@@ -811,21 +846,21 @@ void RenderPeersTab(App* app)
|
||||
ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH);
|
||||
|
||||
if (is_selected) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "banned-row-rounding").size);
|
||||
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size, rowEnd.y), WithAlpha(Error(), 200), S.drawElement("tabs.peers", "banned-accent-rounding").size);
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), S.drawElement("tabs.peers", "banned-row-rounding").size * dp);
|
||||
dl->AddRectFilled(rowPos, ImVec2(rowPos.x + S.drawElement("tabs.peers", "row-accent-width").size * dp, rowEnd.y), WithAlpha(Error(), 200), S.drawElement("tabs.peers", "banned-accent-rounding").size * dp);
|
||||
}
|
||||
|
||||
if (material::IsRectHovered(rowPos, rowEnd) && !is_selected) {
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "banned-row-rounding").size);
|
||||
dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), S.drawElement("tabs.peers", "banned-row-rounding").size * dp);
|
||||
}
|
||||
|
||||
float cx = rowPos.x + pad;
|
||||
float cy = rowPos.y + Layout::spacingXs();
|
||||
|
||||
float banDotR = S.drawElement("tabs.peers", "ban-dot-radius-base").size + S.drawElement("tabs.peers", "ban-dot-radius-scale").size * hs;
|
||||
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ban-dot-x-offset").size, cy + capFont->LegacySize * 0.4f), banDotR, WithAlpha(Error(), 200));
|
||||
dl->AddCircleFilled(ImVec2(cx + S.drawElement("tabs.peers", "ban-dot-x-offset").size * dp, cy + capFont->LegacySize * 0.4f), banDotR, WithAlpha(Error(), 200));
|
||||
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + S.drawElement("tabs.peers", "banned-address-x-offset").size, cy),
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + S.drawElement("tabs.peers", "banned-address-x-offset").size * dp, cy),
|
||||
OnSurfaceDisabled(), banned.address.c_str());
|
||||
|
||||
std::string banUntil = banned.getBannedUntilString();
|
||||
@@ -841,7 +876,7 @@ void RenderPeersTab(App* app)
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(rowPos);
|
||||
ImGui::InvisibleButton("##bannedRow", ImVec2(innerW - S.drawElement("tabs.peers", "banned-row-btn-reserve").size, rowH));
|
||||
ImGui::InvisibleButton("##bannedRow", ImVec2(innerW - S.drawElement("tabs.peers", "banned-row-btn-reserve").size * dp, rowH));
|
||||
if (ImGui::IsItemClicked(0)) {
|
||||
s_selected_banned_idx = static_cast<int>(i);
|
||||
}
|
||||
@@ -861,7 +896,7 @@ void RenderPeersTab(App* app)
|
||||
|
||||
if (i < state.bannedPeers.size() - 1) {
|
||||
ImVec2 divStart = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(divStart.x + pad + 8, divStart.y),
|
||||
dl->AddLine(ImVec2(divStart.x + pad + 8 * dp, divStart.y),
|
||||
ImVec2(divStart.x + innerW - pad, divStart.y),
|
||||
IM_COL32(255, 255, 255, 15));
|
||||
}
|
||||
|
||||
@@ -120,8 +120,10 @@ void QRPopupDialog::render(App* app)
|
||||
widgets::AddressCopyField("##QRAddress", s_address);
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Buttons — size each to its label (so "Copy address" never clips), then center the pair.
|
||||
|
||||
// Footer — a primary "Copy address" + "Close" pair, placed via the shared design-system helper.
|
||||
// Size both buttons to the wider label (so "Copy address" never clips) and hand that width to
|
||||
// DialogActionFooter, which centers the pair.
|
||||
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
||||
ImGui::PushFont(btnFont);
|
||||
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f;
|
||||
@@ -130,14 +132,14 @@ void QRPopupDialog::render(App* app)
|
||||
ImGui::PopFont();
|
||||
if (w_copy < actionBtn.width) w_copy = actionBtn.width;
|
||||
if (w_close < actionBtn.width) w_close = actionBtn.width;
|
||||
const float total_width = w_copy + w_close + ImGui::GetStyle().ItemSpacing.x;
|
||||
ImGui::SetCursorPosX((window_width - total_width) / 2.0f);
|
||||
const float btnW = (w_copy > w_close ? w_copy : w_close);
|
||||
|
||||
if (material::TactileButton(TR("copy_address"), ImVec2(w_copy, 0), btnFont)) {
|
||||
bool doCopy = false, doClose = false;
|
||||
material::DialogActionFooter(TR("copy_address"), true, TR("close"), doCopy, doClose, btnW);
|
||||
if (doCopy) {
|
||||
ImGui::SetClipboardText(s_address.c_str());
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(TR("close"), ImVec2(w_close, 0), btnFont)) {
|
||||
if (doClose) {
|
||||
close();
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
|
||||
+113
-59
@@ -175,6 +175,11 @@ static void RenderAddressDropdown(App* app, float width) {
|
||||
}
|
||||
}
|
||||
|
||||
// Combo/button widths first — the preview truncates to the combo's real pixel width below.
|
||||
float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width));
|
||||
float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width));
|
||||
float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2;
|
||||
|
||||
// Build preview string
|
||||
if (!app->isConnected()) {
|
||||
s_source_preview = TR(app->isLiteBuild() ? "lite_no_wallet_short" : "not_connected");
|
||||
@@ -183,18 +188,27 @@ static void RenderAddressDropdown(App* app, float width) {
|
||||
const auto& addr = state.addresses[s_selected_address_idx];
|
||||
bool isZ = addr.type == "shielded";
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
std::string trunc = util::truncateMiddle(addr.address,
|
||||
static_cast<int>(std::max(schema::UI().drawElement("tabs.receive", "addr-preview-trunc-min").size, width / schema::UI().drawElement("tabs.receive", "addr-preview-trunc-divisor").size)));
|
||||
snprintf(buf, sizeof(buf), "%s %s \xe2\x80\x94 %.8f %s",
|
||||
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
// Reserve pixel room for the tag prefix and the trailing balance, then middle-truncate the
|
||||
// address to whatever remains — measured with the combo's own Body2 font. Char-count
|
||||
// truncation kept MORE chars as the column widened, so at 150% the scaled font overflowed
|
||||
// and the combo hard-clipped "— 12.00000000 DRGX" to "— 1"; measuring in pixels keeps the
|
||||
// balance visible at any scale.
|
||||
ImFont* comboFont = Type().getFont(TypeStyle::Body2);
|
||||
float comboFontSz = comboFont->LegacySize;
|
||||
char prefix[16]; snprintf(prefix, sizeof(prefix), "%s ", tag);
|
||||
char suffix[64]; snprintf(suffix, sizeof(suffix), " \xe2\x80\x94 %.8f %s", addr.balance, DRAGONX_TICKER);
|
||||
float fixedW = comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, prefix).x
|
||||
+ comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, suffix).x;
|
||||
// Combo interior = dropdownW minus its dropdown-arrow button (~frame height) and both frame paddings.
|
||||
float addrBudget = dropdownW - ImGui::GetFrameHeight() - ImGui::GetStyle().FramePadding.x * 2.0f - fixedW;
|
||||
if (addrBudget < 24.0f) addrBudget = 24.0f; // floor: truncate to a stub rather than overflow
|
||||
std::string trunc = material::TruncateToWidth(addr.address, comboFont, comboFontSz, addrBudget);
|
||||
snprintf(buf, sizeof(buf), "%s%s%s", prefix, trunc.c_str(), suffix);
|
||||
s_source_preview = buf;
|
||||
} else {
|
||||
s_source_preview = TR("select_receiving_address");
|
||||
}
|
||||
|
||||
float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width));
|
||||
float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width));
|
||||
float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2;
|
||||
ImGui::SetNextItemWidth(dropdownW);
|
||||
ImGui::PushFont(Type().getFont(TypeStyle::Body2));
|
||||
if (ImGui::BeginCombo("##RecvAddr", s_source_preview.c_str())) {
|
||||
@@ -324,20 +338,9 @@ static void RenderAddressDropdown(App* app, float width) {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helpers: timeAgo / DrawRecvIcon (local copies — originals are static in send_tab)
|
||||
// Helpers: DrawRecvIcon (local copy — original is static in send_tab).
|
||||
// Relative time uses the shared util::formatTimeAgoShort ("14d ago"), matching Overview/Send.
|
||||
// ============================================================================
|
||||
static std::string recvTimeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
char buf[32];
|
||||
if (diff < 60) { snprintf(buf, sizeof(buf), TR("time_seconds_ago"), (long long)diff); return buf; }
|
||||
if (diff < 3600) { snprintf(buf, sizeof(buf), TR("time_minutes_ago"), (long long)(diff / 60)); return buf; }
|
||||
if (diff < 86400) { snprintf(buf, sizeof(buf), TR("time_hours_ago"), (long long)(diff / 3600)); return buf; }
|
||||
snprintf(buf, sizeof(buf), TR("time_days_ago"), (long long)(diff / 86400)); return buf;
|
||||
}
|
||||
|
||||
static void DrawRecvIcon(ImDrawList* dl, float cx, float cy, float s, ImU32 col) {
|
||||
dl->AddTriangleFilled(
|
||||
ImVec2(cx, cy + s),
|
||||
@@ -373,9 +376,14 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
|
||||
if (tx.type != "receive" && tx.type != "mined") continue;
|
||||
recvs.push_back(&tx);
|
||||
}
|
||||
if (recvs.size() > 4) recvs.resize(4); // show only the 4 most recent (newest-first)
|
||||
|
||||
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
|
||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||
if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first
|
||||
|
||||
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
@@ -383,8 +391,9 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
|
||||
|
||||
char buf[64];
|
||||
if (recvs.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_recent_receives"));
|
||||
// Fill the empty canvas with a centered material empty-state (icon + title)
|
||||
// rather than a lone left-aligned caption stranded at the top of dead space.
|
||||
material::DrawEmptyState(ICON_MD_CALL_RECEIVED, TR("no_recent_receives"));
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
@@ -404,30 +413,37 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), typeText);
|
||||
|
||||
// Address (second line)
|
||||
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
// Address — start it AFTER the measured type-label width (not a fixed offset that shrinks below
|
||||
// the label at narrow widths), mirroring Overview's recent-tx list. The schema offset is a floor.
|
||||
float typeW = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, typeText).x;
|
||||
float addrX = txX + std::max(S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f) * hs,
|
||||
typeW + Layout::spacingSm());
|
||||
std::string addrDisplay = util::truncateMiddle(tx.address,
|
||||
(int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2.0f * dp), OnSurfaceDisabled(), addrDisplay.c_str());
|
||||
|
||||
// Amount (right-aligned, first line)
|
||||
// Time ago — short "14d ago" form (shared helper, matches Overview/Send). Measure it
|
||||
// first so the amount can chain its right edge off this width and never overlap.
|
||||
std::string ago = util::formatTimeAgoShort(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float agoMargin = S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs;
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - agoMargin, rowPos.y + 2.0f * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Amount (right-aligned, first line) — anchored to the LEFT of the time-ago text
|
||||
// (measured width + a gap) so the two columns can never collide, whatever the strings.
|
||||
snprintf(buf, sizeof(buf), "+%.4f %s", std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, buf);
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
float amtGap = std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
float amtRightEdge = rightEdge - agoSz.x - agoMargin - amtGap;
|
||||
float amtX = amtRightEdge - amtSz.x;
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2.0f * dp), recvCol, buf);
|
||||
|
||||
// Time ago
|
||||
std::string ago = recvTimeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
|
||||
rowPos.y + 2.0f * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
// Clickable row — hover highlight + navigate to History
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
@@ -488,6 +504,16 @@ void RenderReceiveTab(App* app)
|
||||
float groupStartY = ImGui::GetCursorPosY();
|
||||
float contentStartY = ImGui::GetCursorPosY();
|
||||
|
||||
// Reserve a slice of the available height for RECENT RECEIVED (ratio — mirrors
|
||||
// balance_tab's recent-tx-reserve). The main card's target height is capped so it can
|
||||
// never grow past (available - reserve): a no-op at 1.0x (mainCardTargetH already fits
|
||||
// well within scrollAvailH there), but at HiDPI it prevents the card — whose QR (280*dp)
|
||||
// and pads scale with dp while scrollAvailH is physical px — from eating the whole child
|
||||
// and evicting the list.
|
||||
float recvReserveRatio = S.drawElement("tabs.balance", "recent-tx-reserve-ratio").sizeOr(0.18f);
|
||||
float recvRecentReserve = std::max(0.0f, scrollAvailH) * recvReserveRatio;
|
||||
float recvCardCapH = std::max(0.0f, scrollAvailH - recvRecentReserve);
|
||||
|
||||
float formAvailW = ImGui::GetContentRegionAvail().x;
|
||||
float formW = formAvailW;
|
||||
ImGui::BeginGroup();
|
||||
@@ -514,21 +540,25 @@ void RenderReceiveTab(App* app)
|
||||
|
||||
if (state.addresses.empty()) {
|
||||
ImVec2 emptyMin = ImGui::GetCursorScreenPos();
|
||||
float emptyH = S.drawElement("tabs.receive", "skeleton-height").size;
|
||||
// Hand-drawn skeleton geometry is absolute px — scale heights/offsets/rounding by
|
||||
// dpiScale() so it doesn't render native-thin at >100% (width ratios multiply formW, leave as-is).
|
||||
float dp = Layout::dpiScale();
|
||||
float emptyH = S.drawElement("tabs.receive", "skeleton-height").size * dp;
|
||||
ImVec2 emptyMax(emptyMin.x + formW, emptyMin.y + emptyH);
|
||||
DrawGlassPanel(dl, emptyMin, emptyMax, glassSpec);
|
||||
float alpha = (float)(schema::UI().drawElement("animations", "skeleton-base").size + schema::UI().drawElement("animations", "skeleton-amp").size * std::sin(ImGui::GetTime() * schema::UI().drawElement("animations", "pulse-speed-slow").size));
|
||||
ImU32 skelCol = IM_COL32(255, 255, 255, (int)(alpha * 255));
|
||||
float skelRound = schema::UI().drawElement("tabs.receive", "skeleton-rounding").size * dp;
|
||||
dl->AddRectFilled(
|
||||
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + Layout::spacingLg()),
|
||||
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar1-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar1-height").size),
|
||||
skelCol, schema::UI().drawElement("tabs.receive", "skeleton-rounding").size);
|
||||
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar1-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar1-height").size * dp),
|
||||
skelCol, skelRound);
|
||||
dl->AddRectFilled(
|
||||
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-top").size),
|
||||
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar2-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-bottom").size),
|
||||
skelCol, schema::UI().drawElement("tabs.receive", "skeleton-rounding").size);
|
||||
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-top").size * dp),
|
||||
ImVec2(emptyMin.x + formW * S.drawElement("tabs.receive", "skeleton-bar2-width-ratio").size, emptyMin.y + Layout::spacingLg() + S.drawElement("tabs.receive", "skeleton-bar2-bottom").size * dp),
|
||||
skelCol, skelRound);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + emptyH - S.drawElement("tabs.receive", "skeleton-text-bottom-offset").size),
|
||||
ImVec2(emptyMin.x + Layout::spacingLg(), emptyMin.y + emptyH - S.drawElement("tabs.receive", "skeleton-text-bottom-offset").size * dp),
|
||||
OnSurfaceDisabled(), TR("loading_addresses"));
|
||||
ImGui::Dummy(ImVec2(formW, emptyH));
|
||||
ImGui::EndGroup();
|
||||
@@ -567,21 +597,36 @@ void RenderReceiveTab(App* app)
|
||||
// MAIN CARD — single glass panel (channel split like Send tab)
|
||||
// ================================================================
|
||||
{
|
||||
// Cap + center the card so the address/amount column stops stretching while the
|
||||
// QR plateaus. Shared with the Send tab via mainComposeCardBox() so the two card
|
||||
// envelopes are identical (they must never drift in width/position again).
|
||||
// The RECENT RECEIVED list below stays on the uncapped formW (handled separately).
|
||||
float cardDp = Layout::dpiScale();
|
||||
Layout::CardBox cardBox = Layout::mainComposeCardBox(formW);
|
||||
float cardW = cardBox.width;
|
||||
float cardLeftX = ImGui::GetCursorScreenPos().x;
|
||||
float cardOffsetX = cardBox.offsetX;
|
||||
if (cardOffsetX > 0.0f)
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + cardOffsetX);
|
||||
|
||||
ImVec2 containerMin = ImGui::GetCursorScreenPos();
|
||||
float pad = Layout::spacingLg();
|
||||
float innerW = formW - pad * 2;
|
||||
float innerW = cardW - pad * 2;
|
||||
float innerGap = Layout::spacingLg();
|
||||
|
||||
// Channel split: content on ch1, glass background on ch0
|
||||
dl->ChannelsSplit(2);
|
||||
dl->ChannelsSetCurrent(1);
|
||||
|
||||
ImGui::Indent(pad);
|
||||
// Indent carries the centering offset too, so ImGui auto-layout content (the address
|
||||
// dropdown/inputs/chips) lands at containerMin.x + pad — matching the hand-drawn geometry
|
||||
// (glass panel / QR column) that keys off the offset containerMin.x.
|
||||
ImGui::Indent(pad + cardOffsetX);
|
||||
ImGui::Dummy(ImVec2(0, pad)); // top padding
|
||||
|
||||
// ---- ADDRESS DROPDOWN + QR CODE — side by side ----
|
||||
{
|
||||
float qrColW = innerW * schema::UI().drawElement("tabs.receive", "qr-col-width-ratio").size;
|
||||
float qrColW = std::min(innerW * schema::UI().drawElement("tabs.receive", "qr-col-width-ratio").size, 340.0f * cardDp);
|
||||
float colGap = Layout::spacingLg();
|
||||
float addrColW = innerW - qrColW - colGap;
|
||||
float qrColX = containerMin.x + pad + addrColW + colGap;
|
||||
@@ -599,7 +644,7 @@ void RenderReceiveTab(App* app)
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Amount input with currency toggle
|
||||
float toggleW = S.drawElement("tabs.receive", "currency-toggle-width").size;
|
||||
float toggleW = S.drawElement("tabs.receive", "currency-toggle-width").size * Layout::dpiScale();
|
||||
float amtInputW = addrColW - toggleW - Layout::spacingMd();
|
||||
if (amtInputW < S.drawElement("tabs.receive", "amount-input-min-width").size) amtInputW = S.drawElement("tabs.receive", "amount-input-min-width").size;
|
||||
double usd_price = state.market.price_usd;
|
||||
@@ -663,8 +708,8 @@ void RenderReceiveTab(App* app)
|
||||
float bH = bMax.y - bMin.y;
|
||||
ImFont* font = ImGui::GetFont();
|
||||
ImVec2 textSz = font->CalcTextSizeA(font->LegacySize, 10000, 0, currLabel);
|
||||
float iconW = schema::UI().drawElement("tabs.receive", "currency-icon-width").size;
|
||||
float iconGap2 = schema::UI().drawElement("tabs.receive", "currency-icon-gap").size;
|
||||
float iconW = schema::UI().drawElement("tabs.receive", "currency-icon-width").size * Layout::dpiScale();
|
||||
float iconGap2 = schema::UI().drawElement("tabs.receive", "currency-icon-gap").size * Layout::dpiScale();
|
||||
float totalW2 = iconW + iconGap2 + textSz.x;
|
||||
float startX = bMin.x + ((bMax.x - bMin.x) - totalW2) * 0.5f;
|
||||
float cy = bMin.y + bH * 0.5f;
|
||||
@@ -778,7 +823,7 @@ void RenderReceiveTab(App* app)
|
||||
size_t memo_len = strlen(s_request_memo);
|
||||
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
|
||||
bool memoAtCap = memo_len + 1 >= memoMax;
|
||||
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
|
||||
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
||||
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
@@ -862,7 +907,7 @@ void RenderReceiveTab(App* app)
|
||||
S.drawElement("tabs.receive", "action-btn-height").size * vScale);
|
||||
float footerH = innerGap + actionBtnH + pad;
|
||||
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
||||
float targetCardH = Layout::mainCardTargetH(formW, vScale);
|
||||
float targetCardH = std::min(Layout::mainCardTargetH(cardW, vScale), recvCardCapH);
|
||||
float footerTopH = targetCardH - footerH;
|
||||
if (currentCardH < footerTopH) {
|
||||
ImGui::Dummy(ImVec2(0, footerTopH - currentCardH));
|
||||
@@ -873,7 +918,7 @@ void RenderReceiveTab(App* app)
|
||||
{
|
||||
ImVec2 divPos = ImGui::GetCursorScreenPos();
|
||||
dl->AddLine(ImVec2(containerMin.x + pad, divPos.y),
|
||||
ImVec2(containerMin.x + formW - pad, divPos.y),
|
||||
ImVec2(containerMin.x + cardW - pad, divPos.y),
|
||||
ImGui::GetColorU32(Divider()), S.drawElement("tabs.receive", "divider-thickness").size);
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, innerGap * 0.5f));
|
||||
@@ -882,7 +927,13 @@ void RenderReceiveTab(App* app)
|
||||
{
|
||||
float btnGap = Layout::spacingMd();
|
||||
float btnH = std::max(schema::UI().drawElement("tabs.receive", "action-btn-min-height").size, schema::UI().drawElement("tabs.receive", "action-btn-height").size * vScale);
|
||||
float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size, innerW * S.drawElement("tabs.receive", "action-btn-width-ratio").size);
|
||||
// Justify the footer edge-to-edge like Send's [Review Send][Cancel] row instead of packing
|
||||
// fixed-width buttons from the left (which left a large dead gap on the right). Split innerW
|
||||
// into equal shares over the live button count: 2 by default (Clear Request + Explorer),
|
||||
// 4 when an amount is requested (+ Copy URI + Share).
|
||||
int nBtns = (s_request_amount > 0 ? 2 : 0) + 2;
|
||||
float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size,
|
||||
(innerW - (nBtns - 1) * btnGap) / (float)nBtns);
|
||||
|
||||
bool firstBtn = true;
|
||||
|
||||
@@ -953,23 +1004,26 @@ void RenderReceiveTab(App* app)
|
||||
|
||||
// Bottom padding
|
||||
ImGui::Dummy(ImVec2(0, pad));
|
||||
ImGui::Unindent(pad);
|
||||
ImGui::Unindent(pad + cardOffsetX);
|
||||
|
||||
// Enforce shared card height (matches QR-driven target)
|
||||
// Enforce shared card height (matches QR-driven target), capped so the reserved
|
||||
// RECENT RECEIVED slice below stays on-screen at HiDPI.
|
||||
{
|
||||
float currentCardH = ImGui::GetCursorScreenPos().y - containerMin.y;
|
||||
float targetCardH = Layout::mainCardTargetH(formW, vScale);
|
||||
float targetCardH = std::min(Layout::mainCardTargetH(cardW, vScale), recvCardCapH);
|
||||
if (currentCardH < targetCardH)
|
||||
ImGui::Dummy(ImVec2(0, targetCardH - currentCardH));
|
||||
}
|
||||
|
||||
// Draw glass panel background on channel 0
|
||||
ImVec2 containerMax(containerMin.x + formW, ImGui::GetCursorScreenPos().y);
|
||||
ImVec2 containerMax(containerMin.x + cardW, ImGui::GetCursorScreenPos().y);
|
||||
dl->ChannelsSetCurrent(0);
|
||||
DrawGlassPanel(dl, containerMin, containerMax, glassSpec);
|
||||
dl->ChannelsMerge();
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(containerMin.x, containerMax.y));
|
||||
// Restore the original (uncapped) left edge so RECENT RECEIVED below renders
|
||||
// full-width as-is, unaffected by the card's centering offset.
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardLeftX, containerMax.y));
|
||||
ImGui::Dummy(ImVec2(formW, 0));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include "../notifications.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../widgets/qr_code.h"
|
||||
#include "../widgets/copy_field.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
|
||||
@@ -146,7 +147,7 @@ void RequestPaymentDialog::render(App* app)
|
||||
|
||||
// Amount (optional)
|
||||
ImGui::Text("%s", TR("request_amount"));
|
||||
ImGui::SetNextItemWidth(amountInput.width);
|
||||
ImGui::SetNextItemWidth(amountInput.width * Layout::dpiScale());
|
||||
if (ImGui::InputDouble("##Amount", &s_amount, 0.1, 1.0, "%.8f")) {
|
||||
s_uri_dirty = true;
|
||||
}
|
||||
@@ -198,33 +199,65 @@ void RequestPaymentDialog::render(App* app)
|
||||
|
||||
// Payment URI display
|
||||
if (!s_payment_uri.empty()) {
|
||||
// Use a selectable text area for the URI
|
||||
char uri_buf[1024];
|
||||
strncpy(uri_buf, s_payment_uri.c_str(), sizeof(uri_buf) - 1);
|
||||
material::LabeledInput(TR("request_payment_uri"), "##URI", uri_buf, sizeof(uri_buf),
|
||||
-1.0f, nullptr, ImGuiInputTextFlags_ReadOnly);
|
||||
|
||||
ImGui::Text("%s", TR("request_payment_uri"));
|
||||
// Bordered read-only field: bounds the raw drgx: URI within a box (it horizontal-scrolls if
|
||||
// long) rather than 4-char-chunking it like an address or letting a plain field overflow.
|
||||
// The "Copy URI" button below copies the full value.
|
||||
{
|
||||
char uriBuf[2048];
|
||||
snprintf(uriBuf, sizeof(uriBuf), "%s", s_payment_uri.c_str());
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
|
||||
ImGui::InputText("##URI", uriBuf, sizeof(uriBuf), ImGuiInputTextFlags_ReadOnly);
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Copy button
|
||||
if (material::TactileButton(TR("request_copy_uri"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
|
||||
// Footer buttons: size each to ITS OWN label (action-button.width is a floor, not the size)
|
||||
// so "Copy Full Address" never clips its final letter — mirrors qr_popup_dialog's per-label
|
||||
// sizing. Font metrics are already DPI-scaled, so don't multiply by dpiScale() here.
|
||||
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
||||
ImGui::PushFont(btnFont);
|
||||
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f; // small margin
|
||||
float w_uri = ImGui::CalcTextSize(TR("request_copy_uri")).x + btnPad;
|
||||
float w_addr = ImGui::CalcTextSize(TR("copy_address")).x + btnPad;
|
||||
ImGui::PopFont();
|
||||
if (w_uri < actionBtn.width) w_uri = actionBtn.width;
|
||||
if (w_addr < actionBtn.width) w_addr = actionBtn.width;
|
||||
|
||||
// Center the two-button copy row via the shared helper (no extra divider; the Separator
|
||||
// above already frames this region). Total = both button widths + the ItemSpacing between.
|
||||
material::BeginOverlayDialogFooter(w_uri + w_addr + ImGui::GetStyle().ItemSpacing.x, false);
|
||||
|
||||
// Copy URI button
|
||||
if (material::TactileButton(TR("request_copy_uri"), ImVec2(w_uri, 0), btnFont)) {
|
||||
ImGui::SetClipboardText(s_payment_uri.c_str());
|
||||
Notifications::instance().success(TR("request_uri_copied"));
|
||||
}
|
||||
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (material::TactileButton(TR("copy_address"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
|
||||
if (material::TactileButton(TR("copy_address"), ImVec2(w_addr, 0), btnFont)) {
|
||||
ImGui::SetClipboardText(s_address);
|
||||
Notifications::instance().success(TR("address_copied"));
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Close button
|
||||
if (material::TactileButton(TR("close"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
s_open = false;
|
||||
|
||||
// Close button — sized to its own label with the same floor.
|
||||
{
|
||||
ImFont* btnFont = S.resolveFont(actionBtn.font);
|
||||
ImGui::PushFont(btnFont);
|
||||
const float btnPad = ImGui::GetStyle().FramePadding.x * 2.0f + 12.0f;
|
||||
float w_close = ImGui::CalcTextSize(TR("close")).x + btnPad;
|
||||
ImGui::PopFont();
|
||||
if (w_close < actionBtn.width) w_close = actionBtn.width;
|
||||
// Center the lone Close button via the shared helper (no extra divider — matches the
|
||||
// existing tight spacing above it).
|
||||
material::BeginOverlayDialogFooter(w_close, false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(w_close, 0), btnFont)) {
|
||||
s_open = false;
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
+142
-60
@@ -137,25 +137,41 @@ static double GetAvailableBalance(App* app) {
|
||||
// not a stored list index. The index desyncs from s_from_address after an address-list
|
||||
// refresh, and is left at -1 when the source is chosen from another tab ("Send from this
|
||||
// address") — which previously made the sufficiency check see 0 and block a valid send.
|
||||
// CONFIRMED balance only — this is the spend ceiling (Max button, slider, validation, pre-broadcast
|
||||
// re-check). z_sendmany runs at minconf=1, so offering 0-conf change here would just make the send fail.
|
||||
if (s_from_address[0] != '\0') {
|
||||
for (const auto& a : state.addresses) {
|
||||
if (a.address == s_from_address) return a.balance;
|
||||
if (a.address == s_from_address) return a.spendableBalance;
|
||||
}
|
||||
}
|
||||
if (s_selected_from_idx >= 0 && s_selected_from_idx < static_cast<int>(state.addresses.size())) {
|
||||
return state.addresses[s_selected_from_idx].balance;
|
||||
return state.addresses[s_selected_from_idx].spendableBalance;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
// Recipient validity = prefix/length pre-filter AND a real encoding-checksum check, so a
|
||||
// transcription error that still matches the prefix/length is no longer labelled "Valid".
|
||||
// The checksum verifiers are version-agnostic, so they never reject a genuine address.
|
||||
// Recipient validity via the shared, structure-based recognizers: a real encoding-checksum check
|
||||
// plus the actual DragonX address types — so a transcription error is never "Valid", and a valid
|
||||
// P2SH/multisig ('b…') recipient is no longer dropped by a hardcoded 'R'-only prefix filter.
|
||||
static bool IsValidShieldedAddr(const char* a) {
|
||||
return a[0] == 'z' && a[1] == 's' && strlen(a) > 60 && dragonx::util::isValidBech32(a);
|
||||
return a && dragonx::util::isShieldedAddress(a);
|
||||
}
|
||||
static bool IsValidTransparentAddr(const char* a) {
|
||||
return a[0] == 'R' && strlen(a) >= 34 && dragonx::util::isValidBase58Check(a);
|
||||
return a && dragonx::util::isTransparentAddress(a);
|
||||
}
|
||||
|
||||
// Source-address balance caption for the "sending from" dropdown: the TOTAL balance is the headline (so
|
||||
// it matches the Overview figure and a source whose change is still confirming never looks like it lost
|
||||
// funds), with the confirmed-spendable amount shown as a smaller "(N available)" note ONLY when a pending
|
||||
// send/receive makes it differ. The Max button + validation still cap spends at the spendable amount.
|
||||
static std::string FormatSourceBalance(double total, double spendable) {
|
||||
char b[128];
|
||||
if (total - spendable > 1e-9)
|
||||
snprintf(b, sizeof(b), "%.8f %s (%.8f %s)", total, DRAGONX_TICKER,
|
||||
spendable, TR("send_available_note"));
|
||||
else
|
||||
snprintf(b, sizeof(b), "%.8f %s", total, DRAGONX_TICKER);
|
||||
return b;
|
||||
}
|
||||
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
@@ -251,8 +267,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
std::string trunc = util::truncateMiddle(addr.address,
|
||||
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
||||
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
|
||||
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
s_source_preview = buf;
|
||||
} else {
|
||||
s_source_preview = TR("send_select_source");
|
||||
@@ -265,8 +282,17 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
if (!app->isConnected() || state.addresses.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("no_addresses_available"));
|
||||
} else {
|
||||
// Sort by balance descending, only show spendable addresses with balance
|
||||
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses);
|
||||
// List every address that HOLDS a balance (confirmed or still-confirming), sorted by total
|
||||
// descending — so a source whose change is pending stays visible with its real total instead
|
||||
// of vanishing. The spend gates (Max / validation) below still cap at the spendable amount.
|
||||
std::vector<size_t> sortedIdx;
|
||||
sortedIdx.reserve(state.addresses.size());
|
||||
for (size_t i = 0; i < state.addresses.size(); ++i)
|
||||
if (state.addresses[i].isSpendable() && state.addresses[i].balance > 0.0)
|
||||
sortedIdx.push_back(i);
|
||||
std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) {
|
||||
return state.addresses[a].balance > state.addresses[b].balance;
|
||||
});
|
||||
|
||||
if (sortedIdx.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("send_no_balance"));
|
||||
@@ -281,8 +307,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
const char* tag = isZ ? "[Z]" : "[T]";
|
||||
|
||||
std::string trunc = util::truncateMiddle(addr.address, (int)addrTruncLen);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
|
||||
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
snprintf(buf, sizeof(buf), "%s %s — %s",
|
||||
tag, trunc.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
if (ImGui::Selectable(buf, isCurrent)) {
|
||||
@@ -291,8 +318,9 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
addr.address.c_str());
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s\nBalance: %.8f %s",
|
||||
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
material::Tooltip("%s\n%s",
|
||||
addr.address.c_str(),
|
||||
FormatSourceBalance(addr.balance, addr.spendableBalance).c_str());
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
@@ -396,11 +424,13 @@ static void RenderAmountBar(ImDrawList* dl, double available, float innerW,
|
||||
? std::clamp((float)((s_amount + s_fee) / available), 0.0f, 1.0f)
|
||||
: 0.0f;
|
||||
|
||||
float maxBtnW = schema::UI().drawElement("tabs.send", "amount-bar-max-btn-width").size;
|
||||
const float dp = Layout::dpiScale();
|
||||
float maxBtnW = schema::UI().drawElement("tabs.send", "amount-bar-max-btn-width").size * dp;
|
||||
float gap = Layout::spacingMd();
|
||||
float barW = innerW - maxBtnW - gap;
|
||||
if (barW < schema::UI().drawElement("tabs.send", "progress-bar-min-width").size) barW = schema::UI().drawElement("tabs.send", "progress-bar-min-width").size;
|
||||
float barH = schema::UI().drawElement("tabs.send", "amount-bar-height").size;
|
||||
float minBarW = schema::UI().drawElement("tabs.send", "progress-bar-min-width").size * dp;
|
||||
if (barW < minBarW) barW = minBarW;
|
||||
float barH = schema::UI().drawElement("tabs.send", "amount-bar-height").size * dp;
|
||||
float barRound = barH * 0.5f;
|
||||
|
||||
ImVec2 barMin = ImGui::GetCursorScreenPos();
|
||||
@@ -509,6 +539,8 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
}
|
||||
if (s_tx_status.empty() && !s_sending) return;
|
||||
|
||||
const float dp = Layout::dpiScale();
|
||||
|
||||
// Drive error styling from the authoritative result flag, not English substrings in a
|
||||
// (translatable) status string — otherwise a failed send renders as a green success
|
||||
// under a non-English locale.
|
||||
@@ -517,8 +549,8 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
// ---- ERROR: absolute-positioned overlay, does not displace layout ----
|
||||
if (is_error) {
|
||||
float pad = Layout::spacingLg();
|
||||
float btnH = std::max(schema::UI().drawElement("tabs.send", "error-btn-min-height").size, schema::UI().drawElement("tabs.send", "error-btn-height").size);
|
||||
float textWrapW = w - pad * 2 - schema::UI().drawElement("tabs.send", "error-icon-inset").size; // icon space
|
||||
float btnH = std::max(schema::UI().drawElement("tabs.send", "error-btn-min-height").size, schema::UI().drawElement("tabs.send", "error-btn-height").size) * dp;
|
||||
float textWrapW = w - pad * 2 - schema::UI().drawElement("tabs.send", "error-icon-inset").size * dp; // icon space
|
||||
ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, textWrapW, s_tx_status.c_str());
|
||||
float contentH = textSz.y + Layout::spacingMd() + btnH + pad * 2;
|
||||
|
||||
@@ -542,7 +574,7 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
DrawGlassPanel(fgDl, pMin, pMax, errGlass);
|
||||
|
||||
// Red accent bar on left
|
||||
fgDl->AddRectFilled(pMin, ImVec2(pMin.x + schema::UI().drawElement("tabs.send", "error-accent-bar-width").size, pMax.y), Error(), errGlass.rounding);
|
||||
fgDl->AddRectFilled(pMin, ImVec2(pMin.x + schema::UI().drawElement("tabs.send", "error-accent-bar-width").size * dp, pMax.y), Error(), errGlass.rounding);
|
||||
|
||||
float ix = pMin.x + pad;
|
||||
float iy = pMin.y + pad;
|
||||
@@ -553,18 +585,18 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
const char* errIcon = ICON_MD_ERROR;
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, errIcon);
|
||||
fgDl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(ix + schema::UI().drawElement("tabs.send", "error-icon-x-offset").size, iy + body2->LegacySize * 0.5f - iSz.y * 0.5f),
|
||||
ImVec2(ix + schema::UI().drawElement("tabs.send", "error-icon-x-offset").size * dp, iy + body2->LegacySize * 0.5f - iSz.y * 0.5f),
|
||||
Error(), errIcon);
|
||||
}
|
||||
|
||||
// Error text (wrapped)
|
||||
fgDl->AddText(body2, body2->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "error-text-x-offset").size, iy), Error(),
|
||||
fgDl->AddText(body2, body2->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "error-text-x-offset").size * dp, iy), Error(),
|
||||
s_tx_status.c_str(), s_tx_status.c_str() + s_tx_status.size(), textWrapW);
|
||||
|
||||
// Buttons row — use invisible window for interactive widgets on top of overlay
|
||||
float btnY = iy + textSz.y + Layout::spacingMd();
|
||||
ImGui::SetNextWindowPos(ImVec2(ix, btnY));
|
||||
ImGui::SetNextWindowSize(ImVec2(w - pad * 2, btnH + schema::UI().drawElement("tabs.send", "error-btn-area-padding").size));
|
||||
ImGui::SetNextWindowSize(ImVec2(w - pad * 2, btnH + schema::UI().drawElement("tabs.send", "error-btn-area-padding").size * dp));
|
||||
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0, 0, 0, 0));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
@@ -576,7 +608,7 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
ImGuiWindowFlags_NoBringToFrontOnFocus);
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "error-btn-bg-alpha").size)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)schema::UI().drawElement("tabs.send", "error-btn-hover-alpha").size)));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.send", "error-btn-rounding").size);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.send", "error-btn-rounding").size * dp);
|
||||
if (TactileSmallButton(TR("send_copy_error"), schema::UI().resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(s_tx_status.c_str());
|
||||
Notifications::instance().info(TR("send_error_copied"));
|
||||
@@ -599,8 +631,8 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
}
|
||||
|
||||
// ---- SENDING / SUCCESS: inline progress card ----
|
||||
float progCardH = schema::UI().drawElement("tabs.send", "progress-card-height").size;
|
||||
float progCardHTxid = schema::UI().drawElement("tabs.send", "progress-card-height-txid").size;
|
||||
float progCardH = schema::UI().drawElement("tabs.send", "progress-card-height").size * dp;
|
||||
float progCardHTxid = schema::UI().drawElement("tabs.send", "progress-card-height-txid").size * dp;
|
||||
float progH = s_result_txid.empty() ? progCardH : progCardHTxid;
|
||||
ImVec2 pMin(x, y);
|
||||
ImVec2 pMax(x + w, y + progH);
|
||||
@@ -609,8 +641,8 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
progGlass.rounding = Layout::glassRounding() * schema::UI().drawElement("tabs.send", "progress-glass-rounding-ratio").size;
|
||||
DrawGlassPanel(dl, pMin, pMax, progGlass);
|
||||
|
||||
float progPadX = schema::UI().drawElement("tabs.send", "progress-card-pad-x").size;
|
||||
float progPadY = schema::UI().drawElement("tabs.send", "progress-card-pad-y").size;
|
||||
float progPadX = schema::UI().drawElement("tabs.send", "progress-card-pad-x").size * dp;
|
||||
float progPadY = schema::UI().drawElement("tabs.send", "progress-card-pad-y").size * dp;
|
||||
float ix = pMin.x + progPadX;
|
||||
float iy = pMin.y + progPadY;
|
||||
char buf[128];
|
||||
@@ -624,7 +656,7 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
ImVec2(ix, iy), Primary(), spinIcon);
|
||||
double elapsed = ImGui::GetTime() - s_send_start_time;
|
||||
snprintf(buf, sizeof(buf), TR("send_submitting"), elapsed);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size, iy), OnSurface(), buf);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size * dp, iy), OnSurface(), buf);
|
||||
} else {
|
||||
// Success checkmark
|
||||
ImFont* iconFont = material::Type().iconMed();
|
||||
@@ -632,19 +664,19 @@ static void RenderTxProgress(ImDrawList* dl, float x, float y, float w,
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, checkIcon);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(ix, iy), Success(), checkIcon);
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size, iy), Success(), TR("send_tx_sent"));
|
||||
dl->AddText(body2, body2->LegacySize, ImVec2(ix + iSz.x + schema::UI().drawElement("tabs.send", "progress-icon-text-gap").size * dp, iy), Success(), TR("send_tx_sent"));
|
||||
|
||||
if (!s_result_txid.empty()) {
|
||||
float txY = iy + body2->LegacySize + schema::UI().drawElement("tabs.send", "txid-y-offset").size;
|
||||
float txY = iy + body2->LegacySize + schema::UI().drawElement("tabs.send", "txid-y-offset").size * dp;
|
||||
int txidThreshold = (int)schema::UI().drawElement("tabs.send", "txid-display-threshold").size;
|
||||
int txidTruncLen = (int)schema::UI().drawElement("tabs.send", "txid-trunc-len").size;
|
||||
std::string dispTxid = (int)s_result_txid.length() > txidThreshold
|
||||
? s_result_txid.substr(0, txidTruncLen) + "..." + s_result_txid.substr(s_result_txid.length() - txidTruncLen)
|
||||
: s_result_txid;
|
||||
snprintf(buf, sizeof(buf), TR("send_txid_label"), dispTxid.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "txid-label-x-offset").size, txY),
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(ix + schema::UI().drawElement("tabs.send", "txid-label-x-offset").size * dp, txY),
|
||||
OnSurfaceDisabled(), buf);
|
||||
ImGui::SetCursorScreenPos(ImVec2(pMax.x - schema::UI().drawElement("tabs.send", "txid-copy-btn-right-offset").size, txY - schema::UI().drawElement("tabs.send", "txid-copy-btn-y-offset").size));
|
||||
ImGui::SetCursorScreenPos(ImVec2(pMax.x - schema::UI().drawElement("tabs.send", "txid-copy-btn-right-offset").size * dp, txY - schema::UI().drawElement("tabs.send", "txid-copy-btn-y-offset").size * dp));
|
||||
if (TactileSmallButton(TR("copy"), schema::UI().resolveFont("button"))) {
|
||||
ImGui::SetClipboardText(s_result_txid.c_str());
|
||||
Notifications::instance().info(TR("send_txid_copied"));
|
||||
@@ -696,6 +728,7 @@ void RenderSendConfirmPopup(App* app) {
|
||||
float popupAvailW = ImGui::GetMainViewport()->Size.x * S.drawElement("tabs.send", "confirm-popup-width-ratio").size;
|
||||
float popupW = std::min(schema::UI().drawElement("tabs.send", "confirm-popup-max-width").size, popupAvailW);
|
||||
float popVs = Layout::vScale();
|
||||
const float dp = Layout::dpiScale();
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = TR("confirm_send"); ov.p_open = nullptr;
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
@@ -767,7 +800,12 @@ void RenderSendConfirmPopup(App* app) {
|
||||
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_amount_details"));
|
||||
ImVec2 cMin = ImGui::GetCursorScreenPos();
|
||||
// Floor the row step at the *actual* scaled caption-row height (+ a small dp-scaled
|
||||
// gap) so the Fee/Total divider — drawn at rowStep*0.5 below the Fee baseline —
|
||||
// clears the (already-DPI-scaled) fee text instead of striking through it at HiDPI.
|
||||
// capFont->LegacySize is already DPI-scaled; do not scale it again.
|
||||
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
|
||||
rowStep = std::max(rowStep, capFont->LegacySize + 6.0f * dp);
|
||||
float configuredH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
|
||||
float contentH = Layout::spacingMd() * 2.0f + capFont->LegacySize * 2.0f + sub1->LegacySize + rowStep * 2.0f;
|
||||
float cH = std::max(configuredH, contentH);
|
||||
@@ -822,7 +860,17 @@ void RenderSendConfirmPopup(App* app) {
|
||||
if (s_sending) {
|
||||
Type().text(TypeStyle::Body2, TR("sending"));
|
||||
} else {
|
||||
if (TactileButton(TR("confirm_and_send"), ImVec2(S.button("tabs.send", "confirm-button").width * Layout::dpiScale(), std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size, schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs)), S.resolveFont(S.button("tabs.send", "confirm-button").font))) {
|
||||
// Size to max(schema width, measured label + padding) so a longer translation (e.g. the
|
||||
// Russian "Подтвердить и отправить") isn't clipped on this pre-broadcast confirm button.
|
||||
ImFont* confirmFont = S.resolveFont(S.button("tabs.send", "confirm-button").font);
|
||||
if (!confirmFont) confirmFont = Type().button();
|
||||
const float confirmW = std::max(
|
||||
S.button("tabs.send", "confirm-button").width * Layout::dpiScale(),
|
||||
confirmFont->CalcTextSizeA(confirmFont->LegacySize, FLT_MAX, 0, TR("confirm_and_send")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * Layout::dpiScale());
|
||||
const float confirmH = std::max(schema::UI().drawElement("tabs.send", "confirm-btn-min-height").size,
|
||||
schema::UI().drawElement("tabs.send", "confirm-btn-base-height").size * popVs);
|
||||
if (TactileButton(TR("confirm_and_send"), ImVec2(confirmW, confirmH), confirmFont)) {
|
||||
// Re-validate against LIVE state — the confirm dialog persists across frames, so the
|
||||
// balance could have dropped or sync (re)started (or the fee bumped total over available)
|
||||
// since Review. Don't broadcast a now-invalid transaction.
|
||||
@@ -896,9 +944,10 @@ static bool RenderZeroBalanceCTA(App* app, ImDrawList* dl, float width) {
|
||||
|
||||
ImFont* sub1 = Type().subtitle1();
|
||||
ImFont* capFont = Type().caption();
|
||||
const float dp = Layout::dpiScale();
|
||||
|
||||
ImVec2 ctaMin = ImGui::GetCursorScreenPos();
|
||||
float ctaH = schema::UI().drawElement("tabs.send", "cta-height").size;
|
||||
float ctaH = schema::UI().drawElement("tabs.send", "cta-height").size * dp;
|
||||
ImVec2 ctaMax(ctaMin.x + width, ctaMin.y + ctaH);
|
||||
GlassPanelSpec ctaGlass;
|
||||
ctaGlass.rounding = Layout::glassRounding();
|
||||
@@ -912,7 +961,7 @@ static bool RenderZeroBalanceCTA(App* app, ImDrawList* dl, float width) {
|
||||
TR("send_switch_to_receive"));
|
||||
cy += capFont->LegacySize + Layout::spacingMd();
|
||||
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
|
||||
if (TactileButton(TR("send_go_to_receive"), ImVec2(schema::UI().drawElement("tabs.send", "cta-button-width").size, schema::UI().drawElement("tabs.send", "cta-button-height").size), schema::UI().resolveFont("button"))) {
|
||||
if (TactileButton(TR("send_go_to_receive"), ImVec2(schema::UI().drawElement("tabs.send", "cta-button-width").size * dp, schema::UI().drawElement("tabs.send", "cta-button-height").size * dp), schema::UI().resolveFont("button"))) {
|
||||
app->setCurrentPage(NavPage::Receive);
|
||||
}
|
||||
ImGui::SetCursorScreenPos(ImVec2(ctaMin.x, ctaMax.y + Layout::spacingLg()));
|
||||
@@ -962,7 +1011,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
|
||||
ImGui::BeginDisabled(!can_send);
|
||||
char sendId[64];
|
||||
snprintf(sendId, sizeof(sendId), "Review Send%s", suffix);
|
||||
snprintf(sendId, sizeof(sendId), "%s##ReviewSend%s", TR("review_send"), suffix);
|
||||
if (TactileButton(sendId, ImVec2(sendBtnW, btnH), S.resolveFont(S.button("tabs.send", "send-button").font))) {
|
||||
s_show_confirm = true;
|
||||
}
|
||||
@@ -982,7 +1031,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
else if (total > available)
|
||||
material::Tooltip("%s", TR("send_tooltip_exceeds_balance"));
|
||||
else if (!sourceSpendable)
|
||||
material::Tooltip("%s", "View-only address — no spending key, cannot send");
|
||||
material::Tooltip("%s", TR("send_tooltip_view_only"));
|
||||
else if (s_sending)
|
||||
material::Tooltip("%s", TR("send_tooltip_in_progress"));
|
||||
}
|
||||
@@ -995,7 +1044,7 @@ static void RenderActionButtons(App* app, float width, float vScale,
|
||||
ImGui::PushStyleColor(ImGuiCol_Border, ImGui::ColorConvertU32ToFloat4(OnSurfaceDisabled()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameBorderSize, S.drawElement("tabs.send", "cancel-btn-border-size").size);
|
||||
char clearId[64];
|
||||
snprintf(clearId, sizeof(clearId), "Cancel%s", suffix);
|
||||
snprintf(clearId, sizeof(clearId), "%s##Cancel%s", TR("cancel"), suffix);
|
||||
if (TactileButton(clearId, ImVec2(cancelBtnW, btnH), S.resolveFont(S.button("tabs.send", "clear-button").font))) {
|
||||
if (FormHasData()) {
|
||||
s_clear_confirm_pending = true;
|
||||
@@ -1074,9 +1123,14 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
||||
if (tx.type != "send" && tx.type != "shield") continue;
|
||||
sends.push_back(&tx);
|
||||
}
|
||||
if (sends.size() > 4) sends.resize(4); // show only the 4 most recent (newest-first)
|
||||
|
||||
// Grow the list to fill the dead space beneath the (fixed-height) compose card:
|
||||
// fit as many newest-first rows as the remaining region can show, instead of a
|
||||
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
|
||||
// lost. A floor of 4 keeps the section substantial when the region is short.
|
||||
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
|
||||
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
|
||||
if (sends.size() > maxRows) sends.resize(maxRows); // newest-first
|
||||
|
||||
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
@@ -1084,8 +1138,9 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
||||
|
||||
char buf[64];
|
||||
if (sends.empty()) {
|
||||
ImGui::SetCursorPosY(Layout::spacingMd());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("send_no_recent"));
|
||||
// Fill the empty canvas with a centered material empty-state (icon + title)
|
||||
// rather than a lone left-aligned caption stranded at the top of dead space.
|
||||
material::DrawEmptyState(ICON_MD_CALL_MADE, TR("send_no_recent"));
|
||||
ImGui::EndChild();
|
||||
return;
|
||||
}
|
||||
@@ -1105,7 +1160,7 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
||||
ImVec2(txX, rowPos.y + 2.0f * dp), OnSurfaceMedium(), TR("sent_type"));
|
||||
|
||||
// Address (second line)
|
||||
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
float addrX = txX + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f) * hs;
|
||||
std::string addrDisplay = util::truncateMiddle(tx.address,
|
||||
(int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
@@ -1124,7 +1179,7 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000.0f, 0.0f, ago.c_str());
|
||||
rowDL->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f),
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f) * hs,
|
||||
rowPos.y + 2.0f * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
|
||||
@@ -1202,8 +1257,12 @@ void RenderSendTab(App* app)
|
||||
// SCROLLABLE CONTENT
|
||||
// ================================================================
|
||||
ImVec2 formAvail = ImGui::GetContentRegionAvail();
|
||||
// NOTE: no NoScrollbar/NoScrollWithMouse here (mirrors receive_tab's ##ReceiveScroll).
|
||||
// At font_scale 1.5 the form card grows to mainCardTargetH and pushes the appended
|
||||
// "Recent Sends" list below the fold; letting this child scroll keeps it reachable.
|
||||
// No-op at 1.0x where the content already fits (no scrollbar appears).
|
||||
ImGui::BeginChild("##SendFormScroll", formAvail, false,
|
||||
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
ImGuiWindowFlags_NoBackground);
|
||||
dl = ImGui::GetWindowDrawList();
|
||||
|
||||
// Top-aligned content — consistent vertical position across all tabs
|
||||
@@ -1213,6 +1272,13 @@ void RenderSendTab(App* app)
|
||||
float contentStartY = ImGui::GetCursorPosY();
|
||||
|
||||
float formAvailW = ImGui::GetContentRegionAvail().x;
|
||||
// Fill the available column up to the content-max-width cap, then center. Shared with the
|
||||
// Receive tab via mainComposeCardBox() so the two card envelopes are identical in width and
|
||||
// position (Send previously capped at 760dp vs Receive's 860dp, so Send rendered narrower).
|
||||
// The recent-sends list below deliberately keeps the full column width (formAvailW).
|
||||
Layout::CardBox formBox = Layout::mainComposeCardBox(formAvailW);
|
||||
float formCardW = formBox.width;
|
||||
float formOffsetX = formBox.offsetX;
|
||||
float formW = formAvailW;
|
||||
ImGui::BeginGroup();
|
||||
|
||||
@@ -1227,7 +1293,15 @@ void RenderSendTab(App* app)
|
||||
// ================================================================
|
||||
// COMPOSE FORM — single container for all fields
|
||||
// ================================================================
|
||||
// Full-column left edge, restored after the centered card so the recent-sends
|
||||
// list below spans the full width again.
|
||||
float formLeftX = ImGui::GetCursorPosX();
|
||||
{
|
||||
// Center the capped compose card: offset the cursor by the leftover half-margin,
|
||||
// then derive every field/divider/button from the card width (not the full column).
|
||||
float formW = formCardW;
|
||||
if (formOffsetX > 0.0f)
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + formOffsetX);
|
||||
ImVec2 containerMin = ImGui::GetCursorScreenPos();
|
||||
float pad = Layout::spacingLg();
|
||||
float innerW = formW - pad * 2;
|
||||
@@ -1240,8 +1314,10 @@ void RenderSendTab(App* app)
|
||||
dl->ChannelsSplit(2);
|
||||
dl->ChannelsSetCurrent(1);
|
||||
|
||||
// Indent content by pad so every line is inset from the card edges
|
||||
ImGui::Indent(pad);
|
||||
// Indent content by pad so every line is inset from the card edges. Fold in formOffsetX so the
|
||||
// auto-layout fields land inside the centered card (Indent() positions from the window's left, so
|
||||
// without this the fields would sit at the un-offset column while the glass card is centered).
|
||||
ImGui::Indent(pad + formOffsetX);
|
||||
ImGui::Dummy(ImVec2(0, pad * vScale)); // top padding
|
||||
|
||||
// ---- SOURCE ADDRESS ----
|
||||
@@ -1292,7 +1368,10 @@ void RenderSendTab(App* app)
|
||||
|
||||
float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size);
|
||||
float contactsW = ImGui::GetFrameHeight(); // compact square icon button for the contact picker
|
||||
ImGui::PushItemWidth(colW - pasteW - contactsW - Layout::spacingSm() * 2.0f);
|
||||
// Reserve the TWO real SameLine gaps (each = ItemSpacing.x) between input|Paste|icon.
|
||||
// Reserving spacingSm (a smaller token) under-counted the gap, so the row overshot colW by
|
||||
// ~2*(ItemSpacing.x - spacingSm) and the icon's right border clipped past the card edge.
|
||||
ImGui::PushItemWidth(colW - pasteW - contactsW - ImGui::GetStyle().ItemSpacing.x * 2.0f);
|
||||
|
||||
// Show clipboard preview as transparent overlay when paste button is hovered
|
||||
bool paste_hovered = false;
|
||||
@@ -1318,8 +1397,7 @@ void RenderSendTab(App* app)
|
||||
trimmed.erase(trimmed.begin());
|
||||
while (!trimmed.empty() && (trimmed.back() == ' ' || trimmed.back() == '\n' || trimmed.back() == '\r' || trimmed.back() == '\t'))
|
||||
trimmed.pop_back();
|
||||
bool looksValid = (trimmed.size() > 30 &&
|
||||
((trimmed[0] == 'z' && trimmed[1] == 's') || trimmed[0] == 'R'));
|
||||
bool looksValid = dragonx::util::isValidRecipientAddress(trimmed);
|
||||
if (looksValid && s_to_address[0] == '\0') {
|
||||
s_preview_text = trimmed;
|
||||
s_paste_previewing = true;
|
||||
@@ -1340,7 +1418,7 @@ void RenderSendTab(App* app)
|
||||
s_preview_text.c_str(), s_preview_text.c_str() + std::min(s_preview_text.size(), (size_t)S.drawElement("tabs.send", "paste-preview-max-chars").size));
|
||||
}
|
||||
|
||||
if (TactileButton("Paste##to", ImVec2(pasteW, 0), S.resolveFont(S.button("tabs.send", "paste-button").font))) {
|
||||
if (TactileButton((std::string(TR("paste")) + "##to").c_str(), ImVec2(pasteW, 0), S.resolveFont(S.button("tabs.send", "paste-button").font))) {
|
||||
if (s_paste_previewing) {
|
||||
// Commit the preview
|
||||
snprintf(s_to_address, sizeof(s_to_address), "%s", s_preview_text.c_str());
|
||||
@@ -1351,9 +1429,12 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// Contact picker — pick a saved contact's address as the recipient.
|
||||
// Contact picker — pick a saved contact's address as the recipient. Pin the height to the
|
||||
// frame height (== the input + Paste height) so the larger iconMed font doesn't auto-size
|
||||
// this square button taller than its row-mates. (Passing a non-zero size also routes
|
||||
// TactileButton through its precise InvisibleButton + centered-glyph path.)
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, 0),
|
||||
if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, ImGui::GetFrameHeight()),
|
||||
material::Type().iconMed()))
|
||||
ImGui::OpenPopup("##ContactPickerPopup");
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("send_contacts_button"));
|
||||
@@ -1380,7 +1461,7 @@ void RenderSendTab(App* app)
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Toggle between DRGX and USD input
|
||||
float toggleW = schema::UI().drawElement("tabs.send", "toggle-currency-width").size;
|
||||
float toggleW = schema::UI().drawElement("tabs.send", "toggle-currency-width").size * Layout::dpiScale();
|
||||
float amtInputW = colW - toggleW - Layout::spacingMd();
|
||||
if (amtInputW < schema::UI().drawElement("tabs.send", "amount-input-min-width").size) amtInputW = schema::UI().drawElement("tabs.send", "amount-input-min-width").size;
|
||||
|
||||
@@ -1453,8 +1534,8 @@ void RenderSendTab(App* app)
|
||||
float bH = bMax.y - bMin.y;
|
||||
ImFont* font = ImGui::GetFont();
|
||||
ImVec2 textSz = font->CalcTextSizeA(font->LegacySize, 10000, 0, currLabel);
|
||||
float iconW = schema::UI().drawElement("tabs.send", "swap-icon-width").size;
|
||||
float iconGap = schema::UI().drawElement("tabs.send", "swap-icon-gap").size;
|
||||
float iconW = schema::UI().drawElement("tabs.send", "swap-icon-width").size * Layout::dpiScale();
|
||||
float iconGap = schema::UI().drawElement("tabs.send", "swap-icon-gap").size * Layout::dpiScale();
|
||||
float totalW = iconW + iconGap + textSz.x;
|
||||
float startX = bMin.x + ((bMax.x - bMin.x) - totalW) * 0.5f;
|
||||
float cy = bMin.y + bH * 0.5f;
|
||||
@@ -1510,7 +1591,7 @@ void RenderSendTab(App* app)
|
||||
size_t memo_len = strlen(s_memo);
|
||||
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
|
||||
bool memoAtCap = memo_len + 1 >= memoMax;
|
||||
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
|
||||
snprintf(buf, sizeof(buf), TR("byte_count_fmt"), memo_len, memoMax);
|
||||
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
|
||||
}
|
||||
|
||||
@@ -1541,7 +1622,7 @@ void RenderSendTab(App* app)
|
||||
|
||||
// Add bottom padding
|
||||
ImGui::Dummy(ImVec2(0, pad * vScale));
|
||||
ImGui::Unindent(pad);
|
||||
ImGui::Unindent(pad + formOffsetX);
|
||||
|
||||
// Enforce shared card height (matches receive tab)
|
||||
{
|
||||
@@ -1574,7 +1655,8 @@ void RenderSendTab(App* app)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- RECENT SENDS ----
|
||||
// ---- RECENT SENDS ---- (full column width; reset X off the centered card)
|
||||
ImGui::SetCursorPosX(formLeftX);
|
||||
RenderRecentSends(state, formW, capFont, app);
|
||||
|
||||
ImGui::EndGroup();
|
||||
|
||||
@@ -478,7 +478,12 @@ void RenderSettingsWindow(App* app, bool* p_open)
|
||||
|
||||
// Confirmation dialog
|
||||
if (s_confirm_clear_ztx) {
|
||||
if (material::BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_confirm_clear_ztx, 480.0f, 0.94f)) {
|
||||
// Distinct idSuffix: this confirm renders nested inside (and the same frame as) the parent
|
||||
// settings dialog, so it must not share the default ##OverlayDialogContent key — otherwise it
|
||||
// inherits the parent's OverlayCardState (incl. the sticky overflow flag) and collides on the
|
||||
// child window id.
|
||||
if (material::BeginOverlayDialog(TR("confirm_clear_ztx_title"), &s_confirm_clear_ztx, 480.0f,
|
||||
0.94f, 0.85f, "settings_clearztx")) {
|
||||
material::DialogWarningHeader(TR("warning"), ImVec4(1.0f, 0.6f, 0.0f, 1.0f));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
+414
-252
@@ -5,6 +5,7 @@
|
||||
#include "shield_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../data/wallet_state.h"
|
||||
#include "../../rpc/rpc_client.h"
|
||||
#include "../../rpc/rpc_worker.h"
|
||||
#include "../../util/i18n.h"
|
||||
@@ -15,39 +16,98 @@
|
||||
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// Static state
|
||||
static bool s_open = false;
|
||||
// ── Static dialog state ─────────────────────────────────────────────────────────────────────────
|
||||
static bool s_open = false;
|
||||
static ShieldDialog::Mode s_mode = ShieldDialog::Mode::ShieldCoinbase;
|
||||
static char s_from_address[512] = "*";
|
||||
static char s_to_address[512] = "";
|
||||
static bool s_consolidate = false; // opened from the wallet-bloat nudge (shielded preset + framing)
|
||||
static int s_src = 2; // merge source: 0 = transparent, 1 = shielded, 2 = both
|
||||
static char s_from_address[512] = "*";
|
||||
static char s_to_address[512] = "";
|
||||
static int s_selected_zaddr_idx = -1;
|
||||
static double s_fee = DRAGONX_DEFAULT_FEE;
|
||||
static int s_utxo_limit = 50; // overridden by schema at runtime
|
||||
static bool s_operation_pending = false;
|
||||
static int s_utxo_limit = 50; // overridden by schema at runtime
|
||||
static bool s_advanced = false; // Advanced (fee + batch size) disclosure
|
||||
static bool s_confirm = false; // inline "confirm before moving funds" phase
|
||||
static bool s_operation_pending = false;
|
||||
static bool s_op_terminal = false; // async op reached success/failed — freeze inputs
|
||||
static std::string s_operation_id;
|
||||
static std::string s_status_message;
|
||||
static int s_selected_zaddr_idx = -1;
|
||||
static double s_last_poll = 0.0; // live-progress self-poll timer (ImGui::GetTime seconds)
|
||||
// Scope of what can be consolidated (fetched once on open, merge mode only).
|
||||
static bool s_scope_loading = false;
|
||||
static bool s_scope_loaded = false;
|
||||
static int s_t_count = 0, s_z_count = 0;
|
||||
static double s_t_amount = 0.0, s_z_amount = 0.0;
|
||||
static bool s_creating_addr = false; // z_getnewaddress in flight (empty state)
|
||||
|
||||
static void resetTransient()
|
||||
{
|
||||
s_operation_pending = false;
|
||||
s_op_terminal = false;
|
||||
s_confirm = false;
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
s_creating_addr = false;
|
||||
}
|
||||
|
||||
// Count + sum spendable transparent UTXOs and shielded notes so the user can see the scope of a
|
||||
// consolidation (and how many batches it may take). Read-only; runs off the UI thread.
|
||||
static void loadScope(App* app)
|
||||
{
|
||||
if (!app || !app->worker()) return;
|
||||
s_scope_loading = true; s_scope_loaded = false;
|
||||
app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
int tC = 0, zC = 0; double tA = 0.0, zA = 0.0; std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Scope count");
|
||||
nlohmann::json us = rpc->call("listunspent", nlohmann::json::array({0}));
|
||||
if (us.is_array()) for (const auto& u : us) {
|
||||
if (u.value("confirmations", 0) >= 1 && u.value("spendable", true)) { ++tC; tA += u.value("amount", 0.0); }
|
||||
}
|
||||
nlohmann::json zs = rpc->call("z_listunspent", nlohmann::json::array({0}));
|
||||
if (zs.is_array()) for (const auto& z : zs) {
|
||||
if (z.value("confirmations", 0) >= 1) { ++zC; zA += z.value("amount", 0.0); }
|
||||
}
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [tC, zC, tA, zA, error]() {
|
||||
s_scope_loading = false; s_scope_loaded = error.empty();
|
||||
s_t_count = tC; s_z_count = zC; s_t_amount = tA; s_z_amount = zA;
|
||||
// Clamp the source to what actually has inputs (unless the user is mid-op).
|
||||
const bool tOk = tC > 0, zOk = zC > 0;
|
||||
if (!s_operation_pending) {
|
||||
if (s_consolidate && zOk) s_src = 1; // bloat nudge → shielded
|
||||
else if (s_src == 0 && !tOk) s_src = zOk ? 1 : 2;
|
||||
else if (s_src == 1 && !zOk) s_src = tOk ? 0 : 2;
|
||||
else if (!tOk && zOk) s_src = 1;
|
||||
else if (tOk && !zOk) s_src = 0;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void ShieldDialog::show(Mode mode)
|
||||
{
|
||||
s_mode = mode;
|
||||
s_open = true;
|
||||
s_operation_pending = false;
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
|
||||
if (mode == Mode::ShieldCoinbase) {
|
||||
strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||
} else {
|
||||
s_from_address[0] = '\0';
|
||||
}
|
||||
s_consolidate = false; // reset preset flags so stale statics don't leak across opens
|
||||
s_src = 2;
|
||||
resetTransient();
|
||||
s_from_address[0] = '\0';
|
||||
if (mode == Mode::ShieldCoinbase) strncpy(s_from_address, "*", sizeof(s_from_address));
|
||||
s_to_address[0] = '\0';
|
||||
s_selected_zaddr_idx = -1;
|
||||
s_fee = DRAGONX_DEFAULT_FEE;
|
||||
s_utxo_limit = (int)schema::UI().drawElement("business", "utxo-limit").size;
|
||||
s_selected_zaddr_idx = -1;
|
||||
if (s_utxo_limit < 1) s_utxo_limit = 50;
|
||||
s_advanced = false;
|
||||
s_scope_loaded = false; s_scope_loading = false;
|
||||
s_t_count = s_z_count = 0; s_t_amount = s_z_amount = 0.0;
|
||||
s_last_poll = 0.0;
|
||||
}
|
||||
|
||||
void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
|
||||
@@ -59,14 +119,146 @@ void ShieldDialog::showShieldCoinbase(const std::string& fromAddress)
|
||||
void ShieldDialog::showMerge()
|
||||
{
|
||||
show(Mode::MergeToAddress);
|
||||
s_consolidate = false;
|
||||
s_src = 2; // generic merge: both sources
|
||||
}
|
||||
|
||||
void ShieldDialog::showConsolidate()
|
||||
{
|
||||
show(Mode::MergeToAddress);
|
||||
s_consolidate = true;
|
||||
s_src = 1; // wallet-bloat consolidation targets shielded notes (witness bloat)
|
||||
}
|
||||
|
||||
void ShieldDialog::hide()
|
||||
{
|
||||
s_open = false;
|
||||
s_operation_pending = false;
|
||||
s_status_message.clear();
|
||||
s_operation_id.clear();
|
||||
resetTransient();
|
||||
}
|
||||
|
||||
// Relevant count/amount for the currently-selected merge source.
|
||||
static int srcCount() { return s_src == 0 ? s_t_count : s_src == 1 ? s_z_count : (s_t_count + s_z_count); }
|
||||
static double srcAmount() { return s_src == 0 ? s_t_amount : s_src == 1 ? s_z_amount : (s_t_amount + s_z_amount); }
|
||||
|
||||
static std::string fmtAmt(double v) { char b[48]; std::snprintf(b, sizeof(b), "%.4f", v); return b; }
|
||||
|
||||
static std::string shortAddr(const std::string& a)
|
||||
{
|
||||
if (a.size() <= 20) return a;
|
||||
return a.substr(0, 10) + "…" + a.substr(a.size() - 8);
|
||||
}
|
||||
|
||||
// Auto-pick the best spendable z-address as the default destination (fewest hops for the user).
|
||||
static void autoSelectDestination(const WalletState& state)
|
||||
{
|
||||
if (s_to_address[0] != '\0' || state.z_addresses.empty()) return;
|
||||
int idx = bestSpendableAddressIndex(state.z_addresses);
|
||||
if (idx < 0) idx = 0;
|
||||
s_selected_zaddr_idx = idx;
|
||||
strncpy(s_to_address, state.z_addresses[idx].address.c_str(), sizeof(s_to_address) - 1);
|
||||
}
|
||||
|
||||
// Fire the actual shield/merge op. Registers the opid with the shared poller (for balance refresh)
|
||||
// AND kicks the modal's own live-progress poll.
|
||||
static void submitOperation(App* app)
|
||||
{
|
||||
s_operation_pending = true;
|
||||
s_op_terminal = false;
|
||||
s_status_message = TR("shield_submitting");
|
||||
s_last_poll = ImGui::GetTime();
|
||||
|
||||
if (s_mode == ShieldDialog::Mode::ShieldCoinbase) {
|
||||
std::string from(s_from_address), to(s_to_address);
|
||||
double fee = s_fee; int limit = s_utxo_limit;
|
||||
if (!app->worker()) return;
|
||||
app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Shield coinbase");
|
||||
result = rpc->call("z_shieldcoinbase", {from, to, fee, limit});
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [app, result, error]() {
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("shield_started"));
|
||||
app->trackOperation(s_operation_id);
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
||||
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
|
||||
}
|
||||
};
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Merge / consolidate. Source → z_mergetoaddress fromaddress selector (this is the fix: shielded
|
||||
// notes, not just transparent UTXOs — the wallet-bloat the nudge warns about is shielded witnesses).
|
||||
std::vector<std::string> fromAddrs;
|
||||
if (s_src == 0) fromAddrs = { "ANY_TADDR" };
|
||||
else if (s_src == 1) fromAddrs = { "ANY_SAPLING" };
|
||||
else fromAddrs = { "*" };
|
||||
std::string to(s_to_address);
|
||||
double fee = s_fee; int limit = s_utxo_limit;
|
||||
if (!app->worker()) return;
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json addrs = nlohmann::json::array();
|
||||
for (const auto& a : fromAddrs) addrs.push_back(a);
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Consolidate");
|
||||
// fromaddrs, toaddr, fee, transparent_limit, shielded_limit — cap both to the batch size.
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, limit, limit});
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [app, result, error]() {
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = TR("merge_progress");
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
app->trackOperation(s_operation_id);
|
||||
} else {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
||||
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Live-progress self-poll: while an op is in flight, poll z_getoperationstatus every ~2s so the modal
|
||||
// shows "Consolidating… → Done/Failed" without a manual button. (The shared poller also tracks it for
|
||||
// balance refresh; this drives only the inline display.)
|
||||
static void pollOperation(App* app)
|
||||
{
|
||||
if (s_operation_id.empty() || s_op_terminal || !app->worker()) return;
|
||||
const double now = ImGui::GetTime();
|
||||
if (now - s_last_poll < 2.0) return;
|
||||
s_last_poll = now;
|
||||
std::string opid = s_operation_id;
|
||||
app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result; std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / Op status");
|
||||
result = rpc->call("z_getoperationstatus", {nlohmann::json::array({opid})});
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [result, error]() {
|
||||
if (!error.empty() || !result.is_array() || result.empty()) return; // transient — retry next tick
|
||||
const auto& op = result[0];
|
||||
const std::string status = op.value("status", "");
|
||||
if (status == "success") {
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = TR("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
} else if (status == "failed") {
|
||||
std::string msg = op.value("error", nlohmann::json{}).value("message", std::string(TR("shield_unknown_error")));
|
||||
s_operation_pending = false; s_op_terminal = true;
|
||||
s_status_message = std::string(TR("shield_op_failed")) + msg;
|
||||
Notifications::instance().error(std::string(TR("shield_op_failed")) + msg);
|
||||
}
|
||||
// queued / executing → leave the "Consolidating…" message and keep polling.
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
void ShieldDialog::render(App* app)
|
||||
@@ -74,251 +266,221 @@ void ShieldDialog::render(App* app)
|
||||
if (!s_open) return;
|
||||
|
||||
auto& S = schema::UI();
|
||||
auto win = S.window("dialogs.shield");
|
||||
auto addrLbl = S.label("dialogs.shield", "address-label");
|
||||
auto addrFrontLbl = S.label("dialogs.shield", "address-front-label");
|
||||
auto addrBackLbl = S.label("dialogs.shield", "address-back-label");
|
||||
auto feeInput = S.input("dialogs.shield", "fee-input");
|
||||
auto utxoInput = S.input("dialogs.shield", "utxo-limit-input");
|
||||
auto shieldBtn = S.button("dialogs.shield", "shield-button");
|
||||
auto cancelBtn = S.button("dialogs.shield", "cancel-button");
|
||||
auto win = S.window("dialogs.shield");
|
||||
auto addrLbl = S.label("dialogs.shield", "address-label");
|
||||
auto addrFront = S.label("dialogs.shield", "address-front-label");
|
||||
auto addrBack = S.label("dialogs.shield", "address-back-label");
|
||||
auto feeInput = S.input("dialogs.shield", "fee-input");
|
||||
auto utxoInput = S.input("dialogs.shield", "utxo-limit-input");
|
||||
auto shieldBtn = S.button("dialogs.shield", "shield-button");
|
||||
auto cancelBtn = S.button("dialogs.shield", "cancel-button");
|
||||
const float dp = Layout::dpiScale();
|
||||
const bool isMerge = (s_mode == Mode::MergeToAddress);
|
||||
|
||||
const char* title = (s_mode == Mode::ShieldCoinbase)
|
||||
? TR("shield_title")
|
||||
: TR("merge_title");
|
||||
const char* title = s_consolidate ? TR("consolidate_title")
|
||||
: isMerge ? TR("merge_title")
|
||||
: TR("shield_title");
|
||||
|
||||
material::OverlayDialogSpec ov;
|
||||
ov.title = title; ov.p_open = &s_open;
|
||||
ov.style = material::OverlayStyle::BlurFloat;
|
||||
ov.cardWidth = win.width; ov.idSuffix = "shielddialog";
|
||||
if (material::BeginOverlayDialog(ov)) {
|
||||
const auto& state = app->getWalletState();
|
||||
if (!material::BeginOverlayDialog(ov)) return;
|
||||
|
||||
// Description
|
||||
if (s_mode == Mode::ShieldCoinbase) {
|
||||
ImGui::TextWrapped("%s", TR("shield_description"));
|
||||
} else {
|
||||
ImGui::TextWrapped("%s", TR("merge_description"));
|
||||
const auto& state = app->getWalletState();
|
||||
autoSelectDestination(state);
|
||||
pollOperation(app);
|
||||
if (isMerge && !s_scope_loaded && !s_scope_loading && s_operation_id.empty()) loadScope(app);
|
||||
|
||||
// ── Description ──────────────────────────────────────────────────────────────────────────────
|
||||
ImGui::TextWrapped("%s", s_consolidate ? TR("consolidate_desc")
|
||||
: isMerge ? TR("merge_description")
|
||||
: TR("shield_description"));
|
||||
ImGui::Spacing();
|
||||
|
||||
const bool opInFlight = !s_operation_id.empty(); // submitted — inputs frozen, showing progress
|
||||
|
||||
// ── Merge: scope + source selector ──────────────────────────────────────────────────────────
|
||||
if (isMerge && !opInFlight) {
|
||||
if (s_scope_loading) {
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
|
||||
TR("merge_scope_loading"));
|
||||
} else if (s_scope_loaded) {
|
||||
char buf[160];
|
||||
std::snprintf(buf, sizeof(buf), TR("merge_scope_fmt"),
|
||||
s_t_count, s_z_count, fmtAmt(s_t_amount + s_z_amount).c_str());
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), buf);
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// From address (for shield coinbase)
|
||||
if (s_mode == Mode::ShieldCoinbase) {
|
||||
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
|
||||
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
|
||||
// Source selector — only offer the types that actually have inputs.
|
||||
const bool tOk = s_t_count > 0, zOk = s_z_count > 0;
|
||||
if (tOk && zOk) {
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextUnformatted(TR("merge_source"));
|
||||
ImGui::SameLine(0, Layout::spacingLg());
|
||||
ImGui::RadioButton(TR("merge_src_shielded"), &s_src, 1); ImGui::SameLine();
|
||||
ImGui::RadioButton(TR("merge_src_transparent"), &s_src, 0); ImGui::SameLine();
|
||||
ImGui::RadioButton(TR("merge_src_both"), &s_src, 2);
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// To address (z-address dropdown)
|
||||
ImGui::Text("%s", TR("shield_to_address"));
|
||||
|
||||
// Get z-addresses for dropdown
|
||||
std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z");
|
||||
if (to_display.length() > static_cast<size_t>(addrLbl.truncate)) {
|
||||
to_display = to_display.substr(0, addrFrontLbl.truncate) + "..." + to_display.substr(to_display.length() - addrBackLbl.truncate);
|
||||
}
|
||||
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) {
|
||||
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
||||
const auto& addr = state.z_addresses[i];
|
||||
std::string label = addr.address;
|
||||
if (label.length() > static_cast<size_t>(addrLbl.truncate)) {
|
||||
label = label.substr(0, addrFrontLbl.truncate) + "..." + label.substr(label.length() - addrBackLbl.truncate);
|
||||
}
|
||||
|
||||
bool selected = (s_selected_zaddr_idx == static_cast<int>(i));
|
||||
if (ImGui::Selectable(label.c_str(), selected)) {
|
||||
s_selected_zaddr_idx = static_cast<int>(i);
|
||||
strncpy(s_to_address, addr.address.c_str(), sizeof(s_to_address) - 1);
|
||||
}
|
||||
if (selected) {
|
||||
ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
if (state.z_addresses.empty()) {
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::Warning(),
|
||||
TR("shield_no_zaddr_hint"));
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Fee
|
||||
ImGui::Text("%s", TR("fee_label"));
|
||||
ImGui::SetNextItemWidth(feeInput.width);
|
||||
ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f");
|
||||
if (s_fee < 0.0) s_fee = 0.0; // no negative fee
|
||||
if (s_fee > 1.0) s_fee = 1.0; // guard a fat-fingered huge fee (mirrors utxo clamp)
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("DRGX");
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// UTXO limit
|
||||
ImGui::Text("%s", TR("shield_utxo_limit"));
|
||||
ImGui::SetNextItemWidth(utxoInput.width);
|
||||
ImGui::InputInt("##Limit", &s_utxo_limit);
|
||||
ImGui::SameLine();
|
||||
ImGui::TextDisabled("%s", TR("shield_max_utxos"));
|
||||
if (s_utxo_limit < 1) s_utxo_limit = 1;
|
||||
if (s_utxo_limit > 100) s_utxo_limit = 100;
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Status message
|
||||
if (!s_status_message.empty()) {
|
||||
if (s_operation_pending) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
||||
} else {
|
||||
ImGui::TextWrapped("%s", s_status_message.c_str());
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// Buttons — guard on connection/sync like the Send tab (a disconnected or mid-sync submit just
|
||||
// fails at the daemon with a raw error).
|
||||
bool sh_connected = app->isConnected();
|
||||
bool sh_syncing = state.sync.syncing;
|
||||
bool can_submit = !s_operation_pending && s_to_address[0] != '\0' && sh_connected && !sh_syncing;
|
||||
|
||||
if (!can_submit) ImGui::BeginDisabled();
|
||||
|
||||
const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds");
|
||||
if (material::TactileButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
||||
s_operation_pending = true;
|
||||
s_status_message = TR("shield_submitting");
|
||||
|
||||
if (s_mode == Mode::ShieldCoinbase) {
|
||||
std::string from(s_from_address), to(s_to_address);
|
||||
double fee = s_fee;
|
||||
int limit = s_utxo_limit;
|
||||
if (app->worker()) {
|
||||
app->worker()->post([app, rpc = app->rpc(), from, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Shield coinbase");
|
||||
result = rpc->call("z_shieldcoinbase", {from, to, fee, limit});
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
return [app, result, error]() {
|
||||
s_operation_pending = false;
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
|
||||
Notifications::instance().success(TR("shield_started"));
|
||||
// Register with the shared poller so an async failure is
|
||||
// surfaced (and balances refresh) even after this dialog closes.
|
||||
app->trackOperation(s_operation_id);
|
||||
} else {
|
||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
||||
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else {
|
||||
std::vector<std::string> fromAddrs;
|
||||
fromAddrs.push_back("ANY_TADDR");
|
||||
std::string to(s_to_address);
|
||||
double fee = s_fee;
|
||||
int limit = s_utxo_limit;
|
||||
if (app->worker()) {
|
||||
app->worker()->post([app, rpc = app->rpc(), fromAddrs, to, fee, limit]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json addrs = nlohmann::json::array();
|
||||
for (const auto& addr : fromAddrs) addrs.push_back(addr);
|
||||
nlohmann::json result;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Merge funds");
|
||||
result = rpc->call("z_mergetoaddress", {addrs, to, fee, 0, limit});
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
return [app, result, error]() {
|
||||
s_operation_pending = false;
|
||||
if (error.empty()) {
|
||||
s_operation_id = result.value("opid", "");
|
||||
s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
|
||||
Notifications::instance().success(TR("merge_started"));
|
||||
// Register with the shared poller so an async failure is
|
||||
// surfaced (and balances refresh) even after this dialog closes.
|
||||
app->trackOperation(s_operation_id);
|
||||
} else {
|
||||
s_status_message = std::string(TR("shield_error_prefix")) + error;
|
||||
Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!can_submit) ImGui::EndDisabled();
|
||||
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
if (!sh_connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
|
||||
else if (sh_syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||
else if (s_to_address[0]=='\0') material::Tooltip("%s", TR("shield_select_z"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (material::TactileButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
||||
s_open = false;
|
||||
}
|
||||
|
||||
// Show operation status if we have an opid
|
||||
if (!s_operation_id.empty()) {
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text(TR("shield_operation_id"), s_operation_id.c_str());
|
||||
|
||||
if (material::TactileButton(TR("shield_check_status"), ImVec2(0,0), S.resolveFont(shieldBtn.font))) {
|
||||
std::string opid = s_operation_id;
|
||||
if (app->worker()) {
|
||||
app->worker()->post([rpc = app->rpc(), opid]() -> rpc::RPCWorker::MainCb {
|
||||
nlohmann::json result;
|
||||
std::string error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Send tab / Shield operation status");
|
||||
nlohmann::json ids = nlohmann::json::array();
|
||||
ids.push_back(opid);
|
||||
result = rpc->call("z_getoperationstatus", {ids});
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
}
|
||||
return [result, error]() {
|
||||
if (error.empty() && result.is_array() && !result.empty()) {
|
||||
auto& op = result[0];
|
||||
std::string status = op.value("status", "unknown");
|
||||
if (status == "success") {
|
||||
s_status_message = TR("shield_completed");
|
||||
Notifications::instance().success(TR("shield_merge_done"));
|
||||
} else if (status == "failed") {
|
||||
std::string errMsg = op.value("error", nlohmann::json{}).value("message", TR("shield_unknown_error"));
|
||||
s_status_message = std::string(TR("shield_op_failed")) + errMsg;
|
||||
Notifications::instance().error(std::string(TR("shield_op_failed")) + errMsg);
|
||||
} else if (status == "executing") {
|
||||
s_status_message = TR("shield_in_progress");
|
||||
} else {
|
||||
s_status_message = std::string(TR("shield_status_label")) + status;
|
||||
}
|
||||
} else if (!error.empty()) {
|
||||
s_status_message = std::string(TR("shield_status_check_error")) + error;
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
// ── Shield coinbase: from address ───────────────────────────────────────────────────────────
|
||||
if (!isMerge && !opInFlight) {
|
||||
material::LabeledInput(TR("shield_from_address"), "##FromAddr", s_from_address, sizeof(s_from_address));
|
||||
ImGui::TextDisabled("%s", TR("shield_wildcard_hint"));
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Destination (z-address) ─────────────────────────────────────────────────────────────────
|
||||
if (!opInFlight) {
|
||||
ImGui::TextUnformatted(TR("shield_to_address"));
|
||||
if (state.z_addresses.empty()) {
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), TR("shield_no_zaddr_hint"));
|
||||
ImGui::Spacing();
|
||||
if (s_creating_addr) {
|
||||
ImGui::TextDisabled("%s", TR("merge_creating"));
|
||||
} else if (material::TactileButton(TR("merge_create_zaddr"), ImVec2(0, 0), S.resolveFont(shieldBtn.font))) {
|
||||
s_creating_addr = true;
|
||||
if (app->worker()) app->worker()->post([app, rpc = app->rpc()]() -> rpc::RPCWorker::MainCb {
|
||||
std::string addr, error;
|
||||
try {
|
||||
rpc::RPCClient::TraceScope trace("Shield dialog / New z-address");
|
||||
addr = rpc->call("z_getnewaddress", nlohmann::json::array()).get<std::string>();
|
||||
} catch (const std::exception& e) { error = e.what(); }
|
||||
return [app, addr, error]() {
|
||||
s_creating_addr = false;
|
||||
if (error.empty() && !addr.empty()) {
|
||||
strncpy(s_to_address, addr.c_str(), sizeof(s_to_address) - 1);
|
||||
Notifications::instance().success(TR("merge_addr_created"));
|
||||
} else {
|
||||
Notifications::instance().error(std::string(TR("shield_error_prefix")) + error);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
} else {
|
||||
std::string to_display = s_to_address[0] ? s_to_address : TR("shield_select_z");
|
||||
if (to_display.length() > static_cast<size_t>(addrLbl.truncate))
|
||||
to_display = to_display.substr(0, addrFront.truncate) + "..." + to_display.substr(to_display.length() - addrBack.truncate);
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ImGui::BeginCombo("##ToAddr", to_display.c_str())) {
|
||||
for (size_t i = 0; i < state.z_addresses.size(); i++) {
|
||||
std::string label = state.z_addresses[i].address;
|
||||
if (label.length() > static_cast<size_t>(addrLbl.truncate))
|
||||
label = label.substr(0, addrFront.truncate) + "..." + label.substr(label.length() - addrBack.truncate);
|
||||
bool selected = (s_selected_zaddr_idx == static_cast<int>(i));
|
||||
if (ImGui::Selectable(label.c_str(), selected)) {
|
||||
s_selected_zaddr_idx = static_cast<int>(i);
|
||||
strncpy(s_to_address, state.z_addresses[i].address.c_str(), sizeof(s_to_address) - 1);
|
||||
}
|
||||
if (selected) ImGui::SetItemDefaultFocus();
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
}
|
||||
ImGui::Spacing();
|
||||
|
||||
// ── Advanced (fee + batch size) ─────────────────────────────────────────────────────────
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
material::CollapsibleHeader(dl, "##AdvToggle", TR("merge_advanced"), s_advanced,
|
||||
ImGui::GetContentRegionAvail().x, material::Type().caption(),
|
||||
material::OnSurfaceMedium());
|
||||
if (s_advanced) {
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted(TR("fee_label"));
|
||||
ImGui::SetNextItemWidth(feeInput.width * dp);
|
||||
ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f");
|
||||
if (s_fee < 0.0) s_fee = 0.0;
|
||||
if (s_fee > 1.0) s_fee = 1.0;
|
||||
ImGui::SameLine(); ImGui::TextDisabled("DRGX");
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("merge_fee_hint"));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::TextUnformatted(TR("merge_max_inputs"));
|
||||
ImGui::SetNextItemWidth(utxoInput.width * dp);
|
||||
ImGui::InputInt("##Limit", &s_utxo_limit);
|
||||
if (s_utxo_limit < 1) s_utxo_limit = 1;
|
||||
if (s_utxo_limit > 100) s_utxo_limit = 100;
|
||||
}
|
||||
|
||||
// Batch hint: one run only merges up to the limit; large sets need repeats.
|
||||
if (isMerge && s_scope_loaded && srcCount() > s_utxo_limit) {
|
||||
char hb[160];
|
||||
std::snprintf(hb, sizeof(hb), TR("merge_batch_fmt"), s_utxo_limit);
|
||||
ImGui::Spacing();
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::Warning(), hb);
|
||||
}
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Live progress / status ──────────────────────────────────────────────────────────────────
|
||||
if (!s_status_message.empty()) {
|
||||
if (s_operation_pending && !s_op_terminal)
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.0f, 1.0f), "%s", s_status_message.c_str());
|
||||
else
|
||||
ImGui::TextWrapped("%s", s_status_message.c_str());
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
// ── Footer ──────────────────────────────────────────────────────────────────────────────────
|
||||
const bool connected = app->isConnected();
|
||||
const bool syncing = state.sync.syncing;
|
||||
const bool haveDest = s_to_address[0] != '\0';
|
||||
|
||||
if (opInFlight) {
|
||||
// After submit: just a Close button (progress shows above; op continues in the background).
|
||||
material::BeginOverlayDialogFooter(cancelBtn.width, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(s_op_terminal ? TR("done") : TR("close"),
|
||||
ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font)))
|
||||
s_open = false;
|
||||
material::EndOverlayDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const char* primaryLabel = s_confirm ? TR("merge_confirm_btn")
|
||||
: s_consolidate ? TR("consolidate_funds_btn")
|
||||
: isMerge ? TR("merge_funds")
|
||||
: TR("shield_funds");
|
||||
const char* secondaryLabel = s_confirm ? TR("merge_back") : TR("cancel");
|
||||
|
||||
// Confirm summary (inline, before the fund-moving call). Merge/consolidate shows amount + input
|
||||
// count; shield-coinbase just gets the button relabel (its inputs aren't enumerated here).
|
||||
if (s_confirm && isMerge) {
|
||||
char cb[200];
|
||||
std::snprintf(cb, sizeof(cb), TR("merge_confirm_fmt"),
|
||||
fmtAmt(srcAmount()).c_str(), srcCount(), shortAddr(s_to_address).c_str());
|
||||
ImGui::TextWrapped("%s", cb);
|
||||
ImGui::Spacing();
|
||||
}
|
||||
|
||||
bool can_submit = haveDest && connected && !syncing;
|
||||
if (isMerge && s_scope_loaded && srcCount() == 0) can_submit = false;
|
||||
|
||||
float footerBtnW = shieldBtn.width + cancelBtn.width + ImGui::GetStyle().ItemSpacing.x;
|
||||
material::BeginOverlayDialogFooter(footerBtnW, /*drawSeparator=*/false);
|
||||
|
||||
if (!can_submit) ImGui::BeginDisabled();
|
||||
if (material::TactileButton(primaryLabel, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
|
||||
if (s_confirm) { submitOperation(app); }
|
||||
else { s_confirm = true; } // first click → show the confirm summary
|
||||
}
|
||||
if (!can_submit) ImGui::EndDisabled();
|
||||
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
|
||||
if (!connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
|
||||
else if (syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
|
||||
else if (!haveDest) material::Tooltip("%s", TR("shield_select_z"));
|
||||
else if (isMerge && srcCount() == 0) material::Tooltip("%s", TR("merge_no_spendable"));
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(secondaryLabel, ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {
|
||||
if (s_confirm) s_confirm = false; // Back → return to the form
|
||||
else s_open = false; // Cancel → close
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -33,10 +33,16 @@ public:
|
||||
static void showShieldCoinbase(const std::string& fromAddress = "*");
|
||||
|
||||
/**
|
||||
* @brief Show merge to address dialog
|
||||
* @brief Show merge to address dialog (generic — both transparent + shielded sources)
|
||||
*/
|
||||
static void showMerge();
|
||||
|
||||
/**
|
||||
* @brief Show the consolidate-funds flow preset for wallet-bloat reduction (shielded notes).
|
||||
* Used by the large-wallet nudges (Settings banner + alert action).
|
||||
*/
|
||||
static void showConsolidate();
|
||||
|
||||
/**
|
||||
* @brief Render the dialog (call each frame)
|
||||
*/
|
||||
|
||||
@@ -121,7 +121,7 @@ void TransactionDetailsDialog::render(App* app)
|
||||
char txid_buf[128];
|
||||
strncpy(txid_buf, tx.txid.c_str(), sizeof(txid_buf) - 1);
|
||||
txid_buf[sizeof(txid_buf) - 1] = '\0';
|
||||
ImGui::SetNextItemWidth(txidInput.width);
|
||||
ImGui::SetNextItemWidth(txidInput.width); // negative = fill, reserving |width| px for the Copy button (a content-region margin, not raw px — must NOT be dpi-scaled)
|
||||
ImGui::InputText("##TxID", txid_buf, sizeof(txid_buf), ImGuiInputTextFlags_ReadOnly);
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton("Copy##TxID", ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) {
|
||||
@@ -139,13 +139,14 @@ void TransactionDetailsDialog::render(App* app)
|
||||
char addr_buf[512];
|
||||
strncpy(addr_buf, tx.address.c_str(), sizeof(addr_buf) - 1);
|
||||
addr_buf[sizeof(addr_buf) - 1] = '\0';
|
||||
// width is a negative fill-with-right-margin sentinel — keep unscaled; only height is raw px.
|
||||
ImGui::InputTextMultiline("##Address", addr_buf, sizeof(addr_buf),
|
||||
ImVec2(addrInput.width, addrInput.height > 0 ? addrInput.height : 50), ImGuiInputTextFlags_ReadOnly);
|
||||
ImVec2(addrInput.width, (addrInput.height > 0 ? addrInput.height : 50) * Layout::dpiScale()), ImGuiInputTextFlags_ReadOnly);
|
||||
} else {
|
||||
char addr_buf[128];
|
||||
strncpy(addr_buf, tx.address.c_str(), sizeof(addr_buf) - 1);
|
||||
addr_buf[sizeof(addr_buf) - 1] = '\0';
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::SetNextItemWidth(addrInput.width); // negative fill sentinel — must NOT be dpi-scaled
|
||||
ImGui::InputText("##Address", addr_buf, sizeof(addr_buf), ImGuiInputTextFlags_ReadOnly);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
@@ -173,25 +174,18 @@ void TransactionDetailsDialog::render(App* app)
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
// Buttons
|
||||
float button_width = bottomBtn.width;
|
||||
float total_width = button_width * 2 + ImGui::GetStyle().ItemSpacing.x;
|
||||
float start_x = (ImGui::GetWindowWidth() - total_width) / 2.0f;
|
||||
ImGui::SetCursorPosX(start_x);
|
||||
|
||||
// Buttons — centered primary + Close pair via the shared footer helper.
|
||||
// Guard against an empty/whitespace explorer URL so we never open a garbage link.
|
||||
std::string explorerBase = app->settings()->getTxExplorerUrl();
|
||||
bool explorerValid = explorerBase.find_first_not_of(" \t\r\n") != std::string::npos;
|
||||
if (!explorerValid) ImGui::BeginDisabled();
|
||||
if (material::StyledButton(TR("tx_view_explorer"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) {
|
||||
bool doExplorer = false, doClose = false;
|
||||
material::DialogActionFooter(TR("tx_view_explorer"), explorerValid, TR("close"),
|
||||
doExplorer, doClose, bottomBtn.width);
|
||||
if (doExplorer) {
|
||||
std::string url = explorerBase + tx.txid;
|
||||
util::Platform::openUrl(url);
|
||||
}
|
||||
if (!explorerValid) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
if (material::StyledButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) {
|
||||
if (doClose) {
|
||||
s_open = false;
|
||||
}
|
||||
material::EndOverlayDialog();
|
||||
|
||||
@@ -293,11 +293,21 @@ void RenderTransactionsTab(App* app)
|
||||
TR("mined_filter"), TR("chat_filter") };
|
||||
ImGui::Combo("##TxType", &type_filter, types, IM_ARRAYSIZE(types));
|
||||
|
||||
// Sort selector
|
||||
// Sort selector — the sort labels ("Newest first"/localized) run longer than the type
|
||||
// labels, so this combo needs its own width. Size it to the widest localized sort option
|
||||
// measured with the active (default) font — the same font Combo renders with — plus ImGui's
|
||||
// combo chrome (2x horizontal frame padding + the dropdown arrow button, GetFrameHeight()).
|
||||
// CalcTextSize and the style paddings are already DPI-scaled, so this grows at font_scale 1.5
|
||||
// without any hs multiply; clamp to at least the shared type-filter comboW.
|
||||
ImGui::SameLine(0, filterGap);
|
||||
ImGui::SetNextItemWidth(comboW);
|
||||
const char* sorts[] = { TR("sort_date_newest"), TR("sort_date_oldest"),
|
||||
TR("sort_amount_high"), TR("sort_amount_low") };
|
||||
float sortTextW = 0.0f;
|
||||
for (const char* s : sorts) sortTextW = std::max(sortTextW, ImGui::CalcTextSize(s).x);
|
||||
float sortComboW = std::max(comboW,
|
||||
sortTextW + ImGui::GetStyle().FramePadding.x * 2.0f
|
||||
+ ImGui::GetFrameHeight());
|
||||
ImGui::SetNextItemWidth(sortComboW);
|
||||
ImGui::Combo("##TxSort", &s_sort_mode, sorts, IM_ARRAYSIZE(sorts));
|
||||
|
||||
ImGui::SameLine(0, filterGap);
|
||||
@@ -531,32 +541,22 @@ void RenderTransactionsTab(App* app)
|
||||
float startX = ImGui::GetContentRegionMax().x - totalPagW;
|
||||
ImGui::SetCursorPosX(startX);
|
||||
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, Layout::spacingSm());
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_ButtonTextAlign, ImVec2(0.5f, 0.5f));
|
||||
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 15)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 30)));
|
||||
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, 45)));
|
||||
|
||||
// First page
|
||||
// First page — TactileButton (shared glass press, matches the explorer pager)
|
||||
ImGui::BeginDisabled(s_current_page == 0);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW))) {
|
||||
if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||
s_current_page = 0;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Previous page
|
||||
ImGui::BeginDisabled(s_current_page == 0);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW))) {
|
||||
if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||
s_current_page--;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
@@ -580,28 +580,21 @@ void RenderTransactionsTab(App* app)
|
||||
|
||||
// Next page
|
||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW))) {
|
||||
if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||
s_current_page++;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, gap);
|
||||
|
||||
// Last page
|
||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||
ImGui::PushFont(Type().iconSmall());
|
||||
if (ImGui::Button(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW))) {
|
||||
if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||
s_current_page = totalPages - 1;
|
||||
s_expanded_row = -1;
|
||||
}
|
||||
ImGui::PopFont();
|
||||
ImGui::EndDisabled();
|
||||
|
||||
ImGui::PopStyleColor(3);
|
||||
ImGui::PopStyleVar(2);
|
||||
}
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
@@ -631,20 +624,17 @@ void RenderTransactionsTab(App* app)
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
{
|
||||
if (!app->isConnected()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(),
|
||||
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
|
||||
material::DrawEmptyState(ICON_MD_CLOUD_OFF,
|
||||
TR(app->isLiteBuild() ? "lite_no_wallet" : "not_connected"));
|
||||
} else if (state.transactions.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
if (txLoading) {
|
||||
snprintf(buf, sizeof(buf), "%s%s", txLoadingText.c_str(), material::LoadingDots());
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
|
||||
material::DrawEmptyState(ICON_MD_HOURGLASS_EMPTY, buf);
|
||||
} else {
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_transactions"));
|
||||
material::DrawEmptyState(ICON_MD_RECEIPT_LONG, TR("no_transactions"));
|
||||
}
|
||||
} else if (filtered_indices.empty()) {
|
||||
ImGui::Dummy(ImVec2(0, 20));
|
||||
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_matching"));
|
||||
material::DrawEmptyState(ICON_MD_SEARCH_OFF, TR("no_matching"));
|
||||
} else {
|
||||
float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd();
|
||||
float innerW = ImGui::GetContentRegionAvail().x;
|
||||
|
||||
@@ -208,13 +208,20 @@ void ValidateAddressDialog::render(App* app)
|
||||
}
|
||||
} else if (!app->isConnected()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected"));
|
||||
} else {
|
||||
// Pre-interaction placeholder: keeps the results area from reading as a large
|
||||
// dead gap between the Validate/Paste row and the Close button until a result exists.
|
||||
ImGui::Spacing();
|
||||
float cw = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 ts = ImGui::CalcTextSize(TR("validate_results_placeholder"));
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cw - ts.x) * 0.5f);
|
||||
ImGui::TextDisabled("%s", TR("validate_results_placeholder"));
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
// Close button at bottom
|
||||
// Close button at bottom — centered via the shared footer helper (no separator, matching
|
||||
// the prior hand-rolled placement).
|
||||
float button_width = closeBtn.width;
|
||||
ImGui::SetCursorPosX((ImGui::GetWindowWidth() - button_width) / 2.0f);
|
||||
material::BeginOverlayDialogFooter(button_width, /*drawSeparator=*/false);
|
||||
if (material::TactileButton(TR("close"), ImVec2(button_width, 0), S.resolveFont(closeBtn.font))) {
|
||||
s_open = false;
|
||||
}
|
||||
|
||||
@@ -71,9 +71,12 @@ public:
|
||||
const int kMaxVisibleRows = 7;
|
||||
ImFont* nameFont = Type().subtitle1();
|
||||
ImFont* metaFont = Type().caption();
|
||||
// The list is always sized to its MAX height (kMaxVisibleRows) for a consistent modal size — it
|
||||
// does not shrink to fit a few wallets; fewer rows leave empty space, more than 7 scroll.
|
||||
const int visRows = kMaxVisibleRows;
|
||||
// Size the list to the ACTUAL wallet count so a few wallets sit tight against the controls below
|
||||
// instead of stranding ~200px of empty rows before the create/scan prompts; cap at kMaxVisibleRows
|
||||
// (more than that scrolls within the cap). An empty list keeps two rows of height so the centered
|
||||
// empty-state hint (drawn inside the list child) still has room to show.
|
||||
const int numWallets = (int)s_rows.size();
|
||||
const int visRows = numWallets <= 0 ? 2 : std::min(numWallets, kMaxVisibleRows);
|
||||
const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm)
|
||||
const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
|
||||
const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards
|
||||
@@ -200,7 +203,20 @@ public:
|
||||
// create/scan/footer controls so it scrolls internally and those stay visible. Uncapped,
|
||||
// it equals the content height exactly (no spurious scrollbar — the common case).
|
||||
float listHFit = listH;
|
||||
if (capped) listHFit = std::max(walRowH, ImGui::GetContentRegionAvail().y - belowH);
|
||||
if (capped) {
|
||||
// Fill the height left above the pinned controls, but clip on a CLEAN row boundary:
|
||||
// floor the available space to a whole number of card strides (walRowH + cardGap, the
|
||||
// per-card advance from the loop) so the list never ends mid-row. Keep the same bottom
|
||||
// pad as the uncapped listH so the last visible card stays off the clip edge.
|
||||
const float stride = walRowH + cardGap;
|
||||
const float avail = ImGui::GetContentRegionAvail().y - belowH;
|
||||
// N rows occupy N*walRowH + (N-1)*cardGap = N*stride - cardGap; solve for the most whole
|
||||
// rows that fit (a non-negative cast truncates toward zero = floor here), clamped to at
|
||||
// least one so a tiny viewport still shows a row.
|
||||
int nRows = stride > 0.0f ? (int)((avail + cardGap) / stride) : 1;
|
||||
if (nRows < 1) nRows = 1;
|
||||
listHFit = (float)nRows * stride - cardGap + Layout::spacingSm();
|
||||
}
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
// NoScrollWithMouse + ApplySmoothScroll gives the wheel the same eased scrolling as the
|
||||
// Settings page (ApplySmoothScroll handles the wheel itself, so let it own that input).
|
||||
@@ -253,13 +269,13 @@ public:
|
||||
bool hov = ImGui::IsWindowHovered() && ImGui::IsMouseHoveringRect(rMin, rMax);
|
||||
|
||||
// Card background + state
|
||||
GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
|
||||
GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = hov ? 40 : 26; g.borderAlpha = 45;
|
||||
DrawGlassPanel(dl, rMin, rMax, g);
|
||||
if (isCurrent) {
|
||||
dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f);
|
||||
dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f, 0, 1.6f * dp);
|
||||
dl->AddRectFilled(rMin, rMax, WithAlpha(Success(), 16), 10.0f * dp);
|
||||
dl->AddRect(rMin, rMax, WithAlpha(Success(), 130), 10.0f * dp, 0, 1.6f * dp);
|
||||
} else if (hov) {
|
||||
dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
|
||||
dl->AddRect(rMin, rMax, WithAlpha(OnSurface(), 70), 10.0f * dp, 0, 1.0f);
|
||||
}
|
||||
|
||||
const float padX = Layout::spacingMd();
|
||||
@@ -288,17 +304,21 @@ public:
|
||||
// encryption — so absence of a lock never falsely reads as "unencrypted" on a huge wallet.
|
||||
const ProbeResult pres = probeAt(i); // from the frame-consistent snapshot above
|
||||
const bool bLock = pres.probed && pres.encrypted;
|
||||
// Seed-phrase vs legacy. Runtime status (z_exportmnemonic → activeWalletSeedBadge) is
|
||||
// authoritative for the ACTIVE wallet; otherwise the offline probe reads the hdchain
|
||||
// record's fMnemonicSeed flag directly (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy
|
||||
// with no phrase, 0 = couldn't tell) — which, unlike bare HD-record presence, actually
|
||||
// distinguishes the two. seed uses the same 1/2/0 encoding.
|
||||
const int activeBadge = rowActive[i] ? app->activeWalletSeedBadge() : 0;
|
||||
int seed = activeBadge;
|
||||
if (seed == 0 && pres.probed) {
|
||||
if (pres.mnemonic != 0) seed = pres.mnemonic; // read the flag off disk
|
||||
else if (pres.complete && !pres.hdSeed) seed = 2; // no HD records at all → no phrase
|
||||
}
|
||||
// Seed-phrase vs legacy. The offline probe reads the hdchain record's fMnemonicSeed flag
|
||||
// straight off disk (pres.mnemonic: 1 = BIP39 seed phrase, 2 = HD/legacy with no phrase,
|
||||
// 0 = couldn't tell). That is the SAME flag the daemon's IsMnemonicSeed()/z_exportmnemonic
|
||||
// consult, so a definitive read is authoritative and takes precedence. The runtime badge
|
||||
// (activeWalletSeedBadge, reset only on disconnect) is used ONLY when the offline probe
|
||||
// couldn't decide — it must NEVER override a definitive on-disk read, or a stale
|
||||
// HasMnemonic carried from a previously-active mnemonic wallet mislabels a legacy wallet as
|
||||
// a seed-phrase wallet. seed uses the same 1/2/0 encoding.
|
||||
int seed = 0;
|
||||
if (pres.probed && pres.mnemonic != 0)
|
||||
seed = pres.mnemonic; // definitive on-disk flag wins
|
||||
else if (rowActive[i] && app->activeWalletSeedBadge() != 0)
|
||||
seed = app->activeWalletSeedBadge(); // runtime fallback (active row only)
|
||||
else if (pres.probed && pres.complete && !pres.hdSeed)
|
||||
seed = 2; // no HD records at all → no phrase
|
||||
const bool bSeed = (seed == 1);
|
||||
const bool bLegacy = (seed == 2);
|
||||
// seed==0 splits by what the probe DID learn: if it saw HD records we know it's an HD
|
||||
@@ -404,9 +424,10 @@ public:
|
||||
ImGui::Dummy(ImVec2(rowW, walRowH));
|
||||
if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
|
||||
}
|
||||
// Empty-state nudge: the list sits at a fixed max height, so a handful of wallets leave blank
|
||||
// space below. When there's real room to spare, fill it with a subtle centered hint (a folder
|
||||
// glyph + one line) instead of dead space; it's purely decorative — the actions live below.
|
||||
// Empty-state nudge: the list is now sized to the actual wallet count, so a populated list has
|
||||
// no room to spare and this draws nothing (the cards butt up against the controls below). It
|
||||
// fires only when the list is EMPTY (the reserved rows leave room): a subtle centered hint (a
|
||||
// folder glyph + one line) instead of a blank box; purely decorative — the actions live below.
|
||||
{
|
||||
const float remainY = ImGui::GetContentRegionAvail().y;
|
||||
if (remainY > walRowH * 1.6f) {
|
||||
@@ -590,8 +611,9 @@ private:
|
||||
// Each external wallet gets its own STABLE link name derived from its path — so switching between two
|
||||
// of them is a real -wallet=<different name> switch (not a no-op on one shared name), and the wallet
|
||||
// index tracks each separately (correct per-wallet rescan + cached data). Hidden from the list.
|
||||
static constexpr const char* kLinkPrefix = "wallet-ip-";
|
||||
static bool isLinkName(const std::string& n) { return n.rfind(kLinkPrefix, 0) == 0; }
|
||||
// Single source of truth lives in util/wallet_file_probe.h (shared with the offline enumeration helper).
|
||||
static constexpr const char* kLinkPrefix = util::kInPlaceLinkPrefix;
|
||||
static bool isLinkName(const std::string& n) { return util::isInPlaceLinkName(n); }
|
||||
|
||||
// Stable per-target bare link name, e.g. "wallet-ip-1a2b3c4d.dat" (FNV-1a of the absolute path — a
|
||||
// deterministic, cross-platform, cross-run hash, unlike std::hash). Feed it a canonicalOf() path so the
|
||||
@@ -792,6 +814,14 @@ private:
|
||||
} else {
|
||||
const auto pr = util::probeWalletFile(t.first, std::min(budget, kPerFile));
|
||||
res = ProbeResult{ pr.isBerkeleyDB, pr.scanComplete, pr.encrypted, pr.hdSeed };
|
||||
// A cap-truncated btree walk still yields DEFINITIVE positives (a found marker is
|
||||
// authoritative even when the scan didn't finish), so carry what it read — notably
|
||||
// the fMnemonicSeed flag. Otherwise a large wallet probed after the shared budget is
|
||||
// spent loses its seed/legacy classification and the row falls back to the (possibly
|
||||
// stale) runtime badge, mislabelling a legacy wallet as a seed-phrase wallet.
|
||||
if (bt.mnemonicSeed != 0) res.mnemonic = bt.mnemonicSeed;
|
||||
if (bt.hdSeed) res.hdSeed = true;
|
||||
if (bt.encrypted) res.encrypted = true;
|
||||
budget -= std::min(budget, std::max(bt.bytesRead, pr.bytesRead));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -368,7 +368,9 @@ private:
|
||||
|
||||
// ---- Below the info card: verify / stop-mining note + install button (centered, text-fit) ----
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x);
|
||||
Type().textColored(TypeStyle::Caption, (mining || downgrade) ? Warning() : OnSurfaceMedium(), noteStr);
|
||||
ImGui::PopTextWrapPos();
|
||||
ImGui::Spacing();
|
||||
const float bw = ImGui::CalcTextSize(label).x + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f));
|
||||
|
||||
@@ -76,7 +76,7 @@ std::vector<int> bech32HrpExpand(const std::string& hrp)
|
||||
|
||||
} // namespace
|
||||
|
||||
bool isValidBase58Check(const std::string& s)
|
||||
bool decodeBase58Check(const std::string& s, std::vector<std::uint8_t>& payloadOut)
|
||||
{
|
||||
if (s.size() < 5 || s.size() > 256) return false;
|
||||
std::vector<std::uint8_t> data;
|
||||
@@ -88,7 +88,15 @@ bool isValidBase58Check(const std::string& s)
|
||||
unsigned char h2[crypto_hash_sha256_BYTES];
|
||||
crypto_hash_sha256(h1, data.data(), payloadLen);
|
||||
crypto_hash_sha256(h2, h1, sizeof(h1));
|
||||
return std::memcmp(h2, data.data() + payloadLen, 4) == 0;
|
||||
if (std::memcmp(h2, data.data() + payloadLen, 4) != 0) return false;
|
||||
payloadOut.assign(data.begin(), data.begin() + payloadLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isValidBase58Check(const std::string& s)
|
||||
{
|
||||
std::vector<std::uint8_t> payload;
|
||||
return decodeBase58Check(s, payload);
|
||||
}
|
||||
|
||||
bool isValidBech32(const std::string& s)
|
||||
@@ -127,5 +135,39 @@ bool isValidBech32(const std::string& s)
|
||||
return bech32Polymod(combined) == 1; // original Bech32 constant (Sapling, not Bech32m)
|
||||
}
|
||||
|
||||
std::string bech32Hrp(const std::string& s)
|
||||
{
|
||||
if (!isValidBech32(s)) return {};
|
||||
// isValidBech32 already rejected mixed case and guaranteed a non-empty HRP before the
|
||||
// final '1' separator, so lower-casing and splitting there recovers the HRP verbatim.
|
||||
std::string lower(s);
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
const std::size_t sep = lower.rfind('1');
|
||||
if (sep == std::string::npos) return {};
|
||||
return lower.substr(0, sep);
|
||||
}
|
||||
|
||||
bool isTransparentAddress(const std::string& s)
|
||||
{
|
||||
std::vector<std::uint8_t> payload;
|
||||
// version byte (1) + hash160 (20) = 21 bytes, checksum stripped. Covers P2PKH ('R', v60) and
|
||||
// P2SH/multisig ('b', v85); the daemon vets the exact version byte for the active network.
|
||||
return decodeBase58Check(s, payload) && payload.size() == 21;
|
||||
}
|
||||
|
||||
bool isShieldedAddress(const std::string& s)
|
||||
{
|
||||
const std::string hrp = bech32Hrp(s);
|
||||
return hrp == "zs" // mainnet Sapling payment address
|
||||
|| hrp == "ztestsapling" // testnet
|
||||
|| hrp == "zregtestsapling"; // regtest
|
||||
}
|
||||
|
||||
bool isValidRecipientAddress(const std::string& s)
|
||||
{
|
||||
return isTransparentAddress(s) || isShieldedAddress(s);
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user