diff --git a/.gitignore b/.gitignore index 134ccab..47b4daa 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..65b951c --- /dev/null +++ b/CHANGELOG.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 " 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`. diff --git a/CMakeLists.txt b/CMakeLists.txt index 5570548..70df660 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,7 +15,7 @@ if(APPLE) endif() project(ObsidianDragon - VERSION 2.0.0 + VERSION 2.0.1 LANGUAGES C CXX DESCRIPTION "DragonX Cryptocurrency Wallet" ) @@ -26,7 +26,7 @@ set(DRAGONX_VERSION_SUFFIX "") # ObsidianDragonLite is versioned INDEPENDENTLY of the full-node app above. The active variant's # version flows to the generated header, the Windows .rc/manifest, and build.sh's release names via # DRAGONX_APP_VERSION* (resolved in the lite/full block below). -set(DRAGONX_LITE_VERSION "1.0.0") +set(DRAGONX_LITE_VERSION "1.1.0") set(DRAGONX_LITE_VERSION_SUFFIX "") # C++17 standard @@ -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 @@ -521,6 +521,8 @@ set(APP_SOURCES src/ui/windows/settings_window.cpp src/ui/pages/settings_page.cpp src/ui/windows/about_dialog.cpp + src/ui/windows/faq_dialog.cpp + src/ui/windows/faq_content.cpp src/ui/windows/key_export_dialog.cpp src/ui/windows/transaction_details_dialog.cpp src/ui/windows/qr_popup_dialog.cpp @@ -544,6 +546,7 @@ set(APP_SOURCES src/util/async_task_manager.cpp src/util/amount_format.cpp src/util/address_validation.cpp + src/util/seed_phrase.cpp src/util/base64.cpp src/util/single_instance.cpp src/util/i18n.cpp @@ -656,6 +659,8 @@ set(APP_HEADERS src/ui/windows/console_tab_helpers.h src/ui/windows/settings_window.h src/ui/windows/about_dialog.h + src/ui/windows/faq_dialog.h + src/ui/windows/faq_content.h src/ui/windows/key_export_dialog.h src/ui/windows/transaction_details_dialog.h src/ui/windows/qr_popup_dialog.h @@ -1074,6 +1079,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 +1153,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 +1161,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 diff --git a/build.sh b/build.sh index d8681a4..e76cc82 100755 --- a/build.sh +++ b/build.sh @@ -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[@]}" @@ -762,8 +842,16 @@ HDR cmake --build . -j "$JOBS" [[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; } + # Strip the app exe — the Linux and macOS release paths already strip theirs, and even the Windows + # helper (dragonx-wallet-rebuild.exe) is stripped, but the main app exe was shipping unstripped + # (~5MB of symbols on the full node, ~19MB on the params-heavy lite build). Best-effort. + x86_64-w64-mingw32-strip --strip-all "bin/${APP_BASENAME}.exe" 2>/dev/null \ + || warn " strip unavailable — shipping unstripped ${APP_BASENAME}.exe" info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)" + # A full-node release MUST include the recovery helper — fail loudly, never ship without it. + require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb" + # ── Package: release/windows/ ──────────────────────────────────────────── # Remove only THIS variant's prior artifacts so full-node and lite releases coexist here. mkdir -p "$out" @@ -779,6 +867,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 +1125,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/) — 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 +1202,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 diff --git a/docs/daemon-startup-hardening.md b/docs/daemon-startup-hardening.md new file mode 100644 index 0000000..d4a2818 --- /dev/null +++ b/docs/daemon-startup-hardening.md @@ -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_` (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 `/.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. diff --git a/docs/wallet-hardening.md b/docs/wallet-hardening.md new file mode 100644 index 0000000..79994ad --- /dev/null +++ b/docs/wallet-hardening.md @@ -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` 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(/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(/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(/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(/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 ". It now reads "**Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy**: ", 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.) diff --git a/res/fonts/NotoSansCJK-Subset.ttf b/res/fonts/NotoSansCJK-Subset.ttf index 6f3f92d..f751d67 100644 Binary files a/res/fonts/NotoSansCJK-Subset.ttf and b/res/fonts/NotoSansCJK-Subset.ttf differ diff --git a/res/lang/de.json b/res/lang/de.json index 6a3d24d..2b29fd0 100644 --- a/res/lang/de.json +++ b/res/lang/de.json @@ -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", @@ -56,6 +60,80 @@ "amount_label": "Betrag:", "animate_avatars": "Avatare animieren", "appearance": "ERSCHEINUNGSBILD", + "appx_back": "Zurück", + "appx_back_up_seed_phrase_title": "Sichern Sie Ihre Seed-Phrase", + "appx_birthday_block_height": "Geburtstag (Blockhöhe): %llu — sichern Sie auch dies.", + "appx_blockchain_data_deleted": "Blockchain-Daten gelöscht (%d Elemente). Der Daemon wird neu gestartet, um sich erneut mit dem Netzwerk zu synchronisieren.", + "appx_blockchain_maintenance_in_progress": "Eine Blockchain-Wartung läuft bereits.", + "appx_blockchain_rescan_complete": "Blockchain-Neuscan abgeschlossen", + "appx_bootstrap_complete_reconciling": "Bootstrap abgeschlossen — Ihr Wallet wird mit den neuen Chain-Daten abgeglichen.", + "appx_cancel": "Abbrechen", + "appx_cleaning_up": "Aufräumen...", + "appx_confirm_your_backup": "Backup bestätigen", + "appx_copied_clipboard_autoclears": "Kopiert — Zwischenablage wird in 45s automatisch geleert", + "appx_copy": "Kopieren", + "appx_could_not_start_restore": "Wiederherstellung konnte nicht gestartet werden", + "appx_create_failed_prefix": "Erstellen fehlgeschlagen: ", + "appx_creating_your_wallet": "Ihr Wallet wird erstellt…", + "appx_daemon_error": "Daemon-Fehler", + "appx_daemon_reinstall_in_progress": "Die Daemon-Neuinstallation läuft bereits.", + "appx_disconnecting": "Verbindung wird getrennt...", + "appx_done": "Fertig", + "appx_dragonxd_output": "dragonxd-Ausgabe", + "appx_encrypting_wallet": "Wallet wird verschlüsselt...", + "appx_fullnode_lifecycle_unavailable_lite": "Full-Node-Lebenszyklusaktionen sind im Lite-Build nicht verfügbar", + "appx_installing_bundled_daemon": "Mitgelieferter Daemon wird installiert — der Node stoppt, aktualisiert und startet neu...", + "appx_invalid_payment_uri_prefix": "Ungültige Zahlungs-URI: ", + "appx_ive_written_it_down": "Ich habe sie notiert", + "appx_keep_node_running_and_quit": "Node weiterlaufen lassen & beenden", + "appx_last_block_n": "Letzter Block: %d", + "appx_last_used_wallet_not_found_prefix": "Ihre zuletzt verwendete Wallet-Datei (", + "appx_last_used_wallet_not_found_suffix": ") wurde nicht gefunden — stattdessen wurde das Standard-Wallet geöffnet. Wenn Sie sie verschoben haben, stellen Sie sie wieder her und wechseln Sie über die Wallet-Liste zurück.", + "appx_low_spec_mode_disabled": "Low-Spec-Modus deaktiviert", + "appx_low_spec_mode_enabled": "Low-Spec-Modus aktiviert", + "appx_miner_stopped_prefix": "Miner gestoppt: ", + "appx_miner_stopped_unexpectedly": "Miner wurde unerwartet gestoppt.", + "appx_n_min_n_sec": "%d Min %d Sek", + "appx_n_seconds": "%d Sekunden", + "appx_no_bundled_daemon_to_install": "Dieser Build enthält keinen mitgelieferten Daemon zur Installation", + "appx_no_embedded_daemon_to_install": "Dieser Build enthält keinen eingebetteten Daemon zur Installation", + "appx_node_busy_restarting": "Der Node ist mit dem Neustart beschäftigt — versuchen Sie es gleich erneut.", + "appx_node_rebuilding_witness_cache": "Node baut seinen Witness-Cache neu auf", + "appx_not_next_word": " — das ist nicht das nächste Wort", + "appx_payment_request_loaded": "Zahlungsanforderung geladen", + "appx_pool_miner_connected_and_hashing": "Pool-Miner verbunden und aktiv.", + "appx_progress_n_of_n": "Fortschritt: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Sapling-Note-Witnesses werden neu aufgebaut…", + "appx_rebuilding_witness_cache_blocks_left": "Witness-Cache wird neu aufgebaut %.0f%% — %d Blöcke verbleiben", + "appx_rebuilding_witness_cache_pct": "Witness-Cache wird neu aufgebaut %.0f%%", + "appx_recovery_phrase_word_count": "Die Wiederherstellungsphrase sollte 24 Wörter haben — Sie haben %d.", + "appx_restarting_daemon_rescan_flag": "Daemon wird mit -rescan-Flag neu gestartet...", + "appx_restarting_daemon_zapwallettxes": "Daemon wird mit -zapwallettxes=2 neu gestartet (Wallet-Reparatur)...", + "appx_restoring_your_wallet": "Ihr Wallet wird wiederhergestellt…", + "appx_seed_backup_warning": "Diese 24 Wörter sind die EINZIGE Möglichkeit, Ihr Wallet wiederherzustellen. Notieren Sie sie in der richtigen Reihenfolge, bewahren Sie sie offline auf und geben Sie sie niemals weiter. Wenn Sie sie verlieren, sind Ihre Guthaben für immer verloren.", + "appx_seed_not_backed_up_warning": "Sie haben Ihren Seed nicht gesichert — Guthaben könnten verloren gehen. Trotzdem überspringen?", + "appx_sending_stop_command_to_daemon": "Stopp-Befehl wird an Daemon gesendet...", + "appx_setting_initial_sapling_witnesses": "Initiale Sapling-Witnesses werden gesetzt %.0f%%", + "appx_shutdown_complete": "Herunterfahren abgeschlossen", + "appx_simple_background_disabled": "Einfacher Hintergrund deaktiviert", + "appx_simple_background_enabled": "Einfacher Hintergrund aktiviert", + "appx_skip": "Überspringen", + "appx_skip_anyway": "Trotzdem überspringen", + "appx_still_status_prefix": "Weiterhin \"", + "appx_still_status_suffix": "\" — ein erzwungenes Beenden jetzt kann die Chain-Daten beschädigen.", + "appx_stop_anyway_and_quit": "Trotzdem stoppen & beenden", + "appx_stopping_daemon_deleting_blockchain": "Daemon wird gestoppt und Blockchain-Daten werden gelöscht...", + "appx_stopping_node_discards_rebuild": "Wenn Sie den Node jetzt stoppen, wird der laufende Neuaufbau verworfen und beim nächsten Öffnen des Wallets neu gestartet (mehrere Minuten). Sie können den Node stattdessen weiterlaufen lassen.", + "appx_stopping_pool_miner": "Pool-Miner wird gestoppt...", + "appx_syncing_pct_block_n_of_n": "Synchronisierung %.1f%% — Block %d / %d", + "appx_tap_words_in_order": "Tippen Sie die Wörter in der richtigen Reihenfolge an, um zu bestätigen, dass Sie sie gespeichert haben.", + "appx_theme_effects_disabled": "Design-Effekte deaktiviert", + "appx_theme_effects_enabled": "Design-Effekte aktiviert", + "appx_theme_prefix": "Design: ", + "appx_use_settings_restart_daemon_hint": "Verwenden Sie Einstellungen > Daemon neu starten, um es erneut zu versuchen", + "appx_waiting_for_daemon_to_encrypt_wallet": "Warten, bis der Daemon das Wallet verschlüsselt...", + "appx_wallet_created_and_backed_up": "Wallet erstellt und gesichert.", + "appx_wallet_open_failed_prefix": "Öffnen des Wallets fehlgeschlagen: ", "auto_shield": "Mining automatisch abschirmen", "av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:", "av_open_security": "Windows-Sicherheit öffnen", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "Markt", + "baltab_market_price_4dp": "Markt: $%.4f", + "baltab_market_price_8dp": "Markt: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24h", + "baltab_pct_of_total_zaddr": "%.0f%% des Gesamtbetrags · %d Z-addr", + "baltab_shielded": "Geschützt", + "baltab_shielded_amount": "Geschützt %.8f", + "baltab_t_addresses_count": "%d T-Adressen", + "baltab_total_balance": "Gesamtguthaben", + "baltab_transparent": "Transparent", + "baltab_transparent_amount": "Transparent %.8f", "ban": "Sperren", "banned_peers": "Gesperrte Peers", "block": "Block", @@ -128,6 +223,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:", @@ -403,6 +499,7 @@ "contacts_shape_square": "Quadrat", "contacts_shape_tab": "Reiter", "copied": "Kopiert!", + "copied_to_clipboard": "In die Zwischenablage kopiert", "copy": "Kopieren", "copy_address": "Vollständige Adresse kopieren", "copy_error": "Fehler kopieren", @@ -415,6 +512,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 +535,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 +548,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 +581,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...", @@ -566,6 +679,63 @@ "general": "Allgemein", "generating": "Wird generiert", "go_to_receive": "Zum Empfangen", + "grpa_current_block_paren": "(Aktuell: %d)", + "grpa_days_ago": "vor %lld Tagen", + "grpa_dbg_addrman": "Verfolgung und Verwaltung von Peer-Adressen", + "grpa_dbg_alert": "Meldungen des Alarmsystems", + "grpa_dbg_bench": "Benchmark-Zeiten für Operationen", + "grpa_dbg_coindb": "Lese-/Schreiboperationen der Coin-Datenbank", + "grpa_dbg_db": "Berkeley-DB-Operationen", + "grpa_dbg_estimatefee": "Algorithmus zur Gebührenschätzung", + "grpa_dbg_http": "Aktivität des HTTP-RPC-Servers", + "grpa_dbg_libevent": "Libevent-Netzwerkbibliothek", + "grpa_dbg_lock": "Debugging von Lock-Konflikten", + "grpa_dbg_mempool": "Aktivität des Transaktions-Mempools", + "grpa_dbg_net": "Netzwerkverbindungen und -nachrichten", + "grpa_dbg_paymentdisclosure": "Zahlungsoffenlegungsprotokoll", + "grpa_dbg_pow": "Proof-of-Work-Mining-Aktivität", + "grpa_dbg_proxy": "SOCKS5-Proxy-Verbindungen", + "grpa_dbg_prune": "Block-Pruning-Operationen", + "grpa_dbg_rand": "Zufallszahlengenerierung", + "grpa_dbg_reindex": "Fortschritt der Blockchain-Neuindizierung", + "grpa_dbg_rpc": "Verarbeitung von RPC-Befehlen", + "grpa_dbg_selectcoins": "Coin-Auswahl für Transaktionen", + "grpa_dbg_tor": "Tor-Integration und Circuit-Informationen", + "grpa_dbg_zmq": "ZeroMQ-Benachrichtigungssystem", + "grpa_dbg_zrpc": "Geschützte (z-addr) RPC-Operationen", + "grpa_enter_private_key_to_import": "Geben Sie einen privaten Schlüssel zum Importieren ein.", + "grpa_error_prefix": "Fehler: ", + "grpa_hr_ago": "vor %lld Std", + "grpa_invalid_response_from_daemon": "Ungültige Antwort vom Daemon", + "grpa_invalid_suffix": " (ungültig)", + "grpa_min_ago": "vor %lld Min", + "grpa_sec_ago": "vor %lld Sek", + "grpa_seed_demo_chat": "Seed-Demo-Chat", + "grpa_showing_first_100_of": "... die ersten 100 von %d werden angezeigt", + "grpa_tab_about": "Über", + "grpa_tab_appearance": "Darstellung", + "grpa_tab_backup_data": "Backup & Daten", + "grpa_tab_chat": "Chat", + "grpa_tab_explorer": "Explorer", + "grpa_tab_node_security": "Node & Sicherheit", + "grpa_tab_wallet": "Wallet", + "grpa_unexpected_getblockhash_result": "unerwartetes getblockhash-Ergebnis", + "grpb_copy": "Kopieren", + "grpb_max": "Max", + "grpb_new_badge_suffix": " [NEU]", + "grpb_preview_msg_payment_through": "Ist die Zahlung durchgegangen? 🙂", + "grpb_preview_msg_sending_rest": "Sende jetzt den Rest 👍", + "grpb_preview_msg_yep_confirmed": "Ja — gerade bestätigt ✅", + "grpb_selected_suffix": "\n(ausgewählt)", + "grpb_tooltip_address_balance": "%s\nGuthaben: %.8f %s%s", + "grpb_undo_clear": "Löschen rückgängig", + "grpc_benchmark_inconclusive": "Benchmark nicht aussagekräftig: es wurden keine Hashrate-Werte aufgezeichnet. Prüfen Sie die Pool-Verbindung und versuchen Sie es erneut.", + "grpc_benchmark_takes_secs": "Der Benchmark dauert ~%ds und unterbricht das Mining. Erneut klicken zum Starten.", + "grpc_bootstrap_failed": "Bootstrap fehlgeschlagen", + "grpc_bootstrap_not_initialized": "Bootstrap nicht initialisiert", + "grpc_hashrate_fee": "%s %s%% Gebühr", + "grpc_key_not_available": "Schlüssel für diese Adresse nicht verfügbar", + "grpc_na": "N/V", "height": "Höhe", "help": "Hilfe", "hidden_tag": " (versteckt)", @@ -640,6 +810,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 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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 +1345,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 +1362,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,12 +1376,52 @@ "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.", "screenshot_sweep_full": "Vollständiger UI-Durchlauf", "search_icons": "Symbole suchen...", "search_placeholder": "Suchen...", + "sec_changing_passphrase": "Passphrase wird geändert...", + "sec_changing_pin": "PIN wird geändert...", + "sec_couldnt_lock_wallet": "Wallet konnte nicht gesperrt werden — es ist weiterhin entsperrt. Prüfen Sie die Daemon-Verbindung.", + "sec_encrypted_backup_suffix": "\nVerschlüsseltes Backup: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "Wallet wird verschlüsselt...", + "sec_encryption_did_not_complete": "Die Wallet-Verschlüsselung wurde nicht abgeschlossen — Ihr Wallet ist NICHT verschlüsselt. Öffnen Sie die Einstellungen, um die Verschlüsselung abzuschließen.", + "sec_encryption_failed_prefix": "Verschlüsselung fehlgeschlagen: ", + "sec_failed_prefix": "Fehlgeschlagen: ", + "sec_failed_to_create_vault": "Tresor konnte nicht erstellt werden", + "sec_importing_keys_rescanning": "Schlüssel werden importiert & Blockchain neu gescannt — Wallet ist währenddessen nutzbar", + "sec_incorrect_current_pin": "Aktuelle PIN falsch", + "sec_incorrect_passphrase_decrypt": "Falsche Passphrase", + "sec_incorrect_passphrase_pin_setup": "Falsche Passphrase", + "sec_incorrect_pin_remove": "Falsche PIN", + "sec_internal_error_change_pin": "Interner Fehler", + "sec_internal_error_remove_pin": "Interner Fehler", + "sec_mode_passphrase": " Passphrase", + "sec_not_connected_to_daemon": "Nicht mit Daemon verbunden", + "sec_not_connected_to_daemon_pin": "Nicht mit Daemon verbunden", + "sec_passphrase_changed_successfully": "Passphrase erfolgreich geändert", + "sec_pin_changed_successfully": "PIN erfolgreich geändert", + "sec_pin_removed": "PIN entfernt", + "sec_pin_set_successfully": "PIN erfolgreich festgelegt", + "sec_restart_daemon_for_encryption": "Bitte starten Sie Ihren Daemon neu, damit die Verschlüsselung wirksam wird.", + "sec_too_many_attempts_wait": "Zu viele Versuche. Warten Sie %.0f Sekunden...", + "sec_total_elapsed_fmt": "Gesamtdauer: %dm %02ds", + "sec_unlock_button": "Entsperren", + "sec_unlock_failed_prefix": "Entsperren fehlgeschlagen: ", + "sec_unlocking_fmt": "Entsperren%s", + "sec_use_passphrase_instead": "Stattdessen Passphrase verwenden", + "sec_use_pin_instead": "Stattdessen PIN verwenden", + "sec_verifying_passphrase": "Passphrase wird überprüft...", + "sec_verifying_pin": "PIN wird überprüft...", + "sec_wallet_decrypted_all_keys_imported": "Wallet erfolgreich entschlüsselt! Alle Schlüssel importiert.", + "sec_wallet_encrypted_and_pin_set": "Wallet verschlüsselt & PIN festgelegt", + "sec_wallet_encrypted_but_pin_vault_failed": "Wallet verschlüsselt, aber PIN-Tresor fehlgeschlagen", + "sec_wallet_encrypted_restarting_daemon": "Wallet verschlüsselt. Daemon wird neu gestartet...", + "sec_wallet_encrypted_successfully": "Wallet erfolgreich verschlüsselt", + "sec_wallet_locked_title": "Wallet gesperrt", "security": "SICHERHEIT", "seed_backup_button": "Wiederherstellungsphrase", "seed_backup_close": "Schließen", @@ -1246,6 +1480,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 +1500,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 +1520,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", @@ -1397,6 +1643,17 @@ "sweep_to": "Gefegt an:", "sweep_toggle": "In meine Wallet fegen (Schlüssel nicht behalten)", "sweep_tx": "Transaktion:", + "swin_connection_failed": "Verbindung fehlgeschlagen: ", + "swin_connection_successful": "Verbindung erfolgreich!\ndragonxd-Version: ", + "swin_invalid_suffix": " (ungültig)", + "swin_no_history_file_found": "Keine Verlaufsdatei gefunden", + "swin_rescan_failed": "Neuscan fehlgeschlagen: ", + "swin_rescan_started_from_block": "Neuscan gestartet ab Block ", + "swin_rescan_to": " bis ", + "swin_rpc_client_not_initialized": "RPC-Client nicht initialisiert", + "swin_settings_saved": "Einstellungen gespeichert", + "swin_theme_list_refreshed": "Design-Liste aktualisiert", + "swin_ztx_history_cleared": "Z-Transaktionsverlauf gelöscht", "switch_corrupt_body": "Diese Wallet scheint beschädigt zu sein – der Knoten konnte sie nicht öffnen. Aus einem Backup wiederherstellen, neu erstellen oder eine Reparatur versuchen.", "switch_corrupt_repair": "Reparatur versuchen (Salvage)", "switch_progress_background": "Im Hintergrund fortsetzen", @@ -1421,6 +1678,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 +1693,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 +1717,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 +1730,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 +1780,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 +1819,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 +1872,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 +2013,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", diff --git a/res/lang/es.json b/res/lang/es.json index aadc861..8be75dc 100644 --- a/res/lang/es.json +++ b/res/lang/es.json @@ -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", @@ -56,6 +60,80 @@ "amount_label": "Cantidad:", "animate_avatars": "Animar avatares", "appearance": "APARIENCIA", + "appx_back": "Atrás", + "appx_back_up_seed_phrase_title": "Haz una copia de tu frase semilla", + "appx_birthday_block_height": "Fecha de creación (altura de bloque): %llu — guárdala también.", + "appx_blockchain_data_deleted": "Datos de la cadena de bloques eliminados (%d elementos). El daemon se está reiniciando para volver a sincronizar desde la red.", + "appx_blockchain_maintenance_in_progress": "Ya hay una operación de mantenimiento de la cadena de bloques en curso.", + "appx_blockchain_rescan_complete": "Reescaneo de la cadena de bloques completado", + "appx_bootstrap_complete_reconciling": "Arranque inicial completado; reconciliando tu cartera con los nuevos datos de la cadena.", + "appx_cancel": "Cancelar", + "appx_cleaning_up": "Limpiando...", + "appx_confirm_your_backup": "Confirma tu copia de seguridad", + "appx_copied_clipboard_autoclears": "Copiado; el portapapeles se borra automáticamente en 45 s", + "appx_copy": "Copiar", + "appx_could_not_start_restore": "No se pudo iniciar la restauración", + "appx_create_failed_prefix": "Error al crear: ", + "appx_creating_your_wallet": "Creando tu cartera…", + "appx_daemon_error": "Error del daemon", + "appx_daemon_reinstall_in_progress": "La reinstalación del daemon ya está en curso.", + "appx_disconnecting": "Desconectando...", + "appx_done": "Listo", + "appx_dragonxd_output": "Salida de dragonxd", + "appx_encrypting_wallet": "Cifrando la cartera...", + "appx_fullnode_lifecycle_unavailable_lite": "Las acciones de ciclo de vida del nodo completo no están disponibles en la versión lite", + "appx_installing_bundled_daemon": "Instalando el daemon incluido; el nodo se detendrá, se actualizará y se reiniciará...", + "appx_invalid_payment_uri_prefix": "URI de pago no válida: ", + "appx_ive_written_it_down": "Ya la anoté", + "appx_keep_node_running_and_quit": "Mantener el nodo y salir", + "appx_last_block_n": "Último bloque: %d", + "appx_last_used_wallet_not_found_prefix": "Tu último archivo de cartera usado (", + "appx_last_used_wallet_not_found_suffix": ") no se encontró; se abrió la cartera predeterminada en su lugar. Si lo moviste, restáuralo y vuelve a él desde la lista de carteras.", + "appx_low_spec_mode_disabled": "Modo de bajos recursos desactivado", + "appx_low_spec_mode_enabled": "Modo de bajos recursos activado", + "appx_miner_stopped_prefix": "Minero detenido: ", + "appx_miner_stopped_unexpectedly": "El minero se detuvo inesperadamente.", + "appx_n_min_n_sec": "%d min %d s", + "appx_n_seconds": "%d segundos", + "appx_no_bundled_daemon_to_install": "Esta versión no incluye un daemon para instalar", + "appx_no_embedded_daemon_to_install": "Esta versión no incluye un daemon integrado para instalar", + "appx_node_busy_restarting": "El nodo está ocupado reiniciándose; inténtalo de nuevo en un momento.", + "appx_node_rebuilding_witness_cache": "El nodo está reconstruyendo su caché de testigos", + "appx_not_next_word": " — esa no es la siguiente palabra", + "appx_payment_request_loaded": "Solicitud de pago cargada", + "appx_pool_miner_connected_and_hashing": "Minero de pool conectado y calculando hashes.", + "appx_progress_n_of_n": "Progreso: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Reconstruyendo testigos de notas Sapling…", + "appx_rebuilding_witness_cache_blocks_left": "Reconstruyendo la caché de testigos %.0f%% — %d bloques restantes", + "appx_rebuilding_witness_cache_pct": "Reconstruyendo la caché de testigos %.0f%%", + "appx_recovery_phrase_word_count": "La frase de recuperación debe tener 24 palabras; tú tienes %d.", + "appx_restarting_daemon_rescan_flag": "Reiniciando el daemon con la opción -rescan...", + "appx_restarting_daemon_zapwallettxes": "Reiniciando el daemon con -zapwallettxes=2 (reparación de cartera)...", + "appx_restoring_your_wallet": "Restaurando tu cartera…", + "appx_seed_backup_warning": "Estas 24 palabras son la ÚNICA forma de restaurar tu cartera. Anótalas en orden, guárdalas sin conexión y nunca las compartas. Si las pierdes, tus fondos se perderán para siempre.", + "appx_seed_not_backed_up_warning": "No has hecho una copia de tu semilla; podrías perder tus fondos. ¿Omitir de todos modos?", + "appx_sending_stop_command_to_daemon": "Enviando el comando de parada al daemon...", + "appx_setting_initial_sapling_witnesses": "Estableciendo testigos Sapling iniciales %.0f%%", + "appx_shutdown_complete": "Apagado completado", + "appx_simple_background_disabled": "Fondo simple desactivado", + "appx_simple_background_enabled": "Fondo simple activado", + "appx_skip": "Omitir", + "appx_skip_anyway": "Omitir de todos modos", + "appx_still_status_prefix": "Aún \"", + "appx_still_status_suffix": "\" — forzar el cierre ahora podría dañar los datos de la cadena.", + "appx_stop_anyway_and_quit": "Detener de todos modos y salir", + "appx_stopping_daemon_deleting_blockchain": "Deteniendo el daemon y eliminando los datos de la cadena de bloques...", + "appx_stopping_node_discards_rebuild": "Detener el nodo ahora descarta la reconstrucción en curso y la reinicia (varios minutos) la próxima vez que abras la cartera. También puedes dejar el nodo en ejecución.", + "appx_stopping_pool_miner": "Deteniendo el minero de pool...", + "appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloque %d / %d", + "appx_tap_words_in_order": "Toca las palabras en el orden correcto para confirmar que las guardaste.", + "appx_theme_effects_disabled": "Efectos de tema desactivados", + "appx_theme_effects_enabled": "Efectos de tema activados", + "appx_theme_prefix": "Tema: ", + "appx_use_settings_restart_daemon_hint": "Usa Ajustes > Reiniciar daemon para intentarlo de nuevo", + "appx_waiting_for_daemon_to_encrypt_wallet": "Esperando a que el daemon cifre la cartera...", + "appx_wallet_created_and_backed_up": "Cartera creada y respaldada.", + "appx_wallet_open_failed_prefix": "Error al abrir la cartera: ", "auto_shield": "Auto-proteger minería", "av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:", "av_open_security": "Abrir Seguridad de Windows", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "Mercado", + "baltab_market_price_4dp": "Mercado: $%.4f", + "baltab_market_price_8dp": "Mercado: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24h", + "baltab_pct_of_total_zaddr": "%.0f%% del total · %d Z-addr", + "baltab_shielded": "Blindado", + "baltab_shielded_amount": "Blindado %.8f", + "baltab_t_addresses_count": "%d T-addresses", + "baltab_total_balance": "Saldo total", + "baltab_transparent": "Transparente", + "baltab_transparent_amount": "Transparente %.8f", "ban": "Bloquear", "banned_peers": "Nodos Bloqueados", "block": "Bloque", @@ -128,6 +223,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:", @@ -403,6 +499,7 @@ "contacts_shape_square": "Cuadrado", "contacts_shape_tab": "Pestaña", "copied": "¡Copiado!", + "copied_to_clipboard": "Copiado al portapapeles", "copy": "Copiar", "copy_address": "Copiar Dirección Completa", "copy_error": "Copiar Error", @@ -415,6 +512,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 +535,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 +548,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 +581,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...", @@ -566,6 +679,63 @@ "general": "General", "generating": "Generando", "go_to_receive": "Ir a Recibir", + "grpa_current_block_paren": "(Actual: %d)", + "grpa_days_ago": "hace %lld días", + "grpa_dbg_addrman": "Seguimiento y gestión de direcciones de pares", + "grpa_dbg_alert": "Mensajes del sistema de alertas", + "grpa_dbg_bench": "Tiempos de referencia de las operaciones", + "grpa_dbg_coindb": "Operaciones de lectura/escritura de la base de datos de monedas", + "grpa_dbg_db": "Operaciones de Berkeley DB", + "grpa_dbg_estimatefee": "Algoritmo de estimación de comisiones", + "grpa_dbg_http": "Actividad del servidor RPC HTTP", + "grpa_dbg_libevent": "Biblioteca de red Libevent", + "grpa_dbg_lock": "Depuración de contención de bloqueos", + "grpa_dbg_mempool": "Actividad del pool de memoria de transacciones", + "grpa_dbg_net": "Conexiones y mensajes de red", + "grpa_dbg_paymentdisclosure": "Protocolo de divulgación de pagos", + "grpa_dbg_pow": "Actividad de minería por prueba de trabajo", + "grpa_dbg_proxy": "Conexiones de proxy SOCKS5", + "grpa_dbg_prune": "Operaciones de poda de bloques", + "grpa_dbg_rand": "Generación de números aleatorios", + "grpa_dbg_reindex": "Progreso de reindexación de la cadena de bloques", + "grpa_dbg_rpc": "Procesamiento de comandos RPC", + "grpa_dbg_selectcoins": "Selección de monedas para transacciones", + "grpa_dbg_tor": "Integración de Tor e información de circuitos", + "grpa_dbg_zmq": "Sistema de notificaciones ZeroMQ", + "grpa_dbg_zrpc": "Operaciones RPC blindadas (z-addr)", + "grpa_enter_private_key_to_import": "Introduce una clave privada para importar.", + "grpa_error_prefix": "Error: ", + "grpa_hr_ago": "hace %lld h", + "grpa_invalid_response_from_daemon": "Respuesta no válida del daemon", + "grpa_invalid_suffix": " (no válido)", + "grpa_min_ago": "hace %lld min", + "grpa_sec_ago": "hace %lld s", + "grpa_seed_demo_chat": "Chat de demostración con semilla", + "grpa_showing_first_100_of": "... mostrando los primeros 100 de %d", + "grpa_tab_about": "Acerca de", + "grpa_tab_appearance": "Apariencia", + "grpa_tab_backup_data": "Copia de seguridad y datos", + "grpa_tab_chat": "Chat", + "grpa_tab_explorer": "Explorador", + "grpa_tab_node_security": "Nodo y seguridad", + "grpa_tab_wallet": "Cartera", + "grpa_unexpected_getblockhash_result": "resultado inesperado de getblockhash", + "grpb_copy": "Copiar", + "grpb_max": "Máx.", + "grpb_new_badge_suffix": " [NUEVO]", + "grpb_preview_msg_payment_through": "¿Se realizó el pago? 🙂", + "grpb_preview_msg_sending_rest": "Enviando el resto ahora 👍", + "grpb_preview_msg_yep_confirmed": "Sí — acabo de confirmarlo ✅", + "grpb_selected_suffix": "\n(seleccionado)", + "grpb_tooltip_address_balance": "%s\nSaldo: %.8f %s%s", + "grpb_undo_clear": "Deshacer borrado", + "grpc_benchmark_inconclusive": "Prueba de rendimiento no concluyente: no se registraron muestras de tasa de hash. Comprueba la conexión con el pool e inténtalo de nuevo.", + "grpc_benchmark_takes_secs": "La prueba de rendimiento tarda ~%ds e interrumpe la minería. Haz clic de nuevo para empezar.", + "grpc_bootstrap_failed": "Error en el arranque inicial", + "grpc_bootstrap_not_initialized": "Arranque inicial no inicializado", + "grpc_hashrate_fee": "%s %s%% de comisión", + "grpc_key_not_available": "Clave no disponible para esta dirección", + "grpc_na": "N/D", "height": "Altura", "help": "Ayuda", "hidden_tag": " (oculto)", @@ -640,6 +810,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 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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 +1345,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 +1362,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,12 +1376,52 @@ "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.", "screenshot_sweep_full": "Barrido completo de la interfaz", "search_icons": "Buscar iconos...", "search_placeholder": "Buscar...", + "sec_changing_passphrase": "Cambiando la frase de contraseña...", + "sec_changing_pin": "Cambiando el PIN...", + "sec_couldnt_lock_wallet": "No se pudo bloquear la cartera; sigue desbloqueada. Comprueba la conexión con el daemon.", + "sec_encrypted_backup_suffix": "\nCopia de seguridad cifrada: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "Cifrando la cartera...", + "sec_encryption_did_not_complete": "El cifrado de la cartera no se completó; tu cartera NO está cifrada. Abre Ajustes para terminar de cifrarla.", + "sec_encryption_failed_prefix": "Error de cifrado: ", + "sec_failed_prefix": "Error: ", + "sec_failed_to_create_vault": "No se pudo crear la bóveda", + "sec_importing_keys_rescanning": "Importando claves y volviendo a escanear la cadena de bloques; la cartera se puede usar mientras tanto", + "sec_incorrect_current_pin": "PIN actual incorrecto", + "sec_incorrect_passphrase_decrypt": "Frase de contraseña incorrecta", + "sec_incorrect_passphrase_pin_setup": "Frase de contraseña incorrecta", + "sec_incorrect_pin_remove": "PIN incorrecto", + "sec_internal_error_change_pin": "Error interno", + "sec_internal_error_remove_pin": "Error interno", + "sec_mode_passphrase": " Frase de contraseña", + "sec_not_connected_to_daemon": "Sin conexión con el daemon", + "sec_not_connected_to_daemon_pin": "Sin conexión con el daemon", + "sec_passphrase_changed_successfully": "Frase de contraseña cambiada correctamente", + "sec_pin_changed_successfully": "PIN cambiado correctamente", + "sec_pin_removed": "PIN eliminado", + "sec_pin_set_successfully": "PIN configurado correctamente", + "sec_restart_daemon_for_encryption": "Reinicia el daemon para que el cifrado surta efecto.", + "sec_too_many_attempts_wait": "Demasiados intentos. Espera %.0f segundos...", + "sec_total_elapsed_fmt": "Tiempo total: %dm %02ds", + "sec_unlock_button": "Desbloquear", + "sec_unlock_failed_prefix": "Error al desbloquear: ", + "sec_unlocking_fmt": "Desbloqueando%s", + "sec_use_passphrase_instead": "Usar frase de contraseña en su lugar", + "sec_use_pin_instead": "Usar PIN en su lugar", + "sec_verifying_passphrase": "Verificando la frase de contraseña...", + "sec_verifying_pin": "Verificando el PIN...", + "sec_wallet_decrypted_all_keys_imported": "¡Cartera descifrada correctamente! Todas las claves importadas.", + "sec_wallet_encrypted_and_pin_set": "Cartera cifrada y PIN configurado", + "sec_wallet_encrypted_but_pin_vault_failed": "Cartera cifrada, pero falló la bóveda del PIN", + "sec_wallet_encrypted_restarting_daemon": "Cartera cifrada. Reiniciando el daemon...", + "sec_wallet_encrypted_successfully": "Cartera cifrada correctamente", + "sec_wallet_locked_title": "Cartera bloqueada", "security": "SEGURIDAD", "seed_backup_button": "Frase de recuperación", "seed_backup_close": "Cerrar", @@ -1246,6 +1480,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 +1500,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 +1520,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", @@ -1397,6 +1643,17 @@ "sweep_to": "Barrido a:", "sweep_toggle": "Barrer a mi monedero (no conservar la clave)", "sweep_tx": "Transacción:", + "swin_connection_failed": "Error de conexión: ", + "swin_connection_successful": "¡Conexión correcta!\nVersión de dragonxd: ", + "swin_invalid_suffix": " (no válido)", + "swin_no_history_file_found": "No se encontró ningún archivo de historial", + "swin_rescan_failed": "Error en el reescaneo: ", + "swin_rescan_started_from_block": "Reescaneo iniciado desde el bloque ", + "swin_rescan_to": " hasta ", + "swin_rpc_client_not_initialized": "Cliente RPC no inicializado", + "swin_settings_saved": "Ajustes guardados", + "swin_theme_list_refreshed": "Lista de temas actualizada", + "swin_ztx_history_cleared": "Historial de transacciones Z borrado", "switch_corrupt_body": "Esta cartera parece dañada: el nodo no pudo abrirla. Restáurala desde una copia de seguridad, vuelve a crearla o intenta repararla.", "switch_corrupt_repair": "Intentar reparar (salvage)", "switch_progress_background": "Continuar en segundo plano", @@ -1421,6 +1678,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 +1693,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 +1717,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 +1730,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 +1780,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 +1819,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 +1872,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 +2013,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", diff --git a/res/lang/fr.json b/res/lang/fr.json index e26f9a2..80e2b96 100644 --- a/res/lang/fr.json +++ b/res/lang/fr.json @@ -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", @@ -56,6 +60,80 @@ "amount_label": "Montant :", "animate_avatars": "Animer les avatars", "appearance": "APPARENCE", + "appx_back": "Retour", + "appx_back_up_seed_phrase_title": "Sauvegardez votre phrase de récupération", + "appx_birthday_block_height": "Naissance (hauteur de bloc) : %llu — sauvegardez-la également.", + "appx_blockchain_data_deleted": "Données de la blockchain supprimées (%d éléments). Le démon redémarre pour se resynchroniser depuis le réseau.", + "appx_blockchain_maintenance_in_progress": "Une opération de maintenance de la blockchain est déjà en cours.", + "appx_blockchain_rescan_complete": "Nouvelle analyse de la blockchain terminée", + "appx_bootstrap_complete_reconciling": "Amorçage terminé — réconciliation de votre portefeuille avec les nouvelles données de la chaîne.", + "appx_cancel": "Annuler", + "appx_cleaning_up": "Nettoyage...", + "appx_confirm_your_backup": "Confirmez votre sauvegarde", + "appx_copied_clipboard_autoclears": "Copié — le presse-papiers s'efface automatiquement dans 45 s", + "appx_copy": "Copier", + "appx_could_not_start_restore": "Impossible de démarrer la restauration", + "appx_create_failed_prefix": "Échec de la création : ", + "appx_creating_your_wallet": "Création de votre portefeuille…", + "appx_daemon_error": "Erreur du démon", + "appx_daemon_reinstall_in_progress": "La réinstallation du démon est déjà en cours.", + "appx_disconnecting": "Déconnexion...", + "appx_done": "Terminé", + "appx_dragonxd_output": "Sortie de dragonxd", + "appx_encrypting_wallet": "Chiffrement du portefeuille...", + "appx_fullnode_lifecycle_unavailable_lite": "Les actions de cycle de vie du nœud complet ne sont pas disponibles dans la version allégée", + "appx_installing_bundled_daemon": "Installation du démon intégré — le nœud va s'arrêter, se mettre à jour, puis redémarrer...", + "appx_invalid_payment_uri_prefix": "URI de paiement invalide : ", + "appx_ive_written_it_down": "Je les ai notés", + "appx_keep_node_running_and_quit": "Laisser le nœud actif et quitter", + "appx_last_block_n": "Dernier bloc : %d", + "appx_last_used_wallet_not_found_prefix": "Votre dernier fichier de portefeuille utilisé (", + "appx_last_used_wallet_not_found_suffix": ") est introuvable — le portefeuille par défaut a été ouvert à la place. Si vous l'avez déplacé, restaurez-le et revenez à celui-ci depuis la liste des portefeuilles.", + "appx_low_spec_mode_disabled": "Mode faibles ressources désactivé", + "appx_low_spec_mode_enabled": "Mode faibles ressources activé", + "appx_miner_stopped_prefix": "Mineur arrêté : ", + "appx_miner_stopped_unexpectedly": "Le mineur s'est arrêté de manière inattendue.", + "appx_n_min_n_sec": "%d min %d s", + "appx_n_seconds": "%d secondes", + "appx_no_bundled_daemon_to_install": "Cette version ne contient aucun démon intégré à installer", + "appx_no_embedded_daemon_to_install": "Cette version ne contient aucun démon embarqué à installer", + "appx_node_busy_restarting": "Le nœud est occupé à redémarrer — réessayez dans un instant.", + "appx_node_rebuilding_witness_cache": "Le nœud reconstruit son cache de témoins", + "appx_not_next_word": " — ce n'est pas le mot suivant", + "appx_payment_request_loaded": "Demande de paiement chargée", + "appx_pool_miner_connected_and_hashing": "Mineur de pool connecté et en cours de hachage.", + "appx_progress_n_of_n": "Progression : %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Reconstruction des témoins de notes Sapling…", + "appx_rebuilding_witness_cache_blocks_left": "Reconstruction du cache de témoins %.0f%% — %d blocs restants", + "appx_rebuilding_witness_cache_pct": "Reconstruction du cache de témoins %.0f%%", + "appx_recovery_phrase_word_count": "La phrase de récupération doit comporter 24 mots — vous en avez %d.", + "appx_restarting_daemon_rescan_flag": "Redémarrage du démon avec l'option -rescan...", + "appx_restarting_daemon_zapwallettxes": "Redémarrage du démon avec -zapwallettxes=2 (réparation du portefeuille)...", + "appx_restoring_your_wallet": "Restauration de votre portefeuille…", + "appx_seed_backup_warning": "Ces 24 mots sont le SEUL moyen de restaurer votre portefeuille. Notez-les dans l'ordre, conservez-les hors ligne et ne les partagez jamais. Si vous les perdez, vos fonds seront perdus à jamais.", + "appx_seed_not_backed_up_warning": "Vous n'avez pas sauvegardé votre phrase de récupération — des fonds pourraient être perdus. Ignorer quand même ?", + "appx_sending_stop_command_to_daemon": "Envoi de la commande d'arrêt au démon...", + "appx_setting_initial_sapling_witnesses": "Définition des témoins Sapling initiaux %.0f%%", + "appx_shutdown_complete": "Arrêt terminé", + "appx_simple_background_disabled": "Arrière-plan simple désactivé", + "appx_simple_background_enabled": "Arrière-plan simple activé", + "appx_skip": "Ignorer", + "appx_skip_anyway": "Ignorer quand même", + "appx_still_status_prefix": "Toujours « ", + "appx_still_status_suffix": " » — forcer la fermeture maintenant peut corrompre les données de la chaîne.", + "appx_stop_anyway_and_quit": "Arrêter quand même et quitter", + "appx_stopping_daemon_deleting_blockchain": "Arrêt du démon et suppression des données de la blockchain...", + "appx_stopping_node_discards_rebuild": "Arrêter le nœud maintenant abandonne la reconstruction en cours et la relance (plusieurs minutes) à la prochaine ouverture du portefeuille. Vous pouvez plutôt laisser le nœud en marche.", + "appx_stopping_pool_miner": "Arrêt du mineur de pool...", + "appx_syncing_pct_block_n_of_n": "Synchronisation %.1f%% — Bloc %d / %d", + "appx_tap_words_in_order": "Touchez les mots dans le bon ordre pour confirmer que vous les avez enregistrés.", + "appx_theme_effects_disabled": "Effets de thème désactivés", + "appx_theme_effects_enabled": "Effets de thème activés", + "appx_theme_prefix": "Thème : ", + "appx_use_settings_restart_daemon_hint": "Utilisez Paramètres > Redémarrer le démon pour réessayer", + "appx_waiting_for_daemon_to_encrypt_wallet": "En attente du chiffrement du portefeuille par le démon...", + "appx_wallet_created_and_backed_up": "Portefeuille créé et sauvegardé.", + "appx_wallet_open_failed_prefix": "Échec de l'ouverture du portefeuille : ", "auto_shield": "Auto-blindage du minage", "av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :", "av_open_security": "Ouvrir Sécurité Windows", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "Marché", + "baltab_market_price_4dp": "Marché : %.4f $", + "baltab_market_price_8dp": "Marché : %.8f $", + "baltab_pct_change_24h": "%s%.1f%% 24h", + "baltab_pct_of_total_zaddr": "%.0f%% du total · %d Z-addr", + "baltab_shielded": "Protégé", + "baltab_shielded_amount": "Protégé %.8f", + "baltab_t_addresses_count": "%d T-addresses", + "baltab_total_balance": "Solde total", + "baltab_transparent": "Transparent", + "baltab_transparent_amount": "Transparent %.8f", "ban": "Bannir", "banned_peers": "Pairs bannis", "block": "Bloc", @@ -128,6 +223,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 :", @@ -403,6 +499,7 @@ "contacts_shape_square": "Carré", "contacts_shape_tab": "Onglet", "copied": "Copié !", + "copied_to_clipboard": "Copié dans le presse-papiers", "copy": "Copier", "copy_address": "Copier l'adresse complète", "copy_error": "Copier l'erreur", @@ -415,6 +512,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 +535,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 +548,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 +581,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...", @@ -566,6 +679,63 @@ "general": "Général", "generating": "Génération", "go_to_receive": "Aller à Recevoir", + "grpa_current_block_paren": "(Actuel : %d)", + "grpa_days_ago": "il y a %lld jours", + "grpa_dbg_addrman": "Suivi et gestion des adresses des pairs", + "grpa_dbg_alert": "Messages du système d'alerte", + "grpa_dbg_bench": "Chronométrage des performances des opérations", + "grpa_dbg_coindb": "Opérations de lecture/écriture de la base de données des pièces", + "grpa_dbg_db": "Opérations Berkeley DB", + "grpa_dbg_estimatefee": "Algorithme d'estimation des frais", + "grpa_dbg_http": "Activité du serveur RPC HTTP", + "grpa_dbg_libevent": "Bibliothèque réseau Libevent", + "grpa_dbg_lock": "Débogage de la contention des verrous", + "grpa_dbg_mempool": "Activité du pool mémoire des transactions", + "grpa_dbg_net": "Connexions et messages réseau", + "grpa_dbg_paymentdisclosure": "Protocole de divulgation de paiement", + "grpa_dbg_pow": "Activité de minage par preuve de travail", + "grpa_dbg_proxy": "Connexions proxy SOCKS5", + "grpa_dbg_prune": "Opérations d'élagage des blocs", + "grpa_dbg_rand": "Génération de nombres aléatoires", + "grpa_dbg_reindex": "Progression de la réindexation de la blockchain", + "grpa_dbg_rpc": "Traitement des commandes RPC", + "grpa_dbg_selectcoins": "Sélection des pièces pour les transactions", + "grpa_dbg_tor": "Intégration de Tor et informations de circuit", + "grpa_dbg_zmq": "Système de notification ZeroMQ", + "grpa_dbg_zrpc": "Opérations RPC protégées (z-addr)", + "grpa_enter_private_key_to_import": "Saisissez une clé privée à importer.", + "grpa_error_prefix": "Erreur : ", + "grpa_hr_ago": "il y a %lld h", + "grpa_invalid_response_from_daemon": "Réponse invalide du démon", + "grpa_invalid_suffix": " (invalide)", + "grpa_min_ago": "il y a %lld min", + "grpa_sec_ago": "il y a %lld s", + "grpa_seed_demo_chat": "Chat de démonstration seed", + "grpa_showing_first_100_of": "... affichage des 100 premiers sur %d", + "grpa_tab_about": "À propos", + "grpa_tab_appearance": "Apparence", + "grpa_tab_backup_data": "Sauvegarde et données", + "grpa_tab_chat": "Chat", + "grpa_tab_explorer": "Explorateur", + "grpa_tab_node_security": "Nœud et sécurité", + "grpa_tab_wallet": "Portefeuille", + "grpa_unexpected_getblockhash_result": "résultat getblockhash inattendu", + "grpb_copy": "Copier", + "grpb_max": "Max", + "grpb_new_badge_suffix": " [NOUVEAU]", + "grpb_preview_msg_payment_through": "Le paiement est-il passé ? 🙂", + "grpb_preview_msg_sending_rest": "J'envoie le reste maintenant 👍", + "grpb_preview_msg_yep_confirmed": "Oui — je viens de confirmer ✅", + "grpb_selected_suffix": "\n(sélectionné)", + "grpb_tooltip_address_balance": "%s\nSolde : %.8f %s%s", + "grpb_undo_clear": "Annuler l'effacement", + "grpc_benchmark_inconclusive": "Benchmark non concluant : aucun échantillon de taux de hachage n'a été enregistré. Vérifiez la connexion au pool et réessayez.", + "grpc_benchmark_takes_secs": "Le benchmark prend environ %d s et interrompt le minage. Cliquez de nouveau pour démarrer.", + "grpc_bootstrap_failed": "Échec de l'amorçage", + "grpc_bootstrap_not_initialized": "Amorçage non initialisé", + "grpc_hashrate_fee": "%s %s%% de frais", + "grpc_key_not_available": "Clé non disponible pour cette adresse", + "grpc_na": "N/D", "height": "Hauteur", "help": "Aide", "hidden_tag": " (masqué)", @@ -640,6 +810,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 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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 +1345,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 +1362,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,12 +1376,52 @@ "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.", "screenshot_sweep_full": "Balayage complet de l'interface", "search_icons": "Rechercher des icônes...", "search_placeholder": "Rechercher...", + "sec_changing_passphrase": "Modification de la phrase secrète...", + "sec_changing_pin": "Modification du PIN...", + "sec_couldnt_lock_wallet": "Impossible de verrouiller le portefeuille — il reste déverrouillé. Vérifiez la connexion au démon.", + "sec_encrypted_backup_suffix": "\nSauvegarde chiffrée : wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "Chiffrement du portefeuille...", + "sec_encryption_did_not_complete": "Le chiffrement du portefeuille ne s'est pas terminé — votre portefeuille N'est PAS chiffré. Ouvrez les Paramètres pour terminer le chiffrement.", + "sec_encryption_failed_prefix": "Échec du chiffrement : ", + "sec_failed_prefix": "Échec : ", + "sec_failed_to_create_vault": "Échec de la création du coffre", + "sec_importing_keys_rescanning": "Importation des clés et nouvelle analyse de la blockchain — le portefeuille reste utilisable pendant l'opération", + "sec_incorrect_current_pin": "PIN actuel incorrect", + "sec_incorrect_passphrase_decrypt": "Phrase secrète incorrecte", + "sec_incorrect_passphrase_pin_setup": "Phrase secrète incorrecte", + "sec_incorrect_pin_remove": "PIN incorrect", + "sec_internal_error_change_pin": "Erreur interne", + "sec_internal_error_remove_pin": "Erreur interne", + "sec_mode_passphrase": " Phrase secrète", + "sec_not_connected_to_daemon": "Non connecté au démon", + "sec_not_connected_to_daemon_pin": "Non connecté au démon", + "sec_passphrase_changed_successfully": "Phrase secrète modifiée avec succès", + "sec_pin_changed_successfully": "PIN modifié avec succès", + "sec_pin_removed": "PIN supprimé", + "sec_pin_set_successfully": "PIN défini avec succès", + "sec_restart_daemon_for_encryption": "Veuillez redémarrer votre démon pour que le chiffrement prenne effet.", + "sec_too_many_attempts_wait": "Trop de tentatives. Patientez %.0f secondes...", + "sec_total_elapsed_fmt": "Temps total écoulé : %dm %02ds", + "sec_unlock_button": "Déverrouiller", + "sec_unlock_failed_prefix": "Échec du déverrouillage : ", + "sec_unlocking_fmt": "Déverrouillage%s", + "sec_use_passphrase_instead": "Utiliser une phrase secrète", + "sec_use_pin_instead": "Utiliser un PIN", + "sec_verifying_passphrase": "Vérification de la phrase secrète...", + "sec_verifying_pin": "Vérification du PIN...", + "sec_wallet_decrypted_all_keys_imported": "Portefeuille déchiffré avec succès ! Toutes les clés ont été importées.", + "sec_wallet_encrypted_and_pin_set": "Portefeuille chiffré et PIN défini", + "sec_wallet_encrypted_but_pin_vault_failed": "Portefeuille chiffré mais échec du coffre PIN", + "sec_wallet_encrypted_restarting_daemon": "Portefeuille chiffré. Redémarrage du démon...", + "sec_wallet_encrypted_successfully": "Portefeuille chiffré avec succès", + "sec_wallet_locked_title": "Portefeuille verrouillé", "security": "SÉCURITÉ", "seed_backup_button": "Phrase de récupération", "seed_backup_close": "Fermer", @@ -1246,6 +1480,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 +1500,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 +1520,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", @@ -1397,6 +1643,17 @@ "sweep_to": "Balayé vers :", "sweep_toggle": "Balayer vers mon portefeuille (ne pas conserver la clé)", "sweep_tx": "Transaction :", + "swin_connection_failed": "Échec de la connexion : ", + "swin_connection_successful": "Connexion réussie !\nVersion de dragonxd : ", + "swin_invalid_suffix": " (invalide)", + "swin_no_history_file_found": "Aucun fichier d'historique trouvé", + "swin_rescan_failed": "Échec de la nouvelle analyse : ", + "swin_rescan_started_from_block": "Nouvelle analyse démarrée à partir du bloc ", + "swin_rescan_to": " à ", + "swin_rpc_client_not_initialized": "Client RPC non initialisé", + "swin_settings_saved": "Paramètres enregistrés", + "swin_theme_list_refreshed": "Liste des thèmes actualisée", + "swin_ztx_history_cleared": "Historique des transactions Z effacé", "switch_corrupt_body": "Ce portefeuille semble corrompu — le nœud n'a pas pu l'ouvrir. Restaurez-le depuis une sauvegarde, recréez-le ou tentez de le réparer.", "switch_corrupt_repair": "Tenter une réparation (salvage)", "switch_progress_background": "Continuer en arrière-plan", @@ -1421,6 +1678,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 +1693,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 +1717,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 +1730,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 +1780,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 +1819,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 +1872,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 +2013,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", diff --git a/res/lang/ja.json b/res/lang/ja.json index 20faea3..ed45308 100644 --- a/res/lang/ja.json +++ b/res/lang/ja.json @@ -48,6 +48,10 @@ "advanced": "詳細設定", "advanced_effects": "高度なエフェクト...", "ago": "前", + "alerts_clear": "通知履歴を消去", + "alerts_history_tooltip": "最近の通知", + "alerts_none": "通知はまだありません", + "alerts_recent": "最近の通知", "all_filter": "すべて", "allow_custom_fees": "カスタム手数料を許可", "amount": "金額", @@ -56,6 +60,80 @@ "amount_label": "金額:", "animate_avatars": "アバターをアニメーション", "appearance": "外観", + "appx_back": "戻る", + "appx_back_up_seed_phrase_title": "シードフレーズをバックアップ", + "appx_birthday_block_height": "誕生日(ブロック高): %llu — これもバックアップしてください。", + "appx_blockchain_data_deleted": "ブロックチェーンデータを削除しました(%d件)。ネットワークから再同期するため、デーモンを再起動しています。", + "appx_blockchain_maintenance_in_progress": "ブロックチェーンのメンテナンス操作がすでに進行中です。", + "appx_blockchain_rescan_complete": "ブロックチェーンの再スキャンが完了しました", + "appx_bootstrap_complete_reconciling": "ブートストラップが完了しました。新しいチェーンデータとウォレットを照合しています。", + "appx_cancel": "キャンセル", + "appx_cleaning_up": "クリーンアップしています...", + "appx_confirm_your_backup": "バックアップを確認", + "appx_copied_clipboard_autoclears": "コピーしました — クリップボードは45秒後に自動的にクリアされます", + "appx_copy": "コピー", + "appx_could_not_start_restore": "復元を開始できませんでした", + "appx_create_failed_prefix": "作成に失敗しました: ", + "appx_creating_your_wallet": "ウォレットを作成しています…", + "appx_daemon_error": "デーモンエラー", + "appx_daemon_reinstall_in_progress": "デーモンの再インストールがすでに進行中です。", + "appx_disconnecting": "切断しています...", + "appx_done": "完了", + "appx_dragonxd_output": "dragonxd の出力", + "appx_encrypting_wallet": "ウォレットを暗号化しています...", + "appx_fullnode_lifecycle_unavailable_lite": "フルノードのライフサイクル操作はライトビルドでは利用できません", + "appx_installing_bundled_daemon": "同梱デーモンをインストールしています。ノードが停止、更新、再起動されます...", + "appx_invalid_payment_uri_prefix": "無効な支払いURI: ", + "appx_ive_written_it_down": "書き留めました", + "appx_keep_node_running_and_quit": "ノードを起動したまま終了", + "appx_last_block_n": "最新ブロック: %d", + "appx_last_used_wallet_not_found_prefix": "前回使用したウォレットファイル(", + "appx_last_used_wallet_not_found_suffix": ")が見つかりませんでした。代わりにデフォルトのウォレットを開きました。移動した場合は、元に戻してからウォレット一覧で切り替えてください。", + "appx_low_spec_mode_disabled": "低スペックモードを無効にしました", + "appx_low_spec_mode_enabled": "低スペックモードを有効にしました", + "appx_miner_stopped_prefix": "マイナーが停止しました: ", + "appx_miner_stopped_unexpectedly": "マイナーが予期せず停止しました。", + "appx_n_min_n_sec": "%d分 %d秒", + "appx_n_seconds": "%d秒", + "appx_no_bundled_daemon_to_install": "このビルドにはインストールできる同梱デーモンがありません", + "appx_no_embedded_daemon_to_install": "このビルドにはインストールできる組み込みデーモンがありません", + "appx_node_busy_restarting": "ノードが再起動中でビジー状態です。少し待ってから再試行してください。", + "appx_node_rebuilding_witness_cache": "ノードがウィットネスキャッシュを再構築しています", + "appx_not_next_word": " — 次の単語ではありません", + "appx_payment_request_loaded": "支払いリクエストを読み込みました", + "appx_pool_miner_connected_and_hashing": "プールマイナーが接続し、ハッシュ計算中です。", + "appx_progress_n_of_n": "進捗: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Saplingノートウィットネスを再構築しています…", + "appx_rebuilding_witness_cache_blocks_left": "ウィットネスキャッシュを再構築中 %.0f%% — 残り%dブロック", + "appx_rebuilding_witness_cache_pct": "ウィットネスキャッシュを再構築中 %.0f%%", + "appx_recovery_phrase_word_count": "リカバリーフレーズは24単語である必要があります。現在は%d単語です。", + "appx_restarting_daemon_rescan_flag": "-rescanフラグ付きでデーモンを再起動しています...", + "appx_restarting_daemon_zapwallettxes": "-zapwallettxes=2(ウォレット修復)付きでデーモンを再起動しています...", + "appx_restoring_your_wallet": "ウォレットを復元しています…", + "appx_seed_backup_warning": "この24個の単語は、ウォレットを復元する唯一の手段です。順番どおりに書き留め、オフラインで保管し、決して他人に教えないでください。紛失すると、資金は永久に失われます。", + "appx_seed_not_backed_up_warning": "シードをバックアップしていません。資金を失う可能性があります。それでもスキップしますか?", + "appx_sending_stop_command_to_daemon": "デーモンに停止コマンドを送信しています...", + "appx_setting_initial_sapling_witnesses": "初期Saplingウィットネスを設定中 %.0f%%", + "appx_shutdown_complete": "シャットダウンが完了しました", + "appx_simple_background_disabled": "シンプル背景を無効にしました", + "appx_simple_background_enabled": "シンプル背景を有効にしました", + "appx_skip": "スキップ", + "appx_skip_anyway": "それでもスキップ", + "appx_still_status_prefix": "まだ「", + "appx_still_status_suffix": "」です — 今強制終了するとチェーンデータが破損する可能性があります。", + "appx_stop_anyway_and_quit": "それでも停止して終了", + "appx_stopping_daemon_deleting_blockchain": "デーモンを停止し、ブロックチェーンデータを削除しています...", + "appx_stopping_node_discards_rebuild": "今ノードを停止すると、進行中の再構築が破棄され、次回ウォレットを開いたときに再度実行されます(数分かかります)。代わりにノードを起動したままにすることもできます。", + "appx_stopping_pool_miner": "プールマイナーを停止しています...", + "appx_syncing_pct_block_n_of_n": "同期中 %.1f%% — ブロック %d / %d", + "appx_tap_words_in_order": "保存したことを確認するため、単語を正しい順番でタップしてください。", + "appx_theme_effects_disabled": "テーマ効果を無効にしました", + "appx_theme_effects_enabled": "テーマ効果を有効にしました", + "appx_theme_prefix": "テーマ: ", + "appx_use_settings_restart_daemon_hint": "設定 > デーモンを再起動 から再試行してください", + "appx_waiting_for_daemon_to_encrypt_wallet": "デーモンによるウォレットの暗号化を待っています...", + "appx_wallet_created_and_backed_up": "ウォレットを作成し、バックアップしました。", + "appx_wallet_open_failed_prefix": "ウォレットを開けませんでした: ", "auto_shield": "マイニング自動シールド", "av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。", "av_open_security": "Windows セキュリティを開く", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "市場", + "baltab_market_price_4dp": "市場価格: $%.4f", + "baltab_market_price_8dp": "市場価格: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24h", + "baltab_pct_of_total_zaddr": "全体の%.0f%% · %d Z-addr", + "baltab_shielded": "シールド", + "baltab_shielded_amount": "シールド %.8f", + "baltab_t_addresses_count": "%d 個のT-address", + "baltab_total_balance": "合計残高", + "baltab_transparent": "透明", + "baltab_transparent_amount": "透明 %.8f", "ban": "ブロック", "banned_peers": "ブロック済みピア", "block": "ブロック", @@ -128,6 +223,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": "現在のパスフレーズ:", @@ -403,6 +499,7 @@ "contacts_shape_square": "四角", "contacts_shape_tab": "左タブ", "copied": "コピーしました!", + "copied_to_clipboard": "クリップボードにコピーしました", "copy": "コピー", "copy_address": "完全なアドレスをコピー", "copy_error": "エラーをコピー", @@ -415,6 +512,7 @@ "daemon_bundled": "バンドル版", "daemon_install_bundled": "バンドル版をインストール", "daemon_installed": "インストール済み", + "daemon_maintenance_label": "メンテナンス", "daemon_none_bundled": "このビルドにはなし", "daemon_not_installed": "未インストール", "daemon_status_differ": "インストール済みのバイナリはバンドル版と異なります。", @@ -437,6 +535,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 +548,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 +581,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": "ウォレットを暗号化しています...", @@ -566,6 +679,63 @@ "general": "一般", "generating": "生成中", "go_to_receive": "受信へ移動", + "grpa_current_block_paren": "(現在: %d)", + "grpa_days_ago": "%lld日前", + "grpa_dbg_addrman": "ピアアドレスの追跡と管理", + "grpa_dbg_alert": "アラートシステムのメッセージ", + "grpa_dbg_bench": "操作のベンチマーク計測", + "grpa_dbg_coindb": "コインデータベースの読み書き操作", + "grpa_dbg_db": "Berkeley DB の操作", + "grpa_dbg_estimatefee": "手数料見積もりアルゴリズム", + "grpa_dbg_http": "HTTP RPCサーバーの動作", + "grpa_dbg_libevent": "Libevent ネットワークライブラリ", + "grpa_dbg_lock": "ロック競合のデバッグ", + "grpa_dbg_mempool": "トランザクションメモリプールの動作", + "grpa_dbg_net": "ネットワーク接続とメッセージ", + "grpa_dbg_paymentdisclosure": "支払い開示プロトコル", + "grpa_dbg_pow": "プルーフオブワークのマイニング動作", + "grpa_dbg_proxy": "SOCKS5 プロキシ接続", + "grpa_dbg_prune": "ブロックのプルーニング操作", + "grpa_dbg_rand": "乱数生成", + "grpa_dbg_reindex": "ブロックチェーンの再インデックス進捗", + "grpa_dbg_rpc": "RPCコマンドの処理", + "grpa_dbg_selectcoins": "トランザクション用のコイン選択", + "grpa_dbg_tor": "Tor統合とサーキット情報", + "grpa_dbg_zmq": "ZeroMQ 通知システム", + "grpa_dbg_zrpc": "シールド(z-addr)RPC操作", + "grpa_enter_private_key_to_import": "インポートする秘密鍵を入力してください。", + "grpa_error_prefix": "エラー: ", + "grpa_hr_ago": "%lld時間前", + "grpa_invalid_response_from_daemon": "デーモンからの応答が無効です", + "grpa_invalid_suffix": "(無効)", + "grpa_min_ago": "%lld分前", + "grpa_sec_ago": "%lld秒前", + "grpa_seed_demo_chat": "デモチャットのシード", + "grpa_showing_first_100_of": "... %d件中の最初の100件を表示", + "grpa_tab_about": "情報", + "grpa_tab_appearance": "外観", + "grpa_tab_backup_data": "バックアップとデータ", + "grpa_tab_chat": "チャット", + "grpa_tab_explorer": "エクスプローラー", + "grpa_tab_node_security": "ノードとセキュリティ", + "grpa_tab_wallet": "ウォレット", + "grpa_unexpected_getblockhash_result": "予期しない getblockhash の結果", + "grpb_copy": "コピー", + "grpb_max": "最大", + "grpb_new_badge_suffix": " [新規]", + "grpb_preview_msg_payment_through": "支払いは完了しましたか? 🙂", + "grpb_preview_msg_sending_rest": "残りを今送金します 👍", + "grpb_preview_msg_yep_confirmed": "はい — たった今確認できました ✅", + "grpb_selected_suffix": "\n(選択済み)", + "grpb_tooltip_address_balance": "%s\n残高: %.8f %s%s", + "grpb_undo_clear": "消去を元に戻す", + "grpc_benchmark_inconclusive": "ベンチマークの結果が不明確です: ハッシュレートのサンプルが記録されませんでした。プール接続を確認して再試行してください。", + "grpc_benchmark_takes_secs": "ベンチマークには約%d秒かかり、マイニングが中断されます。開始するにはもう一度クリックしてください。", + "grpc_bootstrap_failed": "ブートストラップに失敗しました", + "grpc_bootstrap_not_initialized": "ブートストラップが初期化されていません", + "grpc_hashrate_fee": "%s 手数料 %s%%", + "grpc_key_not_available": "このアドレスの鍵は利用できません", + "grpc_na": "該当なし", "height": "高さ", "help": "ヘルプ", "hidden_tag": " (非表示)", @@ -640,6 +810,7 @@ "light": "ライト", "lite_account_label": "アカウント", "lite_action": "アクション", + "lite_backend_unavailable": "ライトウォレットのバックエンドが利用できません", "lite_backup_keys": "バックアップと鍵", "lite_birthday_backup": "誕生日:%llu (これもバックアップしてください)", "lite_birthday_hint": "スキャンを開始するブロック高。不明な場合は0のままにしてください(完全スキャンが遅くなります)。", @@ -647,9 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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,12 +1373,52 @@ "sb_waiting_daemon_err": "dragonxd を待機中 — %s", "sb_warming_up": "ウォームアップ中...", "sb_witness_cache": "ウィットネスを再構築中", + "scale_effects": "スケールとエフェクト", "screenshot_open_dir": "場所を開く", "screenshot_sweep": "スクリーンショットスイープを実行", "screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。", "screenshot_sweep_full": "UI全体スイープ", "search_icons": "アイコンを検索...", "search_placeholder": "検索...", + "sec_changing_passphrase": "パスフレーズを変更しています...", + "sec_changing_pin": "PINを変更しています...", + "sec_couldnt_lock_wallet": "ウォレットをロックできませんでした。まだロック解除された状態です。デーモンの接続を確認してください。", + "sec_encrypted_backup_suffix": "\n暗号化バックアップ: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "ウォレットを暗号化しています...", + "sec_encryption_did_not_complete": "ウォレットの暗号化が完了しませんでした。ウォレットは暗号化されていません。設定を開いて暗号化を完了してください。", + "sec_encryption_failed_prefix": "暗号化に失敗しました: ", + "sec_failed_prefix": "失敗しました: ", + "sec_failed_to_create_vault": "ボールトの作成に失敗しました", + "sec_importing_keys_rescanning": "鍵をインポートし、ブロックチェーンを再スキャンしています。処理中もウォレットは使用できます", + "sec_incorrect_current_pin": "現在のPINが正しくありません", + "sec_incorrect_passphrase_decrypt": "パスフレーズが正しくありません", + "sec_incorrect_passphrase_pin_setup": "パスフレーズが正しくありません", + "sec_incorrect_pin_remove": "PINが正しくありません", + "sec_internal_error_change_pin": "内部エラー", + "sec_internal_error_remove_pin": "内部エラー", + "sec_mode_passphrase": " パスフレーズ", + "sec_not_connected_to_daemon": "デーモンに接続されていません", + "sec_not_connected_to_daemon_pin": "デーモンに接続されていません", + "sec_passphrase_changed_successfully": "パスフレーズを変更しました", + "sec_pin_changed_successfully": "PINを変更しました", + "sec_pin_removed": "PINを削除しました", + "sec_pin_set_successfully": "PINを設定しました", + "sec_restart_daemon_for_encryption": "暗号化を有効にするには、デーモンを再起動してください。", + "sec_too_many_attempts_wait": "試行回数が多すぎます。%.0f秒お待ちください...", + "sec_total_elapsed_fmt": "合計経過時間: %d分 %02d秒", + "sec_unlock_button": "ロック解除", + "sec_unlock_failed_prefix": "ロック解除に失敗しました: ", + "sec_unlocking_fmt": "ロック解除中%s", + "sec_use_passphrase_instead": "代わりにパスフレーズを使用", + "sec_use_pin_instead": "代わりにPINを使用", + "sec_verifying_passphrase": "パスフレーズを確認しています...", + "sec_verifying_pin": "PINを確認しています...", + "sec_wallet_decrypted_all_keys_imported": "ウォレットの復号に成功しました。すべての鍵をインポートしました。", + "sec_wallet_encrypted_and_pin_set": "ウォレットを暗号化し、PINを設定しました", + "sec_wallet_encrypted_but_pin_vault_failed": "ウォレットは暗号化されましたが、PINボールトの作成に失敗しました", + "sec_wallet_encrypted_restarting_daemon": "ウォレットを暗号化しました。デーモンを再起動しています...", + "sec_wallet_encrypted_successfully": "ウォレットを暗号化しました", + "sec_wallet_locked_title": "ウォレットがロックされています", "security": "セキュリティ", "seed_backup_button": "シードフレーズ", "seed_backup_close": "閉じる", @@ -1246,6 +1477,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 +1497,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 +1517,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 +1580,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": "ステータスを確認", @@ -1397,6 +1640,17 @@ "sweep_to": "集約先:", "sweep_toggle": "ウォレットに集約(鍵は保持しない)", "sweep_tx": "取引:", + "swin_connection_failed": "接続に失敗しました: ", + "swin_connection_successful": "接続に成功しました!\ndragonxd バージョン: ", + "swin_invalid_suffix": "(無効)", + "swin_no_history_file_found": "履歴ファイルが見つかりません", + "swin_rescan_failed": "再スキャンに失敗しました: ", + "swin_rescan_started_from_block": "再スキャンを開始しました。開始ブロック ", + "swin_rescan_to": " 〜 ", + "swin_rpc_client_not_initialized": "RPCクライアントが初期化されていません", + "swin_settings_saved": "設定を保存しました", + "swin_theme_list_refreshed": "テーマ一覧を更新しました", + "swin_ztx_history_cleared": "Zトランザクション履歴を消去しました", "switch_corrupt_body": "このウォレットは破損しているようです。ノードが開けませんでした。バックアップから復元するか、作り直すか、修復を試してください。", "switch_corrupt_repair": "修復を試す(salvage)", "switch_progress_background": "バックグラウンドで続行", @@ -1421,6 +1675,7 @@ "theme": "テーマ", "theme_effects": "テーマ効果", "theme_language": "テーマと言語", + "tile_click_to_open": "クリックして開く", "time_days_ago": "%d日前", "time_hours_ago": "%d時間前", "time_minutes_ago": "%d分前", @@ -1435,7 +1690,9 @@ "to_upper": "宛先", "tools": "ツール", "tools_actions": "ツールとアクション...", + "tools_actions_hdr": "ツールと操作", "total": "合計", + "total_balance_label": "総残高", "transaction_id": "取引ID", "transaction_sent": "取引の送信に成功しました", "transaction_sent_msg": "取引を送信しました!", @@ -1457,7 +1714,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 +1727,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 +1777,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 +1816,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 +1869,7 @@ "validate_not_mine": "このウォレットに属していません", "validate_ownership": "所有者:", "validate_results": "結果:", + "validate_results_placeholder": "ここに結果が表示されます", "validate_shielded_type": "シールド(zアドレス)", "validate_status": "ステータス:", "validate_title": "アドレスを検証", @@ -1750,6 +2010,7 @@ "xmrig_loading_releases": "リリースを読み込み中…", "xmrig_none": "なし", "xmrig_reinstall": "再インストール", + "xmrig_releases": "xmrig リリース", "xmrig_stop_mining_first": "マイナーを更新する前にマイニングを停止してください。", "xmrig_unavailable_body": "このプラットフォーム向けのマイナービルドは利用できません。", "xmrig_unavailable_title": "マイナーの更新は利用できません", diff --git a/res/lang/ko.json b/res/lang/ko.json index 4bf3704..7615671 100644 --- a/res/lang/ko.json +++ b/res/lang/ko.json @@ -48,6 +48,10 @@ "advanced": "고급 설정", "advanced_effects": "고급 효과...", "ago": "전", + "alerts_clear": "알림 기록 지우기", + "alerts_history_tooltip": "최근 알림", + "alerts_none": "아직 알림이 없습니다", + "alerts_recent": "최근 알림", "all_filter": "전체", "allow_custom_fees": "사용자 정의 수수료 허용", "amount": "금액", @@ -56,6 +60,80 @@ "amount_label": "금액:", "animate_avatars": "아바타 애니메이션", "appearance": "외관", + "appx_back": "뒤로", + "appx_back_up_seed_phrase_title": "시드 문구 백업", + "appx_birthday_block_height": "생성 시점(블록 높이): %llu — 이것도 함께 백업하세요.", + "appx_blockchain_data_deleted": "블록체인 데이터를 삭제했습니다(%d개 항목). 네트워크에서 다시 동기화하기 위해 데몬을 재시작합니다.", + "appx_blockchain_maintenance_in_progress": "블록체인 유지 관리 작업이 이미 진행 중입니다.", + "appx_blockchain_rescan_complete": "블록체인 다시 스캔 완료", + "appx_bootstrap_complete_reconciling": "부트스트랩 완료 — 새 체인 데이터와 지갑을 대조하는 중입니다.", + "appx_cancel": "취소", + "appx_cleaning_up": "정리하는 중...", + "appx_confirm_your_backup": "백업 확인", + "appx_copied_clipboard_autoclears": "복사됨 — 클립보드가 45초 후 자동으로 지워집니다", + "appx_copy": "복사", + "appx_could_not_start_restore": "복구를 시작할 수 없습니다", + "appx_create_failed_prefix": "생성 실패: ", + "appx_creating_your_wallet": "지갑을 생성하는 중…", + "appx_daemon_error": "데몬 오류", + "appx_daemon_reinstall_in_progress": "데몬 재설치가 이미 진행 중입니다.", + "appx_disconnecting": "연결 해제 중...", + "appx_done": "완료", + "appx_dragonxd_output": "dragonxd 출력", + "appx_encrypting_wallet": "지갑 암호화 중...", + "appx_fullnode_lifecycle_unavailable_lite": "풀노드 수명 주기 작업은 라이트 빌드에서 사용할 수 없습니다", + "appx_installing_bundled_daemon": "번들 데몬을 설치하는 중 — 노드가 중지, 업데이트 후 다시 시작됩니다...", + "appx_invalid_payment_uri_prefix": "잘못된 결제 URI: ", + "appx_ive_written_it_down": "적어 두었습니다", + "appx_keep_node_running_and_quit": "노드 유지하고 종료", + "appx_last_block_n": "마지막 블록: %d", + "appx_last_used_wallet_not_found_prefix": "마지막으로 사용한 지갑 파일(", + "appx_last_used_wallet_not_found_suffix": ")을 찾을 수 없어 기본 지갑을 대신 열었습니다. 옮기셨다면 복원한 후 지갑 목록에서 다시 전환하세요.", + "appx_low_spec_mode_disabled": "저사양 모드 비활성화됨", + "appx_low_spec_mode_enabled": "저사양 모드 활성화됨", + "appx_miner_stopped_prefix": "채굴기 중지됨: ", + "appx_miner_stopped_unexpectedly": "채굴기가 예기치 않게 중지되었습니다.", + "appx_n_min_n_sec": "%d분 %d초", + "appx_n_seconds": "%d초", + "appx_no_bundled_daemon_to_install": "이 빌드에는 설치할 번들 데몬이 없습니다", + "appx_no_embedded_daemon_to_install": "이 빌드에는 설치할 내장 데몬이 없습니다", + "appx_node_busy_restarting": "노드가 재시작 중입니다 — 잠시 후 다시 시도하세요.", + "appx_node_rebuilding_witness_cache": "노드가 위트니스 캐시를 재구성하는 중입니다", + "appx_not_next_word": " — 다음 단어가 아닙니다", + "appx_payment_request_loaded": "결제 요청을 불러왔습니다", + "appx_pool_miner_connected_and_hashing": "풀 채굴기가 연결되어 해싱 중입니다.", + "appx_progress_n_of_n": "진행 상황: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Sapling 노트 위트니스 재구성 중…", + "appx_rebuilding_witness_cache_blocks_left": "위트니스 캐시 재구성 중 %.0f%% — %d개 블록 남음", + "appx_rebuilding_witness_cache_pct": "위트니스 캐시 재구성 중 %.0f%%", + "appx_recovery_phrase_word_count": "복구 문구는 24개 단어여야 합니다 — 현재 %d개입니다.", + "appx_restarting_daemon_rescan_flag": "-rescan 플래그로 데몬을 다시 시작하는 중...", + "appx_restarting_daemon_zapwallettxes": "-zapwallettxes=2로 데몬을 다시 시작하는 중(지갑 복구)...", + "appx_restoring_your_wallet": "지갑을 복구하는 중…", + "appx_seed_backup_warning": "이 24개 단어는 지갑을 복구할 수 있는 유일한 방법입니다. 순서대로 적어 오프라인에 보관하고 절대 공유하지 마세요. 잃어버리면 자금을 영원히 되찾을 수 없습니다.", + "appx_seed_not_backed_up_warning": "시드를 아직 백업하지 않았습니다 — 자금을 잃을 수 있습니다. 그래도 건너뛰시겠습니까?", + "appx_sending_stop_command_to_daemon": "데몬에 중지 명령을 보내는 중...", + "appx_setting_initial_sapling_witnesses": "초기 Sapling 위트니스 설정 중 %.0f%%", + "appx_shutdown_complete": "종료 완료", + "appx_simple_background_disabled": "단순 배경 비활성화됨", + "appx_simple_background_enabled": "단순 배경 활성화됨", + "appx_skip": "건너뛰기", + "appx_skip_anyway": "그래도 건너뛰기", + "appx_still_status_prefix": "아직 \"", + "appx_still_status_suffix": "\" 상태입니다 — 지금 강제 종료하면 체인 데이터가 손상될 수 있습니다.", + "appx_stop_anyway_and_quit": "그래도 중지하고 종료", + "appx_stopping_daemon_deleting_blockchain": "데몬을 중지하고 블록체인 데이터를 삭제하는 중...", + "appx_stopping_node_discards_rebuild": "지금 노드를 중지하면 진행 중인 재구성이 취소되며, 다음에 지갑을 열 때 다시 시작됩니다(몇 분 소요). 대신 노드를 계속 실행할 수 있습니다.", + "appx_stopping_pool_miner": "풀 채굴기를 중지하는 중...", + "appx_syncing_pct_block_n_of_n": "동기화 중 %.1f%% — 블록 %d / %d", + "appx_tap_words_in_order": "저장한 내용을 확인하도록 단어를 올바른 순서대로 탭하세요.", + "appx_theme_effects_disabled": "테마 효과 비활성화됨", + "appx_theme_effects_enabled": "테마 효과 활성화됨", + "appx_theme_prefix": "테마: ", + "appx_use_settings_restart_daemon_hint": "설정 > 데몬 다시 시작을 사용해 다시 시도하세요", + "appx_waiting_for_daemon_to_encrypt_wallet": "데몬이 지갑을 암호화하기를 기다리는 중...", + "appx_wallet_created_and_backed_up": "지갑이 생성되고 백업되었습니다.", + "appx_wallet_open_failed_prefix": "지갑 열기 실패: ", "auto_shield": "채굴 자동 차폐", "av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:", "av_open_security": "Windows 보안 열기", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "시세", + "baltab_market_price_4dp": "시세: $%.4f", + "baltab_market_price_8dp": "시세: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24시간", + "baltab_pct_of_total_zaddr": "전체의 %.0f%% · Z-addr %d개", + "baltab_shielded": "실드됨", + "baltab_shielded_amount": "실드됨 %.8f", + "baltab_t_addresses_count": "T-address %d개", + "baltab_total_balance": "총 잔액", + "baltab_transparent": "투명", + "baltab_transparent_amount": "투명 %.8f", "ban": "차단", "banned_peers": "차단된 피어", "block": "블록", @@ -128,6 +223,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": "현재 암호:", @@ -403,6 +499,7 @@ "contacts_shape_square": "사각형", "contacts_shape_tab": "왼쪽 탭", "copied": "복사됨!", + "copied_to_clipboard": "클립보드에 복사됨", "copy": "복사", "copy_address": "전체 주소 복사", "copy_error": "오류 복사", @@ -415,6 +512,7 @@ "daemon_bundled": "번들", "daemon_install_bundled": "번들 버전 설치", "daemon_installed": "설치됨", + "daemon_maintenance_label": "유지 관리", "daemon_none_bundled": "이 빌드에 없음", "daemon_not_installed": "설치되지 않음", "daemon_status_differ": "설치된 바이너리가 번들 버전과 다릅니다.", @@ -437,6 +535,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 +548,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 +581,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": "지갑을 암호화하는 중...", @@ -566,6 +679,63 @@ "general": "일반", "generating": "생성 중", "go_to_receive": "수신으로 이동", + "grpa_current_block_paren": "(현재: %d)", + "grpa_days_ago": "%lld일 전", + "grpa_dbg_addrman": "피어 주소 추적 및 관리", + "grpa_dbg_alert": "경고 시스템 메시지", + "grpa_dbg_bench": "작업 벤치마크 시간 측정", + "grpa_dbg_coindb": "코인 데이터베이스 읽기/쓰기 작업", + "grpa_dbg_db": "Berkeley DB 작업", + "grpa_dbg_estimatefee": "수수료 추정 알고리즘", + "grpa_dbg_http": "HTTP RPC 서버 활동", + "grpa_dbg_libevent": "Libevent 네트워킹 라이브러리", + "grpa_dbg_lock": "락 경합 디버깅", + "grpa_dbg_mempool": "트랜잭션 메모리 풀 활동", + "grpa_dbg_net": "네트워크 연결 및 메시지", + "grpa_dbg_paymentdisclosure": "결제 공개 프로토콜", + "grpa_dbg_pow": "작업 증명 채굴 활동", + "grpa_dbg_proxy": "SOCKS5 프록시 연결", + "grpa_dbg_prune": "블록 정리 작업", + "grpa_dbg_rand": "난수 생성", + "grpa_dbg_reindex": "블록체인 재색인 진행 상황", + "grpa_dbg_rpc": "RPC 명령 처리", + "grpa_dbg_selectcoins": "트랜잭션용 코인 선택", + "grpa_dbg_tor": "Tor 연동 및 회로 정보", + "grpa_dbg_zmq": "ZeroMQ 알림 시스템", + "grpa_dbg_zrpc": "실드(z-addr) RPC 작업", + "grpa_enter_private_key_to_import": "가져올 개인 키를 입력하세요.", + "grpa_error_prefix": "오류: ", + "grpa_hr_ago": "%lld시간 전", + "grpa_invalid_response_from_daemon": "데몬으로부터 잘못된 응답", + "grpa_invalid_suffix": " (유효하지 않음)", + "grpa_min_ago": "%lld분 전", + "grpa_sec_ago": "%lld초 전", + "grpa_seed_demo_chat": "시드 데모 채팅", + "grpa_showing_first_100_of": "... %d개 중 처음 100개 표시", + "grpa_tab_about": "정보", + "grpa_tab_appearance": "화면 표시", + "grpa_tab_backup_data": "백업 및 데이터", + "grpa_tab_chat": "채팅", + "grpa_tab_explorer": "탐색기", + "grpa_tab_node_security": "노드 및 보안", + "grpa_tab_wallet": "지갑", + "grpa_unexpected_getblockhash_result": "예기치 않은 getblockhash 결과", + "grpb_copy": "복사", + "grpb_max": "최대", + "grpb_new_badge_suffix": " [신규]", + "grpb_preview_msg_payment_through": "결제가 완료됐나요? 🙂", + "grpb_preview_msg_sending_rest": "지금 나머지를 보낼게요 👍", + "grpb_preview_msg_yep_confirmed": "네 — 방금 확인했어요 ✅", + "grpb_selected_suffix": "\n(선택됨)", + "grpb_tooltip_address_balance": "%s\n잔액: %.8f %s%s", + "grpb_undo_clear": "지우기 취소", + "grpc_benchmark_inconclusive": "벤치마크 결과가 불확실합니다: 해시레이트 샘플이 기록되지 않았습니다. 풀 연결을 확인한 후 다시 시도하세요.", + "grpc_benchmark_takes_secs": "벤치마크는 약 %d초가 걸리며 채굴을 중단합니다. 시작하려면 다시 클릭하세요.", + "grpc_bootstrap_failed": "부트스트랩 실패", + "grpc_bootstrap_not_initialized": "부트스트랩이 초기화되지 않았습니다", + "grpc_hashrate_fee": "%s 수수료 %s%%", + "grpc_key_not_available": "이 주소에 대한 키를 사용할 수 없습니다", + "grpc_na": "해당 없음", "height": "높이", "help": "도움말", "hidden_tag": " (숨김)", @@ -640,6 +810,7 @@ "light": "라이트", "lite_account_label": "계정", "lite_action": "작업", + "lite_backend_unavailable": "라이트 지갑 백엔드를 사용할 수 없습니다", "lite_backup_keys": "백업 및 키", "lite_birthday_backup": "생성 블록: %llu (이 값도 백업하세요)", "lite_birthday_hint": "스캔을 시작할 블록 높이입니다. 모르면 0으로 두세요(전체 스캔이 느려짐).", @@ -647,9 +818,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 +908,8 @@ "lite_working": "작업 중…", "loading": "로딩 중...", "loading_addresses": "주소 로딩 중...", + "loading_stall_body": "데몬이 %.0f초 동안 초기화 중입니다. 업데이트 후나 첫 실행 시(블록 인덱스 로드 또는 재스캔)에는 정상일 수 있습니다. 준비되면 자동으로 연결됩니다.", + "loading_stall_title": "예상보다 오래 걸리고 있습니다", "loading_transactions": "거래를 불러오는 중", "local_hashrate": "로컬 해시레이트", "low_spec_mode": "저사양 모드", @@ -866,6 +1042,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 +1071,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 +1106,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 +1135,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 +1278,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 +1329,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 +1344,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 +1361,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,12 +1375,52 @@ "sb_waiting_daemon_err": "dragonxd 대기 중 — %s", "sb_warming_up": "워밍업 중...", "sb_witness_cache": "증인 재구축 중", + "scale_effects": "배율 및 효과", "screenshot_open_dir": "위치 열기", "screenshot_sweep": "스크린샷 스윕 실행", "screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.", "screenshot_sweep_full": "전체 UI 스윕", "search_icons": "아이콘 검색...", "search_placeholder": "검색...", + "sec_changing_passphrase": "암호문 변경 중...", + "sec_changing_pin": "PIN 변경 중...", + "sec_couldnt_lock_wallet": "지갑을 잠글 수 없습니다 — 아직 잠금 해제 상태입니다. 데몬 연결을 확인하세요.", + "sec_encrypted_backup_suffix": "\n암호화된 백업: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "지갑 암호화 중...", + "sec_encryption_did_not_complete": "지갑 암호화가 완료되지 않았습니다 — 지갑이 암호화되지 않은 상태입니다. 설정을 열어 암호화를 완료하세요.", + "sec_encryption_failed_prefix": "암호화 실패: ", + "sec_failed_prefix": "실패: ", + "sec_failed_to_create_vault": "볼트 생성에 실패했습니다", + "sec_importing_keys_rescanning": "키를 가져오고 블록체인을 다시 스캔하는 중 — 진행 중에도 지갑을 사용할 수 있습니다", + "sec_incorrect_current_pin": "현재 PIN이 올바르지 않습니다", + "sec_incorrect_passphrase_decrypt": "잘못된 암호문", + "sec_incorrect_passphrase_pin_setup": "잘못된 암호문", + "sec_incorrect_pin_remove": "잘못된 PIN", + "sec_internal_error_change_pin": "내부 오류", + "sec_internal_error_remove_pin": "내부 오류", + "sec_mode_passphrase": " 암호문", + "sec_not_connected_to_daemon": "데몬에 연결되지 않음", + "sec_not_connected_to_daemon_pin": "데몬에 연결되지 않음", + "sec_passphrase_changed_successfully": "암호문이 성공적으로 변경되었습니다", + "sec_pin_changed_successfully": "PIN이 성공적으로 변경되었습니다", + "sec_pin_removed": "PIN이 제거되었습니다", + "sec_pin_set_successfully": "PIN이 성공적으로 설정되었습니다", + "sec_restart_daemon_for_encryption": "암호화를 적용하려면 데몬을 다시 시작하세요.", + "sec_too_many_attempts_wait": "시도 횟수가 너무 많습니다. %.0f초 기다리세요...", + "sec_total_elapsed_fmt": "총 경과 시간: %d분 %02d초", + "sec_unlock_button": "잠금 해제", + "sec_unlock_failed_prefix": "잠금 해제 실패: ", + "sec_unlocking_fmt": "잠금 해제 중%s", + "sec_use_passphrase_instead": "대신 암호문 사용", + "sec_use_pin_instead": "대신 PIN 사용", + "sec_verifying_passphrase": "암호문 확인 중...", + "sec_verifying_pin": "PIN 확인 중...", + "sec_wallet_decrypted_all_keys_imported": "지갑 복호화 완료! 모든 키를 가져왔습니다.", + "sec_wallet_encrypted_and_pin_set": "지갑 암호화 및 PIN 설정 완료", + "sec_wallet_encrypted_but_pin_vault_failed": "지갑은 암호화되었으나 PIN 볼트 설정에 실패했습니다", + "sec_wallet_encrypted_restarting_daemon": "지갑이 암호화되었습니다. 데몬을 다시 시작하는 중...", + "sec_wallet_encrypted_successfully": "지갑이 성공적으로 암호화되었습니다", + "sec_wallet_locked_title": "지갑 잠김", "security": "보안", "seed_backup_button": "시드 문구", "seed_backup_close": "닫기", @@ -1246,6 +1479,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 +1499,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 +1519,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": "상태 확인", @@ -1397,6 +1642,17 @@ "sweep_to": "쓸어담은 주소:", "sweep_toggle": "내 지갑으로 쓸어담기 (키 보관 안 함)", "sweep_tx": "거래:", + "swin_connection_failed": "연결 실패: ", + "swin_connection_successful": "연결에 성공했습니다!\ndragonxd 버전: ", + "swin_invalid_suffix": " (유효하지 않음)", + "swin_no_history_file_found": "기록 파일을 찾을 수 없습니다", + "swin_rescan_failed": "다시 스캔 실패: ", + "swin_rescan_started_from_block": "다시 스캔이 시작된 블록 ", + "swin_rescan_to": " ~ ", + "swin_rpc_client_not_initialized": "RPC 클라이언트가 초기화되지 않았습니다", + "swin_settings_saved": "설정이 저장되었습니다", + "swin_theme_list_refreshed": "테마 목록을 새로고침했습니다", + "swin_ztx_history_cleared": "Z-트랜잭션 기록이 삭제되었습니다", "switch_corrupt_body": "이 지갑이 손상된 것 같습니다. 노드가 열 수 없습니다. 백업에서 복원하거나 다시 만들거나 복구를 시도하세요.", "switch_corrupt_repair": "복구 시도(salvage)", "switch_progress_background": "백그라운드에서 계속", @@ -1421,6 +1677,7 @@ "theme": "테마", "theme_effects": "테마 효과", "theme_language": "테마 및 언어", + "tile_click_to_open": "클릭하여 열기", "time_days_ago": "%d일 전", "time_hours_ago": "%d시간 전", "time_minutes_ago": "%d분 전", @@ -1435,7 +1692,9 @@ "to_upper": "받는 곳", "tools": "도구", "tools_actions": "도구 및 작업...", + "tools_actions_hdr": "도구 및 작업", "total": "합계", + "total_balance_label": "총 잔액", "transaction_id": "거래 ID", "transaction_sent": "거래 전송 성공", "transaction_sent_msg": "거래가 전송되었습니다!", @@ -1457,7 +1716,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 +1729,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 +1779,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 +1818,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 +1871,7 @@ "validate_not_mine": "이 지갑에 속하지 않음", "validate_ownership": "소유자:", "validate_results": "결과:", + "validate_results_placeholder": "결과가 여기에 표시됩니다", "validate_shielded_type": "차폐 (z 주소)", "validate_status": "상태:", "validate_title": "주소 검증", @@ -1750,6 +2012,7 @@ "xmrig_loading_releases": "릴리스를 불러오는 중…", "xmrig_none": "없음", "xmrig_reinstall": "재설치", + "xmrig_releases": "xmrig 릴리스", "xmrig_stop_mining_first": "채굴기를 업데이트하기 전에 채굴을 중지하세요.", "xmrig_unavailable_body": "이 플랫폼에서 사용 가능한 채굴기 빌드가 없습니다.", "xmrig_unavailable_title": "채굴기 업데이트를 사용할 수 없습니다", diff --git a/res/lang/pt.json b/res/lang/pt.json index 76b9db0..bc244ea 100644 --- a/res/lang/pt.json +++ b/res/lang/pt.json @@ -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", @@ -56,6 +60,80 @@ "amount_label": "Valor:", "animate_avatars": "Animar avatares", "appearance": "APARÊNCIA", + "appx_back": "Voltar", + "appx_back_up_seed_phrase_title": "Faça o backup da sua frase-semente", + "appx_birthday_block_height": "Nascimento (altura do bloco): %llu — faça o backup disto também.", + "appx_blockchain_data_deleted": "Dados da blockchain excluídos (%d itens). O daemon está reiniciando para ressincronizar com a rede.", + "appx_blockchain_maintenance_in_progress": "Uma operação de manutenção da blockchain já está em andamento.", + "appx_blockchain_rescan_complete": "Reescaneamento da blockchain concluído", + "appx_bootstrap_complete_reconciling": "Bootstrap concluído — reconciliando sua carteira com os novos dados da chain.", + "appx_cancel": "Cancelar", + "appx_cleaning_up": "Limpando...", + "appx_confirm_your_backup": "Confirme seu backup", + "appx_copied_clipboard_autoclears": "Copiado — a área de transferência será limpa em 45s", + "appx_copy": "Copiar", + "appx_could_not_start_restore": "Não foi possível iniciar a restauração", + "appx_create_failed_prefix": "Falha na criação: ", + "appx_creating_your_wallet": "Criando sua carteira…", + "appx_daemon_error": "Erro do Daemon", + "appx_daemon_reinstall_in_progress": "A reinstalação do daemon já está em andamento.", + "appx_disconnecting": "Desconectando...", + "appx_done": "Concluído", + "appx_dragonxd_output": "saída do dragonxd", + "appx_encrypting_wallet": "Criptografando a carteira...", + "appx_fullnode_lifecycle_unavailable_lite": "As ações de ciclo de vida do nó completo não estão disponíveis na versão lite", + "appx_installing_bundled_daemon": "Instalando o daemon incluído — o nó vai parar, atualizar e reiniciar...", + "appx_invalid_payment_uri_prefix": "URI de pagamento inválida: ", + "appx_ive_written_it_down": "Já anotei", + "appx_keep_node_running_and_quit": "Manter o nó em execução e sair", + "appx_last_block_n": "Último bloco: %d", + "appx_last_used_wallet_not_found_prefix": "Seu último arquivo de carteira usado (", + "appx_last_used_wallet_not_found_suffix": ") não foi encontrado — a carteira padrão foi aberta em seu lugar. Se você o moveu, restaure-o e volte a ele pela lista de carteiras.", + "appx_low_spec_mode_disabled": "Modo de baixo desempenho desativado", + "appx_low_spec_mode_enabled": "Modo de baixo desempenho ativado", + "appx_miner_stopped_prefix": "Minerador parado: ", + "appx_miner_stopped_unexpectedly": "O minerador parou inesperadamente.", + "appx_n_min_n_sec": "%d min %d s", + "appx_n_seconds": "%d segundos", + "appx_no_bundled_daemon_to_install": "Esta versão não tem daemon incluído para instalar", + "appx_no_embedded_daemon_to_install": "Esta versão não tem daemon embutido para instalar", + "appx_node_busy_restarting": "O nó está ocupado reiniciando — tente novamente em instantes.", + "appx_node_rebuilding_witness_cache": "O nó está reconstruindo seu cache de testemunhas", + "appx_not_next_word": " — essa não é a próxima palavra", + "appx_payment_request_loaded": "Solicitação de pagamento carregada", + "appx_pool_miner_connected_and_hashing": "Minerador de pool conectado e processando hashes.", + "appx_progress_n_of_n": "Progresso: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Reconstruindo testemunhas de notas Sapling…", + "appx_rebuilding_witness_cache_blocks_left": "Reconstruindo cache de testemunhas %.0f%% — %d blocos restantes", + "appx_rebuilding_witness_cache_pct": "Reconstruindo cache de testemunhas %.0f%%", + "appx_recovery_phrase_word_count": "A frase de recuperação deve ter 24 palavras — você tem %d.", + "appx_restarting_daemon_rescan_flag": "Reiniciando o daemon com a flag -rescan...", + "appx_restarting_daemon_zapwallettxes": "Reiniciando o daemon com -zapwallettxes=2 (reparo da carteira)...", + "appx_restoring_your_wallet": "Restaurando sua carteira…", + "appx_seed_backup_warning": "Estas 24 palavras são a ÚNICA forma de restaurar sua carteira. Anote-as na ordem, guarde-as offline e nunca as compartilhe. Se perdê-las, seus fundos desaparecem para sempre.", + "appx_seed_not_backed_up_warning": "Você não fez o backup da sua semente — os fundos podem ser perdidos. Pular mesmo assim?", + "appx_sending_stop_command_to_daemon": "Enviando comando de parada ao daemon...", + "appx_setting_initial_sapling_witnesses": "Definindo testemunhas Sapling iniciais %.0f%%", + "appx_shutdown_complete": "Encerramento concluído", + "appx_simple_background_disabled": "Fundo simples desativado", + "appx_simple_background_enabled": "Fundo simples ativado", + "appx_skip": "Pular", + "appx_skip_anyway": "Pular mesmo assim", + "appx_still_status_prefix": "Ainda \"", + "appx_still_status_suffix": "\" — forçar o encerramento agora pode corromper os dados da chain.", + "appx_stop_anyway_and_quit": "Parar mesmo assim e sair", + "appx_stopping_daemon_deleting_blockchain": "Parando o daemon e excluindo os dados da blockchain...", + "appx_stopping_node_discards_rebuild": "Parar o nó agora descarta a reconstrução em andamento e a reinicia (vários minutos) na próxima vez que você abrir a carteira. Em vez disso, você pode manter o nó em execução.", + "appx_stopping_pool_miner": "Parando o minerador de pool...", + "appx_syncing_pct_block_n_of_n": "Sincronizando %.1f%% — Bloco %d / %d", + "appx_tap_words_in_order": "Toque nas palavras na ordem correta para confirmar que você as salvou.", + "appx_theme_effects_disabled": "Efeitos de tema desativados", + "appx_theme_effects_enabled": "Efeitos de tema ativados", + "appx_theme_prefix": "Tema: ", + "appx_use_settings_restart_daemon_hint": "Use Configurações > Reiniciar Daemon para tentar novamente", + "appx_waiting_for_daemon_to_encrypt_wallet": "Aguardando o daemon criptografar a carteira...", + "appx_wallet_created_and_backed_up": "Carteira criada e com backup feito.", + "appx_wallet_open_failed_prefix": "Falha ao abrir a carteira: ", "auto_shield": "Auto-blindar mineração", "av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:", "av_open_security": "Abrir Segurança do Windows", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "Mercado", + "baltab_market_price_4dp": "Mercado: $%.4f", + "baltab_market_price_8dp": "Mercado: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24h", + "baltab_pct_of_total_zaddr": "%.0f%% do total · %d Z-addr", + "baltab_shielded": "Blindado", + "baltab_shielded_amount": "Blindado %.8f", + "baltab_t_addresses_count": "%d T-addresses", + "baltab_total_balance": "Saldo Total", + "baltab_transparent": "Transparente", + "baltab_transparent_amount": "Transparente %.8f", "ban": "Banir", "banned_peers": "Pares Banidos", "block": "Bloco", @@ -128,6 +223,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:", @@ -403,6 +499,7 @@ "contacts_shape_square": "Quadrado", "contacts_shape_tab": "Aba", "copied": "Copiado!", + "copied_to_clipboard": "Copiado para a área de transferência", "copy": "Copiar", "copy_address": "Copiar Endereço Completo", "copy_error": "Copiar Erro", @@ -415,6 +512,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 +535,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 +548,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 +581,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...", @@ -566,6 +679,63 @@ "general": "Geral", "generating": "Gerando", "go_to_receive": "Ir para Receber", + "grpa_current_block_paren": "(Atual: %d)", + "grpa_days_ago": "há %lld dias", + "grpa_dbg_addrman": "Rastreamento e gerenciamento de endereços de pares", + "grpa_dbg_alert": "Mensagens do sistema de alertas", + "grpa_dbg_bench": "Medições de tempo de benchmark das operações", + "grpa_dbg_coindb": "Operações de leitura/gravação do banco de dados de moedas", + "grpa_dbg_db": "Operações do Berkeley DB", + "grpa_dbg_estimatefee": "Algoritmo de estimativa de taxa", + "grpa_dbg_http": "Atividade do servidor RPC HTTP", + "grpa_dbg_libevent": "Biblioteca de rede Libevent", + "grpa_dbg_lock": "Depuração de contenção de bloqueios", + "grpa_dbg_mempool": "Atividade do pool de memória de transações", + "grpa_dbg_net": "Conexões e mensagens de rede", + "grpa_dbg_paymentdisclosure": "Protocolo de divulgação de pagamento", + "grpa_dbg_pow": "Atividade de mineração de prova de trabalho", + "grpa_dbg_proxy": "Conexões de proxy SOCKS5", + "grpa_dbg_prune": "Operações de poda de blocos", + "grpa_dbg_rand": "Geração de números aleatórios", + "grpa_dbg_reindex": "Progresso da reindexação da blockchain", + "grpa_dbg_rpc": "Processamento de comandos RPC", + "grpa_dbg_selectcoins": "Seleção de moedas para transações", + "grpa_dbg_tor": "Integração com Tor e informações de circuito", + "grpa_dbg_zmq": "Sistema de notificações ZeroMQ", + "grpa_dbg_zrpc": "Operações RPC blindadas (z-address)", + "grpa_enter_private_key_to_import": "Insira uma chave privada para importar.", + "grpa_error_prefix": "Erro: ", + "grpa_hr_ago": "há %lld h", + "grpa_invalid_response_from_daemon": "Resposta inválida do daemon", + "grpa_invalid_suffix": " (inválido)", + "grpa_min_ago": "há %lld min", + "grpa_sec_ago": "há %lld s", + "grpa_seed_demo_chat": "Chat de demonstração da semente", + "grpa_showing_first_100_of": "... mostrando os primeiros 100 de %d", + "grpa_tab_about": "Sobre", + "grpa_tab_appearance": "Aparência", + "grpa_tab_backup_data": "Backup e Dados", + "grpa_tab_chat": "Chat", + "grpa_tab_explorer": "Explorer", + "grpa_tab_node_security": "Nó e Segurança", + "grpa_tab_wallet": "Carteira", + "grpa_unexpected_getblockhash_result": "resultado inesperado de getblockhash", + "grpb_copy": "Copiar", + "grpb_max": "Máx", + "grpb_new_badge_suffix": " [NOVO]", + "grpb_preview_msg_payment_through": "O pagamento foi concluído? 🙂", + "grpb_preview_msg_sending_rest": "Enviando o restante agora 👍", + "grpb_preview_msg_yep_confirmed": "Sim — acabei de confirmar ✅", + "grpb_selected_suffix": "\n(selecionado)", + "grpb_tooltip_address_balance": "%s\nSaldo: %.8f %s%s", + "grpb_undo_clear": "Desfazer Limpar", + "grpc_benchmark_inconclusive": "Benchmark inconclusivo: nenhuma amostra de taxa de hash foi registrada. Verifique a conexão com a pool e tente novamente.", + "grpc_benchmark_takes_secs": "O benchmark leva cerca de %ds e interrompe a mineração. Clique novamente para iniciar.", + "grpc_bootstrap_failed": "Falha no bootstrap", + "grpc_bootstrap_not_initialized": "Bootstrap não inicializado", + "grpc_hashrate_fee": "%s %s%% de taxa", + "grpc_key_not_available": "Chave não disponível para este endereço", + "grpc_na": "N/D", "height": "Altura", "help": "Ajuda", "hidden_tag": " (oculto)", @@ -640,6 +810,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 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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 +1345,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 +1362,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,12 +1376,52 @@ "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.", "screenshot_sweep_full": "Varredura completa da interface", "search_icons": "Pesquisar ícones...", "search_placeholder": "Pesquisar...", + "sec_changing_passphrase": "Alterando a frase-senha...", + "sec_changing_pin": "Alterando o PIN...", + "sec_couldnt_lock_wallet": "Não foi possível bloquear a carteira — ela continua desbloqueada. Verifique a conexão com o daemon.", + "sec_encrypted_backup_suffix": "\nBackup criptografado: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "Criptografando a carteira...", + "sec_encryption_did_not_complete": "A criptografia da carteira não foi concluída — sua carteira NÃO está criptografada. Abra as Configurações para terminar de criptografá-la.", + "sec_encryption_failed_prefix": "Falha na criptografia: ", + "sec_failed_prefix": "Falhou: ", + "sec_failed_to_create_vault": "Falha ao criar o cofre", + "sec_importing_keys_rescanning": "Importando chaves e reescaneando a blockchain — a carteira pode ser usada durante o processo", + "sec_incorrect_current_pin": "PIN atual incorreto", + "sec_incorrect_passphrase_decrypt": "Frase-senha incorreta", + "sec_incorrect_passphrase_pin_setup": "Frase-senha incorreta", + "sec_incorrect_pin_remove": "PIN incorreto", + "sec_internal_error_change_pin": "Erro interno", + "sec_internal_error_remove_pin": "Erro interno", + "sec_mode_passphrase": " Frase-senha", + "sec_not_connected_to_daemon": "Não conectado ao daemon", + "sec_not_connected_to_daemon_pin": "Não conectado ao daemon", + "sec_passphrase_changed_successfully": "Frase-senha alterada com sucesso", + "sec_pin_changed_successfully": "PIN alterado com sucesso", + "sec_pin_removed": "PIN removido", + "sec_pin_set_successfully": "PIN definido com sucesso", + "sec_restart_daemon_for_encryption": "Reinicie o daemon para que a criptografia entre em vigor.", + "sec_too_many_attempts_wait": "Tentativas em excesso. Aguarde %.0f segundos...", + "sec_total_elapsed_fmt": "Tempo total: %dm %02ds", + "sec_unlock_button": "Desbloquear", + "sec_unlock_failed_prefix": "Falha ao desbloquear: ", + "sec_unlocking_fmt": "Desbloqueando%s", + "sec_use_passphrase_instead": "Usar frase-senha", + "sec_use_pin_instead": "Usar PIN", + "sec_verifying_passphrase": "Verificando a frase-senha...", + "sec_verifying_pin": "Verificando o PIN...", + "sec_wallet_decrypted_all_keys_imported": "Carteira descriptografada com sucesso! Todas as chaves importadas.", + "sec_wallet_encrypted_and_pin_set": "Carteira criptografada e PIN definido", + "sec_wallet_encrypted_but_pin_vault_failed": "Carteira criptografada, mas o cofre do PIN falhou", + "sec_wallet_encrypted_restarting_daemon": "Carteira criptografada. Reiniciando o daemon...", + "sec_wallet_encrypted_successfully": "Carteira criptografada com sucesso", + "sec_wallet_locked_title": "Carteira Bloqueada", "security": "SEGURANÇA", "seed_backup_button": "Frase de recuperação", "seed_backup_close": "Fechar", @@ -1246,6 +1480,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 +1500,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 +1520,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", @@ -1397,6 +1643,17 @@ "sweep_to": "Varrido para:", "sweep_toggle": "Varrer para minha carteira (não manter a chave)", "sweep_tx": "Transação:", + "swin_connection_failed": "Falha na conexão: ", + "swin_connection_successful": "Conexão bem-sucedida!\nversão do dragonxd: ", + "swin_invalid_suffix": " (inválido)", + "swin_no_history_file_found": "Nenhum arquivo de histórico encontrado", + "swin_rescan_failed": "Falha no reescaneamento: ", + "swin_rescan_started_from_block": "Reescaneamento iniciado a partir do bloco ", + "swin_rescan_to": " até ", + "swin_rpc_client_not_initialized": "Cliente RPC não inicializado", + "swin_settings_saved": "Configurações salvas", + "swin_theme_list_refreshed": "Lista de temas atualizada", + "swin_ztx_history_cleared": "Histórico de transações Z limpo", "switch_corrupt_body": "Esta carteira parece corrompida — o nó não conseguiu abri-la. Restaure de um backup, recrie-a ou tente repará-la.", "switch_corrupt_repair": "Tentar reparar (salvage)", "switch_progress_background": "Continuar em segundo plano", @@ -1421,6 +1678,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 +1693,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 +1717,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 +1730,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 +1780,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 +1819,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 +1872,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 +2013,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", diff --git a/res/lang/ru.json b/res/lang/ru.json index e173208..2c6dd82 100644 --- a/res/lang/ru.json +++ b/res/lang/ru.json @@ -48,6 +48,10 @@ "advanced": "ПРОЧЕЕ", "advanced_effects": "Расширенные эффекты...", "ago": "назад", + "alerts_clear": "Очистить историю оповещений", + "alerts_history_tooltip": "Недавние оповещения", + "alerts_none": "Пока нет оповещений", + "alerts_recent": "НЕДАВНИЕ ОПОВЕЩЕНИЯ", "all_filter": "Все", "allow_custom_fees": "Разрешить пользовательские комиссии", "amount": "Сумма", @@ -56,6 +60,80 @@ "amount_label": "Сумма:", "animate_avatars": "Анимировать аватары", "appearance": "ВНЕШНИЙ ВИД", + "appx_back": "Назад", + "appx_back_up_seed_phrase_title": "Сохраните seed-фразу", + "appx_birthday_block_height": "Дата создания (высота блока): %llu — сохраните и её.", + "appx_blockchain_data_deleted": "Данные блокчейна удалены (%d элементов). Демон перезапускается для повторной синхронизации с сетью.", + "appx_blockchain_maintenance_in_progress": "Операция обслуживания блокчейна уже выполняется.", + "appx_blockchain_rescan_complete": "Повторное сканирование блокчейна завершено", + "appx_bootstrap_complete_reconciling": "Начальная загрузка завершена — сверка кошелька с новыми данными цепи.", + "appx_cancel": "Отмена", + "appx_cleaning_up": "Очистка...", + "appx_confirm_your_backup": "Подтвердите резервную копию", + "appx_copied_clipboard_autoclears": "Скопировано — буфер обмена очистится через 45 с", + "appx_copy": "Копировать", + "appx_could_not_start_restore": "Не удалось начать восстановление", + "appx_create_failed_prefix": "Ошибка создания: ", + "appx_creating_your_wallet": "Создание кошелька…", + "appx_daemon_error": "Ошибка демона", + "appx_daemon_reinstall_in_progress": "Переустановка демона уже выполняется.", + "appx_disconnecting": "Отключение...", + "appx_done": "Готово", + "appx_dragonxd_output": "вывод dragonxd", + "appx_encrypting_wallet": "Шифрование кошелька...", + "appx_fullnode_lifecycle_unavailable_lite": "Действия жизненного цикла полного узла недоступны в lite-сборке", + "appx_installing_bundled_daemon": "Установка встроенного демона — узел остановится, обновится и перезапустится...", + "appx_invalid_payment_uri_prefix": "Неверный платёжный URI: ", + "appx_ive_written_it_down": "Я записал её", + "appx_keep_node_running_and_quit": "Оставить узел и выйти", + "appx_last_block_n": "Последний блок: %d", + "appx_last_used_wallet_not_found_prefix": "Ваш последний использованный файл кошелька (", + "appx_last_used_wallet_not_found_suffix": ") не найден — вместо него открыт кошелёк по умолчанию. Если вы его переместили, восстановите файл и переключитесь обратно из списка кошельков.", + "appx_low_spec_mode_disabled": "Режим слабого оборудования выключен", + "appx_low_spec_mode_enabled": "Режим слабого оборудования включён", + "appx_miner_stopped_prefix": "Майнер остановлен: ", + "appx_miner_stopped_unexpectedly": "Майнер неожиданно остановился.", + "appx_n_min_n_sec": "%d мин %d сек", + "appx_n_seconds": "%d сек", + "appx_no_bundled_daemon_to_install": "В этой сборке нет встроенного демона для установки", + "appx_no_embedded_daemon_to_install": "В этой сборке нет встроенного демона для установки", + "appx_node_busy_restarting": "Узел занят перезапуском — попробуйте ещё раз через мгновение.", + "appx_node_rebuilding_witness_cache": "Узел перестраивает кэш свидетелей", + "appx_not_next_word": " — это не следующее слово", + "appx_payment_request_loaded": "Запрос на оплату загружен", + "appx_pool_miner_connected_and_hashing": "Пул-майнер подключён и вычисляет хеши.", + "appx_progress_n_of_n": "Прогресс: %d / %d", + "appx_rebuilding_sapling_note_witnesses": "Перестройка свидетелей заметок Sapling…", + "appx_rebuilding_witness_cache_blocks_left": "Перестройка кэша свидетелей %.0f%% — осталось блоков: %d", + "appx_rebuilding_witness_cache_pct": "Перестройка кэша свидетелей %.0f%%", + "appx_recovery_phrase_word_count": "Фраза восстановления должна содержать 24 слова — у вас %d.", + "appx_restarting_daemon_rescan_flag": "Перезапуск демона с флагом -rescan...", + "appx_restarting_daemon_zapwallettxes": "Перезапуск демона с -zapwallettxes=2 (восстановление кошелька)...", + "appx_restoring_your_wallet": "Восстановление кошелька…", + "appx_seed_backup_warning": "Эти 24 слова — ЕДИНСТВЕННЫЙ способ восстановить кошелёк. Запишите их по порядку, храните офлайн и никому не сообщайте. Если вы их потеряете, средства пропадут навсегда.", + "appx_seed_not_backed_up_warning": "Вы не сохранили seed-фразу — средства могут быть потеряны. Всё равно пропустить?", + "appx_sending_stop_command_to_daemon": "Отправка команды остановки демону...", + "appx_setting_initial_sapling_witnesses": "Установка начальных свидетелей Sapling %.0f%%", + "appx_shutdown_complete": "Завершение работы выполнено", + "appx_simple_background_disabled": "Простой фон выключен", + "appx_simple_background_enabled": "Простой фон включён", + "appx_skip": "Пропустить", + "appx_skip_anyway": "Всё равно пропустить", + "appx_still_status_prefix": "Всё ещё \"", + "appx_still_status_suffix": "\" — принудительный выход сейчас может повредить данные цепи.", + "appx_stop_anyway_and_quit": "Всё равно остановить и выйти", + "appx_stopping_daemon_deleting_blockchain": "Остановка демона и удаление данных блокчейна...", + "appx_stopping_node_discards_rebuild": "Остановка узла сейчас отменит текущую перестройку и запустит её заново (несколько минут) при следующем открытии кошелька. Вместо этого можно оставить узел работать.", + "appx_stopping_pool_miner": "Остановка пул-майнера...", + "appx_syncing_pct_block_n_of_n": "Синхронизация %.1f%% — Блок %d / %d", + "appx_tap_words_in_order": "Нажимайте слова в правильном порядке, чтобы подтвердить, что вы их сохранили.", + "appx_theme_effects_disabled": "Эффекты темы выключены", + "appx_theme_effects_enabled": "Эффекты темы включены", + "appx_theme_prefix": "Тема: ", + "appx_use_settings_restart_daemon_hint": "Откройте Настройки > Перезапустить демон, чтобы повторить попытку", + "appx_waiting_for_daemon_to_encrypt_wallet": "Ожидание шифрования кошелька демоном...", + "appx_wallet_created_and_backed_up": "Кошелёк создан, резервная копия сохранена.", + "appx_wallet_open_failed_prefix": "Не удалось открыть кошелёк: ", "auto_shield": "Авто-экранирование майнинга", "av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:", "av_open_security": "Открыть Безопасность Windows", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "Рынок", + "baltab_market_price_4dp": "Рынок: $%.4f", + "baltab_market_price_8dp": "Рынок: $%.8f", + "baltab_pct_change_24h": "%s%.1f%% за 24ч", + "baltab_pct_of_total_zaddr": "%.0f%% от общего · %d Z-addr", + "baltab_shielded": "Скрытый", + "baltab_shielded_amount": "Скрытый %.8f", + "baltab_t_addresses_count": "%d T-адресов", + "baltab_total_balance": "Общий баланс", + "baltab_transparent": "Прозрачный", + "baltab_transparent_amount": "Прозрачный %.8f", "ban": "Заблокировать", "banned_peers": "Заблокированные узлы", "block": "Блок", @@ -128,6 +223,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": "Текущий пароль:", @@ -403,6 +499,7 @@ "contacts_shape_square": "Квадрат", "contacts_shape_tab": "Вкладка", "copied": "Скопировано!", + "copied_to_clipboard": "Скопировано в буфер обмена", "copy": "Копировать", "copy_address": "Копировать полный адрес", "copy_error": "Копировать ошибку", @@ -415,6 +512,7 @@ "daemon_bundled": "Встроенный", "daemon_install_bundled": "Установить встроенную", "daemon_installed": "Установлено", + "daemon_maintenance_label": "ОБСЛУЖИВАНИЕ", "daemon_none_bundled": "нет в этой сборке", "daemon_not_installed": "не установлен", "daemon_status_differ": "Установленный бинарный файл отличается от встроенной версии.", @@ -437,6 +535,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 +548,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 +581,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": "Шифрование кошелька...", @@ -566,6 +679,63 @@ "general": "Общие", "generating": "Генерация", "go_to_receive": "Перейти к получению", + "grpa_current_block_paren": "(Текущий: %d)", + "grpa_days_ago": "%lld дн назад", + "grpa_dbg_addrman": "Отслеживание и управление адресами узлов", + "grpa_dbg_alert": "Сообщения системы оповещений", + "grpa_dbg_bench": "Замеры производительности операций", + "grpa_dbg_coindb": "Операции чтения/записи базы монет", + "grpa_dbg_db": "Операции Berkeley DB", + "grpa_dbg_estimatefee": "Алгоритм оценки комиссии", + "grpa_dbg_http": "Активность HTTP RPC-сервера", + "grpa_dbg_libevent": "Сетевая библиотека Libevent", + "grpa_dbg_lock": "Отладка конкуренции блокировок", + "grpa_dbg_mempool": "Активность пула транзакций в памяти", + "grpa_dbg_net": "Сетевые соединения и сообщения", + "grpa_dbg_paymentdisclosure": "Протокол раскрытия платежей", + "grpa_dbg_pow": "Активность майнинга proof-of-work", + "grpa_dbg_proxy": "Соединения через прокси SOCKS5", + "grpa_dbg_prune": "Операции обрезки блоков", + "grpa_dbg_rand": "Генерация случайных чисел", + "grpa_dbg_reindex": "Прогресс переиндексации блокчейна", + "grpa_dbg_rpc": "Обработка RPC-команд", + "grpa_dbg_selectcoins": "Выбор монет для транзакций", + "grpa_dbg_tor": "Интеграция Tor и сведения о цепочках", + "grpa_dbg_zmq": "Система уведомлений ZeroMQ", + "grpa_dbg_zrpc": "Скрытые (z-addr) RPC-операции", + "grpa_enter_private_key_to_import": "Введите приватный ключ для импорта.", + "grpa_error_prefix": "Ошибка: ", + "grpa_hr_ago": "%lld ч назад", + "grpa_invalid_response_from_daemon": "Неверный ответ от демона", + "grpa_invalid_suffix": " (неверный)", + "grpa_min_ago": "%lld мин назад", + "grpa_sec_ago": "%lld сек назад", + "grpa_seed_demo_chat": "Демо-чат seed", + "grpa_showing_first_100_of": "... показаны первые 100 из %d", + "grpa_tab_about": "О программе", + "grpa_tab_appearance": "Внешний вид", + "grpa_tab_backup_data": "Резервное копирование и данные", + "grpa_tab_chat": "Чат", + "grpa_tab_explorer": "Обозреватель", + "grpa_tab_node_security": "Узел и безопасность", + "grpa_tab_wallet": "Кошелёк", + "grpa_unexpected_getblockhash_result": "неожиданный результат getblockhash", + "grpb_copy": "Копировать", + "grpb_max": "Макс", + "grpb_new_badge_suffix": " [НОВОЕ]", + "grpb_preview_msg_payment_through": "Платёж прошёл? 🙂", + "grpb_preview_msg_sending_rest": "Отправляю остаток 👍", + "grpb_preview_msg_yep_confirmed": "Да — только что подтвердил ✅", + "grpb_selected_suffix": "\n(выбрано)", + "grpb_tooltip_address_balance": "%s\nБаланс: %.8f %s%s", + "grpb_undo_clear": "Отменить очистку", + "grpc_benchmark_inconclusive": "Бенчмарк не дал результата: не записано ни одной выборки хешрейта. Проверьте соединение с пулом и повторите попытку.", + "grpc_benchmark_takes_secs": "Бенчмарк занимает ~%dс и прерывает майнинг. Нажмите ещё раз для запуска.", + "grpc_bootstrap_failed": "Ошибка начальной загрузки", + "grpc_bootstrap_not_initialized": "Начальная загрузка не инициализирована", + "grpc_hashrate_fee": "%s комиссия %s%%", + "grpc_key_not_available": "Ключ недоступен для этого адреса", + "grpc_na": "Н/Д", "height": "Высота", "help": "Справка", "hidden_tag": " (скрыт)", @@ -640,6 +810,7 @@ "light": "Светлая", "lite_account_label": "Аккаунт", "lite_action": "Действие", + "lite_backend_unavailable": "Бэкенд облегчённого кошелька недоступен", "lite_backup_keys": "Резервная копия и ключи", "lite_birthday_backup": "Дата рождения: %llu (сохраните её тоже)", "lite_birthday_hint": "Высота блока, с которой начинать сканирование. Оставьте 0, если неизвестно (медленное полное сканирование).", @@ -647,9 +818,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 +908,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 +1043,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 +1072,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 +1107,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 +1136,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 +1279,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 +1330,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 +1345,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 +1362,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,12 +1376,52 @@ "sb_waiting_daemon_err": "Ожидание dragonxd — %s", "sb_warming_up": "Прогрев...", "sb_witness_cache": "Перестроение свидетелей", + "scale_effects": "МАСШТАБ И ЭФФЕКТЫ", "screenshot_open_dir": "Открыть расположение", "screenshot_sweep": "Запустить прогон скриншотов", "screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.", "screenshot_sweep_full": "Полный обход интерфейса", "search_icons": "Поиск значков...", "search_placeholder": "Поиск...", + "sec_changing_passphrase": "Смена парольной фразы...", + "sec_changing_pin": "Смена PIN...", + "sec_couldnt_lock_wallet": "Не удалось заблокировать кошелёк — он всё ещё разблокирован. Проверьте соединение с демоном.", + "sec_encrypted_backup_suffix": "\nЗашифрованная резервная копия: wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "Шифрование кошелька...", + "sec_encryption_did_not_complete": "Шифрование кошелька не завершено — ваш кошелёк НЕ зашифрован. Откройте настройки, чтобы завершить шифрование.", + "sec_encryption_failed_prefix": "Ошибка шифрования: ", + "sec_failed_prefix": "Ошибка: ", + "sec_failed_to_create_vault": "Не удалось создать хранилище", + "sec_importing_keys_rescanning": "Импорт ключей и повторное сканирование блокчейна — кошелёк доступен во время выполнения", + "sec_incorrect_current_pin": "Неверный текущий PIN", + "sec_incorrect_passphrase_decrypt": "Неверная парольная фраза", + "sec_incorrect_passphrase_pin_setup": "Неверная парольная фраза", + "sec_incorrect_pin_remove": "Неверный PIN", + "sec_internal_error_change_pin": "Внутренняя ошибка", + "sec_internal_error_remove_pin": "Внутренняя ошибка", + "sec_mode_passphrase": " Парольная фраза", + "sec_not_connected_to_daemon": "Нет соединения с демоном", + "sec_not_connected_to_daemon_pin": "Нет соединения с демоном", + "sec_passphrase_changed_successfully": "Парольная фраза успешно изменена", + "sec_pin_changed_successfully": "PIN успешно изменён", + "sec_pin_removed": "PIN удалён", + "sec_pin_set_successfully": "PIN успешно задан", + "sec_restart_daemon_for_encryption": "Перезапустите демон, чтобы шифрование вступило в силу.", + "sec_too_many_attempts_wait": "Слишком много попыток. Подождите %.0f сек...", + "sec_total_elapsed_fmt": "Всего прошло: %dм %02dс", + "sec_unlock_button": "Разблокировать", + "sec_unlock_failed_prefix": "Ошибка разблокировки: ", + "sec_unlocking_fmt": "Разблокировка%s", + "sec_use_passphrase_instead": "Использовать парольную фразу", + "sec_use_pin_instead": "Использовать PIN", + "sec_verifying_passphrase": "Проверка парольной фразы...", + "sec_verifying_pin": "Проверка PIN...", + "sec_wallet_decrypted_all_keys_imported": "Кошелёк успешно расшифрован! Все ключи импортированы.", + "sec_wallet_encrypted_and_pin_set": "Кошелёк зашифрован, PIN задан", + "sec_wallet_encrypted_but_pin_vault_failed": "Кошелёк зашифрован, но не удалось создать хранилище PIN", + "sec_wallet_encrypted_restarting_daemon": "Кошелёк зашифрован. Перезапуск демона...", + "sec_wallet_encrypted_successfully": "Кошелёк успешно зашифрован", + "sec_wallet_locked_title": "Кошелёк заблокирован", "security": "БЕЗОПАСНОСТЬ", "seed_backup_button": "Сид-фраза", "seed_backup_close": "Закрыть", @@ -1246,6 +1480,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 +1500,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 +1520,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": "Проверить статус", @@ -1397,6 +1643,17 @@ "sweep_to": "Переведено на:", "sweep_toggle": "Перевести в мой кошелёк (не сохранять ключ)", "sweep_tx": "Транзакция:", + "swin_connection_failed": "Ошибка соединения: ", + "swin_connection_successful": "Соединение установлено!\nВерсия dragonxd: ", + "swin_invalid_suffix": " (неверный)", + "swin_no_history_file_found": "Файл истории не найден", + "swin_rescan_failed": "Ошибка повторного сканирования: ", + "swin_rescan_started_from_block": "Повторное сканирование начато с блока ", + "swin_rescan_to": " до ", + "swin_rpc_client_not_initialized": "RPC-клиент не инициализирован", + "swin_settings_saved": "Настройки сохранены", + "swin_theme_list_refreshed": "Список тем обновлён", + "swin_ztx_history_cleared": "История Z-транзакций очищена", "switch_corrupt_body": "Похоже, этот кошелёк повреждён — узел не смог его открыть. Восстановите из резервной копии, создайте заново или попробуйте восстановить.", "switch_corrupt_repair": "Попробовать восстановить (salvage)", "switch_progress_background": "Продолжить в фоне", @@ -1421,6 +1678,7 @@ "theme": "Тема", "theme_effects": "Эффекты темы", "theme_language": "ТЕМА И ЯЗЫК", + "tile_click_to_open": "Нажмите, чтобы открыть", "time_days_ago": "%d дней назад", "time_hours_ago": "%d часов назад", "time_minutes_ago": "%d минут назад", @@ -1435,7 +1693,9 @@ "to_upper": "КОМУ", "tools": "УТИЛИТЫ", "tools_actions": "Инструменты и действия...", + "tools_actions_hdr": "ИНСТРУМЕНТЫ И ДЕЙСТВИЯ", "total": "Итого", + "total_balance_label": "Общий баланс", "transaction_id": "ID ТРАНЗАКЦИИ", "transaction_sent": "Транзакция успешно отправлена", "transaction_sent_msg": "Транзакция отправлена!", @@ -1457,7 +1717,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 +1730,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 +1780,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 +1819,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 +1872,7 @@ "validate_not_mine": "Не принадлежит этому кошельку", "validate_ownership": "Принадлежность:", "validate_results": "Результаты:", + "validate_results_placeholder": "Результаты появятся здесь", "validate_shielded_type": "Экранированный (z-адрес)", "validate_status": "Статус:", "validate_title": "Проверить адрес", @@ -1750,6 +2013,7 @@ "xmrig_loading_releases": "Загрузка релизов…", "xmrig_none": "нет", "xmrig_reinstall": "Переустановить", + "xmrig_releases": "релизы xmrig", "xmrig_stop_mining_first": "Остановите майнинг перед обновлением майнера.", "xmrig_unavailable_body": "Для этой платформы нет доступной сборки майнера.", "xmrig_unavailable_title": "Обновления майнера недоступны", diff --git a/res/lang/zh.json b/res/lang/zh.json index 7eed6a3..3ece838 100644 --- a/res/lang/zh.json +++ b/res/lang/zh.json @@ -48,6 +48,10 @@ "advanced": "高级", "advanced_effects": "高级特效...", "ago": "前", + "alerts_clear": "清除通知历史", + "alerts_history_tooltip": "最近通知", + "alerts_none": "暂无通知", + "alerts_recent": "最近通知", "all_filter": "全部", "allow_custom_fees": "允许自定义手续费", "amount": "金额", @@ -56,6 +60,80 @@ "amount_label": "金额:", "animate_avatars": "动画头像", "appearance": "外观", + "appx_back": "返回", + "appx_back_up_seed_phrase_title": "备份你的助记词", + "appx_birthday_block_height": "生日(区块高度):%llu — 也请一并备份。", + "appx_blockchain_data_deleted": "区块链数据已删除(%d 项)。守护进程正在重启,以从网络重新同步。", + "appx_blockchain_maintenance_in_progress": "已有区块链维护操作正在进行中。", + "appx_blockchain_rescan_complete": "区块链重新扫描完成", + "appx_bootstrap_complete_reconciling": "引导数据完成——正在将你的钱包与新的链数据进行核对。", + "appx_cancel": "取消", + "appx_cleaning_up": "正在清理…", + "appx_confirm_your_backup": "确认你的备份", + "appx_copied_clipboard_autoclears": "已复制——剪贴板将在 45 秒后自动清空", + "appx_copy": "复制", + "appx_could_not_start_restore": "无法开始恢复", + "appx_create_failed_prefix": "创建失败:", + "appx_creating_your_wallet": "正在创建你的钱包…", + "appx_daemon_error": "守护进程错误", + "appx_daemon_reinstall_in_progress": "守护进程重新安装已在进行中。", + "appx_disconnecting": "正在断开连接…", + "appx_done": "完成", + "appx_dragonxd_output": "dragonxd 输出", + "appx_encrypting_wallet": "正在加密钱包…", + "appx_fullnode_lifecycle_unavailable_lite": "全节点生命周期操作在轻量版中不可用", + "appx_installing_bundled_daemon": "正在安装捆绑守护进程——节点将停止、更新并重启…", + "appx_invalid_payment_uri_prefix": "无效的支付 URI:", + "appx_ive_written_it_down": "我已抄写完毕", + "appx_keep_node_running_and_quit": "保持节点运行并退出", + "appx_last_block_n": "最新区块:%d", + "appx_last_used_wallet_not_found_prefix": "找不到你上次使用的钱包文件(", + "appx_last_used_wallet_not_found_suffix": ")——已改为打开默认钱包。如果你移动了它,请恢复该文件,然后从钱包列表中切换回来。", + "appx_low_spec_mode_disabled": "已禁用低配模式", + "appx_low_spec_mode_enabled": "已启用低配模式", + "appx_miner_stopped_prefix": "矿工已停止:", + "appx_miner_stopped_unexpectedly": "矿工意外停止。", + "appx_n_min_n_sec": "%d 分 %d 秒", + "appx_n_seconds": "%d 秒", + "appx_no_bundled_daemon_to_install": "此版本没有可安装的捆绑守护进程", + "appx_no_embedded_daemon_to_install": "此版本没有可安装的内嵌守护进程", + "appx_node_busy_restarting": "节点正忙于重启——请稍后再试。", + "appx_node_rebuilding_witness_cache": "节点正在重建其见证缓存", + "appx_not_next_word": " — 这不是下一个词", + "appx_payment_request_loaded": "支付请求已加载", + "appx_pool_miner_connected_and_hashing": "矿池矿工已连接并正在计算哈希。", + "appx_progress_n_of_n": "进度:%d / %d", + "appx_rebuilding_sapling_note_witnesses": "正在重建 Sapling 票据见证…", + "appx_rebuilding_witness_cache_blocks_left": "正在重建见证缓存 %.0f%% —— 剩余 %d 个区块", + "appx_rebuilding_witness_cache_pct": "正在重建见证缓存 %.0f%%", + "appx_recovery_phrase_word_count": "助记词应为 24 个——你输入了 %d 个。", + "appx_restarting_daemon_rescan_flag": "正在以 -rescan 标志重启守护进程…", + "appx_restarting_daemon_zapwallettxes": "正在以 -zapwallettxes=2 重启守护进程(钱包修复)…", + "appx_restoring_your_wallet": "正在恢复你的钱包…", + "appx_seed_backup_warning": "这 24 个词是恢复钱包的唯一方式。请按顺序抄写、离线保存,切勿泄露给他人。一旦丢失,你的资金将永远无法找回。", + "appx_seed_not_backed_up_warning": "你尚未备份助记词——资金可能丢失。仍要跳过吗?", + "appx_sending_stop_command_to_daemon": "正在向守护进程发送停止命令…", + "appx_setting_initial_sapling_witnesses": "正在设置初始 Sapling 见证 %.0f%%", + "appx_shutdown_complete": "关闭完成", + "appx_simple_background_disabled": "已禁用简约背景", + "appx_simple_background_enabled": "已启用简约背景", + "appx_skip": "跳过", + "appx_skip_anyway": "仍要跳过", + "appx_still_status_prefix": "仍处于“", + "appx_still_status_suffix": "”——现在强制退出可能损坏链数据。", + "appx_stop_anyway_and_quit": "仍要停止并退出", + "appx_stopping_daemon_deleting_blockchain": "正在停止守护进程并删除区块链数据…", + "appx_stopping_node_discards_rebuild": "现在停止节点会丢弃正在进行的重建,下次打开钱包时将重新开始(需要几分钟)。你也可以让节点继续运行。", + "appx_stopping_pool_miner": "正在停止矿池矿工…", + "appx_syncing_pct_block_n_of_n": "正在同步 %.1f%% —— 区块 %d / %d", + "appx_tap_words_in_order": "按正确顺序点击这些词,以确认你已保存。", + "appx_theme_effects_disabled": "已禁用主题特效", + "appx_theme_effects_enabled": "已启用主题特效", + "appx_theme_prefix": "主题:", + "appx_use_settings_restart_daemon_hint": "请使用 设置 > 重启守护进程 重试", + "appx_waiting_for_daemon_to_encrypt_wallet": "正在等待守护进程加密钱包…", + "appx_wallet_created_and_backed_up": "钱包已创建并完成备份。", + "appx_wallet_open_failed_prefix": "钱包打开失败:", "auto_shield": "自动屏蔽挖矿", "av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:", "av_open_security": "打开 Windows 安全中心", @@ -70,6 +148,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,8 +169,22 @@ "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", + "baltab_market": "市价", + "baltab_market_price_4dp": "市价:$%.4f", + "baltab_market_price_8dp": "市价:$%.8f", + "baltab_pct_change_24h": "%s%.1f%% 24小时", + "baltab_pct_of_total_zaddr": "占总额 %.0f%% · %d 个 Z-addr", + "baltab_shielded": "隐蔽", + "baltab_shielded_amount": "隐蔽 %.8f", + "baltab_t_addresses_count": "%d 个 T-address", + "baltab_total_balance": "总余额", + "baltab_transparent": "透明", + "baltab_transparent_amount": "透明 %.8f", "ban": "封禁", "banned_peers": "已封禁节点", "block": "区块", @@ -128,6 +223,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": "当前密码短语:", @@ -403,6 +499,7 @@ "contacts_shape_square": "方形", "contacts_shape_tab": "左标签", "copied": "已复制!", + "copied_to_clipboard": "已复制到剪贴板", "copy": "复制", "copy_address": "复制完整地址", "copy_error": "复制错误", @@ -415,6 +512,7 @@ "daemon_bundled": "内置", "daemon_install_bundled": "安装内置版本", "daemon_installed": "已安装", + "daemon_maintenance_label": "维护", "daemon_none_bundled": "此版本未内置", "daemon_not_installed": "未安装", "daemon_status_differ": "已安装的程序文件与内置版本不同。", @@ -437,6 +535,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 +548,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 +581,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": "正在加密钱包...", @@ -566,6 +679,63 @@ "general": "常规", "generating": "正在生成", "go_to_receive": "前往接收", + "grpa_current_block_paren": "(当前:%d)", + "grpa_days_ago": "%lld 天前", + "grpa_dbg_addrman": "对等节点地址追踪与管理", + "grpa_dbg_alert": "警报系统消息", + "grpa_dbg_bench": "操作的基准计时", + "grpa_dbg_coindb": "币数据库读写操作", + "grpa_dbg_db": "Berkeley DB 操作", + "grpa_dbg_estimatefee": "手续费估算算法", + "grpa_dbg_http": "HTTP RPC 服务器活动", + "grpa_dbg_libevent": "Libevent 网络库", + "grpa_dbg_lock": "锁竞争调试", + "grpa_dbg_mempool": "交易内存池活动", + "grpa_dbg_net": "网络连接与消息", + "grpa_dbg_paymentdisclosure": "支付披露协议", + "grpa_dbg_pow": "工作量证明挖矿活动", + "grpa_dbg_proxy": "SOCKS5 代理连接", + "grpa_dbg_prune": "区块修剪操作", + "grpa_dbg_rand": "随机数生成", + "grpa_dbg_reindex": "区块链重新索引进度", + "grpa_dbg_rpc": "RPC 命令处理", + "grpa_dbg_selectcoins": "交易的币选择", + "grpa_dbg_tor": "Tor 集成与线路信息", + "grpa_dbg_zmq": "ZeroMQ 通知系统", + "grpa_dbg_zrpc": "隐蔽(z-addr)RPC 操作", + "grpa_enter_private_key_to_import": "请输入要导入的私钥。", + "grpa_error_prefix": "错误:", + "grpa_hr_ago": "%lld 小时前", + "grpa_invalid_response_from_daemon": "守护进程返回了无效响应", + "grpa_invalid_suffix": "(无效)", + "grpa_min_ago": "%lld 分钟前", + "grpa_sec_ago": "%lld 秒前", + "grpa_seed_demo_chat": "种子演示聊天", + "grpa_showing_first_100_of": "…显示 %d 项中的前 100 项", + "grpa_tab_about": "关于", + "grpa_tab_appearance": "外观", + "grpa_tab_backup_data": "备份与数据", + "grpa_tab_chat": "聊天", + "grpa_tab_explorer": "浏览器", + "grpa_tab_node_security": "节点与安全", + "grpa_tab_wallet": "钱包", + "grpa_unexpected_getblockhash_result": "意外的 getblockhash 结果", + "grpb_copy": "复制", + "grpb_max": "最大", + "grpb_new_badge_suffix": " [新]", + "grpb_preview_msg_payment_through": "付款到账了吗?🙂", + "grpb_preview_msg_sending_rest": "现在把剩下的发过去 👍", + "grpb_preview_msg_yep_confirmed": "到了——刚刚确认 ✅", + "grpb_selected_suffix": "\n(已选)", + "grpb_tooltip_address_balance": "%s\n余额:%.8f %s%s", + "grpb_undo_clear": "撤销清除", + "grpc_benchmark_inconclusive": "基准测试无结果:未记录到任何算力样本。请检查矿池连接后重试。", + "grpc_benchmark_takes_secs": "基准测试约需 %d 秒并会中断挖矿。再次点击以开始。", + "grpc_bootstrap_failed": "引导失败", + "grpc_bootstrap_not_initialized": "引导未初始化", + "grpc_hashrate_fee": "%s %s%% 手续费", + "grpc_key_not_available": "此地址无可用密钥", + "grpc_na": "不适用", "height": "高度", "help": "帮助", "hidden_tag": " (已隐藏)", @@ -640,6 +810,7 @@ "light": "浅色", "lite_account_label": "账户", "lite_action": "操作", + "lite_backend_unavailable": "轻量钱包后端不可用", "lite_backup_keys": "备份与密钥", "lite_birthday_backup": "生日区块:%llu (也请一并备份)", "lite_birthday_hint": "开始扫描的区块高度。如未知请保留 0(完整扫描更慢)。", @@ -647,9 +818,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 +908,8 @@ "lite_working": "处理中…", "loading": "加载中...", "loading_addresses": "正在加载地址...", + "loading_stall_body": "守护进程已初始化 %.0f 秒。更新后或首次启动时(加载区块索引或重新扫描)这可能是正常现象——就绪后会自动连接。", + "loading_stall_title": "耗时超出预期", "loading_transactions": "正在加载交易", "local_hashrate": "本地算力", "low_spec_mode": "低配模式", @@ -866,6 +1042,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 +1071,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 +1106,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 +1135,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 +1278,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 +1329,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 +1344,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,12 +1374,52 @@ "sb_waiting_daemon_err": "等待 dragonxd — %s", "sb_warming_up": "正在预热...", "sb_witness_cache": "正在重建见证", + "scale_effects": "缩放与效果", "screenshot_open_dir": "打开位置", "screenshot_sweep": "运行截图批处理", "screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。", "screenshot_sweep_full": "完整 UI 遍历", "search_icons": "搜索图标...", "search_placeholder": "搜索...", + "sec_changing_passphrase": "正在更改密码短语…", + "sec_changing_pin": "正在更改 PIN…", + "sec_couldnt_lock_wallet": "无法锁定钱包——它仍处于解锁状态。请检查守护进程连接。", + "sec_encrypted_backup_suffix": "\n加密备份:wallet.dat.encrypted.bak", + "sec_encrypting_wallet": "正在加密钱包…", + "sec_encryption_did_not_complete": "钱包加密未完成——你的钱包尚未加密。请打开设置以完成加密。", + "sec_encryption_failed_prefix": "加密失败:", + "sec_failed_prefix": "失败:", + "sec_failed_to_create_vault": "创建保险库失败", + "sec_importing_keys_rescanning": "正在导入密钥并重新扫描区块链——期间钱包仍可使用", + "sec_incorrect_current_pin": "当前 PIN 错误", + "sec_incorrect_passphrase_decrypt": "密码短语错误", + "sec_incorrect_passphrase_pin_setup": "密码短语错误", + "sec_incorrect_pin_remove": "PIN 错误", + "sec_internal_error_change_pin": "内部错误", + "sec_internal_error_remove_pin": "内部错误", + "sec_mode_passphrase": " 密码短语", + "sec_not_connected_to_daemon": "未连接到守护进程", + "sec_not_connected_to_daemon_pin": "未连接到守护进程", + "sec_passphrase_changed_successfully": "密码短语更改成功", + "sec_pin_changed_successfully": "PIN 更改成功", + "sec_pin_removed": "PIN 已移除", + "sec_pin_set_successfully": "PIN 设置成功", + "sec_restart_daemon_for_encryption": "请重启守护进程以使加密生效。", + "sec_too_many_attempts_wait": "尝试次数过多。请等待 %.0f 秒…", + "sec_total_elapsed_fmt": "总耗时:%d 分 %02d 秒", + "sec_unlock_button": "解锁", + "sec_unlock_failed_prefix": "解锁失败:", + "sec_unlocking_fmt": "正在解锁%s", + "sec_use_passphrase_instead": "改用密码短语", + "sec_use_pin_instead": "改用 PIN", + "sec_verifying_passphrase": "正在验证密码短语…", + "sec_verifying_pin": "正在验证 PIN…", + "sec_wallet_decrypted_all_keys_imported": "钱包解密成功!所有密钥已导入。", + "sec_wallet_encrypted_and_pin_set": "钱包已加密并已设置 PIN", + "sec_wallet_encrypted_but_pin_vault_failed": "钱包已加密,但 PIN 保险库创建失败", + "sec_wallet_encrypted_restarting_daemon": "钱包已加密。正在重启守护进程…", + "sec_wallet_encrypted_successfully": "钱包加密成功", + "sec_wallet_locked_title": "钱包已锁定", "security": "安全", "seed_backup_button": "助记词", "seed_backup_close": "关闭", @@ -1246,6 +1478,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 +1498,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 +1518,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 +1581,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": "检查状态", @@ -1397,6 +1641,17 @@ "sweep_to": "归集到:", "sweep_toggle": "归集到我的钱包(不保留密钥)", "sweep_tx": "交易:", + "swin_connection_failed": "连接失败:", + "swin_connection_successful": "连接成功!\ndragonxd 版本:", + "swin_invalid_suffix": "(无效)", + "swin_no_history_file_found": "未找到历史文件", + "swin_rescan_failed": "重新扫描失败:", + "swin_rescan_started_from_block": "重新扫描已开始,起始区块 ", + "swin_rescan_to": " 至 ", + "swin_rpc_client_not_initialized": "RPC 客户端未初始化", + "swin_settings_saved": "设置已保存", + "swin_theme_list_refreshed": "主题列表已刷新", + "swin_ztx_history_cleared": "Z 交易历史已清除", "switch_corrupt_body": "此钱包似乎已损坏——节点无法打开它。请从备份恢复、重新创建,或尝试修复。", "switch_corrupt_repair": "尝试修复(salvage)", "switch_progress_background": "在后台继续", @@ -1421,6 +1676,7 @@ "theme": "主题", "theme_effects": "主题效果", "theme_language": "主题与语言", + "tile_click_to_open": "点击打开", "time_days_ago": "%d 天前", "time_hours_ago": "%d 小时前", "time_minutes_ago": "%d 分钟前", @@ -1435,7 +1691,9 @@ "to_upper": "至", "tools": "工具", "tools_actions": "工具与操作...", + "tools_actions_hdr": "工具与操作", "total": "合计", + "total_balance_label": "总余额", "transaction_id": "交易 ID", "transaction_sent": "交易发送成功", "transaction_sent_msg": "交易已发送!", @@ -1457,7 +1715,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 +1728,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 +1778,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 +1817,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 +1870,7 @@ "validate_not_mine": "不属于此钱包", "validate_ownership": "所有权:", "validate_results": "结果:", + "validate_results_placeholder": "结果将显示在此处", "validate_shielded_type": "屏蔽(z 地址)", "validate_status": "状态:", "validate_title": "验证地址", @@ -1750,6 +2011,7 @@ "xmrig_loading_releases": "正在加载发行版…", "xmrig_none": "无", "xmrig_reinstall": "重新安装", + "xmrig_releases": "xmrig 版本", "xmrig_stop_mining_first": "更新矿工程序前请先停止挖矿。", "xmrig_unavailable_body": "此平台没有可用的矿工构建版本。", "xmrig_unavailable_title": "矿工更新不可用", diff --git a/res/themes/ui.toml b/res/themes/ui.toml index c4db15c..f9cc512 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -700,6 +700,12 @@ status-pill-bg-alpha = { size = 30 } status-pill-y-offset = { size = 1 } confirmed-threshold = { size = 10 } +# Persistent node/RPC error strip at the top of the content column (see App::renderNodeStatusBanner). +# Slightly taller than the per-tab sync banner so it comfortably holds the Reconnect/Restart action. +[banners.node-status] +min-height = { size = 26.0 } +height = { size = 30.0 } + [tabs.transactions] search-max-width = 300.0 search-width-ratio = 0.3 @@ -959,6 +965,11 @@ edition-label = { position = 120 } link-button = { width = 100, font = "button-sm" } close-button = { width = 120, font = "button", align = "center" } +[dialogs.faq] +width = 860.0 +height = 660.0 +window = { width = 860, height = 660 } + [dialogs.settings] width = 600.0 height = 550.0 @@ -1503,6 +1514,7 @@ progress-bar = { height = 6.0, radius = 3.0 } progress-width = { size = 260.0 } backdrop-alpha = { opacity = 0.80 } vertical-gap = { size = 8.0 } +stall-timeout-sec = { size = 45.0 } # --------------------------------------------------------------------------- # First-Run Wizard Screens diff --git a/src/app.cpp b/src/app.cpp index 9258b5d..4e87716 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -37,6 +37,7 @@ #include "ui/windows/market_tab.h" #include "ui/windows/settings_window.h" #include "ui/windows/about_dialog.h" +#include "ui/windows/faq_dialog.h" #include "embedded/IconsMaterialDesign.h" #include "ui/windows/key_export_dialog.h" #include "ui/windows/transaction_details_dialog.h" @@ -68,10 +69,13 @@ #include "ui/material/draw_helpers.h" #include "ui/widgets/copy_field.h" #include "ui/notifications.h" +#include "ui/node_status_banner.h" #include "util/i18n.h" +#include "util/connect_stall.h" #include "util/platform.h" #include "util/text_format.h" #include "util/payment_uri.h" +#include "util/seed_phrase.h" #include "util/texture_loader.h" #include "util/svg_texture.h" #include "ui/material/colors.h" @@ -104,6 +108,7 @@ #include #include #include +#include #include #include #include @@ -123,14 +128,63 @@ App::App() // Seed the auto-balance RNG once per run so weighted-random pool selection isn't // deterministic across launches. balance_rng_.seed(std::random_device{}()); + // Purge any plaintext key export left behind by a crashed/interrupted decrypt flow. (H-02) + sweepStaleDecryptExports(); } App::~App() { - // Scrub any seed/phrase secret still resident (e.g. app quit with a backup/migration modal open). + wipeSecrets(); +} + +// Scrub every resident secret buffer. Idempotent + safe to call from the forced-exit path (main.cpp +// _Exit bypasses destructors), so key/seed material isn't left in freed heap on the real quit path. (L-05) +void App::wipeSecrets() +{ if (!seed_migration_seed_.empty()) sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size()); if (!seed_backup_phrase_.empty()) sodium_memzero(&seed_backup_phrase_[0], seed_backup_phrase_.size()); + sodium_memzero(export_result_, sizeof(export_result_)); // exported WIF/z-key (SECRET) + sodium_memzero(import_key_input_, sizeof(import_key_input_)); // pasted private key (SECRET) +} + +// Enqueue a blocking xmrig start/stop op onto the dedicated serialized control thread so the render thread +// never blocks on stop()'s SIGTERM->SIGKILL->join, while start/stop still execute in FIFO order. (M-03/…) +void App::postMiningControl(std::function job) +{ + { + std::lock_guard lk(mining_ctl_mutex_); + if (mining_ctl_stop_) return; // shutting down — don't enqueue new mining ops + if (!mining_ctl_thread_.joinable()) { + mining_ctl_thread_ = std::thread([this]() { + for (;;) { + std::function j; + { + std::unique_lock lk(mining_ctl_mutex_); + mining_ctl_cv_.wait(lk, [this]{ return mining_ctl_stop_ || !mining_ctl_queue_.empty(); }); + if (mining_ctl_stop_) return; // abandon any pending jobs on shutdown + j = std::move(mining_ctl_queue_.front()); + mining_ctl_queue_.pop_front(); + } + j(); + } + }); + } + mining_ctl_queue_.push_back(std::move(job)); + } + mining_ctl_cv_.notify_one(); +} + +// Signal the mining-control thread to stop and join it. Called at shutdown BEFORE xmrig_manager_ is stopped +// or destroyed, so no control job runs concurrently with teardown. Idempotent. +void App::stopMiningControlThread() +{ + { + std::lock_guard lk(mining_ctl_mutex_); + mining_ctl_stop_ = true; + } + mining_ctl_cv_.notify_all(); + if (mining_ctl_thread_.joinable()) mining_ctl_thread_.join(); } namespace { @@ -152,6 +206,7 @@ void App::updatePoolAutoBalance() if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only) if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return; if (!settings_->getPoolMode()) return; // only while POOL mode is selected + if (ui::IsMiningBenchmarkActive()) return; // don't auto-switch pools mid-benchmark — it restarts xmrig at the wrong thread count (L-12) const long long now = steadyNowMs(); const bool intervalDue = (last_balance_eval_ms_ == 0) || @@ -340,6 +395,28 @@ bool App::init() // Ensure ObsidianDragon config directory and template files exist util::Platform::ensureObsidianDragonSetup(); + // W1-1 (startup): if the recorded active wallet file was moved/deleted between sessions, don't hand a + // missing -wallet= to the daemon — it would auto-create a fresh empty wallet under that name, + // silently "opening" as a zero-balance wallet at launch. Fall back to the always-present default and + // warn. (The default "wallet.dat" is legitimately absent on first run, so it is skipped.) Runs before + // the vault init below so the vault is scoped to the wallet actually opened. + if (settings_) { + const std::string active = settings_->getActiveWalletFile(); + if (!active.empty() && active != "wallet.dat") { + std::error_code walEc; + const std::string walPath = util::Platform::getDragonXDataDir() + "/" + active; + if (!std::filesystem::exists(walPath, walEc)) { + DEBUG_LOGF("[App] active wallet '%s' not found at startup — falling back to wallet.dat\n", + active.c_str()); + settings_->setActiveWalletFile("wallet.dat"); + settings_->save(); + ui::Notifications::instance().warning( + std::string(TR("appx_last_used_wallet_not_found_prefix")) + active + + TR("appx_last_used_wallet_not_found_suffix"), 20.0f); + } + } + } + // Initialize PIN vault, scoped to the active wallet so one wallet's stored passphrase is never // offered for another (the default wallet keeps the legacy vault.dat). vault_ = std::make_unique(settings_ ? settings_->getActiveWalletFile() : ""); @@ -663,6 +740,10 @@ void App::update() { PERF_SCOPE("Update.Total"); ImGuiIO& io = ImGui::GetIO(); + // Clamp the frame delta: after a long pause (window minimized, or the machine slept) NewFrame reports + // a huge DeltaTime that would fire every refresh/animation timer at once. Every timer reads + // io.DeltaTime, so one clamp here bounds them all (also caps the real-clock delta fed while minimized). + if (io.DeltaTime > 0.25f) io.DeltaTime = 0.25f; // Full UI screenshot sweep: demo state is injected once and must stay frozen. Skip every live // op (refresh/connect/pumps) so a real daemon can't clobber it — on Windows a running node's @@ -725,7 +806,7 @@ void App::update() const std::string& err = lite_wallet_->lastOpenError(); if (!err.empty() && err != lite_open_error_) { lite_open_error_ = err; - ui::Notifications::instance().error(std::string("Wallet open failed: ") + err, 8.0f); + ui::Notifications::instance().error(std::string(TR("appx_wallet_open_failed_prefix")) + err, 8.0f); } } // Suppress the status bar's full-node connection-detail line in lite ("" and "Connected" @@ -798,12 +879,20 @@ void App::update() // One-time reminder to back up the wallet's seed phrase (mnemonic wallets only). maybeRemindSeedBackup(); + // One-time warning if the active wallet loaded empty while a sibling wallet file holds funds + // (a prior/unwitnessed salvage likely moved the coins into a wallet..bak). + maybeWarnEmptyWalletWithFundedSiblings(); + + // One-time nudge if wallet.dat has bloated past the threshold (toast + clickable alert → consolidate). + maybeWarnLargeWallet(); + // Classify the wallet's mnemonic status (once per connect) so the Migrate-to-seed button can // glow for a legacy, pre-seed-phrase wallet. probeWalletSeedStatus(); // Pick up progress/result from a running seed-wallet migration (create/sweep/adopt). pumpSeedMigration(); + pumpWalletRestore(); // While confirming the sweep, poll the tx confirmations + legacy balance every ~5s. if (show_seed_migration_ && seed_migration_step_ == SeedMigrationStep::Confirming) { seed_migration_poll_timer_ -= ImGui::GetIO().DeltaTime; @@ -835,7 +924,9 @@ void App::update() // Re-apply the refresh cadence when sync starts/finishes: while syncing we throttle polling to // a low-impact profile so RPC contention doesn't slow block download (see applyRefreshPolicy). - if (state_.sync.syncing != refresh_policy_syncing_) { + // effectivelySyncing() includes the post-sync settle window, so this also reverts to the normal + // per-tab cadence once that window elapses. + if (effectivelySyncing() != refresh_policy_syncing_) { applyRefreshPolicy(current_page_); } @@ -898,7 +989,10 @@ void App::update() // saw the rescan running. Without the confirmed-active gate, the first // poll (which hits the still-running pre-restart daemon, rescanning=false) // would fire a false "complete" the instant rescan was clicked. - ui::Notifications::instance().success("Blockchain rescan complete"); + if (user_initiated_rescan_) { + ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete")); + user_initiated_rescan_ = false; // surfaced once; not for background rebuilds + } resetWitnessRescanProgress(); state_.sync.rescan_progress = 1.0f; } @@ -924,13 +1018,27 @@ void App::update() } } + // Surface an unexpected miner exit (crash / OOM-kill / external SIGKILL) once, and clear the stale + // running flag so the UI and auto-balance don't keep believing it's still hashing. (L-03, L-10) + if (xmrig_manager_ && state_.pool_mining.xmrig_running + && xmrig_manager_->getState() == daemon::XmrigManager::State::Error) { + state_.pool_mining.xmrig_running = false; + state_.pool_mining.hashrate_10s = 0.0; + state_.pool_mining.hashrate_60s = 0.0; + state_.pool_mining.hashrate_15m = 0.0; + pool_starting_.store(false, std::memory_order_relaxed); + const std::string err = xmrig_manager_->getLastError(); + ui::Notifications::instance().error(err.empty() ? TR("appx_miner_stopped_unexpectedly") + : (std::string(TR("appx_miner_stopped_prefix")) + err)); + } + // Poll xmrig stats every ~2 seconds (use a simple toggle) static bool xmrig_poll_tick = false; xmrig_poll_tick = !xmrig_poll_tick; if (xmrig_poll_tick && xmrig_manager_ && xmrig_manager_->isRunning()) { xmrig_manager_->pollStats(); auto& ps = state_.pool_mining; - auto& xs = xmrig_manager_->getStats(); + const auto xs = xmrig_manager_->getStats(); // getStats() now returns a locked copy (M-03) ps.xmrig_running = true; ps.hashrate_10s = xs.hashrate_10s; ps.hashrate_60s = xs.hashrate_60s; @@ -946,7 +1054,7 @@ void App::update() // Pool mining has a connect delay — announce once it's actually connected/hashing. if (pool_starting_.load(std::memory_order_relaxed) && (ps.connected || ps.hashrate_10s > 0.0)) { pool_starting_.store(false, std::memory_order_relaxed); - ui::Notifications::instance().success("Pool miner connected and hashing."); + ui::Notifications::instance().success(TR("appx_pool_miner_connected_and_hashing")); } // Get memory directly from OS (more reliable than API) double memMB = xmrig_manager_->getMemoryUsageMB(); @@ -987,8 +1095,9 @@ void App::update() // Apply results directly — we are already on the main thread. const std::string& status = scan.lastStatus; if (scan.finished) { - if (state_.sync.rescanning) { - ui::Notifications::instance().success("Blockchain rescan complete"); + if (state_.sync.rescanning && user_initiated_rescan_) { + ui::Notifications::instance().success(TR("appx_blockchain_rescan_complete")); + user_initiated_rescan_ = false; // surfaced once; not for background rebuilds } // Witness rebuild finishes with the rescan it's part of. resetWitnessRescanProgress(); @@ -1103,6 +1212,7 @@ void App::update() auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); if (!rpc) return [this](){ opid_poll_in_progress_ = false; }; json result; + services::NetworkRefreshService::OperationStatusPollResult parsed; try { rpc::RPCClient::TraceScope trace("Send tab / Operation status"); // No per-opid filter: this daemon rejects z_getoperationstatus(["opid"]) with @@ -1110,10 +1220,12 @@ void App::update() // "Waiting for operation". The no-arg form returns ALL operations; // parseOperationStatusPoll() filters down to the opids we're tracking. result = rpc->call("z_getoperationstatus", json::array()); + // Parse INSIDE the guard: a malformed/type-anomalous element must never abort the + // poll and leave opid_poll_in_progress_ stuck true for the whole connected session. + parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids); } catch (...) { return [this](){ opid_poll_in_progress_ = false; }; } - auto parsed = services::NetworkRefreshService::parseOperationStatusPoll(result, opids); return [this, parsed = std::move(parsed)]() mutable { opid_poll_in_progress_ = false; @@ -1190,7 +1302,7 @@ void App::update() !runtime_rescan_active_ && !bootstrap_downloading_ && state_.sync.blocks > 1) { // wait until the tip is known so the probe has a real range post_bootstrap_rescan_pending_ = false; - ui::Notifications::instance().info("Bootstrap complete — reconciling your wallet with the new chain data."); + ui::Notifications::instance().info(TR("appx_bootstrap_complete_reconciling")); detectLowestAvailableBlockHeight([this](bool ok, int lowest, bool fullHistory) { if (ok && !fullHistory) { runtimeRescan(lowest); // bootstrapped/pruned: rescan from the snapshot base @@ -1212,7 +1324,16 @@ void App::update() if (!state_.warming_up && !runtime_rescan_active_) { if (network_refresh_.consumeDue(RefreshTimer::Transactions)) { if (shouldRunWalletTransactionRefresh() && shouldRefreshTransactions()) { - refreshTransactionData(); + // Throttle the routine new-block full history rescan (z_listreceivedbyaddress — + // O(mapWallet), holds cs_main) by its measured cost, so a large wallet doesn't + // re-scan every block and starve connection near the tip. Bypass when there's an + // explicit need: a dirty set, an in-progress multi-cycle shielded scan (which must + // continue to completion), or an in-flight send — all need fresh data immediately. + const bool txExplicitNeed = transactions_dirty_ || shielded_history_scan_pending_ || + hasTransactionSendProgress() || !send_txids_.empty(); + if (txExplicitNeed || txRefreshDue()) { + refreshTransactionData(); + } } else if (walletDataPage && shouldRefreshRecentTransactions()) { refreshRecentTransactionData(); } @@ -1228,7 +1349,13 @@ void App::update() fastScanChatMemos(); } if (network_refresh_.consumeDue(RefreshTimer::Addresses)) { - if (walletDataPage || addresses_dirty_ || hasTransactionSendProgress()) { + // Explicit need (a changed address set, or an in-flight send tracking its change output) + // must refresh now; the routine periodic poll on a wallet page is throttled by the last + // address scan's measured cost (addressRefreshDue) — this is the z_listunspent hammering + // that was starving cs_main while synced on a large wallet. + if (addresses_dirty_ || hasTransactionSendProgress()) { + refreshAddressData(); + } else if (walletDataPage && addressRefreshDue()) { refreshAddressData(); } } @@ -1285,7 +1412,7 @@ void App::handleGlobalShortcuts() settings_->setSkinId(skins[cur].id); settings_->save(); } - ui::Notifications::instance().info("Theme: " + skins[cur].name); + ui::Notifications::instance().info(std::string(TR("appx_theme_prefix")) + skins[cur].name); } } } @@ -1322,7 +1449,7 @@ void App::handleGlobalShortcuts() ui::effects::ThemeEffects::instance().setReducedTransparency(!settings_->getThemeEffectsEnabled()); } } - ui::Notifications::instance().info(newLow ? "Low-spec mode enabled" : "Low-spec mode disabled"); + ui::Notifications::instance().info(newLow ? TR("appx_low_spec_mode_enabled") : TR("appx_low_spec_mode_disabled")); } // Keyboard shortcut: Ctrl+Down to toggle theme effects (Shift excluded) @@ -1334,7 +1461,7 @@ void App::handleGlobalShortcuts() settings_->setThemeEffectsEnabled(newState); settings_->save(); } - ui::Notifications::instance().info(newState ? "Theme effects enabled" : "Theme effects disabled"); + ui::Notifications::instance().info(newState ? TR("appx_theme_effects_enabled") : TR("appx_theme_effects_disabled")); } // Keyboard shortcut: Ctrl+Up to toggle simple gradient background @@ -1343,7 +1470,7 @@ void App::handleGlobalShortcuts() settings_->setGradientBackground(newGrad); ui::schema::SkinManager::instance().setGradientMode(newGrad); settings_->save(); - ui::Notifications::instance().info(newGrad ? "Simple background enabled" : "Simple background disabled"); + ui::Notifications::instance().info(newGrad ? TR("appx_simple_background_enabled") : TR("appx_simple_background_disabled")); } // Debug: Ctrl+Shift+W to re-show first-run wizard (set to false to disable) @@ -1404,16 +1531,11 @@ void App::ensureLogoTexture() } } - // 0) DragonX mark — rasterize the embedded SVG recolored to the theme (body = accent, detail = white) - // at ~2x the 128px viewBox for crisp downscaling. This is the branding on every skin; the per-skin - // PNG path below is only a fallback if rasterization ever fails. - if (util::LoadTextureFromSvg(embedded::kLogoDragonXSvg, 256, logoAccent, - detailCol, &logo_tex_, &logo_w_, &logo_h_)) { - DEBUG_LOGF("Rendered DragonX SVG logo (%dx%d, accent %08X)\n", logo_w_, logo_h_, logoAccent); - return; - } + // The header / top-left / About branding is the ObsidianDragon PRODUCT logo — NOT the DragonX coin + // mark (that is coin_logo_tex_ / drgx_emoji_tex_ above). Resolve it below: active-skin override, else + // the ui.toml header-icon, else the bundled ObsidianDragon dark/light PNG (disk, then embedded). - // 1) Fallback — theme-override logo from the active skin + // 1) theme-override logo from the active skin const auto* activeSkin = ui::schema::SkinManager::instance().findById( ui::schema::SkinManager::instance().activeSkinId()); std::string logoPath; @@ -1489,8 +1611,9 @@ void App::render() { int deletedN = pending_delete_result_.exchange(-1, std::memory_order_relaxed); if (deletedN >= 0) { - ui::Notifications::instance().success("Blockchain data deleted (" + std::to_string(deletedN) + - " items). The daemon is restarting to re-sync from the network."); + char deletedMsg[160]; + snprintf(deletedMsg, sizeof(deletedMsg), TR("appx_blockchain_data_deleted"), deletedN); + ui::Notifications::instance().success(deletedMsg); } } @@ -1614,8 +1737,10 @@ void App::render() float v = ui::schema::UI().drawElement("components.sidebar", key).size; return (v >= 0 ? v : fb) * dp; }; - float statusBarH = ui::schema::UI().window("components.status-bar").height; - if (statusBarH <= 0.0f) statusBarH = 24.0f; // safety fallback + // Scale by dp to match the rendered status-bar child height (renderStatusBar), so the reserved + // content strip stays in step with the bar at HiDPI instead of under-reserving. + float statusBarH = ui::schema::UI().window("components.status-bar").height * dp; + if (statusBarH <= 0.0f) statusBarH = 24.0f * dp; // safety fallback // Content area padding from ui.toml schema const auto& caWin = ui::schema::UI().window("components.content-area"); const float caMarginTop = ui::schema::UI().drawElement("components.content-area", "margin-top").size; @@ -1681,7 +1806,10 @@ void App::render() // long confirmed (the other leg holds the real count). So: a txid with ANY confirmed leg is // confirmed, and we count UNIQUE unconfirmed txids — otherwise the badge sticks on stale 0-conf // legs of already-confirmed transactions and double-counts multi-leg ones. - { + // Recompute only when the tx list actually changed (last_tx_update is bumped on every tx refresh; + // size() catches same-second content changes). Otherwise this built two unordered_sets over the whole + // wallet tx list every frame just to size a badge. + if (state_.last_tx_update != sb_unconf_key_ts_ || state_.transactions.size() != sb_unconf_key_n_) { std::unordered_set confirmedTxids; for (const auto& tx : state_.transactions) { if (tx.confirmations >= 1) confirmedTxids.insert(tx.txid); @@ -1692,35 +1820,27 @@ void App::render() unconfirmedTxids.insert(tx.txid); } } - sbStatus.unconfirmedTxCount = static_cast(unconfirmedTxids.size()); + sb_unconf_count_ = static_cast(unconfirmedTxids.size()); + sb_unconf_key_ts_ = state_.last_tx_update; + sb_unconf_key_n_ = state_.transactions.size(); } + sbStatus.unconfirmedTxCount = sb_unconf_count_; - // Sidebar margins from ui.toml schema (DPI-scaled like all sidebar values) - const float sbMarginTop = sbde("margin-top", 0.0f); - const float sbMarginBottom = sbde("margin-bottom", 0.0f); - const float sbMinHeight = sbde("min-height", 360.0f); + // Sidebar minimum height from ui.toml schema (DPI-scaled). + const float sbMinHeight = sbde("min-height", 360.0f); - // Ensure sidebar is tall enough to fit all buttons — shrink margins if needed - float sidebarH = contentH - sbMarginTop - sbMarginBottom; - float effectiveMarginTop = sbMarginTop; - if (sidebarH < sbMinHeight) { - float available = contentH - sbMinHeight; - if (available > 0.0f) { - float ratio = available / (sbMarginTop + sbMarginBottom); - effectiveMarginTop = sbMarginTop * ratio; - } else { - effectiveMarginTop = 0.0f; - } - sidebarH = std::max(contentH - effectiveMarginTop, sbMinHeight); - } - - // Sidebar navigation - // Save cursor Y before applying sidebar margin so the content area - // (placed via SameLine) starts at the original row position, not the - // margin-shifted one. + // Save cursor Y before the sidebar so the content area (restored below) starts at the original row. float preSidebarCursorY = ImGui::GetCursorPosY(); - if (effectiveMarginTop > 0.0f) - ImGui::SetCursorPosY(preSidebarCursorY + effectiveMarginTop); + + // Size the sidebar to span from its own top down to the status-bar top, so the nav panel centers + // within the TRUE visible area (equal top/bottom gaps). Do NOT derive it from contentH (inset by the + // content-area's edge-fade margins) and do NOT apply the legacy sidebar margin-top/-bottom (they are + // asymmetric, -12 / +40, and pushed the panel upward). Window-local reference (matches how the status + // bar is positioned — GetWindowPos/GetWindowSize, not GetMainViewport) so it is correct on every platform. + float sbWindowBottom = ImGui::GetWindowPos().y + ImGui::GetWindowSize().y; + float sbStatusTopY = sbWindowBottom - statusBarH - mainPadBot; + float sbChildTopY = ImGui::GetCursorScreenPos().y; + float sidebarH = std::max(sbMinHeight, sbStatusTopY - sbChildTopY); bool prevCollapsed = sidebar_collapsed_; { PERF_SCOPE("Render.Sidebar"); @@ -1745,6 +1865,16 @@ void App::render() // Page transition: detect change, ramp alpha if (current_page_ != prev_page_) { page_alpha_ = (ui::effects::isLowSpecMode() || (settings_ && settings_->getReduceMotion())) ? 1.0f : 0.0f; + // Switching INTO the console → put the cursor in the command box (toggleable). Done here at the + // transition (not in the console render) because prev_page_ is updated below; the console renders + // later this same frame and consumes the one-shot request. + if ((current_page_ == ui::NavPage::Console || current_page_ == ui::NavPage::LiteConsole) + && settings_ && settings_->getConsoleAutoFocus()) + console_tab_.requestInputFocus(); + // Leaving the Mining tab → cancel a running thread benchmark so the miner isn't abandoned at a + // benchmark step. (L-04) + if (prev_page_ == ui::NavPage::Mining && current_page_ != ui::NavPage::Mining) + ui::CancelMiningBenchmark(this); prev_page_ = current_page_; } if (page_alpha_ < 1.0f) { @@ -1765,10 +1895,26 @@ void App::render() float caPadY = caWin.padding[1] > 0.0f ? caWin.padding[1] : ImGui::GetStyle().WindowPadding.y; ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(caPadX, caPadY)); - // Capture content area screen position for edge fade mask + // Cap the content column to a readable max width and center it in wider windows. Every tab derives + // its cards/forms/tables from this child's width, so an uncapped fill stretches them edge-to-edge on + // wide/ultrawide displays. No-op below the cap (fills as before). + float caAvailW = ImGui::GetContentRegionAvail().x; + float caMaxW = ui::Layout::kContentMaxWidth(); + float caChildW = 0.0f; // 0 -> fill remaining width (default, and whenever the window is below the cap) + if (caMaxW > 0.0f && caAvailW > caMaxW) { + caChildW = caMaxW; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (caAvailW - caMaxW) * 0.5f); + } + + // Capture content area screen position for edge fade mask (after any centering offset) ImVec2 caScreenPos = ImGui::GetCursorScreenPos(); - ImGui::BeginChild("##ContentArea", ImVec2(0, contentH), false, contentFlags); + ImGui::BeginChild("##ContentArea", ImVec2(caChildW, contentH), false, contentFlags); + + // Persistent node/RPC error banner — drawn first (before the edge-fade vertex capture below, + // so it stays fully opaque) and above every page / overlay in the content column. It renders + // nothing and consumes no space while the node is reachable. + renderNodeStatusBanner(); // Capture vertex start for edge fade mask ImDrawList* caDL = ImGui::GetWindowDrawList(); @@ -2015,6 +2161,10 @@ void App::render() ui::RenderAboutDialog(this, &show_about_); } + if (show_faq_) { + ui::RenderFaqDialog(this, &show_faq_); + } + // Lite first-run welcome: prompt to create/restore when no wallet file exists yet. renderLiteFirstRunPrompt(); // Lite send-time unlock prompt (shown when a spend is attempted on a locked wallet). @@ -2109,6 +2259,10 @@ void App::render() renderDecryptWalletDialog(); renderPinDialogs(); renderSwitchStopDaemonDialog(); + renderDaemonStopConfirm(); + renderBlockDbReindexDialog(); + renderWalletRecoveredDialog(); + renderEmptyWalletWarningDialog(); // Render notifications (toast messages) ui::Notifications::instance().render(); @@ -2118,17 +2272,276 @@ void App::render() ui::material::LatchBlurOverlayActive(); } +void App::renderNodeStatusBanner() +{ + namespace m = ui::material; + + // Suppress during flows that legitimately have no connection, so the banner never contradicts + // an overlay the app is already showing: the first-run wizard (no daemon started yet), a + // wallet switch, an in-flight daemon restart, the screenshot sweep (forces demo state), and + // shutdown. tryConnect() sets connection_in_progress_ before the first render on normal + // startup, so the ordinary boot path is covered by the evaluator's own in-progress guard. + if (capture_mode_ || isShuttingDown()) return; + if (getWizardPhase() != WizardPhase::None) return; + if (wallet_switch_phase_.load() != 0) return; + if (daemon_restarting_.load()) return; + + ui::NodeBannerInputs in; + in.lite = isLiteBuild(); + in.connected = state_.connected; + in.warming_up = state_.warming_up; + in.daemon_initializing = state_.daemon_initializing; + in.connection_in_progress = connection_in_progress_; + in.using_embedded_daemon = isUsingEmbeddedDaemon(); + in.has_daemon_controller = (daemon_controller_ != nullptr); + in.daemon_running = isEmbeddedDaemonRunning(); + in.daemon_crash_count = daemon_controller_ ? daemon_controller_->crashCount() : 0; + in.connection_status = connection_status_; + in.daemon_last_error = daemon_controller_ ? daemon_controller_->lastError() : std::string(); + in.lite_open_error = lite_open_error_; + + const ui::NodeBannerState banner = ui::evaluateNodeStatusBanner(in); + if (!banner.show) return; + + const auto& S = ui::schema::UI(); + const float minH = S.drawElement("banners.node-status", "min-height").size; + const float baseH = S.drawElement("banners.node-status", "height").size; + + // Fonts up-front so the banner height can never be shorter than the glyph row it vertically + // centers — otherwise the title/icon draw above the child's top clip rect and slice off (seen at + // HiDPI / large font scale, where the DPI-baked glyphs outgrow the schema height). Metrics scaled. + ImFont* icoFont = m::Type().iconSmall(); + ImFont* txtFont = m::Type().body2(); + const float glyphH = std::max(icoFont ? icoFont->LegacySize : 0.0f, + txtFont ? txtFont->LegacySize : 0.0f); + // Both operands must be in scaled px: vScale() already folds in dpiScale(), so the raw min-height + // floor needs the same dpiScale() or it under-clamps the banner at HiDPI. + float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale()); + bannerH = std::max(bannerH, glyphH + ui::Layout::spacingSm() * 2.0f); // never shorter than text + + const bool isError = (banner.severity == ui::NodeBannerSeverity::Error); + const ImU32 sevCol = isError ? m::Error() : m::Warning(); + const ImU32 bgCol = m::WithAlphaF(sevCol, isError ? 0.20f : 0.15f); + + // Translated headline for the reason; `detail` is the live status text (may be empty). + const char* title; + const char* icon; + switch (banner.reason) { + case ui::NodeBannerReason::DaemonCrashed: + title = TR("node_banner_crashed_title"); icon = ICON_MD_ERROR; break; + case ui::NodeBannerReason::LiteOpenFailed: + title = TR("node_banner_lite_open_failed"); icon = ICON_MD_ERROR; break; + case ui::NodeBannerReason::FullNodeOffline: + default: + title = TR("node_banner_offline_title"); icon = ICON_MD_CLOUD_OFF; break; + } + + const char* actionLabel = nullptr; + if (banner.action == ui::NodeBannerAction::Reconnect) actionLabel = TR("node_banner_reconnect"); + else if (banner.action == ui::NodeBannerAction::RestartNode) actionLabel = TR("node_banner_restart"); + + const float padX = ui::Layout::spacingLg(); + + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(bgCol)); + ImGui::BeginChild("##NodeStatusBanner", + ImVec2(ImGui::GetContentRegionAvail().x, bannerH), false, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + const float winW = ImGui::GetWindowSize().x; + + // Icon — centered on its own metrics (clamped so it never draws above the child's top). + ImGui::SetCursorPos(ImVec2(padX, std::max(0.0f, (bannerH - icoFont->LegacySize) * 0.5f))); + ImGui::PushFont(icoFont); + ImGui::PushStyleColor(ImGuiCol_Text, sevCol); + ImGui::TextUnformatted(icon); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + const float txtCy = std::max(0.0f, (bannerH - txtFont->LegacySize) * 0.5f); + + // Right-aligned action button geometry (measured first so the detail text can be clipped to + // never run underneath it). + float btnW = 0.0f, btnH = 0.0f, actionReserve = 0.0f; + if (actionLabel) { + btnH = std::max(0.0f, bannerH - ui::Layout::spacingSm() * 2.0f); + btnW = ImGui::CalcTextSize(actionLabel).x + ui::Layout::spacingLg() * 1.6f; + actionReserve = btnW + padX + ui::Layout::spacingMd(); + } + + // Title. + ImGui::SameLine(0.0f, ui::Layout::spacingSm()); + ImGui::SetCursorPosY(txtCy); + ImGui::PushFont(txtFont); + ImGui::PushStyleColor(ImGuiCol_Text, sevCol); + ImGui::TextUnformatted(title); + ImGui::PopStyleColor(); + + // Detail (dim) on the same row, clipped with an ellipsis so it can't push the button off-screen. + if (!banner.detail.empty()) { + ImGui::SameLine(0.0f, ui::Layout::spacingSm()); + ImGui::SetCursorPosY(txtCy); + const float budget = winW - ImGui::GetCursorPosX() - actionReserve; + if (budget > ImGui::CalcTextSize("W").x) { + const std::string prefix = "\xC2\xB7 "; // "· " + std::string detail = banner.detail; + std::string shown = prefix + detail; + if (ImGui::CalcTextSize(shown.c_str()).x > budget) { + const std::string ell = "\xE2\x80\xA6"; // "…" + while (!detail.empty() && + ImGui::CalcTextSize((prefix + detail + ell).c_str()).x > budget) { + detail.pop_back(); + while (!detail.empty() && + (static_cast(detail.back()) & 0xC0) == 0x80) + detail.pop_back(); // drop the whole trailing UTF-8 code point + } + shown = prefix + detail + ell; + } + ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceMedium()); + ImGui::TextUnformatted(shown.c_str()); + ImGui::PopStyleColor(); + } + } + ImGui::PopFont(); + + // Action button. + if (actionLabel) { + ImGui::SetCursorPos(ImVec2(winW - btnW - padX, std::max(0.0f, (bannerH - btnH) * 0.5f))); + if (m::TactileButton(actionLabel, ImVec2(btnW, btnH))) { + if (banner.action == ui::NodeBannerAction::RestartNode) restartDaemon(); + else if (banner.action == ui::NodeBannerAction::Reconnect) tryConnect(); + } + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); +} + +void App::renderAlertHistoryPanel() +{ + namespace m = ui::material; + const float dp = ui::Layout::dpiScale(); + auto& notes = ui::Notifications::instance(); + const auto& hist = notes.history(); + const float innerW = ImGui::GetContentRegionAvail().x; + const float padX = 8.0f * dp; + const float padY = 8.0f * dp; + + ImFont* icoF = m::Type().iconSmall(); + ImFont* txtF = m::Type().caption(); + + ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room above the content + + // Header: "Recent alerts" on the left, a Clear-all icon button on the right. + ImGui::SetCursorPosX(padX); + ImGui::PushFont(txtF); + ImGui::TextDisabled("%s", TR("alerts_recent")); + ImGui::PopFont(); + if (!hist.empty()) { + const float clrW = icoF->LegacySize + 8.0f * dp; + ImGui::SameLine(); + ImGui::SetCursorPosX(innerW - clrW); + m::IconButtonStyle cst; + cst.color = m::OnSurfaceMedium(); + cst.hoverColor = m::OnSurface(); + cst.hoverBg = m::StateHover(); + cst.bgRounding = 4.0f * dp; + cst.tooltip = TR("alerts_clear"); + if (m::IconButton("##ClearAlerts", ICON_MD_CLEAR_ALL, icoF, + ImVec2(clrW, icoF->LegacySize + 4.0f * dp), cst)) { + notes.clearHistory(); + alerts_seen_total_ = notes.totalPushed(); + } + } + ImGui::Separator(); + + if (hist.empty()) { + ImGui::SetCursorPosX(padX); + ImGui::PushFont(txtF); + ImGui::TextDisabled("%s", TR("alerts_none")); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content + return; + } + + // Scrollable list, newest first. Measure the TRUE content height so wrapped (multi-line) messages + // and optional action links aren't clipped by an under-estimate; cap so a busy session scrolls + // inside the panel instead of blowing past the popup's max height. + const float msgWrapW = std::max(40.0f * dp, innerW - 2.0f * padX - icoF->LegacySize - 6.0f * dp); + float contentH = 0.0f; + for (const auto& a : hist) { + const float msgH = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, msgWrapW, a.message.c_str()).y; + contentH += std::max(msgH, static_cast(icoF->LegacySize)); // icon + wrapped message + contentH += txtF->LegacySize; // relative-age line + if (a.onClick && !a.actionHint.empty()) contentH += txtF->LegacySize; // action-link line + contentH += 8.0f * dp; // inter-entry spacing + } + const float listH = std::min(300.0f * dp, contentH); + ImGui::BeginChild("##AlertRows", ImVec2(0, listH), false); + int idx = 0; + for (auto it = hist.rbegin(); it != hist.rend(); ++it, ++idx) { + const ui::AlertRecord& a = *it; + ImU32 col; const char* icon; + switch (a.type) { + case ui::NotificationType::Success: col = m::Success(); icon = ICON_MD_CHECK_CIRCLE; break; + case ui::NotificationType::Warning: col = m::Warning(); icon = ICON_MD_WARNING; break; + case ui::NotificationType::Error: col = m::Error(); icon = ICON_MD_ERROR; break; + case ui::NotificationType::Info: + default: col = m::Primary(); icon = ICON_MD_INFO; break; + } + ImGui::PushID(idx); + // Icon + message (message wraps in the remaining width). + ImGui::SetCursorPosX(padX); + ImGui::PushFont(icoF); + ImGui::PushStyleColor(ImGuiCol_Text, col); + ImGui::TextUnformatted(icon); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::SameLine(0.0f, 6.0f * dp); + ImGui::PushFont(txtF); + ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurface()); + ImGui::PushTextWrapPos(innerW - padX); + ImGui::TextWrapped("%s", a.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + // Optional clickable action (accent link), directly under the message so it stays prominent. + if (a.onClick && !a.actionHint.empty()) { + ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); + ImGui::PushStyleColor(ImGuiCol_Text, m::Primary()); + ImGui::TextUnformatted(a.actionHint.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) { + const ImVec2 lmn = ImGui::GetItemRectMin(), lmx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(lmn.x, lmx.y), ImVec2(lmx.x, lmx.y), m::Primary()); + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + } + if (ImGui::IsItemClicked()) { a.onClick(); ImGui::CloseCurrentPopup(); } + } + // Relative age, dim, indented under the message. + ImGui::SetCursorPosX(padX + icoF->LegacySize + 6.0f * dp); + ImGui::PushStyleColor(ImGuiCol_Text, m::OnSurfaceDisabled()); + ImGui::TextUnformatted(util::formatTimeAgoShort(a.epoch).c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::PopID(); + ImGui::Spacing(); + } + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0.0f, padY)); // breathing room below the content +} + void App::renderStatusBar() { // Status bar layout from unified UI schema const auto& S = ui::schema::UI(); const auto& sbWin = S.window("components.status-bar"); - const float sbHeight = sbWin.height; - const float sbPadX = sbWin.padding[0]; - const float sbPadY = sbWin.padding[1]; - const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size; - const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size; - const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size; + // Schema values are logical px; the fonts/icons drawn inside are DPI-baked, so the box and its + // gaps must be scaled by the same factor or they clip the text at HiDPI / font_scale > 1. + const float dp = ui::Layout::dpiScale(); + const float sbHeight = sbWin.height * dp; + const float sbPadX = sbWin.padding[0] * dp; + const float sbPadY = sbWin.padding[1] * dp; + const float sbIconTextGap = S.drawElement("components.status-bar", "icon-text-gap").size * dp; + const float sbSectionGap = S.drawElement("components.status-bar", "section-gap").size * dp; + const float sbSeparatorGap = S.drawElement("components.status-bar", "separator-gap").size * dp; ImGuiWindowFlags childFlags = ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse; @@ -2360,6 +2773,8 @@ void App::renderStatusBar() // Compute positions dynamically from actual text widths so they // never overlap and always stay within the window at any font scale. { + // Where the left-side chain ended, so the right cluster never SameLine()s backward onto it. + const float leftEndX = ImGui::GetCursorPosX(); char versionBuf[32]; snprintf(versionBuf, sizeof(versionBuf), "v%s", DRAGONX_VERSION); float versionW = ImGui::CalcTextSize(versionBuf).x; @@ -2371,10 +2786,36 @@ void App::renderStatusBar() float gap = sbSectionGap; float occupiedX = versionX; // leftmost X used by the version + connection status so far if (!connection_status_.empty() && connection_status_ != "Connected") { - float statusW = ImGui::CalcTextSize(connection_status_.c_str()).x; + // During a post-repair rescan the raw status is a scary "RPC request failed: Timeout" — show a + // calm line instead (the rescan legitimately can't answer RPC yet). + const std::string statusBase = post_recovery_rescan_ ? std::string(TR("sb_finishing_repair")) + : connection_status_; + // Middle-ellipsize to the space between the left chain and the version so a long status + // (e.g. "Wallet needs recovery — see the prompt") can't push off-screen or overprint the + // left chain; then clamp its start so it never crosses left of where the chain ended. + float availW = versionX - leftEndX - gap * 2.0f; + if (availW < 24.0f * dp) availW = 24.0f * dp; + ImFont* stFont = ImGui::GetFont(); + std::string statusShown = ui::material::TruncateToWidth( + statusBase, stFont, stFont->LegacySize, availW); + float statusW = ImGui::CalcTextSize(statusShown.c_str()).x; float statusX = versionX - statusW - gap; + if (statusX < leftEndX + gap) statusX = leftEndX + gap; ImGui::SameLine(statusX); - ImGui::TextDisabled("%s", connection_status_.c_str()); + if (wallet_auto_recovered_ && !post_recovery_rescan_) { + // Actionable re-entry: a full-opacity accent chip that reopens the recovery dialog. + // Dismiss is non-destructive, so this is the guaranteed way back to the prompt. + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextUnformatted(statusShown.c_str()); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + if (ImGui::IsItemClicked() && recovery_phase_ != RecoveryPhase::Working) { + show_wallet_recovered_dialog_ = true; // reopen the (non-destructively dismissed) prompt + recovery_phase_ = RecoveryPhase::Offer; + } + } else { + ImGui::TextDisabled("%s", statusShown.c_str()); + } occupiedX = statusX; } else if (!daemon_status_.empty() && daemon_status_.find("Error") != std::string::npos) { const char* errText = TR("sb_daemon_not_found"); @@ -2394,9 +2835,104 @@ void App::renderStatusBar() float cbX = occupiedX - cbW - gap; ImGui::SameLine(cbX); ImGui::TextUnformatted(cb.c_str()); + occupiedX = cbX; } } + // Alert-history bell — leftmost item of the right cluster. Opens a panel of recent alerts, + // including ones whose toast already faded; an unread dot marks alerts that arrived since + // the panel was last opened. + { + auto& notes = ui::Notifications::instance(); + ImFont* bellFont = ui::material::Type().iconSmall(); + const bool anyHist = notes.hasHistory(); + const char* bellGlyph = anyHist ? ICON_MD_NOTIFICATIONS : ICON_MD_NOTIFICATIONS_NONE; + + ImGui::PushFont(bellFont); + const float glyphW = ImGui::CalcTextSize(bellGlyph).x; + ImGui::PopFont(); + const float bellW = glyphW + 10.0f * dp; + const float bellH = bellFont->LegacySize + 4.0f * dp; + const float bellX = occupiedX - bellW - gap; + + ImGui::SameLine(bellX); + ui::material::IconButtonStyle st; + st.color = ui::material::OnSurfaceMedium(); + st.hoverColor = ui::material::OnSurface(); + st.hoverBg = ui::material::StateHover(); + st.bgRounding = 4.0f * dp; + st.tooltip = TR("alerts_history_tooltip"); + const bool clicked = ui::material::IconButton("##AlertBell", bellGlyph, bellFont, + ImVec2(bellW, bellH), st); + const ImVec2 bellMin = ImGui::GetItemRectMin(); + const ImVec2 bellMax = ImGui::GetItemRectMax(); + + // Unread dot: alerts pushed since the panel was last opened, coloured by the most + // severe unseen alert. totalPushed() is monotonic, so this survives capping/clearing. + const std::uint64_t unseen = notes.totalPushed() - alerts_seen_total_; + if (unseen > 0 && anyHist) { + const auto& h = notes.history(); + size_t scan = (unseen < h.size()) ? static_cast(unseen) : h.size(); + bool anyErr = false, anyWarn = false; + for (size_t i = 0; i < scan; ++i) { + auto t = h[h.size() - 1 - i].type; + if (t == ui::NotificationType::Error) { anyErr = true; break; } + if (t == ui::NotificationType::Warning) anyWarn = true; + } + ImU32 dotCol = anyErr ? ui::material::Error() + : anyWarn ? ui::material::Warning() + : ui::material::Primary(); + const float r = 3.0f * dp; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(bellMax.x - r, bellMin.y + r), r, dotCol); + } + + if (clicked) { + alerts_seen_total_ = notes.totalPushed(); // mark everything currently shown as seen + ImGui::OpenPopup("##AlertHistoryPopup"); + } + + // The bell sits near the window's bottom-right, so anchor the popup's bottom-RIGHT + // corner at the bell's right edge (pivot (1,1)) — it then grows LEFT over the canvas and + // UP from the status bar. A left pivot would push a 320px panel off the right edge (and + // an explicit SetNextWindowPos pivot skips ImGui's on-screen clamp, so it would overflow). + ImGui::SetNextWindowPos(ImVec2(bellMax.x, bellMin.y - 4.0f * dp), + ImGuiCond_Always, ImVec2(1.0f, 1.0f)); + const float panelW = 320.0f * dp; + ImGui::SetNextWindowSizeConstraints(ImVec2(panelW, 0), ImVec2(panelW, 360.0f * dp)); + if (ImGui::BeginPopup("##AlertHistoryPopup")) { + renderAlertHistoryPanel(); + ImGui::EndPopup(); + } + + occupiedX = bellX; + } + + // Help (?) — sits just left of the alert bell, on every platform. A plain, subtle question + // mark (no circle) that opens the FAQ. + { + ImFont* helpFont = ui::material::Type().iconSmall(); + ImGui::PushFont(helpFont); + const float helpGlyphW = ImGui::CalcTextSize(ICON_MD_QUESTION_MARK).x; + ImGui::PopFont(); + const float helpW = helpGlyphW + 10.0f * dp; + const float helpH = helpFont->LegacySize + 4.0f * dp; + const float helpX = occupiedX - helpW - gap; + + ImGui::SameLine(helpX); + ui::material::IconButtonStyle hst; + hst.color = ui::material::OnSurfaceMedium(); + hst.hoverColor = ui::material::OnSurface(); + hst.hoverBg = ui::material::StateHover(); + hst.bgRounding = 4.0f * dp; + hst.tooltip = TR("faq_open_tooltip"); + if (ui::material::IconButton("##HelpFaq", ICON_MD_QUESTION_MARK, helpFont, + ImVec2(helpW, helpH), hst)) { + show_faq_ = true; + } + occupiedX = helpX; + } + // Version always at far right ImGui::SameLine(versionX); ImGui::Text("%s", versionBuf); @@ -2521,7 +3057,8 @@ void App::renderLiteFirstRunPrompt() if (ImGui::BeginPopupModal("##LiteFirstRun", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { - const float btnW = 170.0f; + const float dp = ui::Layout::dpiScale(); + const float btnW = 170.0f * dp; if (step == 0) { // ── Welcome ────────────────────────────────────────────────────────── @@ -2529,7 +3066,7 @@ void App::renderLiteFirstRunPrompt() ImGui::TextUnformatted(TR("lite_welcome_title")); ImGui::PopFont(); ImGui::Spacing(); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 360.0f * dp); ImGui::TextUnformatted(TR("lite_welcome_msg")); ImGui::PopTextWrapPos(); ImGui::Spacing(); ImGui::Spacing(); @@ -2537,7 +3074,7 @@ void App::renderLiteFirstRunPrompt() if (creating) { // Async create (with server failover) is in flight — driven to completion by // App::update()'s pumpAsyncOpen(). Poll the controller for the outcome. - ImGui::TextUnformatted("Creating your wallet\xE2\x80\xA6"); + ImGui::TextUnformatted(TR("appx_creating_your_wallet")); if (lite_wallet_->walletOpen()) { auto s = lite_wallet_->exportSeed(); // read the new seed back (local, fast) if (s.ok && !s.seedPhrase.empty()) { @@ -2555,7 +3092,7 @@ void App::renderLiteFirstRunPrompt() } else if (!lite_wallet_->openInProgress() && !lite_wallet_->lastOpenError().empty()) { ui::Notifications::instance().warning( - std::string("Create failed: ") + lite_wallet_->lastOpenError()); + std::string(TR("appx_create_failed_prefix")) + lite_wallet_->lastOpenError()); creating = false; // back to the buttons so the user can retry } } else { @@ -2581,14 +3118,12 @@ void App::renderLiteFirstRunPrompt() } else if (step == 1) { // ── Reveal the seed + birthday with backup warnings ───────────────────── ImGui::PushFont(ui::material::Type().subtitle1()); - ImGui::TextUnformatted("Back up your seed phrase"); + ImGui::TextUnformatted(TR("appx_back_up_seed_phrase_title")); ImGui::PopFont(); ImGui::Spacing(); - ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f * dp); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); - ImGui::TextUnformatted("These 24 words are the ONLY way to restore your wallet. " - "Write them down in order, store them offline, and never share " - "them. If you lose them, your funds are gone forever."); + ImGui::TextUnformatted(TR("appx_seed_backup_warning")); ImGui::PopStyleColor(); ImGui::PopTextWrapPos(); ImGui::Spacing(); @@ -2598,17 +3133,17 @@ void App::renderLiteFirstRunPrompt() char cell[96]; snprintf(cell, sizeof(cell), "%2zu. %s", i + 1, words[i].c_str()); ImGui::TextUnformatted(cell); - if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f); + if ((i % 4) != 3 && i + 1 < words.size()) ImGui::SameLine(((i % 4) + 1) * 130.0f * dp); } ImGui::Spacing(); char bday[80]; - snprintf(bday, sizeof(bday), "Birthday (block height): %llu — back this up too.", birthday); + snprintf(bday, sizeof(bday), TR("appx_birthday_block_height"), birthday); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); ImGui::TextUnformatted(bday); ImGui::PopStyleColor(); ImGui::Spacing(); ImGui::Spacing(); - if (ui::material::TactileButton("I've written it down", ImVec2(btnW, 0))) { + if (ui::material::TactileButton(TR("appx_ive_written_it_down"), ImVec2(btnW, 0))) { chips.clear(); for (const auto& w : words) chips.emplace_back(w, false); std::mt19937 rng{std::random_device{}()}; @@ -2618,9 +3153,9 @@ void App::renderLiteFirstRunPrompt() step = 2; } ImGui::SameLine(); - if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed); + if (ui::material::TactileButton(TR("appx_copy"), ImVec2(80 * dp, 0))) copySecretToClipboard(seed); ImGui::SameLine(); - if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) { + if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) { if (!skipConfirm) { skipConfirm = true; // require a second, deliberate click } else { @@ -2632,27 +3167,26 @@ void App::renderLiteFirstRunPrompt() ImGui::Spacing(); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); - ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. " - "Skip anyway?"); + ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning")); ImGui::PopStyleColor(); ImGui::PopTextWrapPos(); } } else if (step == 2) { // ── Verify: tap the words in order ────────────────────────────────────── ImGui::PushFont(ui::material::Type().subtitle1()); - ImGui::TextUnformatted("Confirm your backup"); + ImGui::TextUnformatted(TR("appx_confirm_your_backup")); ImGui::PopFont(); ImGui::Spacing(); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); - ImGui::TextUnformatted("Tap the words in the correct order to confirm you saved them."); + ImGui::TextUnformatted(TR("appx_tap_words_in_order")); ImGui::PopTextWrapPos(); ImGui::Spacing(); - ImGui::TextDisabled("Progress: %d / %d", progress, (int)words.size()); + ImGui::TextDisabled(TR("appx_progress_n_of_n"), progress, (int)words.size()); if (ImGui::GetTime() < wrongFlashUntil) { ImGui::SameLine(); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); - ImGui::TextUnformatted(" — that's not the next word"); + ImGui::TextUnformatted(TR("appx_not_next_word")); ImGui::PopStyleColor(); } ImGui::Spacing(); @@ -2661,9 +3195,9 @@ void App::renderLiteFirstRunPrompt() ImGui::PushID((int)i); if (chips[i].second) { ImGui::BeginDisabled(); - ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0)); + ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0)); ImGui::EndDisabled(); - } else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125, 0))) { + } else if (ui::material::TactileButton(chips[i].first.c_str(), ImVec2(125 * dp, 0))) { if (progress < (int)words.size() && chips[i].first == words[progress]) { chips[i].second = true; // correct next word ++progress; @@ -2678,15 +3212,15 @@ void App::renderLiteFirstRunPrompt() const bool verified = progress == (int)words.size(); if (!verified) ImGui::BeginDisabled(); - if (ui::material::TactileButton("Done", ImVec2(btnW, 0))) { - ui::Notifications::instance().success("Wallet created and backed up.", 6.0f); + if (ui::material::TactileButton(TR("appx_done"), ImVec2(btnW, 0))) { + ui::Notifications::instance().success(TR("appx_wallet_created_and_backed_up"), 6.0f); finish(); } if (!verified) ImGui::EndDisabled(); ImGui::SameLine(); - if (ui::material::TactileButton("Back", ImVec2(80, 0))) { skipConfirm = false; step = 1; } + if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { skipConfirm = false; step = 1; } ImGui::SameLine(); - if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) { + if (ui::material::TactileButton(skipConfirm ? TR("appx_skip_anyway") : TR("appx_skip"), ImVec2(120, 0))) { if (!skipConfirm) { skipConfirm = true; // require a second, deliberate click } else { @@ -2698,8 +3232,7 @@ void App::renderLiteFirstRunPrompt() ImGui::Spacing(); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); - ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. " - "Skip anyway?"); + ImGui::TextUnformatted(TR("appx_seed_not_backed_up_warning")); ImGui::PopStyleColor(); ImGui::PopTextWrapPos(); } @@ -2716,7 +3249,7 @@ void App::renderLiteFirstRunPrompt() if (restoring) { // Async restore (with server failover) in flight — driven by pumpAsyncOpen(). - ImGui::TextUnformatted("Restoring your wallet\xE2\x80\xA6"); + ImGui::TextUnformatted(TR("appx_restoring_your_wallet")); if (lite_wallet_->walletOpen()) { ui::Notifications::instance().success(TR("lite_restore_ok"), 6.0f); finish(); // wipes restoreSeed; the wallet then syncs from the lite server @@ -2728,10 +3261,10 @@ void App::renderLiteFirstRunPrompt() } else { ImGui::TextUnformatted(TR("lite_restore_seed_label")); ImGui::InputTextMultiline("##LiteRestoreSeed", restoreSeed, sizeof(restoreSeed), - ImVec2(380.0f, ImGui::GetTextLineHeight() * 3.2f)); + ImVec2(380.0f * dp, ImGui::GetTextLineHeight() * 3.2f)); ImGui::Spacing(); ImGui::TextUnformatted(TR("lite_restore_birthday_label")); - ImGui::SetNextItemWidth(160.0f); + ImGui::SetNextItemWidth(160.0f * dp); ImGui::InputInt("##LiteRestoreBirthday", &restoreBirthday); if (restoreBirthday < 0) restoreBirthday = 0; @@ -2745,27 +3278,23 @@ void App::renderLiteFirstRunPrompt() } ImGui::Spacing(); ImGui::Spacing(); - // Trim surrounding whitespace from the entered seed. - std::string seedTrim(restoreSeed); - while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.front())) seedTrim.erase(seedTrim.begin()); - while (!seedTrim.empty() && std::isspace((unsigned char)seedTrim.back())) seedTrim.pop_back(); + // Normalize the entered seed (trim, fold exotic Unicode whitespace like NBSP to plain + // spaces) so the word count and the phrase we submit agree regardless of paste source. + std::string seedTrim = util::normalizeSeedPhrase(restoreSeed); - // Require a valid BIP39 word count before enabling Restore — otherwise a truncated or - // garbage phrase (previously any non-empty text passed) is submitted and fails opaquely. - int seedWords = 0; - { bool inWord = false; - for (char c : seedTrim) { - bool sp = (c == ' ' || c == '\t' || c == '\n' || c == '\r'); - if (!sp && !inWord) { seedWords++; inWord = true; } - else if (sp) inWord = false; - } } - bool seedLenOk = (seedWords == 12 || seedWords == 15 || seedWords == 18 || - seedWords == 21 || seedWords == 24); + // Require a COMPLETE 24-word phrase before enabling Restore. The SDXL backend only + // accepts 24-word / 32-byte-entropy seeds; a shorter valid-BIP39 phrase (12/15/18/21) + // panics it uncaught across the restore FFI, so it must be refused here (matches the + // Settings restore gate — both go through util::isCompleteRecoveryPhrase). + int seedWords = util::seedPhraseWordCount(seedTrim); + bool seedLenOk = util::isCompleteRecoveryPhrase(seedWords); if (!seedTrim.empty() && !seedLenOk) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::Warning())); ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f); - ImGui::TextUnformatted(("Recovery phrase should be 24 words — you have " + - std::to_string(seedWords) + ".").c_str()); + char recoveryWordsMsg[96]; + snprintf(recoveryWordsMsg, sizeof(recoveryWordsMsg), + TR("appx_recovery_phrase_word_count"), seedWords); + ImGui::TextUnformatted(recoveryWordsMsg); ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); ImGui::Spacing(); @@ -2774,7 +3303,7 @@ void App::renderLiteFirstRunPrompt() ImGui::BeginDisabled(!seedLenOk); if (ui::material::TactileButton(TR("lite_restore_btn"), ImVec2(btnW, 0))) { wallet::LiteWalletRestoreRequest req; - req.seedPhrase = seedTrim; + req.seedPhrase = seedTrim; // normalized: NBSP-glued pastes restore correctly req.birthday = static_cast(std::max(0, restoreBirthday)); req.overwrite = lite_wallet_->walletExists(); // replace any existing wallet file if (lite_wallet_->beginRestoreWalletAsync(std::move(req))) { @@ -2782,13 +3311,13 @@ void App::renderLiteFirstRunPrompt() restoring = true; } else { restoreErr = lite_wallet_->lastOpenError().empty() - ? std::string("Could not start restore") + ? std::string(TR("appx_could_not_start_restore")) : lite_wallet_->lastOpenError(); } } ImGui::EndDisabled(); ImGui::SameLine(); - if (ui::material::TactileButton("Back", ImVec2(80, 0))) { + if (ui::material::TactileButton(TR("appx_back"), ImVec2(80, 0))) { sodium_memzero(restoreSeed, sizeof(restoreSeed)); restoreErr.clear(); step = 0; @@ -2812,17 +3341,18 @@ void App::renderLiteUnlockPrompt() ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); if (ImGui::BeginPopupModal("##LiteUnlock", nullptr, ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + const float dp = ui::Layout::dpiScale(); ImGui::PushFont(ui::material::Type().subtitle1()); ImGui::TextUnformatted(TR("lite_unlock_title")); ImGui::PopFont(); ImGui::Spacing(); ImGui::TextUnformatted(TR("lite_unlock_msg")); ImGui::Spacing(); - ImGui::SetNextItemWidth(280.0f); + ImGui::SetNextItemWidth(280.0f * dp); const bool entered = ImGui::InputText("##LiteUnlockPassModal", pass, sizeof(pass), ImGuiInputTextFlags_Password | ImGuiInputTextFlags_EnterReturnsTrue); ImGui::Spacing(); - const float btnW = 130.0f; + const float btnW = 130.0f * dp; bool doUnlock = ui::material::TactileButton(TR("lite_unlock_btn"), ImVec2(btnW, 0)) || entered; if (doUnlock) { const bool ok = lite_wallet_->unlockWallet(pass); @@ -3461,7 +3991,7 @@ void App::maybeOfferDaemonUpdate() void App::renderDaemonUpdatePrompt() { ui::material::OverlayDialogSpec ov; - ov.title = TR("daemon_update_title"); ov.p_open = &show_daemon_update_prompt_; + ov.title = TR("daemon_update_prompt_title"); ov.p_open = &show_daemon_update_prompt_; ov.style = ui::material::OverlayStyle::BlurFloat; ov.cardWidth = 520.0f; ov.idSuffix = "daemonupdprompt"; if (!ui::material::BeginOverlayDialog(ov)) @@ -3664,9 +4194,16 @@ void App::renderSeedMigrationDialog() ImGui::TextWrapped("%s", TR("mig_already_mnemonic")); ImGui::PopStyleColor(); ImGui::Spacing(); - if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) { - close(); - showSeedBackupDialog(); + { + // Size to the label — the fixed 200px clipped the FR/PT/ES/DE/RU translations. + ImFont* bkFont = ui::material::Type().button(); + const float bkW = std::max(200.0f * dp, + bkFont->CalcTextSizeA(bkFont->LegacySize, FLT_MAX, 0, TR("mig_backup_instead")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp); + if (ui::material::TactileButton(TR("mig_backup_instead"), ImVec2(bkW, 0))) { + close(); + showSeedBackupDialog(); + } } ImGui::SameLine(); if (ui::material::TactileButton(TR("close"), ImVec2(120 * dp, 0))) close(); @@ -3709,7 +4246,12 @@ void App::renderSeedMigrationDialog() case SeedMigrationStep::ShowSeed: { ui::material::DialogWarningHeader(TR("mig_seed_warning")); ImGui::Spacing(); - ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_)); + // Box the 24-word grid in a glass panel so the wallet's most critical secret reads as a + // distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched. + { + ui::material::GlassSectionScope seedPanel; + ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_)); + } ImGui::Spacing(); ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr")); ImGui::TextWrapped("%s", seed_migration_dest_.c_str()); @@ -3837,8 +4379,12 @@ void App::renderSeedMigrationDialog() ImGui::Spacing(); char cbuf[64]; snprintf(cbuf, sizeof(cbuf), TR("mig_confs"), seed_migration_sweep_confs_); ImGui::TextColored(kMedium, "%s", cbuf); - if (!seed_migration_sweep_txid_.empty()) - ImGui::TextColored(kMedium, "%s%s", TR("mig_txid"), seed_migration_sweep_txid_.c_str()); + if (!seed_migration_sweep_txid_.empty()) { + // A 64-hex txid overflows the fixed 580px card; render it in a wrapping, copyable field + // (same widget the import-key sweep result uses) instead of a raw one-line label. + ImGui::TextColored(kMedium, "%s", TR("mig_txid")); + ui::widgets::AddressCopyField("##migtxid", seed_migration_sweep_txid_); + } if (seed_migration_legacy_remaining_ >= 0.0) { char rbuf[96]; snprintf(rbuf, sizeof(rbuf), TR("mig_remaining"), seed_migration_legacy_remaining_); ImGui::TextColored(kMedium, "%s", rbuf); @@ -3953,6 +4499,485 @@ void App::renderAntivirusHelpDialog() #endif } +// Auto-shown when the node auto-recovered (salvaged) wallet.dat: the real wallet is safe in a +// wallet..bak, but a possibly-incomplete salvaged copy is now loaded — warn loudly and point +// the user at the datadir so they can restore the original instead of mistaking it for fund loss. +void App::renderWalletRecoveredDialog() +{ + if (!show_wallet_recovered_dialog_) return; + + ui::material::OverlayDialogSpec ov; + ov.title = TR("wallet_recovered_title"); + // Dismiss (backdrop / [X]) is disabled during Working — files are mid-swap. A null p_open is safe: + // BeginOverlayDialog guards both the close button and backdrop-close on it. + ov.p_open = (recovery_phase_ == RecoveryPhase::Working) ? nullptr : &show_wallet_recovered_dialog_; + ov.style = ui::material::OverlayStyle::BlurFloat; + ov.cardWidth = 620.0f; // wide enough for the two side-by-side choice cards + ov.idSuffix = "walletrecovered"; + if (!ui::material::BeginOverlayDialog(ov)) return; + const float dp = ui::Layout::dpiScale(); + // Size each button to its label (with a modest floor) instead of a magic fixed width, so + // translations of these keys — added additively to res/lang later — can't silently clip. + ImFont* rbf = ui::material::Type().button(); + auto fitBtnW = [&](const char* label) { + return std::max(96.0f * dp, + rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); + }; + // A full-width action row: the button, then a dim wrapped one-line explanation of what it does. + auto actionRow = [&](const char* label, const char* sub) -> bool { + const bool clicked = ui::material::TactileButton(label, ImVec2(fitBtnW(label), 0)); + if (sub && sub[0]) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextDisabled("%s", sub); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + } + return clicked; + }; + // A disclosure that recedes: no filled bar, dim label — so the primary action stays dominant. + auto quietHeader = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0, 0, 0, 0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ui::material::WithAlpha(ui::material::OnSurface(), 20)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ui::material::WithAlpha(ui::material::OnSurface(), 32)); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + const bool open = ImGui::CollapsingHeader(label); + ImGui::PopStyleColor(4); + return open; + }; + // Opt-in daemon log — the same lines that used to be dumped on screen, now behind a quiet disclosure. + auto techDetails = [&]() { + if (!daemon_controller_) return; + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (quietHeader(TR("wallet_recovery_details_label"))) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushTextWrapPos(0.0f); + for (const auto& ln : daemon_controller_->recentLines(8)) + ImGui::TextDisabled("%s", ln.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopFont(); + } + }; + // Funds-safety hero: a shield icon + the reassurance, made the visual focal point of the screen. + auto safetyHero = [&]() { + ImFont* icoF = ui::material::Type().iconLarge(); + ImFont* txtF = ui::material::Type().subtitle1(); + const float rowTop = ImGui::GetCursorPosY(); + ImGui::PushFont(icoF); + ImGui::TextColored(ui::material::SuccessVec4(), ICON_MD_HEALTH_AND_SAFETY); + ImGui::PopFont(); + ImGui::SameLine(); + const float iconH = icoF->LegacySize; + const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize(); + if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f); + ImGui::PushFont(txtF); + ImGui::TextColored(ui::material::SuccessVec4(), "%s", TR("wallet_recovered_safety")); + ImGui::PopFont(); + }; + // Accent-tinted primary action so the recommended button clearly dominates the quiet disclosures + // (a translucent Primary tint over the glass button — keeps the label readable on any theme). + auto primaryAction = [&](const char* label, const char* sub) -> bool { + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 60)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 90)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 115)); + const bool clicked = actionRow(label, sub); + ImGui::PopStyleColor(3); + return clicked; + }; + // A choice card: icon + title (+ optional "recommended" chip) + wrapped blurb + a full-width button + // pinned to the card bottom. The recommended card gets a gold-tinted fill + border so the choice + // reads at a glance. cardH is precomputed by the caller so side-by-side cards stay equal height. + auto renderCard = [&](const char* id, const char* glyph, const char* title, const char* desc, + const char* btn, bool recommended, float cardW, float cardH) -> bool { + const float pad = ui::Layout::spacingMd(); + ImGui::PushStyleColor(ImGuiCol_ChildBg, + recommended ? ui::material::WithAlpha(ui::material::Primary(), 22) + : ui::material::WithAlpha(ui::material::OnSurface(), 8)); + ImGui::PushStyleColor(ImGuiCol_Border, + recommended ? ui::material::WithAlpha(ui::material::Primary(), 140) + : ui::material::WithAlpha(ui::material::OnSurface(), 28)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp); + ImGui::PushStyleVar(ImGuiStyleVar_ChildBorderSize, 1.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(pad, pad)); + ImGui::BeginChild(id, ImVec2(cardW, cardH), true, + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + // Zero implicit item spacing so the only vertical gaps are the explicit Dummy()s below — that + // keeps the caller's measured cardH exact, so the bottom-pinned button never clips. + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0.0f, 0.0f)); + + ImGui::PushFont(ui::material::Type().iconMed()); + ImGui::TextColored(recommended ? ui::material::PrimaryVec4() + : ImGui::ColorConvertU32ToFloat4(ui::material::OnSurfaceMedium()), + "%s", glyph); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + + ImGui::PushFont(ui::material::Type().subtitle2()); + ImGui::TextUnformatted(title); + ImGui::PopFont(); + if (recommended) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::TextColored(ui::material::PrimaryVec4(), "%s", TR("wallet_recovery_recommended")); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", desc); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + + // Pin the button to the card bottom so both cards' buttons line up. + const float btnH = ImGui::GetFrameHeight() + 6.0f * dp; + const float remaining = ImGui::GetContentRegionAvail().y - btnH; + if (remaining > 0.0f) ImGui::Dummy(ImVec2(0, remaining)); + bool clicked; + if (recommended) { + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125)); + clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH)); + ImGui::PopStyleColor(3); + } else { + clicked = ui::material::TactileButton(btn, ImVec2(-FLT_MIN, btnH)); + } + ImGui::PopStyleVar(); // ItemSpacing + ImGui::EndChild(); + ImGui::PopStyleVar(3); + ImGui::PopStyleColor(2); + return clicked; + }; + // A quiet clickable text link for the footer (Show me the files / Not now). + auto linkText = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + const bool clicked = ImGui::IsItemClicked(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y), + ui::material::OnSurface()); + } + return clicked; + }; + + switch (recovery_phase_) { + case RecoveryPhase::Offer: { + safetyHero(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("wallet_recovered_warn")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + + // The two real actions as side-by-side CHOICE CARDS — the recommended one gold-tinted, so the + // decision reads at a glance instead of hiding in a menu. Rebuild is the more complete fix; when + // its helper is missing, Restore becomes the single recommended card (never a dead end). + const bool canRebuild = walletRebuildAvailable(); + const float cardGap = ui::Layout::spacingMd(); + const float pad = cardGap; + const float contentW = ImGui::GetContentRegionAvail().x; + const float cardW = canRebuild ? (contentW - cardGap) * 0.5f + : std::min(contentW, 320.0f * dp); + // Fix a shared card height off the taller card body so the two cards line up. Measure title AND + // desc wraps (titles/descs can be multi-line, esp. after translation), with a little width slack. + ImFont* descF = ui::material::Type().caption(); + ImFont* titleF = ui::material::Type().subtitle2(); + const float innerW = std::max(1.0f, cardW - 2.0f * pad - 6.0f * dp); + auto measureH = [&](ImFont* f, const char* s) { + return f->CalcTextSizeA(f->LegacySize, FLT_MAX, innerW, s).y; + }; + const float titleH = std::max(measureH(titleF, TR("wallet_recovery_rebuild_card")), + measureH(titleF, TR("wallet_recovery_restore_card"))); + const float descH = std::max(measureH(descF, TR("wallet_recovery_rebuild_card_desc")), + measureH(descF, TR("wallet_recovery_restore_card_desc"))); + const float cardH = 2.0f * pad + + ui::material::Type().iconMed()->LegacySize + ui::Layout::spacingXs() // icon + gap + + titleH + descF->LegacySize // title + RECOMMENDED chip + + ui::Layout::spacingXs() // gap before desc + + descH + ui::Layout::spacingSm() // desc + gap before button + + ImGui::GetFrameHeight() + 6.0f * dp // button + + ui::Layout::spacingXs(); // small buffer + + if (canRebuild) { + const bool r = renderCard("##rcRepair", ICON_MD_AUTO_FIX_HIGH, TR("wallet_recovery_rebuild_card"), + TR("wallet_recovery_rebuild_card_desc"), TR("wallet_recovery_repair_go"), + true, cardW, cardH); + ImGui::SameLine(0, cardGap); + const bool s = renderCard("##rcRestore", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"), + TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"), + false, cardW, cardH); + if (r) rebuildWalletDatabase(); + if (s) restoreOriginalWallet(); + } else { + const float indent = (contentW - cardW) * 0.5f; + if (indent > 0.0f) ImGui::Indent(indent); + const bool s = renderCard("##rcRestoreOnly", ICON_MD_SETTINGS_BACKUP_RESTORE, TR("wallet_recovery_restore_card"), + TR("wallet_recovery_restore_card_desc"), TR("wallet_recovery_restore_go"), + true, cardW, cardH); + if (indent > 0.0f) ImGui::Unindent(indent); + if (s) restoreOriginalWallet(); + } + + // Quiet footer: inspect-files link + a clearly-labelled, reversible dismiss (with a tooltip that + // spells out the consequence — plain "Not now" was ambiguous). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (linkText(TR("wallet_recovered_open_folder"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::SameLine(0, ui::Layout::spacingSm()); + ImGui::TextDisabled("\xC2\xB7"); // middle dot separator + ImGui::SameLine(0, ui::Layout::spacingSm()); + if (linkText(TR("wallet_recovery_decide_later"))) { + show_wallet_recovered_dialog_ = false; // non-destructive; reopen from the status bar + recovery_phase_ = RecoveryPhase::Offer; + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f); + ImGui::TextUnformatted(TR("wallet_recovery_decide_later_tip")); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + // Plain-language "what happens to my files" — replaces the unhelpful raw daemon log on this screen + // (the log is still available in the Console tab for troubleshooting). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (quietHeader(TR("wallet_recovery_files_label"))) { + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", TR("wallet_recovery_files_detail")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + break; + } + case RecoveryPhase::Working: { + ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_rebuild_started") + : TR("wallet_restore_started")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + ImGui::Text("%s%s", TR("wallet_recovery_working_label"), ui::material::LoadingDots()); + break; + } + case RecoveryPhase::Done: { + safetyHero(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", recovery_last_action_rebuild_ ? TR("wallet_recovery_success_body") + : TR("wallet_recovery_success_restore")); + // A sev-1 warning (e.g. restored but the node didn't relaunch) carries a specific message. + if (recovery_outcome_sev_ == 1 && !recovery_outcome_msg_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (ui::material::TactileButton(TR("wallet_recovery_done"), ImVec2(fitBtnW(TR("wallet_recovery_done")), 0))) { + show_wallet_recovered_dialog_ = false; + recovery_phase_ = RecoveryPhase::Offer; + // Clean success — clear the session flag so a later unrelated disconnect doesn't re-raise the + // "repair available" chip/dialog for a wallet that's already fixed. (Warnings keep it set.) + if (recovery_outcome_sev_ == 0) wallet_auto_recovered_ = false; + } + techDetails(); + break; + } + case RecoveryPhase::Failed: { + ImGui::PushFont(ui::material::Type().subtitle1()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); + ImGui::TextWrapped("%s", TR("wallet_recovery_failure_title")); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("wallet_recovery_failure_body")); + if (!recovery_outcome_msg_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingXs())); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::ReadableError()); + ImGui::TextWrapped("%s", recovery_outcome_msg_.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + // Offer the UNtried option (Restore needs no helper; Rebuild only if its helper exists). + if (recovery_last_action_rebuild_) { + if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_restore_sub"))) + restoreOriginalWallet(); + } else if (walletRebuildAvailable()) { + if (actionRow(TR("wallet_recovery_try_other"), TR("wallet_recovered_rebuild_sub"))) + rebuildWalletDatabase(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (actionRow(TR("wallet_recovered_open_folder"), TR("wallet_recovered_open_folder_sub"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + if (ui::material::TactileButton(TR("close"), ImVec2(fitBtnW(TR("close")), 0))) { + show_wallet_recovered_dialog_ = false; + recovery_phase_ = RecoveryPhase::Offer; + } + techDetails(); + break; + } + } + + ui::material::EndOverlayDialog(); +} + +// Auto-shown when the active wallet loaded EMPTY but a sibling wallet file in the datadir still holds keys +// (see maybeWarnEmptyWalletWithFundedSiblings). Funds are not lost — they're in another file, most likely a +// wallet..bak left by an earlier BDB salvage. This routes the user to the wallet manager to switch, and +// remembers a per-file dismissal so it never nags again for this wallet. +void App::renderEmptyWalletWarningDialog() +{ + if (!show_empty_wallet_warning_) return; + + const bool salvage = empty_wallet_has_salvage_bak_; // salvage .bak → offer Restore; else → switch wallet + + ui::material::OverlayDialogSpec ov; + ov.title = TR(salvage ? "empty_wallet_salvage_title" : "empty_wallet_warning_title"); + ov.p_open = &show_empty_wallet_warning_; + ov.style = ui::material::OverlayStyle::BlurFloat; + ov.cardWidth = 560.0f; + ov.idSuffix = "emptywalletwarn"; + if (!ui::material::BeginOverlayDialog(ov)) return; + const float dp = ui::Layout::dpiScale(); + + // Header: wallet icon in a warning tint + a calm "your coins are likely in another file" framing. + { + ImFont* icoF = ui::material::Type().iconLarge(); + const float rowTop = ImGui::GetCursorPosY(); + ImGui::PushFont(icoF); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Warning()); + ImGui::TextUnformatted(ICON_MD_ACCOUNT_BALANCE_WALLET); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::SameLine(); + ImFont* txtF = ui::material::Type().subtitle1(); + const float iconH = icoF->LegacySize; + const float textH = txtF ? txtF->LegacySize : ImGui::GetFontSize(); + if (iconH > textH) ImGui::SetCursorPosY(rowTop + (iconH - textH) * 0.5f); + ImGui::PushFont(txtF); + ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_headline" : "empty_wallet_warning_headline")); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + + ImGui::PushTextWrapPos(0.0f); + ImGui::TextWrapped("%s", TR(salvage ? "empty_wallet_salvage_body" : "empty_wallet_warning_body")); + ImGui::PopTextWrapPos(); + + // For the "wrong wallet" case, name the other wallet file(s) that hold keys, with a compact key count — + // concrete evidence the coins are recoverable from them. The count is built with std::to_string so no + // printf format lives in a translatable string (translations are additive and could otherwise drop a %d). + // (The salvage case has no sibling list — restoreOriginalWallet() finds the backup itself.) + if (!empty_wallet_funded_siblings_.empty()) { + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + for (const auto& s : empty_wallet_funded_siblings_) { + ImGui::Bullet(); + ImGui::SameLine(); + ImGui::TextUnformatted(s.fileName.c_str()); + ImGui::PushFont(ui::material::Type().caption()); + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + const std::string keys = " " + std::to_string(s.transparentKeys + s.shieldedKeys) + + " " + TR("empty_wallet_keys_suffix"); + ImGui::SameLine(); + ImGui::TextUnformatted(keys.c_str()); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + } + + // Primary action: route the user to the wallet manager to switch files (accent-tinted so it dominates). + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + ImFont* rbf = ui::material::Type().button(); + auto fitBtnW = [&](const char* label) { + return std::max(120.0f * dp, + rbf->CalcTextSizeA(rbf->LegacySize, FLT_MAX, 0, label).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp); + }; + const char* primaryLabel = salvage ? TR("empty_wallet_restore") : TR("empty_wallet_open_manager"); + ImGui::PushStyleColor(ImGuiCol_Button, ui::material::WithAlpha(ui::material::Primary(), 65)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ui::material::WithAlpha(ui::material::Primary(), 100)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ui::material::WithAlpha(ui::material::Primary(), 125)); + if (ui::material::TactileButton(primaryLabel, ImVec2(fitBtnW(primaryLabel), 0))) { + show_empty_wallet_warning_ = false; + if (salvage) + restoreOriginalWallet(); // self-contained: swaps the .bak back + drives the recovery dialog's progress + else + ui::WalletsDialog::show(this); + } + ImGui::PopStyleColor(3); + + // Quiet footer: open the data folder, or dismiss permanently for THIS wallet file. + auto linkText = [&](const char* label) -> bool { + ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium()); + ImGui::TextUnformatted(label); + ImGui::PopStyleColor(); + const bool clicked = ImGui::IsItemClicked(); + if (ImGui::IsItemHovered()) { + ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 mn = ImGui::GetItemRectMin(), mx = ImGui::GetItemRectMax(); + ImGui::GetWindowDrawList()->AddLine(ImVec2(mn.x, mx.y), ImVec2(mx.x, mx.y), + ui::material::OnSurface()); + } + return clicked; + }; + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (linkText(TR("wallet_recovered_open_folder"))) + util::Platform::openFolder(util::Platform::getDragonXDataDir()); + ImGui::SameLine(0, ui::Layout::spacingSm()); + ImGui::TextDisabled("\xC2\xB7"); // middle dot separator + ImGui::SameLine(0, ui::Layout::spacingSm()); + if (linkText(TR("empty_wallet_warning_dismiss"))) { + if (settings_) { + settings_->ackEmptyWalletWarn(settings_->getActiveWalletFile()); + settings_->save(); + } + show_empty_wallet_warning_ = false; + } + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 22.0f); + ImGui::TextUnformatted(TR("empty_wallet_warning_dismiss_tip")); + ImGui::PopTextWrapPos(); + ImGui::EndTooltip(); + } + + ui::material::EndOverlayDialog(); +} + +// Auto-shown when the embedded node aborts on an unreadable block database — offers the one-click +// -reindex rebuild instead of leaving the wallet stuck on a silent zero balance. +void App::renderBlockDbReindexDialog() +{ + if (!show_block_db_reindex_confirm_) return; + + ui::material::OverlayDialogSpec ov; + ov.title = TR("block_db_reindex_title"); + ov.p_open = &show_block_db_reindex_confirm_; // X / backdrop dismisses (offer remains; loop stays held) + ov.style = ui::material::OverlayStyle::BlurFloat; + ov.cardWidth = 540.0f; + ov.idSuffix = "blockdbreindex"; + if (!ui::material::BeginOverlayDialog(ov)) return; + const float dp = ui::Layout::dpiScale(); + + ui::material::DialogWarningHeader(TR("block_db_reindex_warn")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm())); + ImGui::TextWrapped("%s", TR("block_db_reindex_body")); + ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd())); + if (ui::material::TactileButton(TR("block_db_reindex_confirm"), ImVec2(260.0f * dp, 0))) { + reindexBlockDatabase(); // clears show_block_db_reindex_confirm_ + block_db_reindex_available_ + } + ImGui::SameLine(); + if (ui::material::TactileButton(TR("cancel"), ImVec2(110.0f * dp, 0))) { + show_block_db_reindex_confirm_ = false; + // Leave block_db_reindex_available_ set: the connect loop keeps HOLDING (no crash-restart storm) + // rather than looping into the same abort; the user can rebuild later from Settings. + } + ui::material::EndOverlayDialog(); +} + void App::renderSwitchStopDaemonDialog() { const bool confirm = show_switch_stop_daemon_confirm_; @@ -4082,12 +5107,24 @@ void App::refreshNow() invalidateShieldedHistoryScanProgress(true); } +void App::skipDaemonOutputBacklog() +{ + // While minimized, App::update() is paused, so daemon_output_offset_ is never advanced and a large + // backlog of daemon output piles up. Parsing it all at once on restore would replay a background + // witness rebuild's progress + completion in a single batch and fire a spurious "Blockchain rescan + // complete" toast. Advance the offset to the current end so only NEW (post-restore) output is parsed. + // A genuine user-initiated rescan still surfaces completion via the getrescaninfo monitor. + if (daemon_controller_ && daemon_controller_->isRunning()) { + (void)daemon_controller_->outputSince(daemon_output_offset_); // advances daemon_output_offset_ to the end + } +} + void App::handlePaymentURI(const std::string& uri) { auto payment = util::parsePaymentURI(uri); if (!payment.valid) { - ui::Notifications::instance().error("Invalid payment URI: " + payment.error); + ui::Notifications::instance().error(std::string(TR("appx_invalid_payment_uri_prefix")) + payment.error); return; } @@ -4102,7 +5139,7 @@ void App::handlePaymentURI(const std::string& uri) setCurrentPage(ui::NavPage::Send); // Notify user - std::string msg = "Payment request loaded"; + std::string msg = TR("appx_payment_request_loaded"); if (payment.amount > 0) { char buf[64]; snprintf(buf, sizeof(buf), " for %.8f DRGX", payment.amount); @@ -4149,7 +5186,11 @@ bool App::startEmbeddedDaemon() if (resources::hasEmbeddedResources()) { DEBUG_LOGF("Extracting embedded Sapling params...\n"); daemon_status_ = TR("sb_extracting_sapling"); - resources::extractEmbeddedResources(); + if (!resources::extractEmbeddedResources()) { + daemon_status_ = TR("sb_daemon_extract_failed"); + DEBUG_LOGF("[ERROR] extractEmbeddedResources() failed — disk full or permission denied?\n"); + return false; + } // Check again after extraction if (!rpc::Connection::verifySaplingParams()) { @@ -4168,8 +5209,13 @@ bool App::startEmbeddedDaemon() const char* paramFiles[] = { "sapling-spend.params", "sapling-output.params", "asmap.dat" }; bool copied = false; if (!exe_dir.empty()) { + std::string dirErr; + if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { + daemon_status_ = dirErr; + DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); + return false; + } std::error_code ec; - fs::create_directories(daemon_dir, ec); // On macOS .app bundles, params are in Contents/Resources/ // while the executable is in Contents/MacOS/ @@ -4214,8 +5260,13 @@ bool App::startEmbeddedDaemon() std::string exe_dir = util::Platform::getExecutableDirectory(); std::string daemon_dir = resources::getDaemonDirectory(); if (!exe_dir.empty()) { + std::string dirErr; + if (!util::Platform::ensureDirectory(daemon_dir, &dirErr)) { + daemon_status_ = dirErr; + DEBUG_LOGF("[ERROR] %s\n", dirErr.c_str()); + return false; + } std::error_code ec; - fs::create_directories(daemon_dir, ec); std::vector searchDirs = { exe_dir }; #ifdef __APPLE__ @@ -4226,18 +5277,31 @@ bool App::startEmbeddedDaemon() } #endif const char* extraFiles[] = { "asmap.dat", "dragonxd", "dragonx-cli", "dragonx-tx" }; + bool copyFailed = false; for (const char* name : extraFiles) { fs::path dst = fs::path(daemon_dir) / name; if (fs::exists(dst)) continue; for (const auto& dir : searchDirs) { fs::path src = fs::path(dir) / name; - if (fs::exists(src)) { + if (fs::exists(src)) { // an absent source is optional; only a real copy error counts DEBUG_LOGF("Copying bundled %s from %s to %s\n", name, dir.c_str(), daemon_dir.c_str()); fs::copy_file(src, dst, ec); + if (ec) { + DEBUG_LOGF("[ERROR] Failed to copy %s: %s\n", name, ec.message().c_str()); + 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; + DEBUG_LOGF("[ERROR] One or more daemon files failed to copy to %s\n", daemon_dir.c_str()); + return false; + } } } @@ -4281,21 +5345,27 @@ void App::stopEmbeddedDaemon() return; } + // Do we hold a live process handle for this node? Capture it BEFORE stop() reaps the pid. If owned, + // daemon_controller_->stop() below BLOCKS until the process actually exits. If NOT owned (external / + // adopted / direct-connected), stop() returns at once — we can only ask it over RPC and then watch + // for it to disappear (handled after the stop() call). + const bool owned = daemon_controller_->isRunning(); + // Send RPC "stop" command — this is the graceful path that lets the // daemon flush state, save block indexes, close sockets, etc. bool stop_sent = false; - // Try the existing RPC connection first + // Try the existing RPC connection first. beginShutdown() called rpc_->requestAbort() to unblock any + // in-flight request — but that sticky abort (cleared only by connect()) would make THIS "stop" self- + // abort mid-flight (CURLE_ABORTED_BY_CALLBACK). The old async rpc_->stop() swallowed that error yet + // set stop_sent=true, so the daemon never heard "stop" and the working temp-connection fallback was + // skipped — which is why "Stop external daemon" left an external dragonxd running (it only died via + // the 20s force-kill). Clear the abort and send SYNCHRONOUSLY so real success is surfaced. if (rpc_ && rpc_->isConnected()) { DEBUG_LOGF("Sending stop command via existing RPC connection...\n"); - try { - rpc_->stop([](const json&) { - DEBUG_LOGF("Stop command acknowledged by daemon\n"); - }); + rpc_->clearAbort(); + if (sendStopCommandSafely(*rpc_, "existing connection stop")) stop_sent = true; - } catch (...) { - DEBUG_LOGF("Failed to send stop via existing connection\n"); - } } // If the main connection wasn't established (e.g. daemon was still @@ -4337,6 +5407,27 @@ void App::stopEmbeddedDaemon() // 20s grace period for the RPC "stop" to complete (LevelDB flush). // Only after that does stop() escalate to SIGTERM, then SIGKILL. daemon_controller_->stop(20000); + + // EXTERNAL / adopted node during app shutdown: we hold no process handle, so the stop() above returned + // immediately (it can only wait on a node WE spawned). But the user turned on "Stop external daemon", + // so keep the window on the shutdown screen and poll until the node is actually gone — surfacing a live + // status so they can SEE it stop — rather than closing while it's still flushing. Bounded ~120s (a + // graceful full-node shutdown can flush LevelDB for 60-90s). Scoped to real shutdown; the shutdown + // screen's Force Quit stays available and flips shutdown_complete_, which breaks us out at once. + if (stop_sent && !owned && shutting_down_) { + auto stillUp = []() { + return daemon::EmbeddedDaemon::isRpcPortInUse() || daemon::EmbeddedDaemon::isDaemonProcessRunning(); + }; + // Set the phase text ONCE, then just poll — the shutdown screen already renders a live "N seconds" + // elapsed counter on the UI thread, so the user still sees time passing. (shutdown_status_ is now a + // GuardedStatus, so per-iteration writes would be race-safe; the single write is just a UX choice.) + shutdown_status_ = "Waiting for the external node to stop..."; + for (int i = 0; i < 1200 && stillUp() && !shutdown_complete_; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + shutdown_status_ = stillUp() ? "External node still stopping — closing anyway..." + : "External node stopped"; + DEBUG_LOGF("stopEmbeddedDaemon: external node %s\n", stillUp() ? "still up (timed out)" : "confirmed stopped"); + } } bool App::isEmbeddedDaemonRunning() const @@ -4397,7 +5488,7 @@ bool App::stopDaemonForWalletSwitch() void App::rescanBlockchain() { if (!supportsFullNodeLifecycleActions()) { - ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; } @@ -4412,23 +5503,24 @@ void App::rescanBlockchain() // Re-entrancy guard: a rescan/repair (both drive state_.sync.rescanning) or this exact task already // running would stomp each other — a second confirm must not launch a duplicate operation. if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) { - ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress."); + ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress")); return; } // Don't race a wallet switch / seed-adopt / encryption restart, which drive their own daemon stop/start. if (daemon_restarting_) { - ui::Notifications::instance().warning("The node is busy restarting — try again in a moment."); + ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } DEBUG_LOGF("[App] Starting blockchain rescan - stopping daemon first\n"); - ui::Notifications::instance().info("Restarting daemon with -rescan flag..."); + ui::Notifications::instance().info(TR("appx_restarting_daemon_rescan_flag")); // Initialize rescan state for status bar display. rescan_confirmed_active_ stays false until we // actually observe the restarted daemon rescanning — so the first poll (which may still reach the // pre-restart daemon and see rescanning=false) can't be misread as instant completion. state_.sync.rescanning = true; rescan_confirmed_active_ = false; + user_initiated_rescan_ = true; // user-triggered rescan → its completion should toast state_.sync.rescan_progress = 0.0f; state_.sync.rescan_status = decision.status; transactions_dirty_ = true; @@ -4451,7 +5543,7 @@ void App::rescanBlockchain() void App::repairWallet() { if (!supportsFullNodeLifecycleActions()) { - ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; } @@ -4464,22 +5556,23 @@ void App::repairWallet() } if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) { - ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress."); + ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress")); return; } if (daemon_restarting_) { - ui::Notifications::instance().warning("The node is busy restarting — try again in a moment."); + ui::Notifications::instance().warning(TR("appx_node_busy_restarting")); return; } DEBUG_LOGF("[App] Starting wallet repair (-zapwallettxes=2) - stopping daemon first\n"); - ui::Notifications::instance().info("Restarting daemon with -zapwallettxes=2 (wallet repair)..."); + ui::Notifications::instance().info(TR("appx_restarting_daemon_zapwallettxes")); // -zapwallettxes=2 deletes and rebuilds every wallet tx/note record, then rescans the whole // chain — so reuse the rescan status UI (status bar + warmup-end completion detection). Same // confirmed-active gating as rescan: the first poll may still reach the pre-restart daemon. state_.sync.rescanning = true; rescan_confirmed_active_ = false; + user_initiated_rescan_ = true; // user-triggered repair (implies rescan) → completion should toast state_.sync.rescan_progress = 0.0f; state_.sync.rescan_status = decision.status; transactions_dirty_ = true; @@ -4501,11 +5594,11 @@ void App::repairWallet() void App::reinstallBundledDaemon() { if (!supportsFullNodeLifecycleActions()) { - ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; } if (!resources::getBundledDaemonInfo().available) { - ui::Notifications::instance().warning("This build has no bundled daemon to install"); + ui::Notifications::instance().warning(TR("appx_no_bundled_daemon_to_install")); return; } // Require embedded-daemon *support*, but NOT an active daemon_controller_. When the wallet is @@ -4513,17 +5606,17 @@ void App::reinstallBundledDaemon() // we can still RPC-stop that node, overwrite the binaries, and bring up our own managed daemon — // which is exactly the state that previously blocked "Install bundled" with a cryptic warning. if (!supportsEmbeddedDaemon()) { - ui::Notifications::instance().warning("This build has no embedded daemon to install"); + ui::Notifications::instance().warning(TR("appx_no_embedded_daemon_to_install")); return; } if (async_tasks_.isRunning("reinstall-daemon")) { - ui::Notifications::instance().warning("The daemon reinstall is already in progress."); + ui::Notifications::instance().warning(TR("appx_daemon_reinstall_in_progress")); return; } DEBUG_LOGF("[App] Reinstalling bundled daemon binary — stopping daemon first\n"); - ui::Notifications::instance().info("Installing bundled daemon — the node will stop, update, and restart..."); + ui::Notifications::instance().info(TR("appx_installing_bundled_daemon")); async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) { AppDaemonLifecycleRuntime runtime(*this); @@ -4555,7 +5648,7 @@ void App::reinstallBundledDaemon() void App::deleteBlockchainData() { if (!supportsFullNodeLifecycleActions()) { - ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; } @@ -4568,12 +5661,12 @@ void App::deleteBlockchainData() } if (state_.sync.rescanning || async_tasks_.isRunning(decision.taskName)) { - ui::Notifications::instance().warning("A blockchain maintenance operation is already in progress."); + ui::Notifications::instance().warning(TR("appx_blockchain_maintenance_in_progress")); return; } DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n"); - ui::Notifications::instance().info("Stopping daemon and deleting blockchain data..."); + ui::Notifications::instance().info(TR("appx_stopping_daemon_deleting_blockchain")); daemon_controller_->prepareLifecycleOperation(decision, settings_.get()); async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) { @@ -4608,17 +5701,26 @@ bool App::stopDaemonForBootstrap() double App::getDaemonMemoryUsageMB() const { + // The Mining tab reads this every frame, but the probe is expensive (Linux embedded/external paths + // scan /proc; macOS forks a shell). Throttle to ~1.5s so a 60Hz caller doesn't hammer the OS. + const double now = ImGui::GetTime(); + if (daemon_mem_probe_at_ > 0.0 && now - daemon_mem_probe_at_ < 1.5) + return daemon_mem_cached_mb_; + daemon_mem_probe_at_ = now; + + double result = 0.0; // If we have an embedded daemon with a tracked process handle, use it // directly — more reliable than a process scan since we own the handle. if (daemon_controller_ && daemon_controller_->isRunning()) { double mb = daemon_controller_->memoryUsageMB(); daemon_mem_diag_ = "embedded"; - if (mb > 0.0) return mb; + result = (mb > 0.0) ? mb : util::Platform::getDaemonMemoryUsageMB(); } else { daemon_mem_diag_ = "process scan"; + result = util::Platform::getDaemonMemoryUsageMB(); // external daemon } - // Fall back to platform-level process scan (external daemon) - return util::Platform::getDaemonMemoryUsageMB(); + daemon_mem_cached_mb_ = result; + return result; } // ============================================================================ @@ -4634,6 +5736,16 @@ void App::beginShutdown() { // Only start shutdown once if (shutting_down_) return; + + // Guard: don't silently discard an in-progress witness-cache rebuild. If we're about to stop the + // daemon while it's rebuilding (stopping now forces a multi-minute rebuild on the next launch), + // defer shutdown and let render() show the confirm modal. The user's choice re-enters beginShutdown() + // with shutdown_confirmed_ set (and, for "keep node running", shutdown_keep_daemon_override_). + if (!shutdown_confirmed_ && shouldConfirmDaemonStop()) { + pending_shutdown_confirm_ = true; + return; // NOT shutting down yet — the normal UI + modal keep rendering + } + shutting_down_ = true; quit_requested_ = true; shutdown_timer_ = 0.0f; @@ -4662,9 +5774,13 @@ void App::beginShutdown() fast_worker_->requestStop(); } + // Drain + join the mining-control thread FIRST so no async start/stop job runs while we tear the miner + // down here (avoids two threads driving xmrig_manager_ during shutdown). (M-03 cluster) + stopMiningControlThread(); + // Stop xmrig pool miner before stopping the daemon if (xmrig_manager_ && xmrig_manager_->isRunning()) { - shutdown_status_ = "Stopping pool miner..."; + shutdown_status_ = TR("appx_stopping_pool_miner"); xmrig_manager_->stop(3000); } @@ -4676,7 +5792,7 @@ void App::beginShutdown() // Worker join + RPC disconnect happen in shutdown(). if (!daemon_controller_) { DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n"); - shutdown_status_ = "Disconnecting..."; + shutdown_status_ = TR("appx_disconnecting"); if (settings_) { settings_->save(); } @@ -4690,7 +5806,7 @@ void App::beginShutdown() } auto shutdownDecision = daemon_controller_->shutdownDecision( - settings_ && settings_->getKeepDaemonRunning(), + (settings_ && settings_->getKeepDaemonRunning()) || shutdown_keep_daemon_override_, settings_ && settings_->getStopExternalDaemon()); if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) { DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason); @@ -4707,22 +5823,173 @@ void App::beginShutdown() // modal "Please wait" dialog). shutdown_thread_ = std::thread([this]() { DEBUG_LOGF("shutdown thread: calling stopEmbeddedDaemon()\n"); - shutdown_status_ = "Sending stop command to daemon..."; + shutdown_status_ = TR("appx_sending_stop_command_to_daemon"); // Send RPC stop command stopEmbeddedDaemon(); DEBUG_LOGF("shutdown thread: daemon stopped, disconnecting RPC\n"); - shutdown_status_ = "Cleaning up..."; + shutdown_status_ = TR("appx_cleaning_up"); DEBUG_LOGF("shutdown thread: complete\n"); - shutdown_status_ = "Shutdown complete"; + shutdown_status_ = TR("appx_shutdown_complete"); shutdown_complete_ = true; }); } +std::vector App::tailDaemonDebugLog(int maxLines) const +{ + std::vector out; + if (maxLines <= 0) return out; + const std::string path = util::Platform::getDataDir() + "debug.log"; + std::error_code ec; + const auto sz = std::filesystem::file_size(path, ec); + if (ec || sz == 0) return out; + std::ifstream f(path, std::ios::binary); + if (!f) return out; + + // Read only the last ~16 KB — plenty for a handful of lines, cheap even for a multi-GB log. + const std::uintmax_t kTailBytes = 16 * 1024; + const std::uintmax_t start = sz > kTailBytes ? sz - kTailBytes : 0; + f.seekg(static_cast(start), std::ios::beg); + std::string chunk(static_cast(sz - start), '\0'); + f.read(&chunk[0], static_cast(chunk.size())); + chunk.resize(static_cast(f.gcount())); + + std::vector lines; + std::string cur; + for (char c : chunk) { + if (c == '\n') { if (!cur.empty()) lines.push_back(cur); cur.clear(); } + else if (c != '\r') cur.push_back(c); + } + if (!cur.empty()) lines.push_back(cur); + // When we seeked into the middle of the file the first line is a fragment — drop it. + if (start > 0 && !lines.empty()) lines.erase(lines.begin()); + if (static_cast(lines.size()) > maxLines) + lines.erase(lines.begin(), lines.end() - static_cast(maxLines)); + return lines; +} + +// Parse a "YYYY-MM-DD HH:MM:SS ..." debug.log line prefix to time_t. Interpreted as local time, but it +// is only ever used for DELTAS between two lines of the SAME log, so the timezone cancels. Returns 0 if +// the line has no such timestamp prefix. +static std::time_t parseDaemonLogTimestamp(const std::string& line) +{ + int y = 0, mo = 0, d = 0, h = 0, mi = 0, s = 0; + if (std::sscanf(line.c_str(), "%d-%d-%d %d:%d:%d", &y, &mo, &d, &h, &mi, &s) != 6) return 0; + std::tm tm{}; + tm.tm_year = y - 1900; tm.tm_mon = mo - 1; tm.tm_mday = d; + tm.tm_hour = h; tm.tm_min = mi; tm.tm_sec = s; tm.tm_isdst = -1; + return std::mktime(&tm); +} + +bool App::daemonWitnessRebuildActive() const +{ + // Scan the debug.log tail for the daemon's witness-rebuild markers (wallet.cpp): "Cleared witness + // data from" (start), "Reading blocks for witness rebuild" / "Setting Initial Sapling Witness" + // (progress), vs. "rebuilt N note witness cache(s)" / "aborting…" (finished). + // + // A genuine ongoing rebuild logs progress CONTINUOUSLY. The routine per-tx witness set the daemon + // does as each new wallet tx lands during normal sync is SPARSE (minutes apart) and must NOT trip + // this — that was firing the "node is rebuilding" prompt on wallets that just receive frequently. + // So require the last progress marker to be (a) later than any completion AND (b) part of the CURRENT + // activity — within a few seconds of the newest log line (same-log timestamp delta → timezone-free). + const auto lines = tailDaemonDebugLog(120); + std::time_t newest = 0, lastProgress = 0, lastDone = 0; + for (const auto& l : lines) { + const std::time_t ts = parseDaemonLogTimestamp(l); + if (ts > newest) newest = ts; + if (l.find("note witness cache(s) to height") != std::string::npos || + l.find("aborting witness rebuild") != std::string::npos || + l.find("aborted during witness rebuild") != std::string::npos) { + if (ts > lastDone) lastDone = ts; + } else if (l.find("Reading blocks for witness rebuild") != std::string::npos || + l.find("Setting Initial Sapling Witness") != std::string::npos || + l.find("Cleared witness data from") != std::string::npos) { + if (ts > lastProgress) lastProgress = ts; + } + } + if (lastProgress == 0 || lastDone >= lastProgress || newest == 0) return false; + return (newest - lastProgress) <= 15; // progress is part of the current activity → ongoing rebuild +} + +bool App::shouldConfirmDaemonStop() const +{ + if (!daemon_controller_) return false; + // Only relevant when this shutdown would actually STOP the daemon (embedded, or external with + // stop-on-exit) — a DisconnectOnly shutdown leaves it running and loses nothing. + const auto decision = daemon_controller_->shutdownDecision( + settings_ && settings_->getKeepDaemonRunning(), + settings_ && settings_->getStopExternalDaemon()); + if (decision.action != daemon::DaemonController::ShutdownAction::StopDaemon) return false; + // v1.3.0+ checkpoints witness-rescan progress, so stopping mid-rebuild resumes on the next start + // instead of redoing it from scratch — the warning's premise no longer holds, so don't prompt. + // (daemon_version encodes major*1e6 + minor*1e4 + rev*100 + build; v1.3.0 == 1030000.) + if (state_.daemon_version >= 1030000) return false; + return daemonWitnessRebuildActive(); +} + +void App::renderDaemonStopConfirm() +{ + using namespace ui::material; + if (pending_shutdown_confirm_) { + ImGui::OpenPopup("##DaemonStopConfirm"); + pending_shutdown_confirm_ = false; + daemon_stop_confirm_open_ = true; + } + if (!daemon_stop_confirm_open_) return; + + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f)); + bool proceed = false; + if (ImGui::BeginPopupModal("##DaemonStopConfirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoTitleBar)) { + if (Type().subtitle1()) ImGui::PushFont(Type().subtitle1()); + ImGui::TextUnformatted(TR("appx_node_rebuilding_witness_cache")); + if (Type().subtitle1()) ImGui::PopFont(); + ImGui::Spacing(); + ImGui::PushTextWrapPos(ImGui::GetFontSize() * 26.0f); + ImGui::TextUnformatted(TR("appx_stopping_node_discards_rebuild")); + ImGui::PopTextWrapPos(); + ImGui::Spacing(); + ImGui::Spacing(); + + if (TactileButton(TR("appx_keep_node_running_and_quit"), ImVec2(0, 0))) { + shutdown_keep_daemon_override_ = true; + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 210))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(Error())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(WithAlpha(Error(), 160))); + const bool stopAnyway = TactileButton(TR("appx_stop_anyway_and_quit"), ImVec2(0, 0)); + ImGui::PopStyleColor(3); + if (stopAnyway) { + shutdown_confirmed_ = true; + daemon_stop_confirm_open_ = false; + proceed = true; + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (TactileButton(TR("appx_cancel"), ImVec2(0, 0))) { + daemon_stop_confirm_open_ = false; // abort the quit; stay open + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } else { + daemon_stop_confirm_open_ = false; // dismissed via Esc / click-away = Cancel + } + + // Re-enter shutdown outside the popup scope now that the user has chosen (shutdown_confirmed_ set). + if (proceed) beginShutdown(); +} + void App::renderShutdownScreen() { using namespace ui::material; + const float dp = ui::Layout::dpiScale(); auto shutElem = [](const char* key, float fb) { float v = ui::schema::UI().drawElement("components.shutdown", key).size; return v >= 0 ? v : fb; @@ -4735,7 +6002,8 @@ void App::renderShutdownScreen() // it's never impossible to escape. static std::string s_lastShutStatus; static float s_shutStallTimer = 0.0f; - if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; } + const std::string curShut = shutdown_status_.get(); // one consistent snapshot per frame (M-05) + if (curShut != s_lastShutStatus) { s_lastShutStatus = curShut; s_shutStallTimer = 0.0f; } else s_shutStallTimer += ImGui::GetIO().DeltaTime; const bool shutdownStalled = s_shutStallTimer >= 8.0f; const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f; @@ -4775,7 +6043,7 @@ void App::renderShutdownScreen() // ------------------------------------------------------------------- float lineH = ImGui::GetTextLineHeightWithSpacing(); float titleH = Type().h5() ? Type().h5()->LegacySize : lineH * 1.5f; - float spinnerD = shutElem("spinner-radius", 20.0f) * 2.0f + 12.0f; + float spinnerD = shutElem("spinner-radius", 20.0f) * dp * 2.0f + 12.0f * dp; float statusH = lineH * 2.0f; float sepH = lineH; float panelH = shutElem("panel-max-height", 160.0f); @@ -4806,10 +6074,10 @@ void App::renderShutdownScreen() // 2. Animated arc spinner // ------------------------------------------------------------------- { - float r = shutElem("spinner-radius", 20.0f); - float thick = shutElem("spinner-thickness", 3.0f); + float r = shutElem("spinner-radius", 20.0f) * dp; + float thick = shutElem("spinner-thickness", 3.0f) * dp; // Screen-space centre for draw list - ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f); + ImVec2 sc(wp.x + cx, wp.y + ImGui::GetCursorPosY() + r + 2.0f * dp); // Background ring (dim) dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48); @@ -4823,7 +6091,7 @@ void App::renderShutdownScreen() dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick); // Advance cursor past the spinner - ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f)); + ImGui::Dummy(ImVec2(0, r * 2.0f + 8.0f * dp)); } ImGui::Spacing(); @@ -4831,11 +6099,11 @@ void App::renderShutdownScreen() // ------------------------------------------------------------------- // 3. Phase status (what the shutdown thread is doing) // ------------------------------------------------------------------- - if (!shutdown_status_.empty()) { - ImVec2 ts = ImGui::CalcTextSize(shutdown_status_.c_str()); + if (!curShut.empty()) { + ImVec2 ts = ImGui::CalcTextSize(curShut.c_str()); ImGui::SetCursorPosX(cx - ts.x * 0.5f); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.75f, 0.75f, 0.75f, 1.0f)); - ImGui::TextUnformatted(shutdown_status_.c_str()); + ImGui::TextUnformatted(curShut.c_str()); ImGui::PopStyleColor(); } @@ -4848,9 +6116,9 @@ void App::renderShutdownScreen() char elapsed[64]; int secs = (int)shutdown_timer_; if (secs < 60) - snprintf(elapsed, sizeof(elapsed), "%d seconds", secs); + snprintf(elapsed, sizeof(elapsed), TR("appx_n_seconds"), secs); else - snprintf(elapsed, sizeof(elapsed), "%d min %d sec", secs / 60, secs % 60); + snprintf(elapsed, sizeof(elapsed), TR("appx_n_min_n_sec"), secs / 60, secs % 60); ImGui::PushFont(Type().caption()); ImVec2 ts = ImGui::CalcTextSize(elapsed); @@ -4869,8 +6137,9 @@ void App::renderShutdownScreen() ImGui::Spacing(); // State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the // chainstate; say so instead of a bare button. - if (shutdownStalled && !shutdown_status_.empty()) { - std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data."; + if (shutdownStalled && !curShut.empty()) { + std::string stalledMsg = std::string(TR("appx_still_status_prefix")) + curShut + + TR("appx_still_status_suffix"); ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str()); ImGui::SetCursorPosX(cx - ms.x * 0.5f); ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); @@ -4879,7 +6148,7 @@ void App::renderShutdownScreen() ImGui::Spacing(); } const char* forceLabel = TR("force_quit"); - ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0); + ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f * dp, 0); ImGui::SetCursorPosX(cx - btnSize.x * 0.5f); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.15f, 0.15f, 0.9f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.75f, 0.2f, 0.2f, 1.0f)); @@ -4909,7 +6178,7 @@ void App::renderShutdownScreen() ImGui::Spacing(); ImGui::Spacing(); - float btnW = 120.0f; + float btnW = 120.0f * dp; float totalW = btnW * 2 + ImGui::GetStyle().ItemSpacing.x; ImGui::SetCursorPosX((ImGui::GetWindowWidth() - totalW) * 0.5f); @@ -4941,7 +6210,7 @@ void App::renderShutdownScreen() ImVec2 p0(wp.x + pad, wp.y + ImGui::GetCursorPosY()); ImVec2 p1(wp.x + vp_size.x - pad, p0.y); dl->AddLine(p0, p1, ui::schema::UI().resolveColor("var(--status-divider)", IM_COL32(255, 255, 255, 30)), 1.0f); - ImGui::Dummy(ImVec2(0, 4.0f)); + ImGui::Dummy(ImVec2(0, 4.0f * dp)); } ImGui::Spacing(); @@ -4951,6 +6220,10 @@ void App::renderShutdownScreen() // ------------------------------------------------------------------- if (daemon_controller_) { auto lines = daemon_controller_->recentLines(8); + // External daemon (attached, not spawned) has no captured stdout — tail its debug.log directly + // so the user can still watch the node flush the block index and exit. + if (lines.empty()) + lines = tailDaemonDebugLog(8); if (!lines.empty()) { float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f); float panelX = cx - panelW * 0.5f; @@ -4970,7 +6243,7 @@ void App::renderShutdownScreen() ImGui::GetCursorPosY() + panelPad)); ImGui::PushFont(Type().caption()); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.50f, 1.0f)); - ImGui::TextUnformatted("dragonxd output"); + ImGui::TextUnformatted(TR("appx_dragonxd_output")); ImGui::PopStyleColor(); ImGui::PopFont(); @@ -5010,7 +6283,7 @@ void App::renderShutdownScreen() // Advance cursor past the panel float panelBottom = panelMax.y - wp.y; - ImGui::SetCursorPosY(panelBottom + 4.0f); + ImGui::SetCursorPosY(panelBottom + 4.0f * dp); ImGui::Dummy(ImVec2(0, 0)); } } @@ -5029,6 +6302,10 @@ void App::renderLoadingOverlay(float contentH) using namespace ui::material; constexpr float kPi = 3.14159265f; + // The wallet-recovery dialog owns the screen while a salvage is pending — suppress the loading + // spinner underneath it so "still loading" and "make a decision" are never the same screen. + if (show_wallet_recovered_dialog_) return; + auto loadElem = [](const char* key, float fb) { float v = ui::schema::UI().drawElement("screens.loading", key).size; return v >= 0 ? v : fb; @@ -5040,12 +6317,17 @@ void App::renderLoadingOverlay(float contentH) ImVec2 wp = ImGui::GetWindowPos(); ImVec2 ws = ImGui::GetWindowSize(); + // Hand-drawn geometry here is in logical px; the fonts drawn between the elements are DPI-baked, + // so every spinner/bar/gap constant must be multiplied by dpiScale or it renders native-tiny and + // the spacing/centering drifts at HiDPI / font_scale > 1. (Font metrics are already scaled.) + const float dpi = ui::Layout::dpiScale(); + // Layout constants float lineH = ImGui::GetTextLineHeightWithSpacing(); - float spinnerR = loadElem("spinner-radius", 18.0f); - float gap = loadElem("vertical-gap", 8.0f); - float barH = loadElem("progress-bar", 6.0f); - float barW = loadElem("progress-width", 260.0f); + float spinnerR = loadElem("spinner-radius", 18.0f) * dpi; + float gap = loadElem("vertical-gap", 8.0f) * dpi; + float barH = loadElem("progress-bar", 6.0f) * dpi; + float barW = loadElem("progress-width", 260.0f) * dpi; float cx = ws.x * 0.5f; // centre X (local coords) // Estimate total block height for vertical centering @@ -5061,8 +6343,8 @@ void App::renderLoadingOverlay(float contentH) // ------------------------------------------------------------------- { float r = spinnerR; - float thick = loadElem("spinner-thickness", 2.5f); - ImVec2 sc(wp.x + cx, curY + r + 2.0f); + float thick = loadElem("spinner-thickness", 2.5f) * dpi; + ImVec2 sc(wp.x + cx, curY + r + 2.0f * dpi); // Background ring (dim) dl->PathArcTo(sc, r, 0.0f, kPi * 2.0f, 48); @@ -5075,7 +6357,63 @@ void App::renderLoadingOverlay(float contentH) dl->PathStroke(ui::schema::UI().resolveColor("var(--spinner-active)", IM_COL32(255, 218, 0, 200)), 0, thick); - curY += r * 2.0f + gap + 4.0f; + curY += r * 2.0f + gap + 4.0f * dpi; + } + + // ------------------------------------------------------------------- + // Post-repair rescan — a calm, recovery-aware screen (not the generic "daemon stuck / RPC timeout / + // restart daemon" text). The daemon was intentionally restarted with a full rescan, which + // legitimately takes minutes and won't answer RPC yet; reassure + show elapsed and the growing size. + // ------------------------------------------------------------------- + // Only while the daemon is actually alive-and-rescanning. If it EXITED (crashed for a reason distinct + // from the wallet file), fall through to the normal error/stall UI — tryConnect() clears the flag, but + // gate here too so a lingering frame never shows "everything's fine" over a dead daemon. + if (post_recovery_rescan_ && + !(daemon_controller_ && daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error)) { + if (post_recovery_rescan_since_ <= 0.0) post_recovery_rescan_since_ = ImGui::GetTime(); + ImFont* titleF = Type().subtitle1(); if (!titleF) titleF = ImGui::GetFont(); + ImFont* capF = Type().caption(); if (!capF) capF = ImGui::GetFont(); + + auto centeredLine = [&](ImFont* f, ImU32 col, const char* s, float wrapW) { + ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, wrapW, s); + float x = (wrapW > 0.0f) ? (wp.x + cx - wrapW * 0.5f) : (wp.x + cx - ts.x * 0.5f); + dl->AddText(f, f->LegacySize, ImVec2(x, curY), col, s, nullptr, wrapW); + curY += ts.y + gap; + }; + + centeredLine(titleF, IM_COL32(230, 210, 90, 235), TR("wallet_recovery_rescan_title"), 0.0f); + float wrapW = ws.x * 0.8f; if (wrapW > 620.0f * dpi) wrapW = 620.0f * dpi; + centeredLine(capF, IM_COL32(200, 200, 200, 220), TR("wallet_recovery_rescan_body"), wrapW); + + // Elapsed + the growing wallet size — concrete "it's working" feedback while RPC is silent. Recompute + // at most once a second (the display granularity) to avoid a stat()+format on every frame. + int secs = (int)(ImGui::GetTime() - post_recovery_rescan_since_); if (secs < 0) secs = 0; + static int s_lastSec = -1; + static std::string s_elapsed, s_size; + if (secs != s_lastSec) { + s_lastSec = secs; + char clock[16]; snprintf(clock, sizeof(clock), "%d:%02d", secs / 60, secs % 60); + char ebuf[96]; snprintf(ebuf, sizeof(ebuf), TR("wallet_recovery_rescan_elapsed"), clock); + s_elapsed = ebuf; + const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + + ((settings_ && !settings_->getActiveWalletFile().empty()) + ? settings_->getActiveWalletFile() : std::string("wallet.dat")); + uint64_t wsz = util::Platform::getFileSize(walletPath); + if (wsz > 0) { char sbuf[96]; snprintf(sbuf, sizeof(sbuf), TR("wallet_recovery_rescan_size"), + util::Platform::formatFileSize(wsz).c_str()); s_size = sbuf; } + else s_size.clear(); + } + curY += gap * 0.5f; + centeredLine(capF, IM_COL32(150, 150, 150, 220), s_elapsed.c_str(), 0.0f); + if (!s_size.empty()) centeredLine(capF, IM_COL32(130, 130, 130, 210), s_size.c_str(), 0.0f); + + // Backstop for a genuinely slow (but still running) rescan: after several minutes add a gentle + // "it's safe to leave running / watch the Console" line — never the scary "restart the node". + if (secs > 600) { + curY += gap * 0.5f; + centeredLine(capF, IM_COL32(150, 150, 150, 200), TR("wallet_recovery_rescan_slow"), wrapW); + } + return; // skip the generic status / stall / error / daemon-log sections } // ------------------------------------------------------------------- @@ -5085,11 +6423,23 @@ void App::renderLoadingOverlay(float contentH) const char* statusText = connection_status_.c_str(); ImFont* font = Type().subtitle1(); if (!font) font = ImGui::GetFont(); - ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText); - dl->AddText(font, font->LegacySize, - ImVec2(wp.x + cx - ts.x * 0.5f, curY), - IM_COL32(220, 220, 220, 255), statusText); - curY += ts.y + gap; + // Short statuses (the common case: "Connected", "Starting daemon") stay centered; long ones + // (a full dir_error path, a libcurl connect error) wrap to a clamped box instead of running + // off both edges of the overlay — the same clamp the daemon-error blocks below use. + float wrapW = ws.x * 0.8f; if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi; + ImVec2 full = font->CalcTextSizeA(font->LegacySize, FLT_MAX, 0.0f, statusText); + if (full.x <= wrapW) { + dl->AddText(font, font->LegacySize, + ImVec2(wp.x + cx - full.x * 0.5f, curY), + IM_COL32(220, 220, 220, 255), statusText); + curY += full.y + gap; + } else { + ImVec2 ts = font->CalcTextSizeA(font->LegacySize, FLT_MAX, wrapW, statusText); + dl->AddText(font, font->LegacySize, + ImVec2(wp.x + cx - wrapW * 0.5f, curY), + IM_COL32(220, 220, 220, 255), statusText, nullptr, wrapW); + curY += ts.y + gap; + } } // ------------------------------------------------------------------- @@ -5113,7 +6463,7 @@ void App::renderLoadingOverlay(float contentH) float progress = state_.sync.witness_progress; if (progress < 0.0f) progress = 0.0f; if (progress > 1.0f) progress = 1.0f; - float barRadius = loadElem("progress-bar", 3.0f); + float barRadius = loadElem("progress-bar", 3.0f) * dpi; float barX = wp.x + cx - barW * 0.5f; ImVec2 barMin(barX, curY); ImVec2 barMax(barX + barW, curY + barH); @@ -5128,14 +6478,14 @@ void App::renderLoadingOverlay(float contentH) char wbuf[128]; if (state_.sync.witness_phase == 2 && progress > 0.01f && state_.sync.witness_remaining > 0) - snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%% — %d blocks left", + snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_blocks_left"), progress * 100.0f, state_.sync.witness_remaining); else if (state_.sync.witness_phase == 2 && progress > 0.01f) - snprintf(wbuf, sizeof(wbuf), "Rebuilding witness cache %.0f%%", progress * 100.0f); + snprintf(wbuf, sizeof(wbuf), TR("appx_rebuilding_witness_cache_pct"), progress * 100.0f); else if (progress > 0.01f) - snprintf(wbuf, sizeof(wbuf), "Setting initial Sapling witnesses %.0f%%", progress * 100.0f); + snprintf(wbuf, sizeof(wbuf), TR("appx_setting_initial_sapling_witnesses"), progress * 100.0f); else - snprintf(wbuf, sizeof(wbuf), "Rebuilding Sapling note witnesses…"); + snprintf(wbuf, sizeof(wbuf), "%s", TR("appx_rebuilding_sapling_note_witnesses")); ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, wbuf); @@ -5150,7 +6500,7 @@ void App::renderLoadingOverlay(float contentH) // ------------------------------------------------------------------- if (state_.connected && state_.sync.syncing) { float progress = static_cast(state_.sync.verification_progress); - float barRadius = loadElem("progress-bar", 3.0f); + float barRadius = loadElem("progress-bar", 3.0f) * dpi; float barX = wp.x + cx - barW * 0.5f; ImVec2 barMin(barX, curY); @@ -5173,7 +6523,7 @@ void App::renderLoadingOverlay(float contentH) // Progress text — "Syncing 45.2% — Block 123456 / 234567" char syncBuf[128]; - snprintf(syncBuf, sizeof(syncBuf), "Syncing %.1f%% — Block %d / %d", + snprintf(syncBuf, sizeof(syncBuf), TR("appx_syncing_pct_block_n_of_n"), progress * 100.0f, state_.sync.blocks, state_.sync.headers); ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); @@ -5185,7 +6535,7 @@ void App::renderLoadingOverlay(float contentH) } else if (!state_.connected && state_.sync.blocks > 0) { // Show last known block height while reconnecting char blockBuf[64]; - snprintf(blockBuf, sizeof(blockBuf), "Last block: %d", state_.sync.blocks); + snprintf(blockBuf, sizeof(blockBuf), TR("appx_last_block_n"), state_.sync.blocks); ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, blockBuf); @@ -5204,8 +6554,8 @@ void App::renderLoadingOverlay(float contentH) if (!capFont) capFont = ImGui::GetFont(); const char* encLabel = encrypt_in_progress_ - ? "Encrypting wallet..." - : "Waiting for daemon to encrypt wallet..."; + ? TR("appx_encrypting_wallet") + : TR("appx_waiting_for_daemon_to_encrypt_wallet"); ImVec2 ts = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, encLabel); ImU32 encCol = IM_COL32(255, 218, 0, 200); dl->AddText(capFont, capFont->LegacySize, @@ -5214,7 +6564,7 @@ void App::renderLoadingOverlay(float contentH) // Indeterminate progress bar float encBarW = barW * 0.6f; - float encBarH = 4.0f; + float encBarH = 4.0f * dpi; float encBarX = wp.x + cx - encBarW * 0.5f; dl->AddRectFilled(ImVec2(encBarX, curY), ImVec2(encBarX + encBarW, curY + encBarH), IM_COL32(255, 255, 255, 20), 2.0f); @@ -5231,7 +6581,8 @@ void App::renderLoadingOverlay(float contentH) // 3c. Daemon crash error message // ------------------------------------------------------------------- if (daemon_controller_ && - daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) { + daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error && + !wallet_auto_recovered_) { // a salvage is NOT a crash — it has its own recovery dialog curY += gap; ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); @@ -5239,28 +6590,27 @@ void App::renderLoadingOverlay(float contentH) if (!bodyFont2) bodyFont2 = ImGui::GetFont(); // Error title - const char* errTitle = "Daemon Error"; + const char* errTitle = TR("appx_daemon_error"); ImVec2 ts = bodyFont2->CalcTextSizeA(bodyFont2->LegacySize, FLT_MAX, 0.0f, errTitle); dl->AddText(bodyFont2, bodyFont2->LegacySize, ImVec2(wp.x + cx - ts.x * 0.5f, curY), IM_COL32(255, 90, 90, 255), errTitle); curY += ts.y + gap * 0.5f; - // Error details (wrapped) — show full diagnostic info + // Error details (wrapped) — full diagnostic info for a genuine (non-recovery) crash. const std::string& errDetail = daemon_controller_->lastError(); if (!errDetail.empty()) { float wrapW = ws.x * 0.8f; - if (wrapW > 700.0f) wrapW = 700.0f; + if (wrapW > 700.0f * dpi) wrapW = 700.0f * dpi; ImVec2 es = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, errDetail.c_str()); dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY), IM_COL32(255, 180, 180, 220), errDetail.c_str(), nullptr, wrapW); curY += es.y + gap; } - // Crash count hint if (daemon_controller_->crashCount() >= 3) { - const char* hint = "Use Settings > Restart Daemon to try again"; + const char* hint = TR("appx_use_settings_restart_daemon_hint"); ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint); dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - hs2.x * 0.5f, curY), @@ -5269,22 +6619,27 @@ void App::renderLoadingOverlay(float contentH) } } + // 3d. The "Taking longer than expected" stall notice was intentionally removed — it added + // clutter to the startup screen. The live daemon-output panel below is the real signal that + // the node is making progress. (connect_stall_since_ is still maintained in app_network.cpp + // for connection bookkeeping; it just no longer drives any on-screen text.) + // ------------------------------------------------------------------- // 4. Daemon output snippet (last few lines, if embedded) // ------------------------------------------------------------------- - if (daemon_controller_) { + if (daemon_controller_ && !wallet_auto_recovered_) { // recovery moves the log behind the dialog's disclosure auto lines = daemon_controller_->recentLines(8); if (!lines.empty()) { curY += gap; float panelW = ws.x * 0.85f; - if (panelW > 900.0f) panelW = 900.0f; + if (panelW > 900.0f * dpi) panelW = 900.0f * dpi; float panelX = wp.x + cx - panelW * 0.5f; - float panelPad = 8.0f; + float panelPad = 8.0f * dpi; ImFont* capFont = Type().caption(); if (!capFont) capFont = ImGui::GetFont(); - float panelLineH = capFont->LegacySize + 4.0f; + float panelLineH = capFont->LegacySize + 4.0f * dpi; float panelContentH = panelPad * 2.0f + panelLineH * (float)lines.size(); ImVec2 panelMin(panelX, curY); @@ -5320,6 +6675,10 @@ void App::renderLoadingOverlay(float contentH) void App::shutdown() { + // Ensure the mining-control thread is stopped + joined (idempotent; beginShutdown already did it on the + // normal quit path, but shutdown() can also run without it). Must precede xmrig_manager_ teardown. + stopMiningControlThread(); + // Wipe any copied secret from the OS clipboard before we exit — the 45s auto-clear timer // never fires if the user quits sooner, which would otherwise leave a key/seed resident. // (ImGui context is still alive here; App::shutdown() runs before ImGui::DestroyContext().) @@ -5524,7 +6883,7 @@ void App::copySecretToClipboard(const std::string& secret) clipboard_secret_hash_ = secret.empty() ? 0 : h; clipboard_clear_deadline_ = secret.empty() ? 0.0 : (ImGui::GetTime() + 45.0); if (!secret.empty()) - ui::Notifications::instance().info("Copied — clipboard auto-clears in 45s", 4.0f); + ui::Notifications::instance().info(TR("appx_copied_clipboard_autoclears"), 4.0f); } void App::clearSecretClipboardIfArmed() @@ -5559,10 +6918,66 @@ void App::maybeFinishTransactionSendProgress() if (addresses_dirty_ || network_refresh_.jobInProgress(Job::Addresses)) return; send_progress_active_ = false; } +std::string App::buildDiagnosticsReport() +{ + std::ostringstream os; + os << "=== ObsidianDragon diagnostics ===\n"; + os << "version: " << DRAGONX_VERSION << "\n"; +#if DRAGONX_LITE_BUILD + os << "variant: Lite\n"; +#else + os << "variant: Full-node\n"; +#endif +#if defined(_WIN32) + os << "platform: windows\n"; +#elif defined(__APPLE__) + os << "platform: macos\n"; +#else + os << "platform: linux\n"; +#endif + os << "connected: " << (state_.connected ? "yes" : "no") << "\n"; + os << "status: " << connection_status_ << "\n"; + + const std::string activeWallet = settings_ ? settings_->getActiveWalletFile() : std::string("(none)"); + os << "active wallet: " << activeWallet << "\n"; + { + std::error_code ec; + const std::string wp = util::Platform::getDragonXDataDir() + "/" + activeWallet; + const bool present = std::filesystem::exists(wp, ec); + os << " path: " << wp << (present ? " [present" : " [MISSING"); + if (present) { auto sz = std::filesystem::file_size(wp, ec); if (!ec) os << ", " << sz << " bytes"; } + os << "]\n"; + } + os << "encryption: " + << (state_.encryption_state_known + ? (state_.encrypted ? (state_.locked ? "encrypted, locked" : "encrypted, unlocked") : "unencrypted") + : "unknown") + << "\n"; + os << "sync: block " << state_.sync.blocks << " / " << state_.sync.headers + << (state_.sync.syncing ? " (syncing)" : "") + << (state_.warming_up ? " (warming up)" : "") << "\n"; + +#if !DRAGONX_LITE_BUILD + os << "daemon status: " << daemon_status_ << "\n"; + if (daemon_controller_) { + os << "daemon running: " << (daemon_controller_->isRunning() ? "yes" : "no") + << ", crashes: " << daemon_controller_->crashCount() << "\n"; + const std::string derr = daemon_controller_->lastError(); + if (!derr.empty()) os << "daemon lastError: " << derr << "\n"; + } +#endif + + const std::string cfg = util::Platform::getObsidianDragonDir(); + os << "log folder: " << cfg << "\n"; + os << " " << cfg << "/dragonx-debug.log\n"; + os << " " << cfg << "/dragonx-crash.log\n"; + return os.str(); +} + void App::restartDaemon() { if (!supportsFullNodeLifecycleActions()) { - ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); return; } @@ -5595,4 +7010,25 @@ void App::restartDaemon() }); } +// One-click recovery for an unreadable block database: arm the one-shot -reindex flag, then un-gate the +// connect loop (which detected the abort and stopped restarting). Its next attempt calls +// startEmbeddedDaemon(), which consumes the flag → the node rebuilds its block index + chainstate from +// the raw blocks. The daemon is not running here (it aborted), so no explicit stop/restart is needed. +void App::reindexBlockDatabase() +{ + if (!supportsFullNodeLifecycleActions()) { + ui::Notifications::instance().warning(TR("appx_fullnode_lifecycle_unavailable_lite")); + return; + } + if (!daemon_controller_) return; + daemon_controller_->setReindexOnNextStart(true); + user_initiated_rescan_ = true; // reindex implies a rescan → its completion should toast + daemon_controller_->resetCrashCount(); // the abort no longer counts against the restart budget + show_block_db_reindex_confirm_ = false; + block_db_reindex_available_ = false; // un-gate → the connect loop restarts the node with -reindex + connection_status_ = TR("sb_starting_daemon"); + ui::Notifications::instance().info(TR("block_db_reindex_started"), 12.0f); + DEBUG_LOGF("[App] Block-database reindex requested — restarting node with -reindex\n"); +} + } // namespace dragonx diff --git a/src/app.h b/src/app.h index 9dea83e..dbb6794 100644 --- a/src/app.h +++ b/src/app.h @@ -14,6 +14,7 @@ #include #include #include +#include #include #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 lk(m_); v_ = std::move(v); return *this; } + std::string get() const { std::lock_guard 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,18 @@ public: bool isLiteBuild() const { return wallet::isLiteBuild(walletCapabilities()); } bool supportsEmbeddedDaemon() const { return wallet::supportsEmbeddedDaemon(walletCapabilities()); } bool supportsFullNodeLifecycleActions() const { return wallet::supportsFullNodeLifecycleActions(walletCapabilities()); } + + // Daemon (v1.3.0+) coinbase auto-shield status, from z_autoshieldstatus. "Not probed" / all-false on + // pre-1.3.0 daemons (no such RPC) — callers treat that as "the wallet handles auto-shield itself". + bool daemonAutoShieldProbed() const { return daemon_autoshield_probed_; } + bool daemonAutoShieldActive() const { return daemon_autoshield_active_; } + const std::string& daemonAutoShieldAddress() const { return daemon_autoshield_address_; } + const std::string& daemonAutoShieldDisabledReason() const { return daemon_autoshield_disabled_reason_; } + bool daemonAutoShieldSeedRecoverable() const { return daemon_autoshield_seed_recoverable_; } + + // W7 QoL: a plaintext support-diagnostics snapshot (version, variant, daemon/RPC/wallet/log state) + // for the "Copy diagnostics" action. Contains no secrets. + std::string buildDiagnosticsReport(); bool supportsSoloMining() const { return wallet::supportsSoloMining(walletCapabilities()); } bool supportsPoolMining() const { return wallet::supportsPoolMining(walletCapabilities()); } bool supportsLiteBackend() const { return wallet::supportsLiteBackend(walletCapabilities()); } @@ -156,6 +183,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 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 @@ -352,6 +394,10 @@ public: // Force refresh void refreshNow(); + // Called on window restore: drop the daemon-output backlog that accumulated while minimized (the + // per-frame update loop was paused), so a background witness rebuild that started+finished during + // the minimize isn't parsed in one batch and mistaken for a completed rescan (spurious toast). + void skipDaemonOutputBacklog(); void refreshMiningInfo(); void refreshPeerInfo(); void refreshMarketData(); @@ -389,6 +435,9 @@ public: // each under every skin. Output: /screenshots-full//.png + an index. void startFullUiSweep(); std::string screenshotFullDir() const; + // Debug option: restrict either sweep to just the currently-active theme instead of cycling all. + bool sweepCurrentThemeOnly() const { return sweep_current_theme_only_; } + void setSweepCurrentThemeOnly(bool v) { sweep_current_theme_only_ = v; } bool isScreenshotSweeping() const { return screenshot_sweep_active_; } bool wantsScreenshotThisFrame() const { return sweep_capture_this_frame_; } const std::string& screenshotSweepPath() const { return sweep_current_path_; } @@ -414,6 +463,7 @@ public: return 0; } void showAboutDialog() { show_about_ = true; } + void showFaqDialog() { show_faq_ = true; } // Legacy tab compat — maps int to NavPage void setCurrentTab(int tab); @@ -711,6 +761,22 @@ private: // wallets. In-memory only (resets on app restart). std::map chat_seen_watermark_; + // ── Per-frame render caches (avoid O(N) recompute every frame; see the respective call sites) ── + // Chat nav-badge unread count — recomputed only when the store revision changes or after a short + // interval (mute/hide/seen changes don't bump the store). See App::chatUnreadCount(). + mutable std::uint64_t chat_unread_rev_ = ~0ull; // ~0 forces the first compute + mutable int chat_unread_cached_ = 0; + mutable double chat_unread_computed_at_ = 0.0; + // Sidebar unconfirmed-tx badge — recomputed only when the tx list changes (keyed on last_tx_update + + // size), not every frame. See App::render(). + std::int64_t sb_unconf_key_ts_ = -1; + std::size_t sb_unconf_key_n_ = 0; + int sb_unconf_count_ = 0; + // Daemon-memory probe is expensive (/proc scan on Linux, popen on macOS); throttle it to ~1.5s so the + // Mining tab's per-frame read doesn't hammer the OS. See App::getDaemonMemoryUsageMB(). + mutable double daemon_mem_cached_mb_ = 0.0; + mutable double daemon_mem_probe_at_ = 0.0; + // ── Chat note buffer (BOTH variants) ──────────────────────────────────────────────────────── // Each chat message is a shielded tx that spends a note; its change needs a few confirmations before // it's spendable again (lite: backend ANCHOR_OFFSET+1 = 5; full node: z_sendmany minconf = 1), so @@ -752,6 +818,7 @@ private: // balance poll discards per-note data). Mirrors chat_fast_scan_in_flight_. bool chat_note_scan_in_flight_ = false; double chat_note_scan_last_ = 0.0; // ImGui time of the last note scan (rate limit) + double chat_note_scan_ms_ = 0.0; // measured cost of the last note scan (adaptive back-off) // Most-recent chain tip, cached across refreshes: a lite refresh model that carries spendableOutputs // may NOT carry sync status that same cycle (tolerated partial refresh), so verifiedSelfNoteCount reads // this cache rather than requiring the current model to have both — else the budget flickers to 0. @@ -761,6 +828,9 @@ private: // Coordinator helpers (both variants unless noted; see app_network.cpp). void refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model); // lite: recompute caches on a fresh model void refreshChatNoteBudgetNode(); // full node: rate-limited z_listunspent worker scan + // Feeds the SAME chat send-budget from an already-collected z_listunspent (the address refresh), + // so the dedicated scan above is skipped while the address refresh is active (dedup — see #3). + void updateChatNoteBudgetFromUnspent(const std::vector& unspentNotes); void pumpChatNoteBuffer(); // per-frame: drain the queue / build the buffer int verifiedSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite int pipelineSelfNoteCount(const wallet::LiteWalletAppRefreshModel& model); // lite @@ -782,6 +852,9 @@ public: int chatUnreadCount() const; // Mark a conversation read up to latestTs (called by the Chat tab while a thread is displayed). void markChatConversationSeen(const std::string& cid, std::int64_t latestTs); + // Drop the seen-watermark for a conversation (used on revive-delete so a re-imported message — even one + // whose stamped time predates the deleted thread's last message — still badges as unread). + void forgetChatConversationSeen(const std::string& cid); private: // Provision the chat identity once the wallet seed is reachable+unlocked (per-tick, both // variants); derives via deriveChatIdentityFromSecret and wipes the secret. No-op when the @@ -794,12 +867,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 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 @@ -820,6 +902,8 @@ private: void fastScanChatMemos(); bool chat_fast_scan_in_flight_ = false; // guard against overlapping fast-scan RPCs float chat_fast_scan_accum_ = 0.0f; // seconds since the last fast-scan (dedicated ~2.5s poll) + double chat_fast_scan_last_ = 0.0; // ImGui time of the last 0-conf fast scan (adaptive back-off) + double chat_fast_scan_ms_ = 0.0; // measured cost of the last fast scan (z_listreceivedbyaddress) bool font_rebuild_requested_ = false; // set by requestFontRebuild(); consumed in preFrame() // Lite first-run welcome prompt: dismissed for the session once the user picks an action. bool lite_firstrun_dismissed_ = false; @@ -829,6 +913,16 @@ private: bool lite_startup_lock_checked_ = false; std::unique_ptr daemon_controller_; std::unique_ptr 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> mining_ctl_queue_; + bool mining_ctl_stop_ = false; + void postMiningControl(std::function 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 +955,13 @@ private: std::atomic shutting_down_{false}; std::atomic 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 +986,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..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..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 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. @@ -913,6 +1055,7 @@ private: bool show_demo_window_ = false; bool show_settings_ = false; bool show_about_ = false; + bool show_faq_ = false; bool show_import_key_ = false; bool show_export_key_ = false; bool show_backup_ = false; @@ -926,6 +1069,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 +1163,30 @@ private: bool daemon_start_error_shown_ = false; int daemon_last_seen_crashes_ = 0; // surface each new embedded-daemon crash reason once bool refresh_policy_syncing_ = false; // whether the sync-throttle refresh profile is active + // Sync-settle hysteresis + adaptive balance-poll throttle. Balance polling (z_gettotalbalance) is + // O(mapWallet) and holds the daemon's cs_main, which starves block connection on a large shielded + // wallet — so we keep the low-impact profile briefly after catching up, and back the balance poll + // off in proportion to its own measured cost. See effectivelySyncing() / balanceRefreshDue(). + bool was_core_syncing_ = false; // previous Core-refresh sync state, to detect the caught-up edge + std::time_t sync_settle_until_ = 0; // hold the sync-throttle until this wall-clock time (0 = not settling) + double last_balance_scan_ms_ = 0.0; // measured cost of the last z_gettotalbalance scan + bool force_balance_refresh_ = false; // a wallet mutation forces the next balance poll through the throttle + // Same adaptive back-off applied to the other two O(mapWallet) scans that hold the daemon's cs_main: + // the address scan (z_listunspent) and the history scan (z_listreceivedbyaddress). Without this, a + // large shielded wallet re-scans them every tab cadence (~seconds each), saturating cs_main and + // starving block connection near the tip (where effectivelySyncing() reads false). See + // addressRefreshDue() / txRefreshDue(); only the routine periodic poll is throttled — explicit + // refreshes (tab switch, dirty set, in-flight send) call the refresh directly and bypass this. + double last_address_scan_ms_ = 0.0; // measured cost of the last address scan + double last_tx_scan_ms_ = 0.0; // measured cost of the last history scan // Auto-clear for secrets copied to the clipboard. Only a hash of the copied secret is kept. std::uint64_t clipboard_secret_hash_ = 0; double clipboard_clear_deadline_ = 0.0; float loading_timer_ = 0.0f; // spinner animation for loading overlay + double connect_stall_since_ = 0.0; // ImGui::GetTime() when the daemon first went "reachable but not ready"; 0 = not stalling (see util/connect_stall.h) + bool encryption_incomplete_warned_ = false; // W2-2: once-per-session guard for the "encryption didn't complete" warning + bool lock_failure_warned_ = false; // W2-4: guard so a repeatedly-failing auto-lock warns once, not every retry + std::uint64_t alerts_seen_total_ = 0; // Notifications::totalPushed() at last alert-panel open; drives the bell's unread dot // Current page (sidebar navigation) ui::NavPage current_page_ = ui::NavPage::Overview; @@ -1032,6 +1196,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 +1290,16 @@ private: // Auto-shield guard (prevents concurrent auto-shield operations) std::atomic 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 daemon_autoshield_probe_inflight_{false}; + std::string daemon_autoshield_address_; // z_autoshieldstatus fields (O1); empty on old daemons + std::string daemon_autoshield_disabled_reason_; // daemon's reason auto-shield is off (e.g. seed not recoverable) + bool daemon_autoshield_seed_recoverable_ = false; // P4: Incremental transaction cache int last_tx_block_height_ = -1; // block height at last full tx fetch @@ -1163,6 +1338,11 @@ private: // the per-second mining/rescan-status pollers are suppressed (the daemon holds cs_main for // the whole scan and would block them); completion is signalled by the rescan RPC callback. bool runtime_rescan_active_ = false; + // True only for a rescan the WALLET/USER initiated (the Rescan button, a -rescan/salvage/zap/reindex + // restart, key import, seed migration) — not an autonomous background witness rebuild the daemon does + // on its own. Gates the "Blockchain rescan complete" toast so background rebuilds don't fire it; + // cleared when the toast is shown. + std::atomic user_initiated_rescan_{false}; // atomic: some rescan triggers run on worker threads // Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan // that reconciles the preserved wallet.dat against the freshly-imported chain. bool post_bootstrap_rescan_pending_ = false; @@ -1221,8 +1401,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 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 vault_; @@ -1288,6 +1468,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 +1492,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..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 +1532,13 @@ private: void refreshPrice(); void refreshWalletEncryptionState(); void applyRefreshPolicy(ui::NavPage page); + bool effectivelySyncing() const; // syncing, or within the post-sync settle window (hysteresis) + bool balanceRefreshDue() const; // adaptive: enough time elapsed given the last balance-scan cost? + bool addressRefreshDue() const; // same adaptive back-off for the address scan (z_listunspent) + bool txRefreshDue() const; // same adaptive back-off for the history scan (z_listreceivedbyaddress) + // Shared duty-cycle rule: a scan may resume only once (lastScanMs / kScanDutyCycle) has elapsed since + // lastUpdate, so any single O(mapWallet) scan can occupy at most ~kScanDutyCycle of wall-clock. + bool scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const; bool currentPageNeedsWalletDataRefresh() const; bool shouldRunWalletTransactionRefresh() const; bool shouldRefreshTransactions() const; diff --git a/src/app_network.cpp b/src/app_network.cpp index d331f35..a987c54 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -35,6 +35,8 @@ #include "rpc/connection.h" #include "chat/chat_identity.h" // deriveChatIdentityFromSecret for HushChat identity provisioning #include "ui/windows/chat_tab.h" // ui::ResetChatTab — wipe chat UI plaintext on a wallet switch +#include "ui/windows/shield_dialog.h" // ui::ShieldDialog — Merge to Address shortcut from the bloat nudge +#include "ui/windows/mining_pool_panel.h" // ui::resolveMiningUserAddress #include // sodium_memzero for wiping the fetched mnemonic #include #include "config/settings.h" @@ -43,6 +45,7 @@ #include "wallet/lite_diagnostics.h" // liteLog — chat note-buffer coordinator diagnostics #include "config/version.h" #include "daemon/daemon_controller.h" +#include "daemon/daemon_startup_diagnosis.h" #include "daemon/embedded_daemon.h" #include "daemon/seed_wallet_creator.h" #include "daemon/xmrig_manager.h" @@ -54,9 +57,14 @@ #include "util/http_download.h" #include "data/exchange_info.h" #include "data/exchange_candles.h" +#include "data/seed_migration_resume.h" #include "util/platform.h" +#include "util/wallet_file_probe.h" // verify a salvage-backup is a real BDB before restoring it +#include "resources/embedded_resources.h" // getDaemonDirectory() — locate the wallet-rebuild helper +#include // popen the rebuild helper #include "util/perf_log.h" #include "util/i18n.h" +#include "util/address_validation.h" // isValidRecipientAddress — payout validation at every start path (M-01) #include "util/secure_vault.h" #include @@ -196,10 +204,14 @@ static WarmupText translateWarmup(const std::string& raw) // Used to offer a -salvagewallet repair when a switch fails because the target wallet is corrupt. static bool walletOutputLooksCorrupt(const std::string& out) { + // W1-2: the generic "Error loading wallet" fallback is ALSO printed for DB_TOO_NEW + // ("...requires ... newer version..."), which -salvagewallet cannot fix — so don't misclassify a + // version mismatch as salvageable corruption and offer a repair that can't help. + const bool versionMismatch = out.find("newer version") != std::string::npos; return out.find("Failed to rename") != std::string::npos || out.find("salvage failed") != std::string::npos || out.find("wallet.dat corrupt") != std::string::npos - || out.find("Error loading wallet") != std::string::npos; + || (out.find("Error loading wallet") != std::string::npos && !versionMismatch); } // Phrases dragonxd prints to its console while initializing, in the order translateWarmup() @@ -219,6 +231,39 @@ static constexpr int kDaemonWaitWarnAttempts = 4; // Connection Management // ============================================================================ +// dragonxd moves wallet.dat to wallet..bak and loads a salvaged copy whenever BDB verify fails — +// no flag, and often a false positive (stale/cross-platform env) or an inconsistent-but-readable file. +// The salvage prints to the node's captured output at STARTUP, but the node may then fail to connect +// (block-index abort, long sync, crash) so we must NOT wait for onConnected — scan the output on every +// tryConnect tick, early enough that the line hasn't been trimmed from the rolling buffer. Fires once +// per session; the dialog offers Rebuild (fix the DB) / Restore (swap the .bak back). +void App::detectWalletAutoRecovery() +{ + if (wallet_auto_recovered_warned_) return; + if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return; + if (!daemon::walletAutoRecovered(daemon_controller_->daemon()->getOutput())) return; + wallet_auto_recovered_ = true; + wallet_auto_recovered_warned_ = true; + show_wallet_recovered_dialog_ = true; + ui::Notifications::instance().info(TR("wallet_recovered_notify"), 30.0f); // calm, not red — coins are safe + VERBOSE_LOGF("[recovery] Daemon auto-recovered/salvaged wallet.dat — surfacing the recovery dialog\n"); +} + +// v1.3.0+ opens a wallet that lost its hdchain in DEGRADED mode (funds spendable) instead of aborting, +// but can't derive NEW HD keys — z_getnewaddress / z_shieldcoinbase / t->z z_sendmany fail with "HD seed +// not found". Only a startup log line signals it, so scan the captured output and warn once. Pre-1.3.0 +// daemons never emit it, so this is a no-op there (backwards compatible). +void App::detectWalletDegraded() +{ + if (wallet_degraded_warned_) return; + if (!isUsingEmbeddedDaemon() || !daemon_controller_ || !daemon_controller_->daemon()) return; + if (!daemon::walletOpenedDegraded(daemon_controller_->daemon()->getOutput())) return; + wallet_degraded_ = true; + wallet_degraded_warned_ = true; + ui::Notifications::instance().warning(TR("wallet_degraded_notify"), 30.0f); + VERBOSE_LOGF("[recovery] Daemon opened wallet in DEGRADED mode — new-key derivation disabled\n"); +} + void App::tryConnect() { // Lite builds have no full node / RPC daemon, so never run the RPC connection state machine @@ -226,6 +271,11 @@ void App::tryConnect() // derived from it each frame in App::update(), which also gates the wallet UI (isConnected()). if (isLiteBuild()) return; + // Catch a startup wallet salvage as soon as it appears in the node's output — independent of whether + // the node ever finishes starting or connects (skip only while an orchestrated swap is mid-flight). + if (!daemon_restarting_) detectWalletAutoRecovery(); + if (!daemon_restarting_) detectWalletDegraded(); + if (connection_in_progress_) return; // Don't fight an in-progress restart/adopt orchestration: while it stops the daemon, swaps @@ -240,6 +290,16 @@ void App::tryConnect() // Auto-detect configuration (file I/O — fast, safe on main thread) auto config = rpc::Connection::autoDetectConfig(); + + if (!config.dir_error.empty()) { + // The data directory could not be created (read-only home, permission denied, + // disk full). Retrying won't fix it, so surface it in the status line instead of + // mislabelling it as "waiting for config" below. + connection_in_progress_ = false; + connection_status_ = config.dir_error; + VERBOSE_LOGF("[connect #%d] data dir error: %s\n", connect_attempt, config.dir_error.c_str()); + return; + } if (config.rpcuser.empty() || config.rpcpassword.empty()) { connection_in_progress_ = false; @@ -310,11 +370,21 @@ void App::tryConnect() VERBOSE_LOGF("[connect #%d] Connecting to %s:%s (user=%s)\n", connect_attempt, config.host.c_str(), config.port.c_str(), config.rpcuser.c_str()); - if (rpc::Connection::usesPlaintextRemote(config) && !remote_rpc_plaintext_warning_shown_) { - remote_rpc_plaintext_warning_shown_ = true; - ui::Notifications::instance().warning( - "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS.", - 10.0f); + if (rpc::Connection::usesPlaintextRemote(config) && + !rpc::Connection::allowsPlaintextRemote(config)) { + // Refuse to send Basic-auth credentials in cleartext to a remote host — a local-network + // MITM would otherwise capture rpcuser:rpcpassword. This is a deliberate behaviour change + // from the old warn-and-proceed: opt in explicitly with rpcallowplaintext=1 in + // DRAGONX.conf (or enable TLS with rpctls=1) if the plaintext link is intended. + connection_in_progress_ = false; + connection_status_ = TR("sb_plaintext_remote_blocked"); + if (!remote_rpc_plaintext_warning_shown_) { + remote_rpc_plaintext_warning_shown_ = true; + ui::Notifications::instance().warning(TR("sb_plaintext_remote_blocked"), 20.0f); + } + VERBOSE_LOGF("[connect #%d] refusing plaintext-remote RPC to %s:%s (set rpcallowplaintext=1 to override)\n", + connect_attempt, config.host.c_str(), config.port.c_str()); + return; } // Run the blocking rpc_->connect() on the worker thread so the UI @@ -356,9 +426,12 @@ void App::tryConnect() // "stuck connecting" while the node silently died-and-respawned. Surface each new crash once. const int crashes = daemon_controller_->crashCount(); if (crashes > daemon_last_seen_crashes_) { - daemon_last_seen_crashes_ = crashes; + daemon_last_seen_crashes_ = crashes; // consume it either way, so it can't toast later const std::string detail = daemon_controller_->lastError(); - if (!detail.empty()) { + // Suppress the scary "dragonxd exited unexpectedly" toast during recovery: the stop after a + // salvage, and the intentional restart-with-rescan after a repair, are both EXPECTED here and + // owned by the recovery UI (dialog / calm rescan overlay). + if (!detail.empty() && !wallet_auto_recovered_ && !post_recovery_rescan_) { connection_status_ = TR("sb_daemon_start_failed"); ui::Notifications::instance().error(detail, 30.0f); } @@ -385,6 +458,7 @@ void App::tryConnect() // fail until warmup completes. Set the warmup state so // the UI shows status instead of a blocking overlay. state_.warming_up = true; + if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock auto wt = translateWarmup(warmupStatus); state_.warmup_status = wt.title; state_.warmup_description = wt.description; @@ -474,8 +548,34 @@ void App::tryConnect() VERBOSE_LOGF("[connect #%d] RPC connection failed — no daemon starting, no external detected\n", attempt); if (isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) { + // A repair completed and we restarted with a full rescan, but the fresh daemon has + // now EXITED — a fault distinct from the wallet file (corrupt block index, disk full, + // OOM). Drop out of the calm "finishing repair" state so this surfaces as a normal + // daemon failure (reindex offer / crash toast / restart) instead of silently freezing + // the reconnect loop on a reassuring "don't restart" screen with no way forward. + if (post_recovery_rescan_) { + post_recovery_rescan_ = false; + wallet_auto_recovered_ = false; // repair done; the original-salvage hold is over + } + // If the node aborted because its BLOCK DATABASE is unreadable (a daemon-vs-chaindata + // format mismatch after an update, or a corrupt index), crash-restarting just repeats + // the same abort — and each attempt reloads the whole index (wasteful). Detect it once + // and offer a one-click reindex instead of silently looping into a zero-balance node. + if (!block_db_reindex_available_ && daemon_controller_ && daemon_controller_->daemon() && + daemon::blockDbOutputLooksBroken(daemon_controller_->daemon()->getOutput())) { + block_db_reindex_available_ = true; + show_block_db_reindex_confirm_ = true; + ui::Notifications::instance().error(TR("block_db_reindex_notify"), 20.0f); + VERBOSE_LOGF("[connect #%d] Block database unreadable — offering a one-click reindex\n", attempt); + } // Prevent infinite crash-restart loop - if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { + if (block_db_reindex_available_) { + connection_status_ = TR("sb_block_db_unreadable"); // hold; awaiting the rebuild choice + } else if (wallet_auto_recovered_) { + // A salvage is happening — DON'T restart into another one (each round can shrink + // the wallet further). Hold while the recovery dialog (Rebuild/Restore) is up. + connection_status_ = TR("sb_wallet_needs_recovery"); + } else if (daemon_controller_ && daemon_controller_->crashCount() >= 3) { if (wallet_switch_pending_confirm_.load()) { // The just-switched-to wallet's daemon keeps crashing (e.g. a wallet that // fails LATE in init, past the fast start grace) — revert to the previous @@ -526,10 +626,25 @@ void App::onConnected() } state_.daemon_initializing = false; // RPC is answering now; clear the "initializing" overlay daemon_wait_attempts_ = 0; // re-arm the port-busy / start-failure notifications + connect_stall_since_ = 0.0; // connected — clear the "taking too long" clock + // A repair's rescan finished and connected — retire the recovery session so its crash-toast/error-card + // suppression and status-chip hold can't persist forever. (Only when we were mid-post-repair-rescan; + // a pre-repair salvaged-daemon connect keeps the flag so the recovery dialog/chip stay available.) + if (post_recovery_rescan_) wallet_auto_recovered_ = false; + post_recovery_rescan_ = false; // repair's post-restart rescan is past the RPC-less phase now daemon_start_error_shown_ = false; daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too) connection_status_ = TR("connected"); + detectWalletAutoRecovery(); // also runs every tryConnect tick — catches a salvage even if we never connect + + // Re-arm the empty-wallet-with-funded-sibling check for this (possibly switched) wallet: re-evaluate the + // on-disk state once it finishes loading + syncing. Deliberately DON'T touch empty_wallet_scan_in_flight_ + // here — a scan from a prior connect self-clears it when it posts back, and resetting it while that scan + // is still running would let the next submit() block the UI thread on join() (async_tasks_ is only ever + // cancelled at shutdown, so the flag can't wedge during normal runtime). + empty_wallet_warn_checked_ = false; + // Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance + // address count fill in on the first address refresh (addresses aren't loaded yet here). updateWalletIndexForActiveWallet(/*markOpened=*/true); @@ -606,6 +721,7 @@ void App::onDisconnected(const std::string& reason) state_.connected = false; state_.warming_up = false; state_.warmup_status.clear(); + connect_stall_since_ = 0.0; // reset the "taking too long" clock (App member, untouched by state_.clear()) state_.clear(); connection_status_ = reason; @@ -614,6 +730,11 @@ void App::onDisconnected(const std::string& reason) wallet_seed_status_ = WalletSeedStatus::Unknown; wallet_seed_status_attempts_ = 0; + // Re-probe whether the daemon auto-shields coinbase on the next connect — it may have been + // upgraded/swapped (e.g. v1.0.3 which has no z_autoshieldstatus -> v1.3.0 which auto-shields). + daemon_autoshield_probed_ = false; + daemon_autoshield_active_ = false; + // Clear RPC result caches viewtx_cache_.clear(); confirmed_tx_cache_.clear(); @@ -660,6 +781,7 @@ void App::onDisconnected(const std::string& reason) std::string App::applyDaemonInitStatus(bool reachableButBusy) { state_.daemon_initializing = true; + if (connect_stall_since_ <= 0.0) connect_stall_since_ = ImGui::GetTime(); // start the "taking too long" clock // Find the most recent console line that names an init phase, so we can tell the user exactly // what the node is doing (loading the block index, verifying, activating best chain, …). @@ -736,13 +858,58 @@ void App::applyRefreshPolicy(ui::NavPage page) // While the daemon is syncing, override the per-tab cadence with the low-impact sync profile so // the wallet stops contending for the daemon's cs_main lock (frequent getpeerinfo / per-block // transaction scans / balance polls slow block connection). This makes every tab sync as fast - // as the Console tab does today. Reverts to the per-tab profile once sync finishes. - refresh_policy_syncing_ = state_.sync.syncing; + // as the Console tab does today. effectivelySyncing() keeps this profile on briefly after catching + // up (hysteresis) so a large-wallet scan can't immediately re-starve connection and bounce the + // node back into "syncing". Reverts to the per-tab profile once the settle window passes. + refresh_policy_syncing_ = effectivelySyncing(); network_refresh_.setIntervals(refresh_policy_syncing_ ? services::RefreshScheduler::kSyncProfile : getIntervalsForPage(page)); } +// True while the node is behind, and for a short settle window after it first catches up. The settle +// window is armed only on the syncing→caught-up edge (see the Core refresh callback), so a wallet that +// was synced from the start is never throttled at connect — only a node that just finished catching up. +bool App::effectivelySyncing() const +{ + if (state_.sync.syncing) return true; + if (sync_settle_until_ == 0) return false; // no pending settle → genuinely caught up + return std::time(nullptr) < sync_settle_until_; +} + +// Adaptive throttle: the next balance poll must wait at least (lastScanCost / kBalanceDutyCycle) since +// the last one, so balance scanning can never occupy more than ~kBalanceDutyCycle of wall-clock. A +// cheap wallet (sub-cadence cost) is unaffected — the tab's Core timer stays the real cadence; a ~20s +// scan on a large wallet backs off to roughly every ~200s instead of every 2s, freeing cs_main for +// block connection. A wallet mutation bypasses this via force_balance_refresh_. +bool App::scanRefreshDue(std::int64_t lastUpdate, double lastScanMs) const +{ + constexpr double kScanDutyCycle = 0.10; + if (lastUpdate == 0) return true; // never fetched + if (lastScanMs <= 0.0) return true; // no cost measured yet + const double minInterval = (lastScanMs / 1000.0) / kScanDutyCycle; + return std::difftime(std::time(nullptr), static_cast(lastUpdate)) >= minInterval; +} + +bool App::balanceRefreshDue() const +{ + return scanRefreshDue(state_.last_balance_update, last_balance_scan_ms_); +} + +// The address scan (z_listunspent) and history scan (z_listreceivedbyaddress) are just as O(mapWallet) +// and cs_main-bound as the balance scan, so they get the same adaptive back-off. This is what keeps a +// large synced wallet from re-scanning them every tab cadence — the near-tip starvation the balance-only +// throttle missed (only the periodic poll is gated; explicit refreshes call the refresh directly). +bool App::addressRefreshDue() const +{ + return scanRefreshDue(state_.last_address_update, last_address_scan_ms_); +} + +bool App::txRefreshDue() const +{ + return scanRefreshDue(state_.last_tx_update, last_tx_scan_ms_); +} + bool App::currentPageNeedsWalletDataRefresh() const { using NP = ui::NavPage; @@ -920,10 +1087,13 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo // For a debit (signedAmount < 0) this clamps at >=0 exactly as the debit sites did. For a // restore (signedAmount > 0) the clamp is a no-op — max(0, bal+amt) == bal+amt when bal,amt>=0 — // so the single clamped form reproduces the original restore's unclamped bal += amt. + // Debit/restore the SPENDABLE view ONLY. The DISPLAY balances now come from an honest minconf=0 RPC + // that already reflects the outgoing spend AND the incoming 0-conf change, so debiting them here too + // would re-crater the display. This delta only lowers what can be re-spent before the change confirms. auto applyToAddress = [&](std::vector& addresses) { for (auto& address : addresses) { if (address.address == fromAddress) { - address.balance = std::max(0.0, address.balance + signedAmount); + address.spendableBalance = std::max(0.0, address.spendableBalance + signedAmount); return true; } } @@ -932,11 +1102,12 @@ void App::applyPendingSendDelta(const std::string& fromAddress, double signedAmo if (!applyToAddress(state_.z_addresses)) applyToAddress(state_.t_addresses); if (includeAggregates) { if (!fromAddress.empty() && fromAddress[0] == 'z') { - state_.privateBalance = std::max(0.0, state_.privateBalance + signedAmount); + state_.spendablePrivateBalance = std::max(0.0, state_.spendablePrivateBalance + signedAmount); } else { - state_.transparentBalance = std::max(0.0, state_.transparentBalance + signedAmount); + state_.spendableTransparentBalance = std::max(0.0, state_.spendableTransparentBalance + signedAmount); } - state_.totalBalance = std::max(0.0, state_.totalBalance + signedAmount); + state_.spendableTotalBalance = std::max(0.0, state_.spendableTotalBalance + signedAmount); + state_.unconfirmedBalance = std::max(0.0, state_.totalBalance - state_.spendableTotalBalance); } } @@ -1067,9 +1238,16 @@ void App::updateWalletIndexForActiveWallet(bool markOpened) if (!ec) e.sizeBytesAtLastOpen = static_cast(sz); } + // W1-3: record "synced here" only once the wallet's identity is actually verified (its addresses are + // known -> idHash non-empty). Stamping it on the bare connect (before any address readback) would let + // a freshly-restored wallet skip its needed rescan. It's idempotent, so the post-refresh update + // (updateWalletIndexForActiveWallet after addresses load) sets it once; lastOpenedEpoch is still + // recorded at open time here. + if (!idHash.empty()) { + e.syncedHere = true; // loaded + identity-verified in this datadir -> catch-up (no full rescan) + } if (markOpened) { e.lastOpenedEpoch = static_cast(std::time(nullptr)); - e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch } if (wallet_index_.upsert(e)) wallet_index_.save(); @@ -1096,7 +1274,9 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed ui::Notifications::instance().warning("A rescan or repair is in progress — try again once it finishes."); return; } - if (show_seed_migration_) { + // W3-4: block switching while a migration is PENDING, not only while its dialog is open — closing + // the dialog via "Later" mid-migration leaves the pending state but previously dropped this guard. + if (show_seed_migration_ || (settings_ && settings_->getSeedMigrationPending())) { ui::Notifications::instance().warning("Finish or cancel the seed migration before switching wallets."); return; } @@ -1108,6 +1288,19 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); return; } + // W1-1: verify the target wallet file actually exists before switching. dragonxd auto-CREATES a + // fresh empty wallet for a missing -wallet=, so without this a moved/deleted wallet file would + // silently "open" as a brand-new empty wallet with a zero balance — looking exactly like fund loss. + // (Also closes the W1-4 stale-switcher-row race: the check runs no matter how switchToWallet is called.) + { + std::error_code existEc; + const std::string walletPath = util::Platform::getDragonXDataDir() + "/" + walletFile; + if (!std::filesystem::exists(walletPath, existEc)) { + ui::Notifications::instance().warning( + "Wallet file not found (moved or deleted?): " + walletFile + " — it was not opened.", 15.0f); + return; + } + } // If we're connected to a node this session did NOT spawn (no live process handle — it was left // running by "keep node running", started by the user, or we just direct-connected to a config-provided // one), confirm before stopping it: switching must stop+restart it on the new wallet, but the user may @@ -1191,6 +1384,7 @@ void App::switchToWallet(const std::string& walletFile, bool stopDaemonConfirmed if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); if (salvage && daemon_controller_) daemon_controller_->setSalvageOnNextStart(true); // repair a corrupt wallet else if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); // (salvage implies rescan) + if (salvage || needRescan) user_initiated_rescan_ = true; // wallet-triggered repair/rescan → completion should toast // Start ONCE. Do NOT retry-spawn: a second start while the first is still shutting down leaves // two dragonxd holding wallet.dat against each other (BDB "Failed to rename … Error"). The // stopDaemonForWalletSwitch() wait already ensured the old node's process is gone, so a valid @@ -1264,18 +1458,26 @@ void App::processWalletSwitchRevert() int App::chatUnreadCount() const { if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return 0; - int unread = 0; + // This is called every frame from render() to size the Chat nav badge. Recompute only when the store + // actually changed (new/removed messages bump revision()) or after a short interval (to pick up + // mute/hide/seen changes, which don't bump the store). Previously it ran a full O(conversations x + // messages) scan that COPIED and stable_sorted every conversation's messages, every frame. const auto& store = chat_service_.store(); - for (const auto& cid : store.conversationIds()) { - if (settings_ && settings_->isChatMuted(cid)) continue; // muted conversations don't badge (Q10) - if (settings_ && settings_->isChatHidden(cid)) continue; // hidden conversations don't badge - std::int64_t seen = 0; - const auto it = chat_seen_watermark_.find(cid); - if (it != chat_seen_watermark_.end()) seen = it->second; - for (const auto& m : store.conversation(cid)) - if (m.direction == chat::ChatDirection::Incoming && m.timestamp > seen) ++unread; + const std::uint64_t rev = store.revision(); + const double now = ImGui::GetTime(); + if (rev != chat_unread_rev_ || now - chat_unread_computed_at_ > 0.25) { + chat_unread_cached_ = store.countUnread( + [this](const std::string& cid) { // excluded from the badge + return settings_ && (settings_->isChatMuted(cid) || settings_->isChatHidden(cid)); + }, + [this](const std::string& cid) -> std::int64_t { // seen watermark + const auto it = chat_seen_watermark_.find(cid); + return it != chat_seen_watermark_.end() ? it->second : 0; + }); + chat_unread_rev_ = rev; + chat_unread_computed_at_ = now; } - return unread; + return chat_unread_cached_; } void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs) @@ -1283,6 +1485,11 @@ void App::markChatConversationSeen(const std::string& cid, std::int64_t latestTs if (latestTs > 0) chat_seen_watermark_[cid] = latestTs; } +void App::forgetChatConversationSeen(const std::string& cid) +{ + chat_seen_watermark_.erase(cid); +} + void App::wipePendingTransactionHistoryCachePassphrase() { if (!pending_transaction_history_cache_passphrase_.empty()) { @@ -1488,6 +1695,7 @@ void App::refreshCoreData() state_.warming_up = false; state_.warmup_status.clear(); state_.warmup_description.clear(); + connect_stall_since_ = 0.0; // warmup finished — clear the "taking too long" clock connection_status_ = TR("connected"); VERBOSE_LOGF("[warmup] Daemon ready, warmup complete\n"); @@ -1505,7 +1713,10 @@ void App::refreshCoreData() transactions_dirty_ = true; last_tx_block_height_ = -1; invalidateShieldedHistoryScanProgress(true); - ui::Notifications::instance().success("Blockchain rescan complete"); + if (user_initiated_rescan_) { + ui::Notifications::instance().success("Blockchain rescan complete"); + user_initiated_rescan_ = false; // surfaced once; not for background rebuilds + } } NetworkRefreshService::applyConnectionInfoResult(state_, result.info); @@ -1538,9 +1749,15 @@ void App::refreshCoreData() ? fast_rpc_.get() : rpc_.get(); if (!w || !rpc) return; ui::NavPage tracePage = current_page_; - // Skip the balance call while syncing (it's incomplete anyway and takes the wallet lock + - // cs_main). Captured on the main thread to avoid reading state_ off the worker thread. - const bool includeBalance = !state_.sync.syncing; + // Decide whether to include the balance call (z_gettotalbalance — O(mapWallet), holds cs_main). + // Suppress it (a) while syncing or within the post-sync settle window, so it can't starve block + // connection, and (b) unless enough time has elapsed given the LAST scan's measured cost, so a + // large shielded wallet backs off automatically instead of re-scanning every couple of seconds. + // A wallet mutation (send/shield) forces the next poll through so the user's own action updates the + // balance immediately. Captured on the main thread to avoid reading state_ off the worker thread. + const bool includeBalance = !effectivelySyncing() && + (force_balance_refresh_ || balanceRefreshDue()); + if (includeBalance) force_balance_refresh_ = false; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Core, *w, [this, rpc, tracePage, includeBalance]() -> rpc::RPCWorker::MainCb { AppRefreshRpcGateway refreshRpc(*rpc, traceSource(tracePage, "Core refresh")); @@ -1550,6 +1767,27 @@ void App::refreshCoreData() NetworkRefreshService::applyCoreRefreshResult(state_, result, std::time(nullptr)); applyPendingSendBalanceDeltas(true); + // Feed the adaptive balance throttle + sync-settle hysteresis. Record the last scan's + // cost (0 when balance was skipped), and arm the settle window only on the + // syncing→caught-up edge so a wallet synced from the start is never throttled at connect. + if (result.balanceScanMs > 0.0) last_balance_scan_ms_ = result.balanceScanMs; + // Sticky-behind hysteresis: hold the low-impact profile for kSyncSettleSeconds after the + // LAST time we read "behind" — not only on the caught-up edge. While actually syncing we + // re-arm every Core tick, so the profile stays engaged continuously; once we truly reach + // the tip (within the 2-block tolerance in applyCoreRefreshResult) we stop re-arming and it + // lapses after the window. This is what breaks the near-tip feedback loop: a single stale or + // optimistic "caught up" reading can no longer immediately unleash the heavy O(mapWallet) + // scans and re-starve the tail (which dropped us behind again → oscillation). With the + // scans suppressed, kSyncProfile keeps sync detection itself cheap, so we keep getting fresh + // "behind" readings and stay latched until genuinely caught up. Window must exceed the + // worst-case Core-refresh interval so it can't lapse between two behind readings mid-catchup. + const bool nowSyncing = state_.sync.syncing; + constexpr double kSyncSettleSeconds = 30.0; + if (nowSyncing || was_core_syncing_) { + sync_settle_until_ = std::time(nullptr) + static_cast(kSyncSettleSeconds); + } + was_core_syncing_ = nowSyncing; + // Mid-session connection-loss detection. During normal operation, both core // RPCs failing together means the daemon connection is dead (a busy daemon // fails them individually, not both at once). Warmup is excluded — both fail @@ -1568,9 +1806,40 @@ void App::refreshCoreData() } } - // Auto-shield transparent funds if enabled - if (result.balanceOk && settings_ && settings_->getAutoShield() && - state_.transparent_balance > 0.0001 && !state_.sync.syncing && + // Probe the daemon's auto-shield status once per connection — independent of our own + // toggle/balance — so both the defer-gate below and the Settings UI (O1) can read it. + // Only when synced (daemon past warmup). Fail-closed: a pre-1.3.0 daemon lacks the RPC, + // so the probe leaves active=false and the wallet keeps shielding client-side. + if (result.balanceOk && !state_.sync.syncing && !daemon_autoshield_probed_ && worker_ && + !daemon_autoshield_probe_inflight_.exchange(true)) { + worker_->post([this]() -> rpc::RPCWorker::MainCb { + bool active = false, seedRecoverable = false; + std::string addr, reason; + try { + auto st = rpc_->call("z_autoshieldstatus", json::array()); + if (st.is_object()) { + if (st.contains("autoshield")) active = st["autoshield"].get(); + if (st.contains("autoshieldaddress")) addr = st["autoshieldaddress"].get(); + if (st.contains("disabled_reason")) reason = st["disabled_reason"].get(); + if (st.contains("seed_recoverable")) seedRecoverable = st["seed_recoverable"].get(); + } + } catch (...) {} // pre-1.3.0 daemon: no such method — leave defaults (inactive) + return [this, active, addr, reason, seedRecoverable]() { + daemon_autoshield_active_ = active; + daemon_autoshield_address_ = addr; + daemon_autoshield_disabled_reason_ = reason; + daemon_autoshield_seed_recoverable_ = seedRecoverable; + daemon_autoshield_probed_ = true; + daemon_autoshield_probe_inflight_ = false; + }; + }); + } + + // Auto-shield transparent funds — but defer to the daemon's own coinbase auto-shielder + // (v1.3.0+) when it's active, so we don't double-shield and split funds across z-addrs. + const bool autoShieldEligible = result.balanceOk && settings_ && settings_->getAutoShield() && + state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing; + if (autoShieldEligible && daemon_autoshield_probed_ && !daemon_autoshield_active_ && !auto_shield_pending_.exchange(true)) { std::string targetZAddr; for (const auto& addr : state_.addresses) { @@ -1581,7 +1850,7 @@ void App::refreshCoreData() } if (!targetZAddr.empty() && worker_) { DEBUG_LOGF("[AutoShield] Shielding %.8f DRGX to %s\n", - state_.transparent_balance, targetZAddr.c_str()); + state_.spendableTransparentBalance, targetZAddr.c_str()); // Use the user-configured fee, formatted fixed-decimal so the daemon's // ParseFixedPoint accepts it (a small double would serialize to "5e-05"). const std::string feeStr = @@ -1642,7 +1911,18 @@ void App::refreshAddressData() auto result = NetworkRefreshService::collectAddressRefreshResult(refreshRpc, addressSnapshot); return [this, previousAddressCount, previousWalletIdentity, result = std::move(result)]() mutable { + const bool addrListOk = result.addressListOk; // capture before the move + const double scanMs = result.scanMs; // feed the adaptive throttle (addressRefreshDue) + if (scanMs > 0.0) last_address_scan_ms_ = scanMs; + // #3: this scan already listed the wallet's unspent notes — feed the chat send-budget from it + // (dedups the dedicated chat z_listunspent). Grab it before `result` is moved below. + auto unspentNotes = std::move(result.unspentNotes); NetworkRefreshService::applyAddressRefreshResult(state_, std::move(result)); + updateChatNoteBudgetFromUnspent(unspentNotes); + // Mark the address list as loaded ONLY if enumeration actually succeeded — a swallowed + // z_listaddresses/getaddressesbyaccount failure returns a falsely-short list, and stamping it + // would let the empty-wallet warning trust a spurious 0 count (see maybeWarnEmptyWallet…). + if (addrListOk) state_.last_address_update = std::time(nullptr); applyPendingSendBalanceDeltas(false); address_validation_cache_dirty_ = false; address_list_dirty_ = true; @@ -1697,6 +1977,13 @@ void App::refreshTransactionData() transactionSnapshot.maxShieldedReceiveScans = shieldedReceiveScanBudget(current_page_); transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); + // When fully synced, widen the re-scan tolerance so the O(mapWallet) per-address history scan runs + // every several blocks instead of ~every 2 — the heaviest residual synced-state cs_main cost. The + // balance poll still surfaces incoming funds on its own cadence; only the detailed history list lags a + // few minutes. (During catch-up the tx refresh is suppressed by kSyncProfile, so this is synced-only.) + if (!effectivelySyncing()) + transactionSnapshot.shieldedScanTipTolerance = + std::max(transactionSnapshot.shieldedScanTipTolerance, 8); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, @@ -1707,6 +1994,7 @@ void App::refreshTransactionData() return [this, result = std::move(result)]() mutable { bool shieldedScanComplete = result.shieldedScanComplete; + if (result.scanMs > 0.0) last_tx_scan_ms_ = result.scanMs; // feed the adaptive throttle (txRefreshDue) std::size_t nextShieldedScanStartIndex = result.nextShieldedScanStartIndex; auto shieldedScanHeights = std::move(result.shieldedScanHeights); NetworkRefreshService::TransactionCacheUpdate cacheUpdate{ @@ -1772,6 +2060,9 @@ void App::refreshRecentTransactionData() transactionSnapshot.maxShieldedReceiveScans = 1; transactionSnapshot.shieldedScanTipTolerance = shieldedScanTipTolerance(transactionSnapshot.shieldedAddresses.size()); + if (!effectivelySyncing()) // synced: widen the re-scan tolerance (see refreshTransactionData) + transactionSnapshot.shieldedScanTipTolerance = + std::max(transactionSnapshot.shieldedScanTipTolerance, 8); ui::NavPage tracePage = current_page_; auto enqueued = network_refresh_.enqueue(services::NetworkRefreshService::Job::Transactions, *worker_, [this, currentBlocks, @@ -2207,6 +2498,10 @@ void App::startMining(int threads) return; } if (!state_.connected || !rpc_ || !worker_) return; + // Clamp the requested thread count to [1, logical cores] before setgenerate — an unclamped value + // (from a settings field or idle-scaling) would ask the daemon to spawn arbitrarily many threads. (M-08) + const int maxThreads = std::max(1, (int)std::thread::hardware_concurrency()); + threads = std::clamp(threads, 1, maxThreads); if (mining_toggle_in_progress_.exchange(true)) return; // already in progress worker_->post([this, threads]() -> rpc::RPCWorker::MainCb { @@ -2241,19 +2536,25 @@ void App::stopMining() worker_->post([this]() -> rpc::RPCWorker::MainCb { bool ok = false; + std::string errMsg; try { rpc::RPCClient::TraceScope trace("Mining tab / Stop mining"); rpc_->call("setgenerate", {false, 0}); ok = true; } catch (const std::exception& e) { - DEBUG_LOGF("Failed to stop mining: %s\n", e.what()); + errMsg = e.what(); + DEBUG_LOGF("Failed to stop mining: %s\n", errMsg.c_str()); } - return [this, ok]() { + return [this, ok, errMsg]() { mining_toggle_in_progress_.store(false); if (ok) { state_.mining.generate = false; state_.mining.localHashrate = 0.0; DEBUG_LOGF("Mining stopped\n"); + } else { + // Don't silently leave generate=true as if it worked: tell the user and let the next + // getmininginfo refresh reconcile the true daemon state. (M-07) + ui::Notifications::instance().error("Failed to stop mining: " + errMsg); } }; }); @@ -2266,21 +2567,28 @@ void App::startPoolMining(int threads) ui::Notifications::instance().warning("Pool mining is unavailable in this build"); return; } + // Clamp to [1, logical cores] before the count reaches xmrig (M-06/M-08 pool path). + threads = std::clamp(threads, 1, std::max(1, (int)std::thread::hardware_concurrency())); if (!xmrig_manager_) xmrig_manager_ = std::make_unique(); - // If already running, stop first (e.g. thread count change) - if (xmrig_manager_->isRunning()) { - xmrig_manager_->stop(); - } - - // Stop solo mining first if active + // Stop solo mining first if active (async via the RPC worker). if (state_.mining.generate) stopMining(); + // (the "stop the already-running miner first" step is done inside the control job below, in FIFO order) daemon::XmrigManager::Config cfg; cfg.pool_url = settings_->getPoolUrl(); cfg.worker_name = settings_->getPoolWorker(); + // Validate the payout address at EVERY start entry point (manual Start button, idle auto-start, thread + // scaling) — not just the UI gate — since a stale/hand-edited/wrong-chain address here silently loses + // mining rewards. (M-01) worker_name IS the pool login the rewards are credited to (see below). + if (!cfg.worker_name.empty() && cfg.worker_name != "x" && + !util::isValidRecipientAddress(cfg.worker_name)) { + ui::Notifications::instance().error( + "Pool payout address is not a valid DragonX address — mining not started."); + return; + } // The algo follows the pool: official pools use their own algo (pool.dragonx.cc // needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting. cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo()); @@ -2288,27 +2596,23 @@ void App::startPoolMining(int threads) cfg.tls = settings_->getPoolTls(); cfg.hugepages = settings_->getPoolHugepages(); - // Use first shielded address as the mining wallet address, fall back to transparent + // xmrig "user" is the pool login the block rewards are credited to. The user's + // "Payout Address" field (cfg.worker_name = getPoolWorker) is exactly that, so it + // takes priority — otherwise a payout address that differs from the wallet's own + // first z-address is silently ignored and rewards go to the wrong address. Only when + // no payout address is set do we fall back to the wallet's own first shielded, then + // transparent, address (available even before the daemon is connected/synced). + std::string firstShielded, firstTransparent; for (const auto& addr : state_.z_addresses) { - if (!addr.address.empty()) { - cfg.wallet_address = addr.address; + if (!addr.address.empty()) { firstShielded = addr.address; break; } + } + for (const auto& addr : state_.addresses) { + if (addr.type == "transparent" && !addr.address.empty()) { + firstTransparent = addr.address; break; } } - if (cfg.wallet_address.empty()) { - for (const auto& addr : state_.addresses) { - if (addr.type == "transparent" && !addr.address.empty()) { - cfg.wallet_address = addr.address; - break; - } - } - } - - // Fallback: use pool worker address from settings (available even before - // the daemon is connected or the blockchain is synced). - if (cfg.wallet_address.empty() && !cfg.worker_name.empty()) { - cfg.wallet_address = cfg.worker_name; - } + cfg.wallet_address = ui::resolveMiningUserAddress(cfg.worker_name, firstShielded, firstTransparent); if (cfg.wallet_address.empty()) { DEBUG_LOGF("[ERROR] Pool mining: No wallet address available\n"); @@ -2316,34 +2620,46 @@ void App::startPoolMining(int threads) return; } - if (!xmrig_manager_->start(cfg)) { - std::string err = xmrig_manager_->getLastError(); - DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); - - // Check for Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) - if (err.find("error 225") != std::string::npos || - err.find("virus") != std::string::npos) { - ui::Notifications::instance().error( - "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); + // Run the blocking stop(if running)+start on the serialized mining-control thread so the render thread + // never blocks on stop()'s SIGTERM->SIGKILL->join; marshal the spawn result back to the UI. (M-03/L-06/ + // L-08/L-09/L-13). cfg was fully built above on this (main) thread. + daemon::XmrigManager::Config cfgCopy = cfg; + postMiningControl([this, cfgCopy]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + const bool ok = xmrig_manager_->start(cfgCopy); + const std::string err = ok ? std::string() : xmrig_manager_->getLastError(); + if (!worker_) return; + worker_->post([this, ok, err]() -> rpc::RPCWorker::MainCb { + return [this, ok, err]() { + if (ok) { + // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. + pool_starting_.store(true, std::memory_order_relaxed); + ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); + } else { + DEBUG_LOGF("[ERROR] Pool mining: %s\n", err.c_str()); + // Windows Defender blocking (error 225 = ERROR_VIRUS_INFECTED) + if (err.find("error 225") != std::string::npos || err.find("virus") != std::string::npos) { + ui::Notifications::instance().error( + "Windows Defender blocked xmrig. Add exclusion for %APPDATA%\\ObsidianDragon"); #ifdef _WIN32 - // Offer to open Windows Security settings - pending_antivirus_dialog_ = true; + pending_antivirus_dialog_ = true; #endif - } else { - ui::Notifications::instance().error("Failed to start pool miner: " + err); - } - } else { - // Miner spawned — it still needs a few seconds to connect to the pool and start hashing. - pool_starting_.store(true, std::memory_order_relaxed); - ui::Notifications::instance().info("Starting pool miner — connecting to the pool…"); - } + } else { + ui::Notifications::instance().error("Failed to start pool miner: " + err); + } + } + }; + }); + }); } void App::stopPoolMining() { - if (xmrig_manager_ && xmrig_manager_->isRunning()) { - xmrig_manager_->stop(3000); - } + if (!xmrig_manager_) return; + // Off the render thread — stop()'s SIGTERM->SIGKILL->join can block up to ~3s. (M-03/L-06/L-08/L-09) + postMiningControl([this]() { + if (xmrig_manager_->isRunning()) xmrig_manager_->stop(3000); + }); } // ============================================================================ @@ -2738,6 +3054,10 @@ void App::provisionChatIdentityFromSecret(std::string secret) // Persistence: unlock the seed-derived chat DB with the SAME secret and rehydrate the store // with prior messages (decrypted at rest under a key only this seed can derive). chat_service_.setPersistence(&chat_db_); + // A blocked conversation's messages are dropped at ingest (old + future) until it is unblocked. + chat_service_.setBlockedPredicate([this](const std::string& cid) { + return settings_ && settings_->isChatBlocked(cid); + }); if (chat_db_.unlockWithSecret(trimmed)) { chat_service_.loadFromDatabase(); // Baseline unread: treat everything already in the store at load as read, so only messages @@ -2935,14 +3255,14 @@ std::string App::chatPayFromZaddr(double fee) const std::string reply; if (settings_) reply = settings_->getChatReplyZaddr(); for (const auto& a : state_.z_addresses) - if (a.address == reply && a.has_spending_key && a.balance >= fee) return reply; + if (a.address == reply && a.has_spending_key && a.spendableBalance >= fee) return reply; std::string best; double bestBal = -1.0; for (const auto& a : state_.z_addresses) - if (a.has_spending_key && !a.address.empty() && a.balance >= fee && a.balance > bestBal) { + if (a.has_spending_key && !a.address.empty() && a.spendableBalance >= fee && a.spendableBalance > bestBal) { best = a.address; - bestBal = a.balance; + bestBal = a.spendableBalance; } return best; // empty → no z-address can cover the fee } @@ -3276,9 +3596,25 @@ void App::refreshChatNoteBudget(const wallet::LiteWalletAppRefreshModel& model) void App::refreshChatNoteBudgetNode() { if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (!state_.connected || !rpc_ || !worker_ || state_.isLocked()) return; + // Never contend the daemon's cs_main while the node is catching up. z_listunspent is O(mapWallet) + // and holds cs_main for its whole duration — seconds on a large wallet — so an ungated per-few-second + // chat scan here starves block connection and keeps the node from ever pinning to the tip. Chat + // send-readiness can wait until we're synced; this budget is only advisory (the send path re-checks). + if (effectivelySyncing()) return; + // Only pre-warm the send-budget while the user is actually in chat. Off the Chat tab this heavy + // z_listunspent is unnecessary — and on a wallet-data tab, refreshAddressData's own z_listunspent + // already feeds the budget for free (updateChatNoteBudgetFromUnspent), so this dedicated scan is a + // fallback for the Chat tab (where the address refresh doesn't run). + if (current_page_ != ui::NavPage::Chat) return; if (chat_note_scan_in_flight_) return; // one scan at a time const double now = ImGui::GetTime(); - if (now - chat_note_scan_last_ < 4.0) return; // rate limit; self-throttles via the in-flight flag too + // Adaptive back-off keyed on the last scan's own cost: on a large wallet where the scan takes many + // seconds, this stretches the interval so the scan can't occupy more than ~10% of wall-clock (same + // duty-cycle discipline as the balance/address/tx polls). A cheap wallet stays at the 4s floor. + double minInterval = 4.0; + if (chat_note_scan_ms_ > 0.0) + minInterval = std::max(4.0, (chat_note_scan_ms_ / 1000.0) / 0.10); + if (now - chat_note_scan_last_ < minInterval) return; chat_note_scan_last_ = now; chat_note_scan_in_flight_ = true; @@ -3289,6 +3625,7 @@ void App::refreshChatNoteBudgetNode() { int verified = 0, pipeline = 0; std::uint64_t verifiedZat = 0; bool ok = false; + const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / note-buffer scan"); nlohmann::json notes = rpc_->call("z_listunspent", nlohmann::json::array({0})); // 0 = include maturing @@ -3308,9 +3645,12 @@ void App::refreshChatNoteBudgetNode() { } } } catch (const std::exception&) {} - return [this, scanGen, ok, verified, pipeline, verifiedZat]() { + const double scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); + return [this, scanGen, ok, verified, pipeline, verifiedZat, scanMs]() { if (scanGen != chat_session_generation_) return; // wallet switched — drop (reset cleared the flag) chat_note_scan_in_flight_ = false; + if (scanMs > 0.0) chat_note_scan_ms_ = scanMs; // feed the adaptive back-off if (!ok) return; // z_listunspent unavailable — leave caches as-is chat_note_model_seen_ = true; chat_verified_note_budget_ = verified; @@ -3325,6 +3665,36 @@ void App::refreshChatNoteBudgetNode() { }); } +// #3: derive the chat send-budget from an already-collected z_listunspent (refreshAddressData's scan), +// so we don't issue a duplicate z_listunspent. Runs on the main thread; the notes are pre-parsed to the +// fields we need (amount / locked / TRUE confirmations), so there's no RPC and no worker hop. Mirrors the +// apply in refreshChatNoteBudgetNode and stamps chat_note_scan_last_ so the dedicated scan backs off. +void App::updateChatNoteBudgetFromUnspent( + const std::vector& unspentNotes) { + if (lite_wallet_ || !chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; + const int confsNeeded = chatConfsRequired(); + const std::uint64_t minVal = chatDrgxToZat(kChatMinFeeDrgx); + int verified = 0, pipeline = 0; + std::uint64_t verifiedZat = 0; + for (const auto& nz : unspentNotes) { + if (nz.locked) continue; // tied up by an in-flight send + const std::uint64_t amtZat = chatDrgxToZat(nz.amount); + if (amtZat < minVal) continue; // skip dust below a fee + ++pipeline; // unspent self-note (verified or maturing) + if (nz.confirmations >= confsNeeded) { ++verified; verifiedZat += amtZat; } + } + chat_note_model_seen_ = true; + chat_verified_note_budget_ = verified; + chat_pipeline_note_count_ = pipeline; + chat_verified_shielded_zat_ = verifiedZat; + chat_note_scan_last_ = ImGui::GetTime(); // counts as a fresh scan → dedicated scan backs off + if (chat_split_outstanding_ && + (chat_pipeline_note_count_ >= kChatRefillTrigger || + ImGui::GetTime() - chat_split_submitted_at_ > kChatSplitWatchdogSecs)) { + chat_split_outstanding_ = false; + } +} + void App::enqueueChatSend(LiteOpKind kind, const chat::OutgoingChatMemos& memos, const std::string& echoLocalId) { QueuedChatOp op; op.kind = kind; @@ -3566,15 +3936,28 @@ void App::fastScanChatMemos() if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return; if (!state_.connected || !rpc_ || !worker_) return; if (chat_fast_scan_in_flight_) return; // don't stack RPCs if a previous scan is still running + // z_listreceivedbyaddress is O(mapWallet) (the daemon iterates every wtx even when filtered to one + // address) — seconds on a large wallet — so this 0-conf poll must NOT run while the node is catching + // up, and even when synced it backs off to ~10% wall-clock keyed on its own cost. The block-tip + // harvest still ingests every message on confirmation; this only trades away mempool-speed delivery. + if (effectivelySyncing()) return; + // Mempool-speed 0-conf delivery is only worth its heavy cost while the user is watching chat. Off the + // Chat tab, incoming messages still arrive via the confirmed block-tip harvest (+ toast) within ~1 block. + if (current_page_ != ui::NavPage::Chat) return; + const double nowT = ImGui::GetTime(); + if (chat_fast_scan_ms_ > 0.0 && + nowT - chat_fast_scan_last_ < (chat_fast_scan_ms_ / 1000.0) / 0.10) return; const std::string addr = chatReplyZaddr(); if (addr.empty()) return; + chat_fast_scan_last_ = nowT; chat_fast_scan_in_flight_ = true; const int scanGen = chat_session_generation_; // guard: drop the result if the wallet switches/locks worker_->post([this, addr, scanGen]() -> rpc::RPCWorker::MainCb { std::vector metadata; int rawMemoCount = 0; // received notes carrying a memo at the reply addr (0-conf visibility signal) std::string scanError; + const auto scanStart = std::chrono::steady_clock::now(); try { rpc::RPCClient::TraceScope trace("HushChat / 0-conf fast scan"); nlohmann::json received = rpc_->call("z_listreceivedbyaddress", {addr, 0}); // 0 = include mempool @@ -3605,12 +3988,15 @@ void App::fastScanChatMemos() } } catch (const std::exception& e) { scanError = e.what(); } const int parsedCount = static_cast(metadata.size()); - return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError]() mutable { + const double scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); + return [this, scanGen, metadata = std::move(metadata), rawMemoCount, parsedCount, scanError, scanMs]() mutable { // The wallet was switched/locked between post and now — this metadata belongs to the previous // session. resetChatSession already reset the in-flight flag, so just drop; clearing it here // would clobber a new session's own in-flight scan (mirrors the broadcast/identity guards). if (scanGen != chat_session_generation_) return; chat_fast_scan_in_flight_ = false; + if (scanMs > 0.0) chat_fast_scan_ms_ = scanMs; // feed the adaptive back-off // Diagnostic (console App/chat channel): log when the memo-note count at the reply address // CHANGES, so a stable mempool doesn't spam every 2.5s. Reveals whether inbound 0-conf messages // are reaching us at all, and how many parse as chat vs are unrelated memos. @@ -3761,6 +4147,8 @@ void App::exportAllKeys(std::function callba (*pending)--; if (*pending == 0 && callback) { callback(*keys_result, *exported, *total); + // Scrub the concatenated all-keys buffer once the consumer (backup writer) has used it. + if (!keys_result->empty()) sodium_memzero(&(*keys_result)[0], keys_result->size()); } }); } @@ -3783,7 +4171,9 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight, // Reject anything that isn't a recognized Z/T private key or shielded viewing key before handing // it to the daemon (the dialog's indicator and this guard share isRecognizedImportKey). if (!services::WalletSecurityController::isRecognizedImportKey(key)) { - if (callback) callback(false, "Unrecognized key format.", ""); + if (callback) callback(false, + "Not a recognized DragonX private key or viewing key. Check for missing or " + "mistyped characters, and that this is a DragonX key (not another coin).", ""); return; } @@ -3792,7 +4182,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight, == services::WalletSecurityController::KeyKind::Shielded; // Run on the worker thread — import requests a full rescan (rescan=true), so the // synchronous curl call can take many seconds; never block the UI thread on it. - worker_->post([this, key, viewing, shielded, startHeight, callback]() -> rpc::RPCWorker::MainCb { + worker_->post([this, key, viewing, shielded, startHeight, callback]() mutable -> rpc::RPCWorker::MainCb { std::string err, addr; try { rpc::RPCClient::TraceScope trace("Settings / Import key"); @@ -3804,6 +4194,11 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight, // A start height (shielded RPCs only) rescans from that block instead of genesis. if (startHeight > 0 && (viewing || shielded)) params.push_back(startHeight); nlohmann::json r = rpc_->call(method, params); + // Scrub the key out of the request params (the json holds its own copy of it). + if (params.is_array() && !params.empty() && params[0].is_string()) { + std::string& pk = params[0].get_ref(); + if (!pk.empty()) sodium_memzero(&pk[0], pk.size()); + } // z_import* return {type,address}; importprivkey returns the t-address string. if (r.is_object() && r.contains("address") && r["address"].is_string()) addr = r["address"].get(); @@ -3816,6 +4211,16 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight, // below would never run, leaving a stuck "Importing…" spinner. err = "Import failed (unknown error)"; } + // Scrub the worker's copy of the key now that the request has been sent (all paths). + if (!key.empty()) sodium_memzero(&key[0], key.size()); + // A checksum-valid key the daemon still rejects is almost always the right *format* but the + // wrong network/coin (Komodo-family chains share version bytes) or a corrupted paste — say so, + // since the bare "Invalid …" text reads like a wallet bug (F5). + if (!err.empty() && err.find("Invalid") != std::string::npos && + err.find("DragonX") == std::string::npos) { + err += " — check the key is for DragonX (not another coin or network) and has no missing " + "or altered characters."; + } return [this, err, addr, callback]() { if (!err.empty()) { if (callback) callback(false, err, ""); @@ -3826,6 +4231,7 @@ void App::importPrivateKey(const std::string& rawKey, int startHeight, if (callback) callback(true, "", addr); }; }); + if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy } // Sweep a spending key: import it (a full rescan populates its UTXOs/notes — the stock node has no @@ -3863,7 +4269,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo const bool shielded = services::WalletSecurityController::classifyPrivateKey(key) == services::WalletSecurityController::KeyKind::Shielded; const double fee = DRAGONX_DEFAULT_FEE; - worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() -> rpc::RPCWorker::MainCb { + worker_->post([this, key, startHeight, destMode, destExisting, shielded, fee]() mutable -> rpc::RPCWorker::MainCb { std::string err, dest, sourceAddr, amountStr; double amount = 0.0; try { @@ -3887,6 +4293,11 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo else { method = "importprivkey"; params = {key, "", true}; } if (startHeight > 0 && shielded) params.push_back(startHeight); nlohmann::json r = rpc_->call(method, params); + // Scrub the key out of the request params (the json holds its own copy of it). + if (params.is_array() && !params.empty() && params[0].is_string()) { + std::string& pk = params[0].get_ref(); + if (!pk.empty()) sodium_memzero(&pk[0], pk.size()); + } // 2. Determine the swept address. importprivkey returns the t-address string; z_importkey // returns null, so diff the z-address list to find the one the key just added. @@ -3945,6 +4356,8 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo } catch (...) { err = "Sweep failed (unknown error)"; } + // Scrub the worker's copy of the spending key now that the request has been sent (all paths). + if (!key.empty()) sodium_memzero(&key[0], key.size()); return [this, err, sourceAddr, dest, amount, amountStr, fee]() { invalidateAddressValidationCache(); refreshAddresses(); @@ -3981,6 +4394,7 @@ void App::sweepPrivateKey(const std::string& rawKey, int startHeight, int destMo }); }; }); + if (!key.empty()) sodium_memzero(&key[0], key.size()); // scrub the calling-frame copy } void App::exportSeedPhrase(std::function callback) @@ -4047,6 +4461,141 @@ void App::maybeRemindSeedBackup() }); } +// One-time nudge (full-node) when the BDB wallet.dat has bloated past the threshold. Berkeley DB never +// shrinks in place and shielded-note witness data accumulates, so a mining/shielded wallet can grow +// unbounded. Fires ONCE (persisted flag) a warning toast + a clickable "Consolidate notes…" entry in the +// bell/alert panel that opens Merge to Address; re-arms if the file later drops back under the threshold. +void App::maybeWarnLargeWallet() +{ + if (capture_mode_ || lite_wallet_) return; // no live nags during a UI sweep; lite has no wallet.dat + if (!supportsFullNodeLifecycleActions() || !settings_) return; + if (!state_.connected || !state_.encryption_state_known) return; + if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; + if (large_wallet_checked_) return; // stat wallet.dat at most once per launch + large_wallet_checked_ = true; + + static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB (matches the Settings banner) + const std::string walletPath = util::Platform::getDragonXDataDir() + "/wallet.dat"; + const uint64_t sz = util::Platform::getFileSize(walletPath); + if (sz <= kWalletBloatWarnBytes) { + // Re-arm the one-time warning if the file shrank back under the threshold (e.g. after a fresh seed wallet). + if (settings_->getLargeWalletWarned()) { settings_->setLargeWalletWarned(false); settings_->save(); } + return; + } + if (settings_->getLargeWalletWarned()) return; // already warned once for this bloat episode + settings_->setLargeWalletWarned(true); + settings_->save(); + ui::Notifications::instance().action( + TR("wallet_size_warn"), ui::NotificationType::Warning, + []() { ui::ShieldDialog::showConsolidate(); }, + TR("wallet_size_consolidate"), 12.0f); +} + +// Complementary on-disk safety net (full-node) for a wallet salvage we did NOT witness this launch — it +// happened on a prior run, or under an external daemon whose startup output we never captured, so +// detectWalletAutoRecovery() never fired. If the active wallet loads EMPTY while a sibling wallet file in +// the datadir still holds keys, warn once: the user's funds were likely moved into a wallet..bak by a +// prior BDB salvage and are not lost, just in another file. Fires at most once per wallet-open and once per +// unacknowledged wallet filename; the probe runs off the UI thread (scanFundedSiblingsAsync). +void App::maybeWarnEmptyWalletWithFundedSiblings() +{ + if (capture_mode_) return; // no live ops during a UI sweep + if (lite_wallet_ || !supportsFullNodeLifecycleActions()) return; // full-node only (lite = single-file dir) + if (!settings_) return; + if (empty_wallet_warn_checked_ || empty_wallet_scan_in_flight_) return; // at most once per wallet-open + if (show_empty_wallet_warning_) return; // already surfaced + // The console-driven recovery flow owns the salvage-this-launch case — don't double-warn. + if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; + // Only meaningful once the wallet is truly loaded AND fully synced: a mid-sync wallet reads empty. + if (!state_.connected || !state_.encryption_state_known) return; + if (state_.warming_up || state_.daemon_initializing || !state_.sync.isSynced()) return; + // Wait for the first Core refresh to land. The ConnectionInit prefetch sets sync.blocks but NOT headers, + // so isSynced() (blocks >= headers-2) is spuriously true in the window before the Core refresh — during + // which balance/addresses also read 0. last_balance_update flips non-zero only when the Core refresh + // applies (network_refresh_service.cpp), by which point balance & headers are real. + if (state_.last_balance_update == 0) return; + // And wait for the ADDRESS list to have loaded at least once — otherwise getAddressCount()==0 is + // ambiguous ("no keys" vs "not fetched yet"), which would false-fire on a spent-down wallet (0 balance + // but has addresses) whose address refresh lands a beat after the balance refresh. + if (state_.last_address_update == 0) return; + // "Empty" = no addresses and no funds. A salvage-created fresh wallet has no keys; a legitimately + // spent-down wallet keeps its addresses, so requiring zero addresses avoids nagging the latter. + if (state_.getAddressCount() != 0) return; + if (state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; + if (settings_->isEmptyWalletWarnAcked(settings_->getActiveWalletFile())) return; // dismissed for this file + + empty_wallet_warn_checked_ = true; // evaluate the on-disk state once for this wallet-open + scanFundedSiblingsAsync(); +} + +// Off-UI-thread: enumerate the datadir's OTHER wallet files (incl. salvage wallet..bak backups) and +// offline-probe each for key material. Read-only; never opens a file in the daemon. The probe reads up to a +// bounded prefix per file, so it runs on its own task thread (not the RPC worker) and the result is +// marshaled back to the main thread before touching any UI state. Routing, applied on the main thread: +// • a funded salvage .bak exists → the coins were moved aside by an unwitnessed salvage; hand off to the +// existing recovery dialog, whose Restore action swaps the .bak back (the correct, tested fix). +// • otherwise a funded sibling .dat exists → the user simply opened the wrong (empty) wallet; show the +// lightweight warning modal that routes to the wallet manager to switch. +void App::scanFundedSiblingsAsync() +{ + if (empty_wallet_scan_in_flight_) return; + empty_wallet_scan_in_flight_ = true; + const std::string datadir = util::Platform::getDragonXDataDir(); + const std::string activeFile = settings_ ? settings_->getActiveWalletFile() : std::string("wallet.dat"); + async_tasks_.submit("Empty-wallet sibling scan", + [this, datadir, activeFile](const util::AsyncTaskManager::Token& tok) { + std::vector funded; // funded plain-.dat wallets → the "switch wallet" modal + bool hasSalvageBak = false; // a funded wallet..bak → route to the recovery/restore dialog + for (const auto& path : util::enumerateDatadirWalletFiles(datadir, activeFile, /*includeSalvageBaks=*/true)) { + if (tok.cancelled()) return; + const auto bt = util::parseWalletBtree(path); + if (!(bt.parsed && bt.addresses() > 0)) continue; // ignore junk / empty siblings + const std::string name = std::filesystem::path(path).filename().string(); + const bool isBak = name.size() > 4 && name.compare(name.size() - 4, 4, ".bak") == 0; + if (isBak) { + // Only a salvage-pattern wallet..bak has a defined restore path; other .bak files are + // ignored (the wallet manager lists only .dat, so routing them there would be a dead end). + if (daemon::parseWalletSalvageBakTs(name) >= 0) hasSalvageBak = true; + continue; + } + FundedSibling s; + s.fileName = name; + s.transparentKeys = bt.transparentKeys; + s.shieldedKeys = bt.shieldedKeys; + funded.push_back(std::move(s)); + } + // On teardown/cancel, skip posting (shutdown only; the in-flight flag is irrelevant then). + if (tok.cancelled() || !worker_) return; + // Apply UI state on the main thread only (the render loop reads these members). + worker_->post([this, funded, hasSalvageBak]() -> rpc::RPCWorker::MainCb { + return [this, funded, hasSalvageBak]() { + empty_wallet_scan_in_flight_ = false; + if (wallet_auto_recovered_ || show_wallet_recovered_dialog_) return; // recovery already owns it + // Re-validate emptiness on the main thread: balance/address refreshes may have landed while + // the scan ran (it takes long enough to read+parse sibling files), so a warm-reconnect or a + // spent-down wallet that momentarily read empty is now correctly excluded. + if (!state_.connected || state_.getAddressCount() != 0 || + state_.totalBalance > 0.0 || state_.spendableTotalBalance > 0.0) return; + // Both cases surface OUR modal (renderEmptyWalletWarningDialog), keyed by has_salvage_bak. + // We deliberately DON'T set the wallet_auto_recovered_ latch or auto-open the recovery dialog: + // the daemon is healthy, and that latch gates the crash-restart loop (app_network.cpp:556) + + // crash-toast suppression, so it would wedge the wallet offline on any later unrelated crash. + // For the salvage case the modal's "Restore" button calls restoreOriginalWallet() directly + // (self-contained: it drives the recovery dialog into its Working phase itself). + if (hasSalvageBak) { + empty_wallet_has_salvage_bak_ = true; + empty_wallet_funded_siblings_.clear(); + show_empty_wallet_warning_ = true; + } else if (!funded.empty()) { + empty_wallet_has_salvage_bak_ = false; + empty_wallet_funded_siblings_ = funded; + show_empty_wallet_warning_ = true; + } + }; + }); + }); +} + // One-shot (per connect) probe of the current wallet's mnemonic status, so the Settings // Migrate-to-seed button can glow for a legacy wallet without opening the migration dialog. Same // classification as the migration Intro pre-flight, but proactive and cached. Reads no secret past @@ -4111,27 +4660,56 @@ void App::showSeedMigrationDialog() // Resume a pending migration. If a sweep was already submitted (txid persisted), resume at the // confirm/adopt stage — re-derived from the chain — rather than sweeping again; otherwise start // at the Sweep step. With no pending migration, start fresh at the intro. - if (settings_ && settings_->getSeedMigrationPending() && !settings_->getSeedMigrationDest().empty()) { - seed_migration_dest_ = settings_->getSeedMigrationDest(); + const bool pending = settings_ && settings_->getSeedMigrationPending(); + const bool haveDest = settings_ && !settings_->getSeedMigrationDest().empty(); + const std::string sweepTxid = settings_ ? settings_->getSeedMigrationSweepTxid() : std::string(); + const std::string sweepOpid = settings_ ? settings_->getSeedMigrationSweepOpid() : std::string(); + const bool connected = state_.connected && rpc_ && worker_; + + switch (decideSeedMigrationResume(pending, haveDest, sweepTxid, sweepOpid, connected)) { + case MigrationResume::Confirming: + seed_migration_dest_ = settings_->getSeedMigrationDest(); + seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); + seed_migration_sweep_txid_ = sweepTxid; + seed_migration_sweep_confs_ = 0; + seed_migration_legacy_remaining_ = -1.0; + seed_migration_poll_timer_ = 0.0f; // poll immediately + seed_migration_step_ = SeedMigrationStep::Confirming; + break; + case MigrationResume::RetrackOpid: + // W3-3: a sweep opid was submitted but its txid was never persisted (app closed mid-Sweeping). + // Re-track it to recover the txid. If the daemon forgot it (restart), the opid poller flags it + // stale and makeSweepCompletionCallback(resumed) falls back to the Sweep gate — never a hang. + // Only reached when connected (decideSeedMigrationResume), so the poller can actually run and + // the buttonless "Sweeping" spinner is guaranteed an exit. + seed_migration_dest_ = settings_->getSeedMigrationDest(); + seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); + seed_migration_sweep_txid_.clear(); + pending_send_callbacks_[sweepOpid] = makeSweepCompletionCallback(/*resumed=*/true); + trackOperation(sweepOpid); + seed_migration_step_ = SeedMigrationStep::Sweeping; + seed_migration_status_ = "Checking on the previous sweep…"; + break; + case MigrationResume::SweepGate: + // No txid, and either no opid or not connected to re-track it (a persisted opid is left in + // place so a later reconnect+reopen can re-track it). The Sweep step is dismissable and + // reloads the balance, so the user is never trapped while offline. + seed_migration_dest_ = settings_->getSeedMigrationDest(); seed_migration_temp_dir_ = settings_->getSeedMigrationTempDir(); - seed_migration_sweep_txid_ = settings_->getSeedMigrationSweepTxid(); - if (!seed_migration_sweep_txid_.empty()) { - seed_migration_sweep_confs_ = 0; - seed_migration_legacy_remaining_ = -1.0; - seed_migration_poll_timer_ = 0.0f; // poll immediately - seed_migration_step_ = SeedMigrationStep::Confirming; - } else { - seed_migration_step_ = SeedMigrationStep::Sweep; - seed_migration_balance_loaded_ = false; - seed_migration_nofunds_confirmed_ = false; - refreshSeedMigrationBalance(); - } - } else { + seed_migration_sweep_txid_.clear(); + seed_migration_step_ = SeedMigrationStep::Sweep; + seed_migration_balance_loaded_ = false; + seed_migration_nofunds_confirmed_ = false; + refreshSeedMigrationBalance(); + break; + case MigrationResume::Intro: + default: seed_migration_step_ = SeedMigrationStep::Intro; // Fresh start: the Intro step will pre-flight the wallet (legacy vs already-seeded vs old // daemon) before offering to create anything. seed_migration_precheck_ = SeedMigrationPrecheck::Pending; seed_migration_precheck_started_ = false; + break; } } @@ -4206,29 +4784,71 @@ void App::beginSweepToSeedWallet() seed_migration_step_ = SeedMigrationStep::Error; return; } - pending_send_callbacks_[opid] = [this](bool ok, const std::string& result) { - if (ok) { - seed_migration_sweep_txid_ = result; - // Persist the txid so a restart resumes at the confirm/adopt stage and never - // re-sweeps from scratch. The Confirming step gates adopt on this tx being mined - // (>= 1 confirmation) AND the legacy balance dropping to ~0. - if (settings_) { settings_->setSeedMigrationSweepTxid(result); settings_->save(); } - seed_migration_sweep_confs_ = 0; - seed_migration_legacy_remaining_ = -1.0; - seed_migration_poll_timer_ = 0.0f; - seed_migration_status_.clear(); - seed_migration_step_ = SeedMigrationStep::Confirming; - } else { - seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; - seed_migration_step_ = SeedMigrationStep::Error; - } - }; + // W3-3: adopt this new opid atomically — persist it AND clear any prior sweep txid in the + // SAME settings write. Persisting the opid lets an app-close during Sweeping (opid + // submitted, not yet resolved to a txid) re-poll it on resume instead of dropping it. Doing + // the swap HERE — only once the new submit has succeeded — rather than speculatively at + // function entry means a FAILED "Sweep remaining" remainder re-sweep leaves the + // already-mined first sweep's txid intact and resumable to Confirming; and the txid and + // opid are never both authoritative at once (torn-write safe; resume checks txid first). + seed_migration_sweep_txid_.clear(); + if (settings_) { + settings_->setSeedMigrationSweepTxid(""); + settings_->setSeedMigrationSweepOpid(opid); + settings_->save(); + } + pending_send_callbacks_[opid] = makeSweepCompletionCallback(/*resumed=*/false); trackOperation(opid); seed_migration_status_ = "Waiting for the sweep transaction to be accepted…"; }; }); } +// W3-3: terminal handling for the sweep operation, shared by the initial submit (resumed=false) and +// a resume re-track (resumed=true). On success it persists the txid and clears the opid in the SAME +// settings write, so the txid always outranks the opid on a later resume (torn-write safe). +std::function App::makeSweepCompletionCallback(bool resumed) +{ + return [this, resumed](bool ok, const std::string& result) { + if (ok) { + seed_migration_sweep_txid_ = result; + // Persist the txid (and drop the now-redundant opid) so a restart resumes at the + // confirm/adopt stage and never re-sweeps from scratch. The Confirming step gates adopt on + // this tx being mined (>= 1 confirmation) AND the legacy balance dropping to ~0. + if (settings_) { + settings_->setSeedMigrationSweepTxid(result); + settings_->setSeedMigrationSweepOpid(""); + settings_->save(); + } + seed_migration_sweep_confs_ = 0; + seed_migration_legacy_remaining_ = -1.0; + seed_migration_poll_timer_ = 0.0f; + seed_migration_status_.clear(); + seed_migration_step_ = SeedMigrationStep::Confirming; + } else if (resumed) { + // A resumed opid the daemon no longer knows (it restarted — the op queue is in-memory + // only). "Stale" can't be told apart from "failed", and the earlier sweep may in fact have + // already broadcast/mined, so DON'T dead-end at Error: drop the stale opid and return to + // the Sweep gate, re-fetching the legacy balance. If that sweep did complete, the balance + // reads ~0 and the Sweep step short-circuits to adopt; otherwise the user can sweep again. + if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); } + seed_migration_sweep_txid_.clear(); + seed_migration_balance_loaded_ = false; + seed_migration_nofunds_confirmed_ = false; + seed_migration_status_ = + "Couldn't confirm the earlier sweep — it may have already completed. " + "Check your balance below before sweeping again."; + seed_migration_step_ = SeedMigrationStep::Sweep; + refreshSeedMigrationBalance(); // else the Sweep step sits on a permanent "Checking balance…" + } else { + // A fresh sweep that genuinely failed. Clear the persisted opid so it can't mis-resume. + if (settings_) { settings_->setSeedMigrationSweepOpid(""); settings_->save(); } + seed_migration_status_ = result.empty() ? "The sweep transaction failed." : result; + seed_migration_step_ = SeedMigrationStep::Error; + } + }; +} + // Confirming step: poll the sweep tx's confirmations + the legacy wallet's remaining balance. The // adopt step is gated on the tx being mined (confs >= 1) AND the legacy balance being ~0, so we // never swap wallet.dat while the funds could still bounce back (dropped/reorged tx) or while a @@ -4282,7 +4902,13 @@ void App::beginAdoptSeedWallet() // has its own passphrase; the user can re-enable PIN quick-unlock for it). if (vault_) vault_->removeVault(); const std::string base = seed_migration_temp_dir_; - async_tasks_.submit("Adopt seed wallet", [this, base](const util::AsyncTaskManager::Token&) { + // W3-1: adopt must swap the ACTIVE wallet file (multi-wallet), not a hardcoded "wallet.dat" — + // otherwise a migration run while e.g. wallet-2.dat is active would install the swept seed wallet + // into an unloaded wallet.dat and leave the daemon loading the (now-emptied) legacy wallet. + // Captured on the main thread; wallet switching is blocked during migration so this can't race. + const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) + ? settings_->getActiveWalletFile() : std::string("wallet.dat"); + async_tasks_.submit("Adopt seed wallet", [this, base, activeWalletName](const util::AsyncTaskManager::Token&) { namespace fs = std::filesystem; std::string err; // fatal (swap did not happen; migration incomplete) std::string warn; // non-fatal (swap done but the daemon did not restart) @@ -4302,7 +4928,7 @@ void App::beginAdoptSeedWallet() // 2. Swap wallet.dat. Move the legacy one aside to a timestamped backup (NEVER // delete), then copy the new seed wallet in. On any failure, restore the legacy. const std::string datadir = util::Platform::getDragonXDataDir(); - const std::string legacy = datadir + "/wallet.dat"; + const std::string legacy = datadir + "/" + activeWalletName; const std::string newWallet = base + "/DRAGONX/wallet.dat"; std::time_t t = std::time(nullptr); std::tm tmv{}; // thread-safe local time (the UI thread also uses localtime) @@ -4336,7 +4962,7 @@ void App::beginAdoptSeedWallet() // 3. Rescan on next start (only if the swap happened) and bring the daemon back up — // unless we're quitting, in which case don't resurrect it. - if (swapDone && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); + if (swapDone && daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; } if (!shutting_down_) { // We stopped the daemon ourselves (port_free) — clear the adopted-external latch so the // relaunched process is treated as owned (stop/isRunning/exit behave normally afterward). @@ -4363,6 +4989,301 @@ void App::beginAdoptSeedWallet() }); } +// Undo a daemon wallet auto-recovery: swap the untouched original (wallet..bak) back over the +// salvaged copy and clear the stale BDB env that triggered the false recovery, then restart. Modeled on +// beginAdoptSeedWallet — stop daemon → file ops (copy/rename only, NEVER delete user data) → restart. +void App::restoreOriginalWallet() +{ + if (!supportsFullNodeLifecycleActions()) { + ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + return; + } + if (daemon_restarting_) { + ui::Notifications::instance().warning(TR("wallet_restore_busy")); + return; + } + // Keep the recovery dialog OPEN and drive it into the Working phase — it shows progress and the + // honest outcome in place (pumpWalletRestore flips it to Done/Failed). Presentation only; every + // file-safety step below is unchanged. + show_wallet_recovered_dialog_ = true; + recovery_phase_ = RecoveryPhase::Working; + recovery_last_action_rebuild_ = false; + { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } + daemon_restarting_ = true; // gate the reconnect loop while we swap files + connection_status_ = TR("sb_restarting_daemon"); + if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); + onDisconnected("Restoring original wallet"); + ui::Notifications::instance().info(TR("wallet_restore_started"), 12.0f); + + const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) + ? settings_->getActiveWalletFile() : std::string("wallet.dat"); + + async_tasks_.submit("Restore original wallet", [this, activeWalletName](const util::AsyncTaskManager::Token&) { + namespace fs = std::filesystem; + std::string err, warn; + try { + const std::string datadir = util::Platform::getDragonXDataDir(); + // 1. Find the LARGEST salvage backup (offline — no daemon needed). Largest = least-salvaged = + // the original: a salvage cascade shrinks the wallet each round, so the newest .bak can be + // empty ("Salvage found no records") while the original is untouched and huge. + std::vector> files; + { + std::error_code lec; + for (const auto& e : fs::directory_iterator(datadir, lec)) { + if (lec) break; + std::error_code se; + const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0; + files.emplace_back(e.path().filename().string(), + se ? 0ull : static_cast(sz)); + } + } + const std::string bak = daemon::largestWalletSalvageBak(files); + if (bak.empty()) { + err = TR("wallet_restore_no_backup"); + } else if (!util::probeWalletFile(datadir + "/" + bak).isBerkeleyDB) { + err = TR("wallet_restore_bad_backup"); // don't overwrite a working wallet with a bad .bak + } else if (!stopDaemonForWalletSwitch()) { // 2. Release wallet.dat + the RPC port first. + err = TR("wallet_restore_stop_failed"); + } else { + std::error_code ec; + std::time_t t = std::time(nullptr); + std::tm tmv{}; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); + const std::string active = datadir + "/" + activeWalletName; + const std::string salvagedAside = active + ".salvaged-" + ts + ".dat"; + // 3. Move the salvaged copy aside (NEVER delete), then copy the original .bak into place + // (copy, so the .bak itself stays as a backup). Roll back the move if the copy fails. + bool movedSalvaged = false; + if (fs::exists(active)) { + fs::rename(active, salvagedAside, ec); + if (ec) err = TR("wallet_restore_move_failed"); + else movedSalvaged = true; + } + if (err.empty()) { + fs::copy_file(datadir + "/" + bak, active, fs::copy_options::overwrite_existing, ec); + if (ec) { + if (movedSalvaged) { std::error_code e2; fs::rename(salvagedAside, active, e2); } + err = TR("wallet_restore_copy_failed"); + } + } + // 4. Clear the stale BDB environment that triggered the false recovery — otherwise the + // daemon would just re-salvage the restored wallet on the next start. Move database/ + // aside (keeps its logs) and drop the transient __db.* region files. + if (err.empty()) { + std::error_code e2; + if (fs::exists(datadir + "/database")) + fs::rename(datadir + "/database", datadir + "/database.pre-restore-" + ts + ".bak", e2); + for (const auto& e : fs::directory_iterator(datadir, e2)) { + if (e.path().filename().string().rfind("__db.", 0) == 0) { + std::error_code e3; fs::remove(e.path(), e3); + } + } + } + } + + // 5. Bring the daemon back up (unless quitting). Even on a restore failure we relaunch so the + // node isn't left down; the connect loop reconnects and onConnected clears the gate. + if (!shutting_down_) { + if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); + if (!startEmbeddedDaemon() && err.empty()) + warn = TR("wallet_restore_no_restart"); + } + } catch (const std::exception& e) { + err = std::string("Restore failed: ") + e.what(); + } catch (...) { + err = "Restore failed due to an unexpected error."; + } + daemon_restarting_ = false; // ALWAYS re-arm the reconnect gate + + std::lock_guard lk(wallet_restore_mutex_); + wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0); + wallet_restore_msg_ = !err.empty() ? err : warn; + wallet_restore_done_ = true; + }); +} + +void App::pumpWalletRestore() +{ + if (capture_mode_) return; + bool done = false; int sev = 0; std::string msg; + { + std::lock_guard lk(wallet_restore_mutex_); + if (wallet_restore_done_) { done = true; sev = wallet_restore_severity_; msg = wallet_restore_msg_; wallet_restore_done_ = false; } + } + if (!done) return; + // Primary outcome channel: if the recovery dialog is still up (Working), flip it to Done/Failed in + // place with the real result. The toast below stays as the secondary echo for a dismissed/alt-tabbed + // user. sev 2 = failed (op discarded, nothing changed); sev 0/1 = done (1 carries a warning message). + if (show_wallet_recovered_dialog_ && recovery_phase_ == RecoveryPhase::Working) { + recovery_outcome_sev_ = sev; + recovery_outcome_msg_ = msg; + recovery_phase_ = (sev == 2) ? RecoveryPhase::Failed : RecoveryPhase::Done; + // Clean success (sev 0) → the daemon is now restarting with a full rescan. Flag it so the loading + // overlay shows a calm "finishing repair" screen (not the scary generic stall) until it connects. + // NOT for sev 1 (a warning like "node didn't restart") — nothing is rescanning then, and the Done + // dialog already shows that message. Timestamp is stamped on the first overlay frame. + if (sev == 0) { post_recovery_rescan_ = true; post_recovery_rescan_since_ = 0.0; } + } + if (sev == 2) ui::Notifications::instance().error(msg, 25.0f); + else if (sev == 1) ui::Notifications::instance().warning(msg, 20.0f); + else ui::Notifications::instance().success(msg.empty() ? TR("wallet_restore_ok") : msg, 12.0f); +} + +// Locate the bundled dragonx-wallet-rebuild helper (exe dir → daemon dir). "" if not present. +static std::string findWalletRebuildHelper() +{ + namespace fs = std::filesystem; +#ifdef _WIN32 + const char* exe = "dragonx-wallet-rebuild.exe"; +#else + const char* exe = "dragonx-wallet-rebuild"; +#endif + for (const std::string& d : { util::Platform::getExecutableDirectory(), + dragonx::resources::getDaemonDirectory() }) { + if (d.empty()) continue; + std::error_code ec; + const std::string p = d + "/" + exe; + if (fs::exists(p, ec)) return p; + } + // Not sitting next to the app/daemon — but a self-contained exe carries it embedded. Extract it on + // demand (first-run param extraction is gated on needsParamsExtraction(), so it may never have run + // on a machine that already had the Sapling params). Returns "" on non-embedded builds. + return dragonx::resources::ensureWalletRebuildHelperExtracted(); +} + +bool App::walletRebuildAvailable() const { return !findWalletRebuildHelper().empty(); } + +// Rebuild a BDB-inconsistent wallet into a fresh, daemon-loadable one via the offline helper (see +// tools/wallet_rebuild). This is the real fix for the salvage cascade: plain "Restore original" just +// hands the same broken file back and the daemon re-salvages it. Modeled on restoreOriginalWallet: +// stop daemon → run helper → verify → safe swap (copy/rename only, never delete) → rescan → restart. +void App::rebuildWalletDatabase() +{ + if (!supportsFullNodeLifecycleActions()) { + ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + return; + } + if (daemon_restarting_) { ui::Notifications::instance().warning(TR("wallet_restore_busy")); return; } + const std::string helper = findWalletRebuildHelper(); + if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; } + + // Keep the recovery dialog OPEN through the rebuild (Working → Done/Failed in place). Presentation + // only; the run-helper → verify-before-swap → copy/rename-never-delete steps below are unchanged. + show_wallet_recovered_dialog_ = true; + recovery_phase_ = RecoveryPhase::Working; + recovery_last_action_rebuild_ = true; + { std::lock_guard lk(wallet_restore_mutex_); wallet_restore_done_ = false; } + daemon_restarting_ = true; + connection_status_ = TR("sb_restarting_daemon"); + if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); + onDisconnected("Rebuilding wallet database"); + ui::Notifications::instance().info(TR("wallet_rebuild_started"), 15.0f); + + const std::string activeWalletName = (settings_ && !settings_->getActiveWalletFile().empty()) + ? settings_->getActiveWalletFile() : std::string("wallet.dat"); + + async_tasks_.submit("Rebuild wallet database", [this, helper, activeWalletName](const util::AsyncTaskManager::Token&) { + namespace fs = std::filesystem; + std::string err, warn; + try { + const std::string datadir = util::Platform::getDragonXDataDir(); + const std::string active = datadir + "/" + activeWalletName; + // 1. Rebuild SOURCE = the largest readable wallet file (the active wallet or any salvage .bak). + // Largest = most records = the original / least-salvaged (a salvaged copy is tiny). + std::string src; unsigned long long best = 0; + { + std::error_code ec; + for (const auto& e : fs::directory_iterator(datadir, ec)) { + if (ec) break; + const std::string n = e.path().filename().string(); + if (n != activeWalletName && daemon::parseWalletSalvageBakTs(n) < 0) continue; + std::error_code se; + const auto sz = fs::is_regular_file(e, se) ? fs::file_size(e, se) : 0; + const auto usz = se ? 0ull : static_cast(sz); + if (usz > best && util::probeWalletFile(e.path().string()).isBerkeleyDB) { + best = usz; src = e.path().string(); + } + } + } + if (src.empty()) { + err = TR("wallet_rebuild_no_source"); + } else if (!stopDaemonForWalletSwitch()) { // 2. release wallet.dat + the port + err = TR("wallet_restore_stop_failed"); + } else { + std::time_t t = std::time(nullptr); + std::tm tmv{}; +#ifdef _WIN32 + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + char ts[32]; std::strftime(ts, sizeof(ts), "%Y%m%d-%H%M%S", &tmv); + const std::string tmpOut = datadir + "/wallet.rebuilt-" + std::string(ts) + ".tmp"; + { std::error_code ec; fs::remove(tmpOut, ec); } // helper uses DB_EXCL — path must be fresh + + // 3. Run the helper (src -> tmpOut). Quote both paths; capture its JSON line. Windowless + // (runHiddenCapture) so a wallet rebuild never flashes a cmd.exe console; it runs the + // helper via CreateProcess directly on Windows, so no cmd.exe outer-quote wrap is needed. + const std::string cmd = "\"" + helper + "\" \"" + src + "\" \"" + tmpOut + "\""; + int rc = -1; + const std::string jout = util::Platform::runHiddenCapture(cmd, /*mergeStderr=*/false, &rc); + DEBUG_LOGF("[App] wallet-rebuild helper rc=%d out=%s\n", rc, jout.c_str()); + + // 4. Verify-before-swap: the output must be a readable BDB with the fund-critical keys. + const auto probe = util::parseWalletBtree(tmpOut); + if (rc != 0 || !probe.parsed || probe.addresses() == 0) { + std::error_code ec; fs::remove(tmpOut, ec); + err = TR("wallet_rebuild_failed"); + } else { + // 5. Swap: set the current wallet aside (kept), install the rebuilt one, clear stale env. + std::error_code ec; + const std::string aside = active + ".prerebuild-" + std::string(ts) + ".dat"; + bool moved = false; + if (fs::exists(active)) { + fs::rename(active, aside, ec); + if (ec) err = TR("wallet_restore_move_failed"); else moved = true; + } + if (err.empty()) { + fs::rename(tmpOut, active, ec); + if (ec) { + if (moved) { std::error_code e2; fs::rename(aside, active, e2); } + err = TR("wallet_rebuild_install_failed"); + } + } + if (err.empty()) { + std::error_code e2; + if (fs::exists(datadir + "/database")) + fs::rename(datadir + "/database", datadir + "/database.prerebuild-" + std::string(ts) + ".bak", e2); + for (const auto& e : fs::directory_iterator(datadir, e2)) + if (e.path().filename().string().rfind("__db.", 0) == 0) { std::error_code e3; fs::remove(e.path(), e3); } + if (daemon_controller_) { daemon_controller_->setRescanOnNextStart(true); user_initiated_rescan_ = true; } + } + } + } + + if (!shutting_down_) { + if (daemon_controller_) daemon_controller_->clearExternalDaemonDetected(); + if (!startEmbeddedDaemon() && err.empty()) warn = TR("wallet_restore_no_restart"); + } + } catch (const std::exception& e) { + err = std::string("Rebuild failed: ") + e.what(); + } catch (...) { + err = "Rebuild failed due to an unexpected error."; + } + daemon_restarting_ = false; + + std::lock_guard lk(wallet_restore_mutex_); + wallet_restore_severity_ = !err.empty() ? 2 : (!warn.empty() ? 1 : 0); + wallet_restore_msg_ = !err.empty() ? err : (!warn.empty() ? warn : std::string(TR("wallet_rebuild_ok"))); + wallet_restore_done_ = true; + }); +} + void App::pumpSeedMigration() { if (capture_mode_) return; // no live ops during a UI sweep (steps are set directly) @@ -4386,6 +5307,7 @@ void App::pumpSeedMigration() settings_->setSeedMigrationDest(""); settings_->setSeedMigrationTempDir(""); settings_->setSeedMigrationSweepTxid(""); + settings_->setSeedMigrationSweepOpid(""); // W3-3 settings_->save(); } seed_migration_status_ = err; // a non-empty warning here (e.g. restart hiccup) is shown on Done @@ -4428,6 +5350,11 @@ void App::pumpSeedMigration() settings_->setSeedMigrationPending(true); settings_->setSeedMigrationDest(seed_migration_dest_); settings_->setSeedMigrationTempDir(seed_migration_temp_dir_); + // W3-3: a brand-new migration has done no sweep yet — clear any sweep artifacts left over + // from a prior aborted run so reopening this fresh migration can't mis-resume on a stale + // txid/opid (the resume block reads these whenever the migration is pending). + settings_->setSeedMigrationSweepTxid(""); + settings_->setSeedMigrationSweepOpid(""); settings_->save(); } } else { @@ -4456,13 +5383,12 @@ void App::backupWallet(const std::string& destination, std::function #include #include +#include +#include +#include +#include #include #include #include @@ -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 zeros(static_cast(sz), 0); + scrub.write(zeros.data(), static_cast(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) { @@ -261,14 +300,14 @@ void App::restartDaemonAfterEncryption(const char* taskName, bool announceRestar }); } else { ui::Notifications::instance().warning( - "Please restart your daemon for encryption to take effect."); + TR("sec_restart_daemon_for_encryption")); } } void App::encryptWalletWithPassphrase(const std::string& passphrase) { if (!rpc_ || !rpc_->isConnected()) return; encrypt_in_progress_ = true; - encrypt_status_ = "Encrypting wallet..."; + encrypt_status_ = TR("sec_encrypting_wallet"); if (worker_) { worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb { @@ -278,7 +317,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) { if (result.encrypted) { return [this]() { encrypt_in_progress_ = false; - encrypt_status_ = "Wallet encrypted. Restarting daemon..."; + encrypt_status_ = TR("sec_wallet_encrypted_restarting_daemon"); DEBUG_LOGF("[App] Wallet encrypted — restarting daemon\n"); // Immediately update local encryption state so the @@ -297,7 +336,7 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) { } ui::Notifications::instance().info( - "Wallet encrypted successfully", 5.0f); + TR("sec_wallet_encrypted_successfully"), 5.0f); // The daemon shuts itself down after encryptwallet. // Update connection_status_ so the loading overlay @@ -309,11 +348,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) { std::string err = result.error; return [this, err]() { encrypt_in_progress_ = false; - encrypt_status_ = "Encryption failed: " + err; + encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err; DEBUG_LOGF("[App] encryptwallet failed: %s\n", err.c_str()); ui::Notifications::instance().error( - "Encryption failed: " + err); + std::string(TR("sec_encryption_failed_prefix")) + err); // Return to passphrase entry on failure if (show_encrypt_dialog_ && @@ -354,7 +393,7 @@ void App::processDeferredEncryption() { std::string pin = std::move(deferredEncryption.pin); encrypt_in_progress_ = true; - encrypt_status_ = "Encrypting wallet..."; + encrypt_status_ = TR("sec_encrypting_wallet"); if (worker_) { worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb { @@ -374,13 +413,13 @@ void App::processDeferredEncryption() { if (result.pinStored) { settings_->setPinEnabled(true); settings_->save(); - ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f); + ui::Notifications::instance().info(TR("sec_wallet_encrypted_and_pin_set"), 5.0f); } else { ui::Notifications::instance().warning( - "Wallet encrypted but PIN vault failed"); + TR("sec_wallet_encrypted_but_pin_vault_failed")); } } else { - ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f); + ui::Notifications::instance().info(TR("sec_wallet_encrypted_successfully"), 5.0f); } wallet_security_.clearDeferredEncryption(); @@ -393,9 +432,9 @@ void App::processDeferredEncryption() { std::string err = result.error; return [this, err]() { encrypt_in_progress_ = false; - encrypt_status_ = "Encryption failed: " + err; + encrypt_status_ = std::string(TR("sec_encryption_failed_prefix")) + err; DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str()); - ui::Notifications::instance().error("Encryption failed: " + err); + ui::Notifications::instance().error(std::string(TR("sec_encryption_failed_prefix")) + err); wallet_security_.clearDeferredEncryption(); }; } @@ -483,7 +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( + TR("sec_couldnt_lock_wallet"), 12.0f); + } } }; }); @@ -492,7 +541,7 @@ void App::lockWallet() { void App::changePassphrase(const std::string& oldPass, const std::string& newPass) { if (!rpc_ || !rpc_->isConnected() || !worker_) return; encrypt_in_progress_ = true; - encrypt_status_ = "Changing passphrase..."; + encrypt_status_ = TR("sec_changing_passphrase"); auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get(); auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(); @@ -525,9 +574,9 @@ void App::changePassphrase(const std::string& oldPass, const std::string& newPas memset(change_confirm_buf_, 0, sizeof(change_confirm_buf_)); unlockTransactionHistoryCacheWithPassphrase(newPass); storeTransactionHistoryCacheIfAvailable(); - ui::Notifications::instance().info("Passphrase changed successfully"); + ui::Notifications::instance().info(TR("sec_passphrase_changed_successfully")); } else { - encrypt_status_ = "Failed: " + err_msg; + encrypt_status_ = std::string(TR("sec_failed_prefix")) + err_msg; } util::SecureVault::secureZero(newPass.data(), newPass.size()); }; @@ -560,6 +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,18 @@ void App::refreshWalletEncryptionState() { state_.locked = false; state_.unlocked_until = 0; state_.encryption_state_known = true; + // W2-2: encryption was requested (persisted flag) but the wallet is NOT encrypted, + // and no deferred encryption is pending/in-flight — it was lost to a quit/crash or a + // failed connect before it applied. Warn (once/session) instead of silently leaving + // an unencrypted wallet the user believes is protected. The flag stays set until the + // wallet is actually encrypted, so the warning recurs each launch until resolved. + if (settings_ && settings_->getEncryptionPending() && + !wallet_security_.hasDeferredEncryption() && !encrypt_in_progress_ && + !encryption_incomplete_warned_) { + encryption_incomplete_warned_ = true; + ui::Notifications::instance().warning( + TR("sec_encryption_did_not_complete"), 30.0f); + } if (state_.transactions.empty()) { loadTransactionHistoryCacheIfAvailable(); } else { @@ -691,6 +758,10 @@ void App::checkIdleMining() { // Resolve auto values: active defaults to half, idle defaults to all if (activeThreads <= 0) activeThreads = std::max(1, maxThreads / 2); if (idleThreads <= 0) idleThreads = maxThreads; + // Clamp to [1, logical cores] before these reach setgenerate / startPoolMining — a settings field + // could otherwise carry an arbitrary count straight past every bound. (M-06) + activeThreads = std::clamp(activeThreads, 1, maxThreads); + idleThreads = std::clamp(idleThreads, 1, maxThreads); if (systemIdle) { // System is idle — scale up to idle thread count @@ -879,7 +950,7 @@ void App::renderLockScreen() { ImU32 textCol = ui::material::OnSurface(); { - const char* title = "Wallet Locked"; + const char* title = TR("sec_wallet_locked_title"); ImVec2 ts = titleFont->CalcTextSizeA(titleFont->LegacySize, FLT_MAX, 0, title); dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cardX + (cardW - ts.x) * 0.5f, cy), textCol, title); @@ -892,7 +963,7 @@ void App::renderLockScreen() { if (lock_lockout_timer_ < 0) lock_lockout_timer_ = 0; char msg[128]; - snprintf(msg, sizeof(msg), "Too many attempts. Wait %.0f seconds...", lock_lockout_timer_); + snprintf(msg, sizeof(msg), TR("sec_too_many_attempts_wait"), lock_lockout_timer_); ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg); dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), ui::material::Warning(), msg); @@ -905,10 +976,10 @@ void App::renderLockScreen() { // Mode toggle (PIN / Passphrase) — only show if PIN vault exists if (hasPinVault) { const char* modeIcon = lock_use_pin_ ? ICON_MD_DIALPAD : ICON_MD_PASSWORD; - const char* modeText = lock_use_pin_ ? " PIN" : " Passphrase"; + const char* modeText = lock_use_pin_ ? " PIN" : TR("sec_mode_passphrase"); const char* switchLabel = lock_use_pin_ - ? "Use passphrase instead" - : "Use PIN instead"; + ? TR("sec_use_passphrase_instead") + : TR("sec_use_pin_instead"); // Current mode indicator — icon with icon font, text with caption font ImFont* iconFont = ui::material::Type().iconSmall(); @@ -1010,7 +1081,7 @@ void App::renderLockScreen() { if (lock_unlock_in_progress_) { // Animated spinner dots char msg[64]; - snprintf(msg, sizeof(msg), "Unlocking%s", ui::material::LoadingDots()); + snprintf(msg, sizeof(msg), TR("sec_unlocking_fmt"), ui::material::LoadingDots()); ImVec2 ms = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, msg); dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cardX + (cardW - ms.x) * 0.5f, cy), @@ -1034,7 +1105,7 @@ void App::renderLockScreen() { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnPrimary())); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp); ImGui::BeginDisabled(!canSubmit); - bool btnClicked = ui::material::TactileButton("Unlock", ImVec2(unlockW, unlockH)); + bool btnClicked = ui::material::TactileButton(TR("sec_unlock_button"), ImVec2(unlockW, unlockH)); ImGui::EndDisabled(); ImGui::PopStyleVar(); ImGui::PopStyleColor(3); @@ -1088,7 +1159,7 @@ void App::renderLockScreen() { r->call("walletpassphrase", {passphrase, timeout}); rpcOk = true; } else { - rpcErr = "Not connected to daemon"; + rpcErr = TR("sec_not_connected_to_daemon"); } } catch (const std::exception& e) { rpcErr = e.what(); @@ -1104,7 +1175,7 @@ void App::renderLockScreen() { // so route through applyUnlockFailure so the lockout curve applies // (this path previously bumped the counter but skipped the lockout math). return [this, rpcErr, passphrase = std::move(passphrase)]() mutable { - applyUnlockFailure("Unlock failed: " + rpcErr); + applyUnlockFailure(std::string(TR("sec_unlock_failed_prefix")) + rpcErr); util::SecureVault::secureZero(passphrase.data(), passphrase.size()); }; } @@ -1193,7 +1264,7 @@ void App::renderEncryptWalletDialog() { else if (tier == 1) { strengthLabel = TR("wiz_strength_fair"); strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; } 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 +1314,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 +1382,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 +1449,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 +1480,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,15 +1568,17 @@ 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"); + wallet_security_workflow_.failEntry(TR("sec_incorrect_passphrase_decrypt")); }; } @@ -1553,6 +1645,11 @@ void App::renderDecryptWalletDialog() { std::chrono::steady_clock::now()); auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) { + // Scrub + delete the plaintext key export (obsidiandecryptexport…) on EVERY exit path — + // success, a restart-failure early return, or an exception. A full cleartext dump of all + // private keys must never outlive this step. The startup sweep is a further net for a + // crash/kill mid-flight. (H-02) + struct ExportScrub { std::string p; ~ExportScrub() { App::scrubAndRemoveExport(p); } } exportScrub{exportPath}; WalletSecurityDaemonAdapter daemonAdapter(*this, token); WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(), [this](rpc::RPCClient& client, const char* context) { @@ -1597,7 +1694,7 @@ void App::renderDecryptWalletDialog() { }); ui::Notifications::instance().info( - "Importing keys & rescanning blockchain — wallet is usable while this runs", + TR("sec_importing_keys_rescanning"), 8.0f); }; }); @@ -1606,6 +1703,8 @@ void App::renderDecryptWalletDialog() { WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_); auto importResult = services::WalletSecurityWorkflowExecutor::importWallet( importAdapter, exportPath); + // (exportScrub scrubs + deletes the plaintext key export on scope exit — H-02) + if (!importResult.ok) { std::string err = importResult.error; if (worker_) { @@ -1614,7 +1713,7 @@ void App::renderDecryptWalletDialog() { wallet_security_workflow_.finishImport(); ui::Notifications::instance().error( err + - "\nEncrypted backup: wallet.dat.encrypted.bak", + TR("sec_encrypted_backup_suffix"), 12.0f); }; }); @@ -1640,7 +1739,7 @@ void App::renderDecryptWalletDialog() { refreshPeerInfo(); ui::Notifications::instance().success( - "Wallet decrypted successfully! All keys imported.", + TR("sec_wallet_decrypted_all_keys_imported"), 8.0f); DEBUG_LOGF("[App] Wallet decrypted successfully\n"); }; @@ -1730,7 +1829,7 @@ void App::renderDecryptWalletDialog() { // Indeterminate progress bar { float barW = ImGui::GetContentRegionAvail().x; - float barH = 6.0f; + float barH = 6.0f * ui::Layout::dpiScale(); ImVec2 p = ImGui::GetCursorScreenPos(); ImDrawList* dl = ImGui::GetWindowDrawList(); dl->AddRectFilled(p, ImVec2(p.x + barW, p.y + barH), @@ -1762,7 +1861,7 @@ void App::renderDecryptWalletDialog() { int tMins = (int)(totalElapsed / 60); int tSecs = (int)(totalElapsed % 60); ImGui::Spacing(); - ImGui::TextDisabled("Total elapsed: %dm %02ds", tMins, tSecs); + ImGui::TextDisabled(TR("sec_total_elapsed_fmt"), tMins, tSecs); } // ---- Phase 2: Success ---- @@ -1860,10 +1959,12 @@ void App::renderPinDialogs() { util::SecureVault::isValidPin(pinStr) && strcmp(pin_buf_, pin_confirm_buf_) == 0; + // Two-button footer (primary + Cancel) to match the encrypt/decrypt siblings. + float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; ImGui::BeginDisabled(!valid || pin_in_progress_); - if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(-1, 40))) { + if (ui::material::TactileButton(TR("settings_set_pin"), ImVec2(btnW, 40))) { pin_in_progress_ = true; - pin_status_ = "Verifying passphrase..."; + pin_status_ = TR("sec_verifying_passphrase"); // Verify passphrase + store vault on worker thread to avoid // blocking the UI with Argon2id key derivation. @@ -1874,20 +1975,25 @@ void App::renderPinDialogs() { memset(pin_confirm_buf_, 0, sizeof(pin_confirm_buf_)); if (rpc_ && rpc_->isConnected() && worker_) { - worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb { + worker_->post([this, passphrase, pin]() mutable -> rpc::RPCWorker::MainCb { // Verify passphrase via RPC (worker thread) try { rpc::RPCClient::TraceScope trace("Security / PIN setup"); rpc_->call("walletpassphrase", {passphrase, 5}); } catch (const std::exception& e) { + if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); + if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); return [this]() { - pin_status_ = "Incorrect passphrase"; + pin_status_ = TR("sec_incorrect_passphrase_pin_setup"); pin_in_progress_ = false; }; } // Passphrase correct — store in vault (Argon2id, worker thread) bool storeOk = vault_ && vault_->store(pin, passphrase); + // Captured passphrase + PIN are no longer needed — scrub the worker-queue copies. (M-01) + if (!passphrase.empty()) util::SecureVault::secureZero(&passphrase[0], passphrase.size()); + if (!pin.empty()) util::SecureVault::secureZero(&pin[0], pin.size()); // Lock wallet back try { @@ -1902,19 +2008,26 @@ void App::renderPinDialogs() { pin_status_.clear(); pin_in_progress_ = false; show_pin_setup_ = false; - ui::Notifications::instance().info("PIN set successfully"); + ui::Notifications::instance().info(TR("sec_pin_set_successfully")); } else { - pin_status_ = "Failed to create vault"; + pin_status_ = TR("sec_failed_to_create_vault"); pin_in_progress_ = false; } }; }); } else { - pin_status_ = "Not connected to daemon"; + pin_status_ = TR("sec_not_connected_to_daemon_pin"); pin_in_progress_ = false; } } ImGui::EndDisabled(); + + ImGui::SameLine(); + // Cancel does what Esc/close does — dismiss without applying. Buffers are wiped by + // the !show_pin_setup_ cleanup block below. + if (ui::material::TactileButton(TR("cancel"), ImVec2(btnW, 40))) { + show_pin_setup_ = false; + } EndOverlayDialog(); } // Wipe the passphrase/PIN buffers if the dialog was dismissed (X / Esc / @@ -1968,7 +2081,7 @@ void App::renderPinDialogs() { ImGui::BeginDisabled(!valid || pin_in_progress_); if (ui::material::TactileButton(TR("settings_change_pin"), ImVec2(-1, 40))) { pin_in_progress_ = true; - pin_status_ = "Changing PIN..."; + pin_status_ = TR("sec_changing_pin"); std::string oldPin(pin_old_buf_); std::string newPinCopy = newPin; memset(pin_old_buf_, 0, sizeof(pin_old_buf_)); @@ -1984,15 +2097,15 @@ void App::renderPinDialogs() { pin_status_.clear(); pin_in_progress_ = false; show_pin_change_ = false; - ui::Notifications::instance().info("PIN changed successfully"); + ui::Notifications::instance().info(TR("sec_pin_changed_successfully")); } else { - pin_status_ = "Incorrect current PIN"; + pin_status_ = TR("sec_incorrect_current_pin"); pin_in_progress_ = false; } }; }); } else { - pin_status_ = "Internal error"; + pin_status_ = TR("sec_internal_error_change_pin"); pin_in_progress_ = false; } } @@ -2033,7 +2146,7 @@ void App::renderPinDialogs() { ImGui::BeginDisabled(!valid || pin_in_progress_); if (ui::material::TactileButton(TR("settings_remove_pin"), ImVec2(-1, 40))) { pin_in_progress_ = true; - pin_status_ = "Verifying PIN..."; + pin_status_ = TR("sec_verifying_pin"); std::string oldPin(pin_old_buf_); memset(pin_old_buf_, 0, sizeof(pin_old_buf_)); @@ -2053,15 +2166,15 @@ void App::renderPinDialogs() { pin_status_.clear(); pin_in_progress_ = false; show_pin_remove_ = false; - ui::Notifications::instance().info("PIN removed"); + ui::Notifications::instance().info(TR("sec_pin_removed")); } else { - pin_status_ = "Incorrect PIN"; + pin_status_ = TR("sec_incorrect_pin_remove"); pin_in_progress_ = false; } }; }); } else { - pin_status_ = "Internal error"; + pin_status_ = TR("sec_internal_error_remove_pin"); pin_in_progress_ = false; } } diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index cb6407a..b7309f3 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -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"), @@ -310,6 +310,8 @@ void App::buildSweepCatalog() [](App& a) { ui::BootstrapDownloadDialog::show(&a); }, [](App&) { ui::BootstrapDownloadDialog::hide(); }); add("modal-backup", ui::NavPage::Overview, [](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); }); + add("modal-faq", ui::NavPage::Overview, + [](App& a) { a.show_faq_ = true; }, [](App& a) { a.show_faq_ = false; }); // Encrypt-wallet dialog — the redesigned passphrase-entry phase (never fires the async encrypt). add("modal-encrypt", ui::NavPage::Settings, [](App& a) { a.encrypt_dialog_phase_ = EncryptDialogPhase::PassphraseEntry; a.show_encrypt_dialog_ = true; }, @@ -704,9 +706,26 @@ void App::startSweepImpl(bool full) if (sk.valid) sweep_skins_.push_back(sk.id); if (sweep_skins_.empty()) return; + // Debug Options "Current theme only": sweep just the active skin instead of cycling every theme. + if (sweep_current_theme_only_) + sweep_skins_.assign(1, ui::schema::SkinManager::instance().activeSkinId()); + + // DEV/TEST hook (dormant unless the env is set): DRAGONX_SWEEP_ONLY="send,receive" restricts the + // sweep to the named surfaces and the dark skin, so a slow large-window run captures just the tab + // under review instead of all surfaces x every skin. + const char* sweepOnly = std::getenv("DRAGONX_SWEEP_ONLY"); + std::string sweepOnlyStr = sweepOnly ? sweepOnly : ""; + if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark")); + sweep_full_ = full; if (full) { capture_mode_ = true; installDemoWalletData(); } buildSweepCatalog(); + if (!sweepOnlyStr.empty()) { + std::vector keep; + for (const auto& t : sweep_targets_) + if (sweepOnlyStr.find(t.name) != std::string::npos) keep.push_back(t); + sweep_targets_.swap(keep); + } if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; } sweep_dir_ = full ? screenshotFullDir() : screenshotDir(); diff --git a/src/app_wizard.cpp b/src/app_wizard.cpp index d36c08c..2b30807 100644 --- a/src/app_wizard.cpp +++ b/src/app_wizard.cpp @@ -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(); } diff --git a/src/chat/chat_database.cpp b/src/chat/chat_database.cpp index 13d697f..f533819 100644 --- a/src/chat/chat_database.cpp +++ b/src/chat/chat_database.cpp @@ -89,6 +89,7 @@ bool ChatDatabase::unlockWithSecret(const std::string& secret) lock(); return false; } + loadTombstones(); return true; } @@ -97,11 +98,13 @@ void ChatDatabase::lock() sodium_memzero(key_.data(), key_.size()); key_ready_ = false; wallet_tag_.clear(); + tombstones_.clear(); } bool ChatDatabase::append(const ChatMessage& message) { if (!key_ready_ || !ensureOpen()) return false; + if (isTombstoned(message)) return false; // locally deleted — don't re-persist on a chain re-scan std::vector nonce; std::vector cipher; @@ -193,13 +196,79 @@ std::vector ChatDatabase::load() void ChatDatabase::clearWallet() { if (wallet_tag_.empty() || !ensureOpen()) return; + for (const char* sql : {"DELETE FROM chat_messages WHERE wallet_tag = ?", + "DELETE FROM chat_deleted WHERE wallet_tag = ?"}) { + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &stmt, nullptr) != SQLITE_OK) continue; + sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(stmt); + sqlite3_finalize(stmt); + } + tombstones_.clear(); +} + +bool ChatDatabase::deleteMessages(const std::vector& messages, bool tombstone) +{ + if (!key_ready_ || !ensureOpen()) return false; + if (messages.empty()) return true; + + if (!exec("BEGIN")) return false; + bool ok = true; + for (const auto& m : messages) { + const std::string dedup = dedupHash(m.txid, m.payload_position); + + sqlite3_stmt* del = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ? AND dedup_hash = ?", + -1, &del, nullptr) == SQLITE_OK) { + sqlite3_bind_text(del, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(del, 2, dedup.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(del) != SQLITE_DONE) ok = false; + sqlite3_finalize(del); + } else { + ok = false; + } + + if (tombstone) { + sqlite3_stmt* ins = nullptr; + if (sqlite3_prepare_v2(db_, + "INSERT OR IGNORE INTO chat_deleted (wallet_tag, dedup_hash) VALUES (?, ?)", + -1, &ins, nullptr) == SQLITE_OK) { + sqlite3_bind_text(ins, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ins, 2, dedup.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(ins) != SQLITE_DONE) ok = false; + sqlite3_finalize(ins); + } else { + ok = false; + } + } + } + if (!exec(ok ? "COMMIT" : "ROLLBACK")) ok = false; + // Only reflect the tombstones in the in-memory cache once they are durably committed. + if (ok && tombstone) + for (const auto& m : messages) tombstones_.insert(dedupHash(m.txid, m.payload_position)); + return ok; +} + +bool ChatDatabase::isTombstoned(const ChatMessage& message) const +{ + if (!key_ready_ || tombstones_.empty()) return false; + return tombstones_.count(dedupHash(message.txid, message.payload_position)) > 0; +} + +void ChatDatabase::loadTombstones() +{ + tombstones_.clear(); + if (!key_ready_ || !ensureOpen()) return; sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db_, "DELETE FROM chat_messages WHERE wallet_tag = ?", -1, &stmt, nullptr) - != SQLITE_OK) { + if (sqlite3_prepare_v2(db_, "SELECT dedup_hash FROM chat_deleted WHERE wallet_tag = ?", + -1, &stmt, nullptr) != SQLITE_OK) { return; } sqlite3_bind_text(stmt, 1, wallet_tag_.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_step(stmt); + while (sqlite3_step(stmt) == SQLITE_ROW) { + const auto* h = reinterpret_cast(sqlite3_column_text(stmt, 0)); + if (h) tombstones_.insert(h); + } sqlite3_finalize(stmt); } @@ -260,11 +329,18 @@ bool ChatDatabase::exec(const char* sql) bool ChatDatabase::createSchema() { - return exec("CREATE TABLE IF NOT EXISTS chat_messages (" + if (!exec("CREATE TABLE IF NOT EXISTS chat_messages (" + "wallet_tag TEXT NOT NULL, " + "dedup_hash TEXT NOT NULL, " + "nonce BLOB NOT NULL, " + "payload BLOB NOT NULL, " + "PRIMARY KEY (wallet_tag, dedup_hash))")) + return false; + // Tombstones for locally-deleted messages (dedup_hash only — the same keyed, non-revealing hash the + // message rows use). A chain re-scan checks this so a deleted message never re-imports. + return exec("CREATE TABLE IF NOT EXISTS chat_deleted (" "wallet_tag TEXT NOT NULL, " "dedup_hash TEXT NOT NULL, " - "nonce BLOB NOT NULL, " - "payload BLOB NOT NULL, " "PRIMARY KEY (wallet_tag, dedup_hash))"); } diff --git a/src/chat/chat_database.h b/src/chat/chat_database.h index 1156a60..550c0ae 100644 --- a/src/chat/chat_database.h +++ b/src/chat/chat_database.h @@ -16,6 +16,7 @@ #include #include #include +#include #include struct sqlite3; @@ -54,8 +55,20 @@ public: void clearWallet(); // delete the unlocked wallet's rows + // Per-conversation local delete. Removes the given messages' rows; when `tombstone` is true it also + // records their (txid,position) dedup keys so a chain re-scan never re-imports them — this backs the + // "delete, but a NEW message revives the thread" path. With `tombstone` false the rows are simply + // removed (used by "delete & block", where a settings-level cid block suppresses re-import until the + // user unblocks, at which point the history re-imports from chain). Atomic; no-op while locked. + bool deleteMessages(const std::vector& messages, bool tombstone); + + // True if this message's (txid,position) was locally deleted with a tombstone. Checked against an + // in-memory cache loaded on unlock — O(1), no SQL. False while locked. + bool isTombstoned(const ChatMessage& message) const; + private: bool ensureOpen(); + void loadTombstones(); // populate tombstones_ from chat_deleted for the unlocked wallet bool exec(const char* sql); bool createSchema(); std::string dedupHash(const std::string& txid, std::size_t position) const; @@ -74,6 +87,7 @@ private: std::array key_{}; // AEAD storage key (seed-derived) std::string wallet_tag_; // seed-derived row partition (a keyed hash, hex) bool key_ready_ = false; + std::unordered_set tombstones_; // dedup_hash cache of locally-deleted messages }; } // namespace dragonx::chat diff --git a/src/chat/chat_service.cpp b/src/chat/chat_service.cpp index df97bff..e817a10 100644 --- a/src/chat/chat_service.cpp +++ b/src/chat/chat_service.cpp @@ -28,6 +28,10 @@ int ChatService::ingest(const std::vector& metadata std::int64_t fallbackTimestamp, std::vector* newIncomingCids) { if (!has_identity_) return 0; + // Persistence is attached but not unlocked (e.g. the DB failed to open with the seed): we can't + // consult tombstones, so ingesting now would resurface locally-deleted messages into the store. + // Skip until the DB is usable — chat is degraded anyway without its store. + if (db_ && !db_->hasKey()) return 0; const std::string myPubKey = chatIdentityPublicKeyHex(identity_); @@ -62,6 +66,13 @@ int ChatService::ingest(const std::vector& metadata } message.payload_position = meta.payload_position; + // Suppress locally-removed conversations before the (relatively costly) decrypt. A blocked cid + // is dropped outright (old + future messages) until unblocked; a tombstoned (txid,position) was + // deleted with "revive on new message", so only that exact message is skipped — a new message + // in the same conversation has a different txid and flows through normally. + if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; + if (db_ && db_->isTombstoned(message)) continue; + if (meta.type == HushChatHeaderType::ContactRequest) { message.kind = ChatMessageKind::ContactRequest; message.body = meta.payload_memo; // plaintext request text @@ -92,10 +103,28 @@ int ChatService::ingest(const std::vector& metadata void ChatService::loadFromDatabase() { if (!db_) return; for (const auto& message : db_->load()) { + // Never surface a blocked conversation, even if a prior "delete & block" failed to remove its + // rows (defense-in-depth): the ingest guard already drops live scans, this covers the reload path. + if (blocked_pred_ && blocked_pred_(message.conversation_id)) continue; store_.append(message); } } +bool ChatService::deleteConversation(const std::string& conversationId, bool block) { + // Delete the persisted rows FIRST and only mutate the in-memory view if that succeeds. Doing it the + // other way round means a failed DB write (disk full / locked) would empty the store while the rows + // survive — and on the next reload the conversation silently reappears with no tombstone. + // Revive-on-new-message => tombstone the removed rows so a re-scan won't re-import them. + // Block => remove the rows without a tombstone; the caller's blocked predicate suppresses re-import + // until unblocked, at which point the conversation re-imports from chain. + if (db_) { + std::vector msgs = store_.conversation(conversationId); // snapshot (copy) + if (!db_->deleteMessages(msgs, /*tombstone=*/!block)) return false; + } + store_.eraseConversation(conversationId); + return true; +} + std::string ChatService::identityPublicKeyHex() const { if (!has_identity_) return {}; return chatIdentityPublicKeyHex(identity_); diff --git a/src/chat/chat_service.h b/src/chat/chat_service.h index af9d5d9..b570560 100644 --- a/src/chat/chat_service.h +++ b/src/chat/chat_service.h @@ -11,6 +11,7 @@ #include "chat_store.h" #include +#include #include #include #include @@ -55,6 +56,19 @@ public: // the seed-derived key) into the in-memory store. No-op without an unlocked database. void loadFromDatabase(); + // Set a predicate returning true for a BLOCKED conversation id. ingest() drops those messages + // entirely (they are never stored or persisted) until the predicate stops returning true — this is + // how "delete & block" suppresses old and future messages. Typically wired to Settings::isChatBlocked. + void setBlockedPredicate(std::function pred) { blocked_pred_ = std::move(pred); } + + // Locally delete a conversation: remove its messages from the store and the database. When `block` + // is false, the removed messages are tombstoned so a chain re-scan won't re-import them, but a + // genuinely NEW message (new txid) revives the thread. When `block` is true, the rows are removed + // WITHOUT a tombstone (the caller also records the cid as blocked via the predicate above); unblocking + // later lets the conversation re-import from chain. Returns false (leaving the store untouched) if the + // persisted rows couldn't be removed, so the caller can avoid a store/DB divergence. + bool deleteConversation(const std::string& conversationId, bool block); + // --- Outgoing (compose) --- // My chat public key (hex), or "" without an identity — goes in an outgoing header's "p". std::string identityPublicKeyHex() const; @@ -90,6 +104,7 @@ private: bool has_identity_ = false; ChatStore store_; ChatDatabase* db_ = nullptr; // optional; not owned + std::function blocked_pred_; // true => cid is blocked (drop its messages) }; } // namespace dragonx::chat diff --git a/src/chat/chat_store.cpp b/src/chat/chat_store.cpp index 82cf76b..b493f90 100644 --- a/src/chat/chat_store.cpp +++ b/src/chat/chat_store.cpp @@ -13,6 +13,7 @@ std::string ChatStore::dedupKey(const ChatMessage& message) { bool ChatStore::append(const ChatMessage& message) { if (!seen_.insert(dedupKey(message)).second) return false; messages_.push_back(message); + ++revision_; return true; } @@ -38,12 +39,24 @@ const ChatMessage* ChatStore::updateDelivery(const std::string& txid, ChatDelive for (auto& message : messages_) { if (message.txid == txid) { message.delivery = delivery; + ++revision_; return &message; } } return nullptr; } +int ChatStore::countUnread(const std::function& excluded, + const std::function& seenFor) const { + int unread = 0; + for (const auto& m : messages_) { + if (m.direction != ChatDirection::Incoming) continue; + if (excluded && excluded(m.conversation_id)) continue; + if (m.timestamp > seenFor(m.conversation_id)) ++unread; + } + return unread; +} + std::vector ChatStore::conversationIds() const { std::vector ids; std::unordered_set seenIds; @@ -53,9 +66,27 @@ std::vector ChatStore::conversationIds() const { return ids; } +std::vector ChatStore::eraseConversation(const std::string& conversationId) { + std::vector removed; + std::vector kept; + kept.reserve(messages_.size()); + for (auto& m : messages_) { + if (m.conversation_id == conversationId) { + seen_.erase(dedupKey(m)); + removed.push_back(std::move(m)); + } else { + kept.push_back(std::move(m)); + } + } + messages_.swap(kept); + if (!removed.empty()) ++revision_; + return removed; +} + void ChatStore::clear() { messages_.clear(); seen_.clear(); + ++revision_; } } // namespace dragonx::chat diff --git a/src/chat/chat_store.h b/src/chat/chat_store.h index 3c923e8..4ab6bf2 100644 --- a/src/chat/chat_store.h +++ b/src/chat/chat_store.h @@ -5,6 +5,8 @@ #include "chat_message.h" +#include +#include #include #include #include @@ -38,15 +40,32 @@ public: return out; } + // Remove every message in a conversation from the in-memory view and return the removed messages + // (so the caller can delete/tombstone their persisted rows). Re-appends are prevented by the ingest + // guard (blocked-cid predicate / DB tombstone), not here. + std::vector eraseConversation(const std::string& conversationId); + std::size_t size() const { return messages_.size(); } bool empty() const { return messages_.empty(); } void clear(); + // Monotonic counter bumped on every mutation (append / updateDelivery change / eraseConversation / + // clear). Callers memoize expensive per-frame reads (conversation-list build, unread count) against it + // so they only rebuild when the store actually changed. + std::uint64_t revision() const { return revision_; } + + // Count unread incoming messages in a SINGLE pass over the store: an incoming message counts when its + // cid is not `excluded` and its timestamp is newer than `seenFor(cid)`. Avoids the per-conversation + // copy+sort that conversation() does — the count doesn't need ordering. + int countUnread(const std::function& excluded, + const std::function& seenFor) const; + private: static std::string dedupKey(const ChatMessage& message); std::vector messages_; std::unordered_set seen_; + std::uint64_t revision_ = 0; }; } // namespace dragonx::chat diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 852c4c5..2274f28 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -157,6 +157,13 @@ bool Settings::load(const std::string& path) for (const auto& c : j["hidden_chat_cids"]) if (c.is_string()) hidden_chat_cids_.push_back(c.get()); } + if (j.contains("blocked_chat_convs") && j["blocked_chat_convs"].is_array()) { + blocked_chat_convs_.clear(); + for (const auto& c : j["blocked_chat_convs"]) + if (c.is_object() && c.contains("cid") && c["cid"].is_string()) + blocked_chat_convs_.push_back({c["cid"].get(), + c.value("name", std::string())}); + } // Chat-tab customization (re-clamped through the setters so hand-edited JSON stays in range). loadScalar(j, "chat_emoji_color", chat_emoji_color_); loadScalar(j, "chat_poll_rate_sec", chat_poll_rate_sec_); setChatPollRateSec(chat_poll_rate_sec_); @@ -204,6 +211,7 @@ bool Settings::load(const std::string& path) loadScalar(j, "console_text_color", console_text_color_); loadScalar(j, "console_zoom", console_zoom_); if (!(console_zoom_ >= 0.25f && console_zoom_ <= 4.0f)) console_zoom_ = 1.0f; // guard bad/NaN + loadScalar(j, "console_auto_focus", console_auto_focus_); if (j.contains("hidden_addresses") && j["hidden_addresses"].is_array()) { hidden_addresses_.clear(); for (const auto& a : j["hidden_addresses"]) @@ -231,18 +239,28 @@ 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()); + } + 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_); loadScalar(j, "keep_daemon_running", keep_daemon_running_); loadScalar(j, "stop_external_daemon", stop_external_daemon_); loadScalar(j, "max_connections", max_connections_); + loadScalar(j, "stratum_host_enabled", stratum_host_enabled_); + loadScalar(j, "stratum_allowip", stratum_allowip_); if (j.contains("lite_wallet") && j["lite_wallet"].is_object()) { const auto& lite = j["lite_wallet"]; if (lite.contains("server_selection_mode")) { @@ -450,6 +468,13 @@ bool Settings::save(const std::string& path) j["hidden_chat_cids"] = json::array(); for (const auto& c : hidden_chat_cids_) j["hidden_chat_cids"].push_back(c); + j["blocked_chat_convs"] = json::array(); + for (const auto& b : blocked_chat_convs_) { + json o; + o["cid"] = b.cid; + o["name"] = b.name; + j["blocked_chat_convs"].push_back(o); + } j["chat_emoji_color"] = chat_emoji_color_; j["chat_poll_rate_sec"] = chat_poll_rate_sec_; j["chat_bubble_style"] = chat_bubble_style_; @@ -476,6 +501,7 @@ bool Settings::save(const std::string& path) j["console_line_accents"] = console_line_accents_; j["console_text_color"] = console_text_color_; j["console_zoom"] = console_zoom_; + j["console_auto_focus"] = console_auto_focus_; j["hidden_addresses"] = json::array(); for (const auto& addr : hidden_addresses_) j["hidden_addresses"].push_back(addr); @@ -497,18 +523,26 @@ 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_; j["keep_daemon_running"] = keep_daemon_running_; j["stop_external_daemon"] = stop_external_daemon_; j["max_connections"] = max_connections_; + j["stratum_host_enabled"] = stratum_host_enabled_; + j["stratum_allowip"] = stratum_allowip_; { json lite = json::object(); lite["server_selection_mode"] = liteServerSelectionPreferenceModeName(lite_server_selection_mode_); diff --git a/src/config/settings.h b/src/config/settings.h index 8cdd124..5b2b0a9 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -144,6 +144,26 @@ public: hidden_chat_cids_.end()); } + // Blocked chat conversations (by cid). Unlike hide (reversible, keeps messages), block DELETES the + // local history AND suppresses every message for the cid — old and future — until you unblock, at + // which point the conversation re-imports from the chain. The last-known peer name is kept so the + // "Blocked" list can label the entry (its messages are gone from the local store). + struct BlockedChatConv { std::string cid; std::string name; }; + bool isChatBlocked(const std::string& cid) const { + for (const auto& b : blocked_chat_convs_) if (b.cid == cid) return true; + return false; + } + void setChatBlocked(const std::string& cid, const std::string& name, bool blocked) { + const bool already = isChatBlocked(cid); + if (blocked && !already) blocked_chat_convs_.push_back({cid, name}); + else if (!blocked && already) + blocked_chat_convs_.erase( + std::remove_if(blocked_chat_convs_.begin(), blocked_chat_convs_.end(), + [&](const BlockedChatConv& b) { return b.cid == cid; }), + blocked_chat_convs_.end()); + } + const std::vector& blockedChatConversations() const { return blocked_chat_convs_; } + // ── Chat-tab customization (chat settings modal + Settings → Chat & Contacts) ────── bool getChatEmojiColor() const { return chat_emoji_color_; } void setChatEmojiColor(bool v) { chat_emoji_color_ = v; } @@ -256,6 +276,9 @@ public: void setConsoleTextColor(bool v) { console_text_color_ = v; } float getConsoleZoom() const { return console_zoom_; } void setConsoleZoom(float v) { console_zoom_ = v; } + // Auto-place the text cursor in the command box when the Console tab is opened. + bool getConsoleAutoFocus() const { return console_auto_focus_; } + void setConsoleAutoFocus(bool v) { console_auto_focus_ = v; } // Hidden addresses (addresses hidden from the UI by the user) const std::set& getHiddenAddresses() const { return hidden_addresses_; } @@ -327,6 +350,24 @@ public: bool getSeedBackupReminded() const { return seed_backup_reminded_; } void setSeedBackupReminded(bool v) { seed_backup_reminded_ = v; } + // One-time nudge when wallet.dat grows past the bloat threshold (re-armed if it shrinks back). + bool getLargeWalletWarned() const { return large_wallet_warned_; } + void setLargeWalletWarned(bool v) { large_wallet_warned_ = v; } + + // Wallet filenames for which the one-time "this wallet is empty but a sibling holds funds" + // warning has been dismissed. Keyed per active wallet file so switching to a different empty + // wallet can warn again (see App::maybeWarnEmptyWalletWithFundedSiblings). + bool isEmptyWalletWarnAcked(const std::string& walletFile) const { + return empty_wallet_warning_acked_.count(walletFile) > 0; + } + void ackEmptyWalletWarn(const std::string& walletFile) { empty_wallet_warning_acked_.insert(walletFile); } + + // Persisted the moment deferred (wizard) encryption is requested; cleared only once the wallet is + // observed to be actually encrypted. Lets a quit/crash/failed-connect before it applies be detected + // and surfaced (W2-2). NEVER stores the passphrase — only the fact that encryption was requested. + bool getEncryptionPending() const { return encryption_pending_; } + void setEncryptionPending(bool v) { encryption_pending_ = v; } + // Bundled-daemon size we last prompted to install (see App::renderDaemonUpdatePrompt). Lets the // "a newer node is bundled — update?" prompt fire once per wallet version, never re-nagging. long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; } @@ -350,6 +391,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_; } @@ -374,6 +420,11 @@ public: // Daemon — maximum peer connections (0 = daemon default) int getMaxConnections() const { return max_connections_; } void setMaxConnections(int v) { max_connections_ = std::max(0, v); } + // Host a RandomX stratum pool from the node (v1.3.0+ daemons). Empty allow-IP = loopback only (safe). + bool getStratumHost() const { return stratum_host_enabled_; } + void setStratumHost(bool v) { stratum_host_enabled_ = v; } + const std::string& getStratumAllowIp() const { return stratum_allowip_; } + void setStratumAllowIp(const std::string& v) { stratum_allowip_ = v; } // Lite wallet server selection LiteServerSelectionPreferenceMode getLiteServerSelectionMode() const { return lite_server_selection_mode_; } @@ -528,6 +579,7 @@ private: std::string chat_reply_zaddr_; std::vector muted_chat_cids_; // muted chat conversations by cid (Q10) std::vector hidden_chat_cids_; // hidden chat conversations by cid + std::vector blocked_chat_convs_; // blocked chat conversations (cid + last-known name) // Chat-tab customization (chat settings modal + Settings → Chat & Contacts). bool chat_emoji_color_ = true; // true = color (needs FreeType; falls back to mono if absent), false = monochrome float chat_poll_rate_sec_ = 2.5f; // 0-conf chat fast-scan cadence (full node) @@ -569,23 +621,30 @@ 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 hidden_addresses_; std::set favorite_addresses_; std::map address_meta_; bool wizard_completed_ = false; bool seed_backup_reminded_ = false; + bool large_wallet_warned_ = false; + std::set 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= 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; bool keep_daemon_running_ = false; bool stop_external_daemon_ = false; int max_connections_ = 0; // 0 = daemon default + bool stratum_host_enabled_ = false; // host a RandomX stratum pool from the node (v1.3.0+ daemons) + std::string stratum_allowip_; // -stratumallowip filter (empty = daemon default: loopback only) // Lite wallet server preferences. These are user/server settings only; // wallet secrets, wallet files, and lifecycle state are never stored here. diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index 6f27c48..305656d 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -26,6 +26,7 @@ void DaemonController::syncSettings(const config::Settings* settings) if (!settings) return; daemon_->setDebugCategories(settings->getDebugCategories()); daemon_->setMaxConnections(settings->getMaxConnections()); + daemon_->setStratumHosting(settings->getStratumHost(), settings->getStratumAllowIp()); std::string walletFile = settings->getActiveWalletFile(); // The Wallets dialog opens an out-of-datadir wallet by linking it into the datadir under a @@ -71,9 +72,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 +129,11 @@ void DaemonController::setSalvageOnNextStart(bool enabled) daemon_->setSalvageOnNextStart(enabled); } +void DaemonController::setReindexOnNextStart(bool enabled) +{ + daemon_->setReindexOnNextStart(enabled); +} + bool DaemonController::zapOnNextStart() const { return daemon_->zapOnNextStart(); diff --git a/src/daemon/daemon_controller.h b/src/daemon/daemon_controller.h index 5f2fe25..d55ebfd 100644 --- a/src/daemon/daemon_controller.h +++ b/src/daemon/daemon_controller.h @@ -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, diff --git a/src/daemon/daemon_startup_diagnosis.h b/src/daemon/daemon_startup_diagnosis.h new file mode 100644 index 0000000..325e2bb --- /dev/null +++ b/src/daemon/daemon_startup_diagnosis.h @@ -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 +#include +#include + +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..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 to wallet..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..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& 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>& 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 diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 2e67fd6..c05c4f6 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -205,11 +205,13 @@ std::vector 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 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 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..."); @@ -516,6 +545,15 @@ bool EmbeddedDaemon::start(const std::string& binary_path) args.push_back("-maxconnections=" + std::to_string(max_connections_)); } + // Host a RandomX stratum pool from this node (-stratum). Only v1.3.0+ daemons implement it; older + // ones ignore the unknown flag (no fatal arg check), and the Settings toggle is gated on daemon + // version, so this is only enabled against a daemon that supports it. Without -stratumallowip the + // daemon serves loopback only (safe default); a subnet opens it to that LAN. + if (stratum_enabled_) { + args.push_back("-stratum"); + if (!stratum_allowip_.empty()) args.push_back("-stratumallowip=" + stratum_allowip_); + } + // Active wallet file (multi-wallet). The daemon loads /. Only pass it for a // non-default name so the common case's command line is unchanged; skip during an isolated // start (seed migration manages its own throwaway wallet). @@ -543,6 +581,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 +603,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 +631,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& 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 +700,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 /dragonx/ to avoid conflicts with lock files and data. STARTUPINFOA si; @@ -642,7 +713,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 +721,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, @@ -745,11 +816,21 @@ void EmbeddedDaemon::drainOutput() } size_t currentSize = static_cast(fileSize.QuadPart); + // Truncation / rotation detection. dragonxd shrinks debug.log on startup (ShrinkDebugFile keeps + // only the tail once it has grown large), so the file can become SMALLER than where we left off. + // Our offset was set to the PRE-shrink size at spawn, so without this reset it stays stranded ahead + // of the freshly-truncated file and we read NOTHING for the whole session — no block height (status + // bar shows "Block: 0") and, worse, no witness-rebuild progress, since the "Setting Initial Sapling + // Witness …" lines the warmup progress bar parses only exist in debug.log on Windows. On a shrink, + // restart from the new beginning; the parser is monotonic and converges as it reaches current output. + if (currentSize < debug_log_offset_) { + debug_log_offset_ = 0; + } if (currentSize <= debug_log_offset_) { CloseHandle(hFile); return; // No new data } - + // Seek to where we left off LARGE_INTEGER seekPos; seekPos.QuadPart = static_cast(debug_log_offset_); @@ -962,18 +1043,38 @@ bool EmbeddedDaemon::startProcess(const std::string& binary_path, const std::vec last_error_ = "Failed to create pipe: " + std::string(strerror(errno)); 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 +1141,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(&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(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 +1275,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() diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index 4021cab..b2b8576 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -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 lk(error_mutex_); return last_error_; } /** * @brief Get dragonxd process output (thread-safe copy) @@ -180,6 +182,7 @@ public: * @brief Set maximum peer connections (0 = use daemon default) */ void setMaxConnections(int v) { max_connections_ = v; } + void setStratumHosting(bool enabled, const std::string& allowIp) { stratum_enabled_ = enabled; stratum_allowip_ = allowIp; } /** * @brief Request a blockchain rescan on the next daemon start @@ -206,6 +209,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 +245,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 +289,7 @@ private: std::atomic state_{State::Stopped}; std::atomic 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_; @@ -275,11 +312,14 @@ private: std::atomic should_stop_{false}; std::set debug_categories_; int max_connections_ = 0; // 0 = daemon default + bool stratum_enabled_ = false; // -stratum: host a RandomX pool (v1.3.0+; older daemons ignore it) + std::string stratum_allowip_; // -stratumallowip subnet (empty = daemon default: loopback only) std::string wallet_file_; // -wallet= for the active wallet; empty/"wallet.dat" = default std::atomic crash_count_{0}; // consecutive crash counter std::atomic rescan_on_next_start_{false}; // -rescan flag for next start std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start std::atomic salvage_on_next_start_{false}; // -salvagewallet flag for next start + std::atomic 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 override_extra_args_; // one-shot: extra args for the next start bool skip_port_check_ = false; // isolated instance on a non-default port diff --git a/src/daemon/seed_wallet_creator.cpp b/src/daemon/seed_wallet_creator.cpp index 8d3349b..504fbb9 100644 --- a/src/daemon/seed_wallet_creator.cpp +++ b/src/daemon/seed_wallet_creator.cpp @@ -54,6 +54,16 @@ SeedWalletResult SeedWalletCreator::create(bool keepDatadir, // RPC port. So the wallet lives in /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; } diff --git a/src/daemon/xmrig_manager.cpp b/src/daemon/xmrig_manager.cpp index 696b6d3..776896b 100644 --- a/src/daemon/xmrig_manager.cpp +++ b/src/daemon/xmrig_manager.cpp @@ -23,6 +23,7 @@ #include #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(userdata); - s->append(static_cast(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(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(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 lk(output_mutex_); process_output_.clear(); } - stats_ = PoolStats{}; + { + std::lock_guard 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 lk(g_installed_ver_mutex); g_installed_ver = ver; diff --git a/src/daemon/xmrig_manager.h b/src/daemon/xmrig_manager.h index 3935daf..54d1834 100644 --- a/src/daemon/xmrig_manager.h +++ b/src/daemon/xmrig_manager.h @@ -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 lk(stats_mutex_); return stats_; } + std::string getLastError() const { std::lock_guard 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 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::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_; diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp index ef2d2ea..0c36d4d 100644 --- a/src/data/address_book.cpp +++ b/src/data/address_book.cpp @@ -46,25 +46,31 @@ bool AddressBook::load() entries_.clear(); if (j.contains("entries") && j["entries"].is_array()) { + size_t skipped = 0; for (const auto& entry : j["entries"]) { - AddressBookEntry e; - e.label = entry.value("label", ""); - e.address = entry.value("address", ""); - e.notes = entry.value("notes", ""); - // Legacy entries (no "scope") migrate to "global" so nothing disappears when - // multi-wallet scoping lands — a contact you already had stays visible everywhere. - e.scope = entry.value("scope", "global"); - e.avatar = entry.value("avatar", ""); - - if (!e.address.empty()) { - entries_.push_back(e); - } + // W6-3: skip (and count) a malformed element rather than letting one bad entry throw and + // abort the whole load — which would discard EVERY contact (entries_ was already cleared). + if (!entry.is_object()) { ++skipped; continue; } + try { + AddressBookEntry e; + e.label = entry.value("label", ""); + e.address = entry.value("address", ""); + e.notes = entry.value("notes", ""); + // Legacy entries (no "scope") migrate to "global" so nothing disappears when + // multi-wallet scoping lands — a contact you already had stays visible everywhere. + e.scope = entry.value("scope", "global"); + e.avatar = entry.value("avatar", ""); + if (!e.address.empty()) entries_.push_back(e); + } catch (const std::exception&) { ++skipped; } } + if (skipped > 0) + DEBUG_LOGF("Address book: skipped %zu malformed entr%s\n", skipped, skipped == 1 ? "y" : "ies"); } DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size()); + ++revision_; return true; - + } catch (const std::exception& e) { DEBUG_LOGF("Error loading address book: %s\n", e.what()); return false; @@ -116,6 +122,7 @@ bool AddressBook::addEntry(const AddressBookEntry& entry) } entries_.push_back(entry); + ++revision_; return save(); } @@ -131,6 +138,7 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry) } entries_[index] = entry; + ++revision_; return save(); } @@ -141,6 +149,7 @@ bool AddressBook::removeEntry(size_t index) } entries_.erase(entries_.begin() + index); + ++revision_; return save(); } @@ -154,7 +163,7 @@ int AddressBook::reattachLegacyScopes(const std::string& scopeId) e.scope = scopeId; ++rescoped; } - if (rescoped > 0) save(); + if (rescoped > 0) { ++revision_; save(); } return rescoped; } diff --git a/src/data/address_book.h b/src/data/address_book.h index 1c4ae02..5c2127b 100644 --- a/src/data/address_book.h +++ b/src/data/address_book.h @@ -4,6 +4,7 @@ #pragma once +#include #include #include @@ -121,11 +122,18 @@ public: */ size_t size() const { return entries_.size(); } + /** + * @brief Monotonic counter bumped on every content change (add/update/remove/load/sweep). Consumers + * (e.g. the chat conversation-list memo) key their caches off this so an IN-PLACE edit — a rename or + * address change that keeps size() constant — still invalidates them. + */ + std::uint64_t revision() const { return revision_; } + /** * @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can * seed demo contacts and restore the real book without a disk write. Do not use outside the sweep. */ - void sweepSetEntries(std::vector e) { entries_ = std::move(e); } + void sweepSetEntries(std::vector e) { entries_ = std::move(e); ++revision_; } /** * @brief Check if empty @@ -134,6 +142,7 @@ public: private: std::vector entries_; + std::uint64_t revision_ = 0; std::string file_path_; }; diff --git a/src/data/seed_migration_resume.h b/src/data/seed_migration_resume.h new file mode 100644 index 0000000..fe138d9 --- /dev/null +++ b/src/data/seed_migration_resume.h @@ -0,0 +1,37 @@ +#pragma once + +#include + +// 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 diff --git a/src/data/wallet_state.cpp b/src/data/wallet_state.cpp index 880ec6b..2767c01 100644 --- a/src/data/wallet_state.cpp +++ b/src/data/wallet_state.cpp @@ -19,12 +19,13 @@ std::vector sortedSpendableAddressIndices(const std::vector 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& 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(i); } } diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 1fefe45..dc0a00f 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -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 diff --git a/src/main.cpp b/src/main.cpp index 31a404a..7143d2d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -721,20 +721,105 @@ static void handleDisplayScaleChange(SDL_Window* window, float newScale, } } +#if !defined(_WIN32) +#include +#include +#include +#include +#if defined(__has_include) +# if __has_include() +# include +# define DRAGONX_HAVE_BACKTRACE 1 +# endif +#endif + +// Absolute path to the crash log, filled at install time so the async-signal handler needs no +// allocation. (POSIX counterpart of the Windows SEH CrashHandler above — W7-3.) +static char g_crashLogPath[1024] = {0}; + +// Async-signal-safe crash handler: only open()/write()/backtrace_symbols_fd()/raise() are used — +// no stdio, std::filesystem or malloc (all unsafe inside a signal handler). +static void PosixCrashHandler(int sig) +{ + int fd = g_crashLogPath[0] ? open(g_crashLogPath, O_WRONLY | O_CREAT | O_APPEND, 0600) : -1; + if (fd >= 0) { + auto put = [fd](const char* s) { ssize_t n = write(fd, s, std::strlen(s)); (void)n; }; + put("\n=== CRASH: signal "); + char num[16]; int i = 0, v = sig; // signal number -> decimal, no stdio + if (v == 0) { num[i++] = '0'; } + else { char tmp[16]; int t = 0; while (v > 0) { tmp[t++] = char('0' + v % 10); v /= 10; } + while (t > 0) num[i++] = tmp[--t]; } + num[i] = '\n'; + ssize_t nn = write(fd, num, i + 1); (void)nn; +#ifdef DRAGONX_HAVE_BACKTRACE + void* frames[64]; + int nframes = backtrace(frames, 64); + backtrace_symbols_fd(frames, nframes, fd); // async-signal-safe +#endif + put("=== END CRASH ===\n"); + close(fd); + } + // Restore the default disposition and re-raise so we still get a core dump / normal termination. + signal(sig, SIG_DFL); + raise(sig); +} + +static void installPosixCrashHandler(const std::string& crashLogPath) +{ + std::snprintf(g_crashLogPath, sizeof(g_crashLogPath), "%s", crashLogPath.c_str()); + struct sigaction sa; + std::memset(&sa, 0, sizeof(sa)); + sa.sa_handler = PosixCrashHandler; + sigemptyset(&sa.sa_mask); + sa.sa_flags = 0; + for (int sig : {SIGSEGV, SIGABRT, SIGBUS, SIGFPE, SIGILL}) { + sigaction(sig, &sa, nullptr); + } +} +#endif // !_WIN32 + int main(int argc, char* argv[]) { // Ensure ObsidianDragon config directory exists early (before any file I/O) { std::string odDir = dragonx::util::Platform::getObsidianDragonDir(); - std::error_code ec; - std::filesystem::create_directories(odDir, ec); + std::string odErr; + if (!dragonx::util::Platform::ensureDirectory(odDir, &odErr)) { + // Pre-App-init: nothing (ini, logs, config) can persist if this fails, and the + // Windows log redirect below isn't set up yet — report loudly before any setup. + std::fprintf(stderr, "%s\n", odErr.c_str()); +#ifdef _WIN32 + MessageBoxA(nullptr, odErr.c_str(), DRAGONX_APP_NAME, MB_OK | MB_ICONERROR); +#endif + return 1; + } } -#ifdef _WIN32 - // Redirect stdout/stderr to a log file so diagnostic output is visible - // even when built as a GUI app (WIN32_EXECUTABLE hides the console). + // W7-2: initialize the app-level Logger's file sink on ALL platforms so LOG/LOGF/VERBOSE_LOGF are + // actually persisted to dragonx-debug.log. Previously init() was never called, so on Linux/macOS the + // file never existed at all (the Windows-only stdout freopen below is a separate mechanism). { - std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string(); + const std::string logPath = + (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-debug.log").string(); + dragonx::util::Logger::instance().init(logPath); + } + +#if !defined(_WIN32) + // W7-3: install the POSIX crash handler (the Windows SEH filter is installed below). A segfault or + // abort now leaves a backtrace in dragonx-crash.log instead of vanishing silently on Linux/macOS. + { + const std::string crashPath = + (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-crash.log").string(); + installPosixCrashHandler(crashPath); + } +#endif + +#ifdef _WIN32 + // Redirect raw stdout/stderr (library / daemon-pipe writes) to a log file so it's visible even when + // built as a GUI app (WIN32_EXECUTABLE hides the console). Separate file from the structured Logger + // above so the two writers don't interleave/contend on one file. + { + std::string logPath = (std::filesystem::path(dragonx::util::Platform::getObsidianDragonDir()) / "dragonx-stdout.log").string(); freopen(logPath.c_str(), "w", stdout); freopen(logPath.c_str(), "a", stderr); } @@ -1178,6 +1263,36 @@ int main(int argc, char* argv[]) SDL_SetWindowMinimumSize(window, (int)(1024 * currentDpiScale), (int)(720 * currentDpiScale)); } + // DEV/TEST hook (dormant unless the env is set): DRAGONX_WIN_GEOM="WxH" forces an exact window + // size, placing it on the largest display that can hold it and bypassing the primary-monitor + // clamp. Lets a headless WSLg sweep render at sizes wider than the 1280 primary (e.g. 2560x1440 + // on the mirrored 4K/1440p outputs). Needs the x11 backend so absolute positioning takes effect. + int wantW = 0, wantH = 0; + if (const char* geom = std::getenv("DRAGONX_WIN_GEOM")) + sscanf(geom, "%dx%d", &wantW, &wantH); + if (wantW > 0 && wantH > 0) { + int count = 0; + SDL_DisplayID* disp = SDL_GetDisplays(&count); + SDL_DisplayID best = 0; SDL_Rect bestUsable{0, 0, 0, 0}; + for (int i = 0; i < count; ++i) { + SDL_Rect u; + if (!SDL_GetDisplayUsableBounds(disp[i], &u)) continue; + bool fits = (u.w >= wantW && u.h >= wantH); + bool bestFits = (bestUsable.w >= wantW && bestUsable.h >= wantH); + // Prefer a display that fits; among those, the smallest; else the largest available. + if ((fits && !bestFits) || + (fits && bestFits && (long)u.w * u.h < (long)bestUsable.w * bestUsable.h) || + (!fits && !bestFits && (long)u.w * u.h > (long)bestUsable.w * bestUsable.h)) { + bestUsable = u; best = disp[i]; + } + } + if (disp) SDL_free(disp); + if (best) { + SDL_SetWindowMinimumSize(window, 320, 240); + SDL_SetWindowPosition(window, bestUsable.x + 10, bestUsable.y + 10); + SDL_SetWindowSize(window, wantW, wantH); + } + } else { // Clamp to the current display's work area — runs on EVERY startup (this clamp used to live // inside the HiDPI branch, so a size saved on a larger/disconnected monitor could open the // window off-screen or bigger than the screen on a same-DPI cold start). @@ -1196,6 +1311,7 @@ int main(int argc, char* argv[]) DEBUG_LOGF("Startup: window fitted %dx%d -> %dx%d (scale %.2f)\n", curW, curH, newW, newH, currentDpiScale); } + } } #endif winlog("STARTUP savedSize=%dx%d currentDpiScale=%.3f", savedWinW, savedWinH, currentDpiScale); @@ -1384,6 +1500,7 @@ int main(int argc, char* argv[]) // WINDOW_RESIZED events during the transition can't corrupt savedSizeForScale / lastKnownW/H. int dpiSettleFrames = 0; SDL_DisplayID lastLoggedDisplay = 0; // [WINLOG] throttle: log MOVED only when the display changes + Uint64 minimizedLastTickMs = 0; // real-clock tick for the minimized "keep syncing" update { float s = dragonx::ui::material::Typography::instance().getDpiScale(); int w = 0, h = 0; @@ -1467,6 +1584,7 @@ int main(int argc, char* argv[]) // Window restored from minimized — trigger immediate data refresh if (waitEvent.type == SDL_EVENT_WINDOW_RESTORED && waitEvent.window.windowID == SDL_GetWindowID(window)) { + app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast) app.refreshNow(); } // Handle DPI change that arrived while idle (same logic as poll loop) @@ -1596,6 +1714,7 @@ int main(int argc, char* argv[]) // Window restored from minimized — trigger immediate data refresh if (event.type == SDL_EVENT_WINDOW_RESTORED && event.window.windowID == SDL_GetWindowID(window)) { + app.skipDaemonOutputBacklog(); // drop the minimized-period backlog (avoids a spurious "rescan complete" toast) app.refreshNow(); } // Handle DPI/display scale changes (e.g. window dragged to a @@ -1616,13 +1735,28 @@ int main(int argc, char* argv[]) // Check if window is minimized if (SDL_GetWindowFlags(window) & SDL_WINDOW_MINIMIZED) { - // Still check shouldQuit while minimized to avoid hang - if (app.shouldQuit()) { - running = false; - } - SDL_Delay(10); + // Keep the wallet syncing while minimized: run the logic update (drains RPC results, ticks the + // refresh scheduler, keeps the daemon connection/reconnect + sync status live) but skip the + // ImGui frame + GPU present since nothing is visible. app.update() only reads + // GetIO()/GetTime()/IsAnyItemActive() — all valid outside a frame — so it's safe without a + // NewFrame; feed it a real-clock DeltaTime (NewFrame, which normally sets it, is skipped) and + // let app.update() clamp it. Throttled to ~5 Hz so CPU stays near-idle (refresh cadences are + // seconds-scale). shouldQuit is still checked so a quit request never hangs behind minimize. + Uint64 nowMs = SDL_GetTicks(); + float minDelta = (minimizedLastTickMs == 0) ? 0.001f + : (float)(nowMs - minimizedLastTickMs) / 1000.0f; + minimizedLastTickMs = nowMs; + ImGui::GetIO().DeltaTime = (minDelta > 0.0f) ? minDelta : 0.001f; + try { + app.update(); + } catch (const std::exception& e) { + DEBUG_LOGF("[Main] minimized app.update() threw: %s\n", e.what()); + } catch (...) {} + if (app.shouldQuit()) running = false; + SDL_Delay(200); continue; } + minimizedLastTickMs = 0; // visible again — reset the minimized clock // --- PerfLog: begin frame --- dragonx::util::PerfLog::instance().beginFrame(); @@ -2042,6 +2176,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); diff --git a/src/resources/embedded_resources.cpp b/src/resources/embedded_resources.cpp index f1f2c92..e144eb9 100644 --- a/src/resources/embedded_resources.cpp +++ b/src/resources/embedded_resources.cpp @@ -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 diff --git a/src/resources/embedded_resources.h b/src/resources/embedded_resources.h index a45b13d..ae8f0a7 100644 --- a/src/resources/embedded_resources.h +++ b/src/resources/embedded_resources.h @@ -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"; diff --git a/src/rpc/connection.cpp b/src/rpc/connection.cpp index 276cbaa..9c8bfc1 100644 --- a/src/rpc/connection.cpp +++ b/src/rpc/connection.cpp @@ -14,8 +14,12 @@ #include #include #include +#include +#include #include "../util/logger.h" +#include "../util/platform.h" +#include "../util/xmrig_updater.h" // util::sha256Hex #ifdef _WIN32 #include @@ -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 +} + +// ":" 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(mtime.time_since_epoch()).count(); + return std::to_string(static_cast(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 buf(static_cast(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: /.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& 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 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& 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 kSaplingParamDigests[] = { + { "sapling-spend.params", "8e48ffd23abb3a5fd9c5589204f32d9c31285a04b78096ba40a79b75677efc13" }, + { "sapling-output.params", "2f0ebbcbb9bb0bcffe95a397e7eba89c29eb4dde6191c339db88570e3f3fb0e4" }, +}; + +} // namespace + +bool Connection::verifySaplingParamsIn( + const std::string& dir, + const std::vector>& 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 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 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> 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(); diff --git a/src/rpc/connection.h b/src/rpc/connection.h index 2ee4575..fc0d5f3 100644 --- a/src/rpc/connection.h +++ b/src/rpc/connection.h @@ -5,6 +5,8 @@ #pragma once #include +#include +#include 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>& 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: diff --git a/src/rpc/rpc_client.cpp b/src/rpc/rpc_client.cpp index a1e8c04..72be115 100644 --- a/src/rpc/rpc_client.cpp +++ b/src/rpc/rpc_client.cpp @@ -146,7 +146,11 @@ RPCClient::RPCClient() : impl_(std::make_unique()) { } -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) diff --git a/src/services/network_refresh_service.cpp b/src/services/network_refresh_service.cpp index fefc08e..2d79484 100644 --- a/src/services/network_refresh_service.cpp +++ b/src/services/network_refresh_service.cpp @@ -3,6 +3,7 @@ #include #include +#include #include #include #include @@ -37,16 +38,27 @@ void applyBalancesFromUnspent(std::vector& addresses, const json& u { if (!unspent.is_array()) return; - std::map 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 total; + std::map spendable; for (const auto& output : unspent) { auto address = readOptional(output, "address"); - auto amount = readOptional(output, "amount"); - if (address && amount) balances[*address] += *amount; + auto amount = readOptional(output, "amount"); + if (!address || !amount) continue; + total[*address] += *amount; + auto conf = readOptional(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,19 +291,17 @@ 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()); - balanceOk = true; - } catch (const std::exception& e) { - DEBUG_LOGF("Balance error: %s\n", e.what()); - } - } - + // getblockchaininfo FIRST — it's cheap, and the sync state it returns gates everything else + // (the balance/address/tx throttles and kSyncProfile). Running it BEFORE the O(mapWallet) balance + // scan keeps sync detection from being delayed behind (or, under contention, starved by) that + // scan — the failure mode where the wallet kept reading "synced" while actually falling behind, + // so kSyncProfile never engaged. See effectivelySyncing()'s sticky-behind latch. try { blockInfo = rpc.call("getblockchaininfo", json::array()); blockOk = true; @@ -294,7 +309,39 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre DEBUG_LOGF("BlockchainInfo error: %s\n", e.what()); } - return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk); + // If getblockchaininfo shows we're behind (same 2-block tolerance as applyCoreRefreshResult), skip + // the balance scan this cycle regardless of includeBalance: the balance is incomplete mid-sync + // anyway, and skipping it lets the sync-state update — and hence kSyncProfile — take effect at the + // end of THIS (now-cheap) task instead of being delayed ~20s behind the scan. This is what lets the + // sticky-behind latch engage promptly the first time the node falls behind. + bool behind = false; + if (blockOk && blockInfo.is_object()) { + const long long b = blockInfo.value("blocks", 0LL); + const long long lc = blockInfo.value("longestchain", 0LL); + if (lc > 0 && b < lc - 2) behind = true; + } + + if (includeBalance && !behind) { + // z_gettotalbalance is O(mapWallet) and holds the daemon's cs_main for its whole duration — + // seconds on a large shielded wallet. Time it so the caller can throttle how often it polls + // (balanceRefreshDue()), keeping balance scans from starving block connection. + const auto balanceStart = std::chrono::steady_clock::now(); + try { // DISPLAY total: minconf=0 — includes the user's own pending change so it doesn't crater + totalBalance = rpc.call("z_gettotalbalance", json::array({0})); + balanceOk = true; + } catch (const std::exception& e) { + DEBUG_LOGF("Balance error: %s\n", e.what()); + } + try { // SPENDABLE total: minconf=1 (confirmed). If absent, spendable degrades to display in apply. + spendableBalance = rpc.call("z_gettotalbalance", json::array({1})); + } catch (...) {} + balanceScanMs = std::chrono::duration( + std::chrono::steady_clock::now() - balanceStart).count(); + } + + auto result = parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk); + result.balanceScanMs = balanceScanMs; + return result; } NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult( @@ -426,12 +473,21 @@ std::optional 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() : def; + }; PriceRefreshResult result; - result.market.price_usd = data.value("usd", 0.0); - result.market.price_btc = data.value("btc", 0.0); - result.market.change_24h = data.value("usd_24h_change", 0.0); - result.market.volume_24h = data.value("usd_24h_vol", 0.0); - result.market.market_cap = data.value("usd_market_cap", 0.0); + result.market.price_usd = num("usd", 0.0); + result.market.price_btc = num("btc", 0.0); + result.market.change_24h = num("usd_24h_change", 0.0); + result.market.volume_24h = num("usd_24h_vol", 0.0); + result.market.market_cap = num("usd_market_cap", 0.0); char buf[64]; // Runs on the RPC worker thread — std::localtime shares a process-wide static tm, so use the @@ -564,6 +620,10 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres const AddressRefreshSnapshot& snapshot) { AddressRefreshResult result; + // Time the whole scan — z_listunspent (and per-address z_getbalance fallback) hold the daemon's + // cs_main for the duration, seconds on a large shielded wallet. The measured cost feeds the + // caller's adaptive throttle so the address poll can't starve block connection. + const auto scanStart = std::chrono::steady_clock::now(); try { json zList = rpc.call("z_listaddresses", json::array()); @@ -599,18 +659,39 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres } } catch (const std::exception& e) { DEBUG_LOGF("z_listaddresses error: %s\n", e.what()); + result.addressListOk = false; // enumeration failed → the shielded list may be falsely short } try { - json unspent = rpc.call("z_listunspent", json::array()); + json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent); + // Retain a minimal view so a downstream consumer (chat note-budget) can reuse this scan instead + // of issuing its own z_listunspent. rawconfirmations is the TRUE depth; `confirmations` is + // dPoW-clamped to 1 and understates it. + if (unspent.is_array()) { + result.unspentNotes.reserve(unspent.size()); + for (const auto& nz : unspent) { + if (!nz.is_object()) continue; + UnspentNoteLite lite; + lite.amount = nz.value("amount", 0.0); + lite.locked = nz.value("locked", false); + lite.confirmations = (nz.contains("rawconfirmations") && nz["rawconfirmations"].is_number_integer()) + ? nz["rawconfirmations"].get() + : nz.value("confirmations", 0); + result.unspentNotes.push_back(lite); + } + } } catch (const std::exception& e) { DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what()); for (auto& info : result.shieldedAddresses) { - try { - json balance = rpc.call("z_getbalance", json::array({info.address})); - if (!balance.is_null()) info.balance = balance.get(); + 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(); } 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() : info.balance; + } catch (...) { info.spendableBalance = info.balance; } } } @@ -619,15 +700,18 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres result.transparentAddresses = parseTransparentAddressList(tList); } catch (const std::exception& e) { DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what()); + result.addressListOk = false; // enumeration failed → the transparent list may be falsely short } try { - json unspent = rpc.call("listunspent", json::array()); + json unspent = rpc.call("listunspent", json::array({0})); // minconf=0 → include 0-conf change applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent); } catch (const std::exception& e) { DEBUG_LOGF("listunspent error: %s\n", e.what()); } + result.scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); return result; } @@ -819,6 +903,10 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr result.blockHeight = currentBlockHeight; result.shieldedAddressCount = snapshot.shieldedAddresses.size(); result.shieldedScanHeights = snapshot.shieldedScanHeights; + // Time the whole scan — the per-address z_listreceivedbyaddress pass is O(mapWallet) and holds the + // daemon's cs_main. The measured cost feeds the caller's adaptive throttle (txRefreshDue()) so the + // routine full history rescan can't starve block connection on a large wallet. + const auto scanStart = std::chrono::steady_clock::now(); std::set knownTxids; HushChatMemoOutputMap hushChatReceivedOutputs; @@ -999,6 +1087,8 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTr } sortTransactionsNewestFirst(result.transactions); + result.scanMs = std::chrono::duration( + std::chrono::steady_clock::now() - scanStart).count(); return result; } @@ -1018,6 +1108,22 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe ? &hushChatReceivedOutputs : nullptr; + // Index result.transactions by (txid, type) once so the two replace-loops below + // can find-and-replace in O(1) instead of a nested linear scan over the full + // (potentially thousands-large) tx list on every 'recent' refresh cycle. The map + // holds the index of the FIRST occurrence of each key (preserving the linear + // scan's break-on-first-match), and is kept in sync on every append so a later + // entry in the same cycle still finds an earlier appended one — exactly as the + // re-scanned vector did before. + auto txKey = [](const TransactionInfo& tx) { + return tx.txid + '\x1f' + tx.type; + }; + std::unordered_map byKey; + byKey.reserve(result.transactions.size()); + for (std::size_t i = 0; i < result.transactions.size(); ++i) { + byKey.emplace(txKey(result.transactions[i]), i); // keep first-occurrence index + } + try { std::set recentTxids; std::vector recentTransactions; @@ -1025,15 +1131,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe appendTransparentTransactions(recentTransactions, recentTxids, transactions, snapshot.miningAddresses); for (auto& recent : recentTransactions) { - bool replaced = false; - for (auto& existing : result.transactions) { - if (existing.txid == recent.txid && existing.type == recent.type) { - existing = recent; - replaced = true; - break; - } + auto it = byKey.find(txKey(recent)); + if (it != byKey.end()) { + result.transactions[it->second] = recent; + } else { + byKey.emplace(txKey(recent), result.transactions.size()); + result.transactions.push_back(std::move(recent)); } - if (!replaced) result.transactions.push_back(std::move(recent)); } } catch (const std::exception& e) { DEBUG_LOGF("recent listtransactions error: %s\n", e.what()); @@ -1059,15 +1163,13 @@ NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectRe snapshot.miningAddresses, hushChatReceivedOutputsPtr); for (auto& scanned : scannedTransactions) { - bool replaced = false; - for (auto& existing : result.transactions) { - if (existing.txid == scanned.txid && existing.type == scanned.type) { - existing = scanned; - replaced = true; - break; - } + auto it = byKey.find(txKey(scanned)); + if (it != byKey.end()) { + result.transactions[it->second] = scanned; + } else { + byKey.emplace(txKey(scanned), result.transactions.size()); + result.transactions.push_back(std::move(scanned)); } - if (!replaced) result.transactions.push_back(std::move(scanned)); } if (currentBlockHeight >= 0) result.shieldedScanHeights[address] = currentBlockHeight; ++result.shieldedAddressesScanned; @@ -1101,12 +1203,16 @@ NetworkRefreshService::OperationStatusPollResult NetworkRefreshService::parseOpe std::set 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(); 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(); if (status == "success") { parsed.doneOpids.push_back(opid); parsed.anySuccess = true; @@ -1184,6 +1290,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; } diff --git a/src/services/network_refresh_service.h b/src/services/network_refresh_service.h index efdf024..426463e 100644 --- a/src/services/network_refresh_service.h +++ b/src/services/network_refresh_service.h @@ -98,9 +98,12 @@ public: struct CoreRefreshResult { bool balanceOk = false; - std::optional shieldedBalance; + std::optional shieldedBalance; // display (minconf=0, incl. pending change) std::optional transparentBalance; std::optional totalBalance; + std::optional spendableShieldedBalance; // confirmed (minconf=1) + std::optional spendableTransparentBalance; + std::optional spendableTotalBalance; bool blockchainOk = false; std::optional blocks; std::optional headers; @@ -108,6 +111,7 @@ public: std::optional verificationProgress; std::optional longestChain; std::optional notarized; + double balanceScanMs = 0.0; // wall-clock spent in z_gettotalbalance this refresh (0 if balance skipped) }; struct MiningRefreshResult { @@ -143,9 +147,27 @@ public: std::string errorMessage; }; + // Minimal per-note view of a z_listunspent entry — just what a downstream consumer needs to derive + // spendable-note budgets without re-scanning. Kept UI/feature-agnostic (no chat specifics here). + struct UnspentNoteLite { + double amount = 0.0; // note value, DRGX + bool locked = false; // tied up by an in-flight send + int confirmations = 0; // TRUE depth (rawconfirmations when present, else confirmations) + }; + struct AddressRefreshResult { std::vector shieldedAddresses; std::vector transparentAddresses; + // False if either address-enumeration RPC (z_listaddresses / getaddressesbyaccount) threw, so the + // lists may be falsely short. Consumers that treat an empty list as authoritative (e.g. the + // empty-wallet warning) must not trust a 0 count unless this is true. + bool addressListOk = true; + // Wall-clock spent in the address scan (dominated by z_listunspent — O(mapWallet), holds the + // daemon's cs_main). Lets the caller throttle how often it polls (addressRefreshDue()). + double scanMs = 0.0; + // The wallet's unspent notes from this same z_listunspent scan, so a consumer (e.g. the chat + // note-budget) can be fed for free instead of running its own duplicate z_listunspent. + std::vector unspentNotes; }; struct AddressRefreshSnapshot { @@ -197,6 +219,9 @@ public: std::size_t shieldedAddressCount = 0; std::unordered_map shieldedScanHeights; bool shieldedScanComplete = true; + // Wall-clock spent in the history scan (z_listreceivedbyaddress — O(mapWallet), holds cs_main). + // Lets the caller throttle the routine full rescan by its measured cost (txRefreshDue()). + double scanMs = 0.0; }; struct OperationStatusPollResult { @@ -227,6 +252,7 @@ public: RefreshRpcGateway& rpc, const std::optional& prefetchedInfo = std::nullopt); static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance, + const nlohmann::json& spendableBalance, bool balanceOk, const nlohmann::json& blockInfo, bool blockOk); diff --git a/src/services/wallet_security_controller.cpp b/src/services/wallet_security_controller.cpp index cf07b1f..29e30ad 100644 --- a/src/services/wallet_security_controller.cpp +++ b/src/services/wallet_security_controller.cpp @@ -1,9 +1,12 @@ #include "wallet_security_controller.h" #include "../util/secure_vault.h" +#include "../util/address_validation.h" #include +#include #include #include +#include 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 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; } diff --git a/src/services/wallet_security_controller.h b/src/services/wallet_security_controller.h index 239c3ba..da7cf14 100644 --- a/src/services/wallet_security_controller.h +++ b/src/services/wallet_security_controller.h @@ -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); diff --git a/src/ui/effects/theme_effects.cpp b/src/ui/effects/theme_effects.cpp index d799060..104faf4 100644 --- a/src/ui/effects/theme_effects.cpp +++ b/src/ui/effects/theme_effects.cpp @@ -5,6 +5,7 @@ #include "theme_effects.h" #include "low_spec.h" #include "../schema/ui_schema.h" +#include "../layout.h" #include #include #include @@ -58,6 +59,7 @@ void ThemeEffects::beginFrame() { void ThemeEffects::loadFromTheme() { auto& S = schema::UI(); + const float dp = Layout::dpiScale(); auto eff = [&](const char* name) { return S.drawElement("effects", name); }; @@ -98,7 +100,7 @@ void ThemeEffects::loadFromTheme() { // ---- Shimmer ---- shimmer_.enabled = eff("shimmer-enabled").sizeOr(0.0f) > 0.5f; shimmer_.speed = eff("shimmer-speed").sizeOr(0.12f); - shimmer_.width = eff("shimmer-width").sizeOr(80.0f); + shimmer_.width = eff("shimmer-width").sizeOr(80.0f) * dp; shimmer_.alpha = eff("shimmer-alpha").sizeOr(0.06f); shimmer_.angle = eff("shimmer-angle").sizeOr(30.0f); // Shimmer color: read from the schema's color resolver @@ -124,7 +126,7 @@ void ThemeEffects::loadFromTheme() { glow_pulse_.speed = eff("glow-pulse-speed").sizeOr(2.0f); glow_pulse_.minAlpha = eff("glow-pulse-min-alpha").sizeOr(0.0f); glow_pulse_.maxAlpha = eff("glow-pulse-max-alpha").sizeOr(0.15f); - glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f); + glow_pulse_.radius = eff("glow-pulse-radius").sizeOr(4.0f) * dp; auto glowColorElem = eff("glow-pulse-color"); if (!glowColorElem.color.empty()) { glow_pulse_.color = S.resolveColor(glowColorElem.color, IM_COL32(255, 218, 0, 255)); @@ -136,7 +138,7 @@ void ThemeEffects::loadFromTheme() { edge_trace_.enabled = eff("edge-trace-enabled").sizeOr(0.0f) > 0.5f; edge_trace_.speed = eff("edge-trace-speed").sizeOr(0.3f); edge_trace_.length = eff("edge-trace-length").sizeOr(0.20f); - edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f); + edge_trace_.thickness = eff("edge-trace-thickness").sizeOr(1.5f) * dp; edge_trace_.alpha = eff("edge-trace-alpha").sizeOr(0.6f); auto edgeColorElem = eff("edge-trace-color"); if (!edgeColorElem.color.empty()) { @@ -149,7 +151,7 @@ void ThemeEffects::loadFromTheme() { ember_rise_.enabled = eff("ember-rise-enabled").sizeOr(0.0f) > 0.5f; ember_rise_.count = (int)eff("ember-rise-count").sizeOr(8.0f); ember_rise_.speed = eff("ember-rise-speed").sizeOr(0.4f); - ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f); + ember_rise_.particleSize = eff("ember-rise-particle-size").sizeOr(1.5f) * dp; ember_rise_.alpha = eff("ember-rise-alpha").sizeOr(0.5f); auto emberColorElem = eff("ember-rise-color"); if (!emberColorElem.color.empty()) { @@ -165,7 +167,7 @@ void ThemeEffects::loadFromTheme() { // accent (e.g. Obsidian) are unaffected; Jade turns it on as its hero. gradient_border_.panels = eff("gradient-border-panels").sizeOr(0.0f) > 0.5f; gradient_border_.speed = eff("gradient-border-speed").sizeOr(0.15f); - gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f); + gradient_border_.thickness = eff("gradient-border-thickness").sizeOr(1.5f) * dp; gradient_border_.alpha = eff("gradient-border-alpha").sizeOr(0.6f); auto gbColorA = eff("gradient-border-color-a"); if (!gbColorA.color.empty()) { @@ -194,7 +196,7 @@ void ThemeEffects::loadFromTheme() { sandstorm_.count = (int)eff("sandstorm-count").sizeOr(80.0f); sandstorm_.speed = eff("sandstorm-speed").sizeOr(0.35f); sandstorm_.windAngle = eff("sandstorm-wind-angle").sizeOr(15.0f); - sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f); + sandstorm_.particleSize = eff("sandstorm-particle-size").sizeOr(1.5f) * dp; sandstorm_.alpha = eff("sandstorm-alpha").sizeOr(0.35f); sandstorm_.gustSpeed = eff("sandstorm-gust-speed").sizeOr(0.07f); sandstorm_.gustStrength = eff("sandstorm-gust-strength").sizeOr(0.4f); @@ -694,6 +696,7 @@ void ThemeEffects::drawEdgeTrace(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax, void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const { if (!enabled_ || !ember_rise_.enabled) return; + const float dp = Layout::dpiScale(); float w = pMax.x - pMin.x; float h = pMax.y - pMin.y; if (w <= 0 || h <= 0) return; @@ -707,7 +710,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const // Deterministic pseudo-random x position per particle // Simple hash: sin of large prime multiples float xHash = std::sin((float)(i + 1) * 127.1f) * 0.5f + 0.5f; - float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f; // gentle sway + float xDrift = std::sin(time_ * 0.7f + i * 2.4f) * 4.0f * dp; // gentle sway float x = pMin.x + w * xHash + xDrift; float y = pMax.y - phase * (h + 8.0f); // rise from bottom past top @@ -745,6 +748,7 @@ void ThemeEffects::drawEmberRise(ImDrawList* dl, ImVec2 pMin, ImVec2 pMax) const void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const { if (!enabled_ || !ember_rise_.enabled) return; + const float dp = Layout::dpiScale(); ImGuiViewport* vp = ImGui::GetMainViewport(); float vpW = vp->WorkSize.x; float vpH = vp->WorkSize.y; @@ -765,7 +769,7 @@ void ThemeEffects::drawViewportEmbers(ImDrawList* dl) const { float xHash = std::sin((float)(i + 1) * 127.1f) * 43758.5453f; xHash = xHash - (int)xHash; // fractional part if (xHash < 0) xHash += 1.0f; - float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f; + float xDrift = std::sin(time_ * 0.5f + i * 1.7f) * 8.0f * dp; float x = vpX + vpW * xHash + xDrift; float y = vpY + vpH * (1.0f - phase); // rise from bottom to top diff --git a/src/ui/layout.h b/src/ui/layout.h index 4c0169e..54fe686 100644 --- a/src/ui/layout.h +++ b/src/ui/layout.h @@ -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); } diff --git a/src/ui/material/colors.h b/src/ui/material/colors.h index 1c29124..5b1a782 100644 --- a/src/ui/material/colors.h +++ b/src/ui/material/colors.h @@ -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 * diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index 0111650..f7cbd52 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -79,6 +79,63 @@ inline const char* LoadingDots() { return kDots[n]; } +// ── Centered empty state ───────────────────────────────────────────────── +// A big muted icon + title + optional wrapped hint, centered on BOTH axes within +// GetContentRegionAvail(). Mirrors chat_tab's centeredEmptyState so list-empty states +// read the same across tabs. Call at the start of the region you want it centered in +// (e.g. right after a BeginChild / a leading Dummy). Font metrics use the live font +// scale (LegacySize * FontScaleMain) and PushFont draws at that same scale, so this is +// crisp at HiDPI / font_scale 1.5 without any manual dpiScale multiply on the metrics. +inline void DrawEmptyState(const char* iconGlyph, const char* title, const char* hint = nullptr) +{ + auto scaled = [](ImFont* f) { return f->LegacySize * ImGui::GetStyle().FontScaleMain; }; + const ImVec2 avail = ImGui::GetContentRegionAvail(); + const ImVec2 origin = ImGui::GetCursorPos(); + ImFont* iconF = Type().iconXL(); + ImFont* titleF = Type().subtitle1(); + ImFont* hintF = Type().body2(); + const float dp = Layout::dpiScale(); + const float gap = 8.0f * dp; + const float wrap = std::min(avail.x - 40.0f * dp, 360.0f * dp); + const float iconSz = iconF ? scaled(iconF) : 40.0f; + const float iconH = (iconF && iconGlyph) ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).y : 0.0f; + const float titleH = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).y; + const float hintH = hint ? hintF->CalcTextSizeA(scaled(hintF), wrap, wrap, hint).y : 0.0f; + const float totalH = iconH + (iconH > 0.0f ? gap : 0.0f) + titleH + (hint ? gap + hintH : 0.0f); + float y = origin.y + std::max(0.0f, (avail.y - totalH) * 0.5f); + + if (iconF && iconGlyph && iconGlyph[0]) { + const float iw = iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, iconGlyph).x; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - iw) * 0.5f, y)); + ImGui::PushFont(iconF); + ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 70)); + ImGui::TextUnformatted(iconGlyph); + ImGui::PopStyleColor(); + ImGui::PopFont(); + y += iconH + gap; + } + { + const float tw = titleF->CalcTextSizeA(scaled(titleF), FLT_MAX, 0.0f, title).x; + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - tw) * 0.5f, y)); + ImGui::PushFont(titleF); + ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceMedium()); + ImGui::TextUnformatted(title); + ImGui::PopStyleColor(); + ImGui::PopFont(); + y += titleH + gap; + } + if (hint) { + ImGui::SetCursorPos(ImVec2(origin.x + (avail.x - wrap) * 0.5f, y)); + ImGui::PushFont(hintF); + ImGui::PushStyleColor(ImGuiCol_Text, WithAlpha(OnSurface(), 120)); + ImGui::PushTextWrapPos(ImGui::GetCursorPos().x + wrap); + ImGui::TextUnformatted(hint); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } +} + // ============================================================================ // Text Drop Shadow // ============================================================================ @@ -487,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0), ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMax = ImGui::GetItemRectMax(); - // For icon fonts, manually draw centered icon after getting button rect + // For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the + // VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way + // ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and + // shove the glyph left off-center (and try to draw the notdef id chars). if (isIconFont && size.x > 0 && size.y > 0) { - ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label); + const char* labelEnd = label; + while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd; + ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd); ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f); - dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label); + dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd); } float rounding = ImGui::GetStyle().FrameRounding; @@ -863,7 +925,7 @@ inline void DrawStatCard(ImDrawList* dl, // Draw a full-height rounded rect with card rounding (left corners) // and clip to stripe width so the shape follows the corner radius. if ((card.accentCol & IM_COL32_A_MASK) != 0) { - float stripeW = 4.0f; + float stripeW = 4.0f * Layout::dpiScale(); dl->PushClipRect(cMin, ImVec2(cMin.x + stripeW, cMax.y), true); dl->AddRectFilled(cMin, cMax, card.accentCol, rnd, ImDrawFlags_RoundCornersLeft); @@ -1220,7 +1282,8 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 winPos = ImGui::GetWindowPos(); float winWidth = ImGui::GetWindowWidth(); - float barHeight = 36.0f; + const float dp = Layout::dpiScale(); + float barHeight = 36.0f * dp; // Get accent color from theme if not provided if (!accent_col) { @@ -1244,15 +1307,15 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col ImFont* titleFont = Type().subtitle1(); ImGui::PushFont(titleFont); ImVec2 titleSize = ImGui::CalcTextSize(title); - float titleX = barMin.x + 16.0f; + float titleX = barMin.x + 16.0f * dp; float titleY = barMin.y + (barHeight - titleSize.y) * 0.5f; DrawTextShadow(dl, ImVec2(titleX, titleY), OnSurface(), title); ImGui::PopFont(); // Close button (X) on right side if (p_open) { - float btnSize = 24.0f; - float btnX = barMax.x - btnSize - 12.0f; + float btnSize = 24.0f * dp; + float btnX = barMax.x - btnSize - 12.0f * dp; float btnY = barMin.y + (barHeight - btnSize) * 0.5f; ImVec2 btnMin(btnX, btnY); ImVec2 btnMax(btnX + btnSize, btnY + btnSize); @@ -1265,7 +1328,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col // Button background on hover if (hovered) { - dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f); + dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, held ? 40 : 25), 4.0f * dp); } // Draw X icon @@ -1286,7 +1349,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col } // Reserve space for title bar so content starts below it - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + barHeight + 8.0f * dp); return closeClicked; } @@ -1403,6 +1466,7 @@ struct OverlayCardState { int stableCount = 0; // consecutive frames the height held steady (within 1px) int appearFrames = 0; // frames since (re)appearing while still hidden — a safety cap bool shown = false; // revealed (centered) at least once this open; don't re-hide after + bool overflow = false; // content once exceeded the viewport → clamp to viewport + scroll (sticky/open) }; inline std::unordered_map g_overlayCardHeights; inline std::string g_overlayCurrentKey; @@ -1528,6 +1592,7 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f; float cardY, cardBottomY; bool hideForMeasure = false; // true on an auto-height dialog's first (unmeasured) frame + bool autoOverflow = false; // auto-height content taller than the viewport → clamp + scroll const bool fixedHeight = (spec.cardHeight > 0.0f); if (fixedHeight) { float cardH = std::min(spec.cardHeight * dp, vp_size.y - 32.0f); @@ -1537,9 +1602,16 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) } else { g_overlayCurrentKey = childId; OverlayCardState& cs = g_overlayCardHeights[childId]; - if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; } + if (scrimAppearing) { cs.shown = false; cs.stableCount = 0; cs.appearFrames = 0; cs.overflow = false; } if (!cs.shown) cs.appearFrames++; const float measuredH = cs.height; + const float maxCardH = vp_size.y - 32.0f; + // Once the measured content is taller than the viewport, lock the card to the viewport height and + // let its content child scroll (autoOverflow) so the footer/actions stay reachable. Sticky for this + // open: clamping makes next frame's measured height the clamped value, so re-deciding from it would + // oscillate — decide once and hold until the dialog re-opens. + if (measuredH > maxCardH) cs.overflow = true; + autoOverflow = cs.overflow; // Reveal once the measured height has settled (auto-resize converges in ~2 frames) or it's // already been shown this open (don't re-hide on a mid-dialog content change); a frame cap // guarantees a pathological ever-changing height can't hide the dialog forever. @@ -1547,11 +1619,18 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) (cs.shown || cs.stableCount >= 1 || cs.appearFrames >= 8); if (ready) { cs.shown = true; - // Center the measured content; if it's taller than the window, anchor at the top margin. - cardY = (measuredH < vp_size.y - 32.0f) - ? vp_pos.y + (vp_size.y - measuredH) * 0.5f - : vp_pos.y + 16.0f; - cardBottomY = cardY + measuredH; + if (autoOverflow) { + // Taller than the screen: top-anchor at the 16px margin, clamp to the viewport; the + // content child (below) becomes the scroll region so the footer/actions stay reachable. + cardY = vp_pos.y + 16.0f; + cardBottomY = cardY + maxCardH; + } else { + // Center the measured content; if it's taller than the window, anchor at the top margin. + cardY = (measuredH < maxCardH) + ? vp_pos.y + (vp_size.y - measuredH) * 0.5f + : vp_pos.y + 16.0f; + cardBottomY = cardY + measuredH; + } } else { // Still settling: lay the content out (so the auto-height child gets measured) but keep // the card hidden (hideForMeasure below) so it never flashes off-center — it appears, @@ -1567,7 +1646,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) // the measuring frame (its geometry is a placeholder; the whole card is hidden until centered). if (!floating && !hideForMeasure) { GlassPanelSpec cardGlass; - cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f; + // Fill/border alpha govern every overlay dialog's card boundary — kept well above the default + // glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining + // tiles, chat) while staying translucent rather than opaque. + cardGlass.rounding = 16.0f * dp; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f; DrawGlassPanel(dl, cardMin, cardMax, cardGlass); } @@ -1581,17 +1663,24 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec) // Content child. ImGui::SetCursorScreenPos(ImVec2(cardX, cardY)); - ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f : 16.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28, 20) : ImVec2(28, 24)); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, floating ? 20.0f * dp : 16.0f * dp); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, floating ? ImVec2(28 * dp, 20 * dp) : ImVec2(28 * dp, 24 * dp)); ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // transparent (glass/blur behind) - ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (fixedHeight ? 0 : ImGuiChildFlags_AutoResizeY); + // A card with a known height is a fixed frame (fixed-height dialogs, and auto-height dialogs whose + // content overflowed the viewport); otherwise the child auto-resizes to its content. + const bool clampedCard = fixedHeight || autoOverflow; + ImGuiChildFlags cflags = ImGuiChildFlags_AlwaysUseWindowPadding | (clampedCard ? 0 : ImGuiChildFlags_AutoResizeY); // NoScrollWithMouse (not just NoScrollbar): a modal is a fixed frame — the wheel must never drift // the WHOLE card. If content marginally overflows a fixed card, the wheel would otherwise scroll // the entire dialog (title + footer and all). Inner scroll regions (lists, notes) still scroll on - // their own; auto-height cards resize to content so they never overflow anyway. + // their own; auto-height cards resize to content so they normally never overflow — EXCEPT when the + // content is taller than the viewport (autoOverflow), where the card itself IS the scroll region. + ImGuiWindowFlags childScroll = autoOverflow + ? ImGuiWindowFlags_None + : (ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); bool childVisible = ImGui::BeginChild(childId.c_str(), - ImVec2(cardWidth, fixedHeight ? (cardBottomY - cardY) : 0.0f), - cflags, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); + ImVec2(cardWidth, clampedCard ? (cardBottomY - cardY) : 0.0f), + cflags, childScroll); // Floating (portfolio-style) cards: the padding applies to this content child only, so pop it // now (nested children mustn't inherit it), and center button labels. Net style-var count stays // at 2 (ChildRounding + ButtonTextAlign) so EndOverlayDialog's PopStyleVar(2) is unchanged. @@ -1714,7 +1803,7 @@ inline void DialogWarningHeader(const char* warningLabel, const ImVec4& col = Wa inline void DialogConfirmFooter(const char* cancelId, const char* confirmLabel, bool danger, bool& outCancel, bool& outConfirm) { - float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f); + float btnH = schema::UI().drawElement("components.overlay-dialog", "confirm-btn-height").sizeOr(40.0f) * Layout::dpiScale(); float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; if (ImGui::Button(cancelId, ImVec2(btnW, btnH))) { outCancel = true; diff --git a/src/ui/material/settings_controls.h b/src/ui/material/settings_controls.h index 8d11a92..ee79e86 100644 --- a/src/ui/material/settings_controls.h +++ b/src/ui/material/settings_controls.h @@ -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; diff --git a/src/ui/node_status_banner.h b/src/ui/node_status_banner.h new file mode 100644 index 0000000..6233e5e --- /dev/null +++ b/src/ui/node_status_banner.h @@ -0,0 +1,111 @@ +#pragma once + +#include + +// 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 diff --git a/src/ui/notifications.cpp b/src/ui/notifications.cpp index 7f0b012..7d6d1d5 100644 --- a/src/ui/notifications.cpp +++ b/src/ui/notifications.cpp @@ -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); diff --git a/src/ui/notifications.h b/src/ui/notifications.h index 3b3c77c..25e764e 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -9,6 +9,8 @@ #include #include #include +#include +#include #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 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 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 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::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& 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 notifications_; size_t max_notifications_ = 5; std::function console_callback_; + std::deque 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; diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 7283f24..6a53da9 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -16,6 +16,7 @@ #include "../windows/console_tab.h" #include "../../util/i18n.h" #include "../../util/platform.h" +#include "../../util/seed_phrase.h" #include "../../resources/embedded_resources.h" #include #include "../../rpc/rpc_client.h" @@ -115,6 +116,8 @@ struct SettingsPageState { LowSpecSnapshot low_spec_snapshot; bool keep_daemon_running = false; bool stop_external_daemon = false; + bool stratum_host = false; // O2: host a RandomX stratum pool from the node (v1.3.0+) + char stratum_allowip[64] = ""; // -stratumallowip subnet (blank = loopback only) bool lite_lifecycle_expanded = false; int lite_lifecycle_operation = 0; char lite_wallet_path[256] = ""; @@ -163,6 +166,7 @@ struct SettingsPageState { bool effects_expanded = false; bool tools_expanded = false; bool rpc_expanded = false; // Node & Security: reveal the RPC connection fields + int current_tab = 0; // active settings category tab (see SettingsTab enum) bool confirm_clear_ztx = false; bool confirm_delete_blockchain = false; bool confirm_rescan = false; @@ -231,16 +235,12 @@ static void exitLowSpec(bool applyEffects) { s_settingsState.low_spec_snapshot.valid = false; } -// Count whitespace-separated words in a (seed) buffer — used to validate/guide restore input. +// Count words in a (seed) buffer — used to validate/guide restore input. Normalizes exotic Unicode +// whitespace (NBSP etc.) first so the count matches the phrase actually submitted (shared with the +// first-run restore gate via util::seed_phrase). static int liteSeedWordCount(const char* s) { - int words = 0; - bool inWord = false; - for (; s && *s; ++s) { - const bool space = std::isspace(static_cast(*s)) != 0; - if (space) inWord = false; - else if (!inWord) { inWord = true; ++words; } - } - return words; + return dragonx::util::seedPhraseWordCount( + dragonx::util::normalizeSeedPhrase(s ? std::string(s) : std::string())); } static wallet::LiteWalletLifecycleOperation liteLifecycleOperationFromPageState() { @@ -277,7 +277,9 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { break; case wallet::LiteWalletLifecycleOperation::RestoreFromSeed: input.request.restoreRequest.walletPath = s_settingsState.lite_wallet_path; - input.request.restoreRequest.seedPhrase = s_settingsState.lite_restore_seed; + // Normalize (fold NBSP/exotic whitespace to plain spaces) so an NBSP-pasted phrase the + // gate counted as 24 words also restores correctly at the backend. + input.request.restoreRequest.seedPhrase = dragonx::util::normalizeSeedPhrase(s_settingsState.lite_restore_seed); input.request.restoreRequest.passphrase = s_settingsState.lite_lifecycle_passphrase; input.request.restoreRequest.birthday = static_cast(std::max(0, s_settingsState.lite_restore_birthday)); input.request.restoreRequest.account = static_cast(std::max(0, s_settingsState.lite_restore_account)); @@ -310,7 +312,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { std::string trimmedPath(s_settingsState.lite_wallet_path); const auto first = trimmedPath.find_first_not_of(" \t\r\n"); if (first == std::string::npos) { - s_settingsState.lite_lifecycle_status = "Enter a wallet path"; + s_settingsState.lite_lifecycle_status = TR("lite_enter_wallet_path"); s_settingsState.lite_lifecycle_summary.clear(); return; } @@ -320,9 +322,10 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { // entered secret on this return path). if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) { const int words = liteSeedWordCount(s_settingsState.lite_restore_seed); - if (words != 24) { - s_settingsState.lite_lifecycle_status = - "Enter all 24 seed words to restore (got " + std::to_string(words) + ")"; + if (!dragonx::util::isCompleteRecoveryPhrase(words)) { + char seedBuf[128]; + snprintf(seedBuf, sizeof(seedBuf), TR("lite_enter_all_seed_words"), words); + s_settingsState.lite_lifecycle_status = seedBuf; s_settingsState.lite_lifecycle_summary.clear(); return; } @@ -354,7 +357,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { // Rejected before any thread launched (wallet already open, an attempt in flight, or no // usable server). The controller's status carries the reason. s_settingsState.lite_lifecycle_status = lite->status().message.empty() - ? "Could not start the operation" + ? TR("lite_could_not_start") : lite->status().message; Notifications::instance().warning(s_settingsState.lite_lifecycle_status); } @@ -365,7 +368,7 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) { // failed to load / rollout-disabled). The live path above returns when a backend is // present, so reaching here means there is nothing to run. s_settingsState.lite_lifecycle_summary.clear(); - s_settingsState.lite_lifecycle_status = "Lite wallet backend unavailable"; + s_settingsState.lite_lifecycle_status = TR("lite_backend_unavailable"); Notifications::instance().warning(s_settingsState.lite_lifecycle_status); } @@ -419,6 +422,9 @@ static void loadSettingsPageState(config::Settings* settings) { Layout::setUserFontScale(s_settingsState.font_scale); // sync with Layout on load s_settingsState.keep_daemon_running = settings->getKeepDaemonRunning(); s_settingsState.stop_external_daemon = settings->getStopExternalDaemon(); + s_settingsState.stratum_host = settings->getStratumHost(); + std::snprintf(s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip), "%s", + settings->getStratumAllowIp().c_str()); // Lite-server selection is managed entirely by the Network tab (not the Settings page). s_settingsState.mine_when_idle = settings->getMineWhenIdle(); s_settingsState.mine_idle_delay = settings->getMineIdleDelay(); @@ -482,6 +488,8 @@ static void saveSettingsPageState(config::Settings* settings) { settings->setFontScale(s_settingsState.font_scale); settings->setKeepDaemonRunning(s_settingsState.keep_daemon_running); settings->setStopExternalDaemon(s_settingsState.stop_external_daemon); + settings->setStratumHost(s_settingsState.stratum_host); + settings->setStratumAllowIp(s_settingsState.stratum_allowip); // Lite-server selection is owned by the Network tab; the Settings page no longer writes it. settings->setMineWhenIdle(s_settingsState.mine_when_idle); settings->setMineIdleDelay(s_settingsState.mine_idle_delay); @@ -516,6 +524,15 @@ static void renderConsoleColorToggles(App* app) { app->settings()->save(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_text_color")); + ImGui::SameLine(0, Layout::spacingLg()); + // Console behavior (not a GPU effect): focus the command input when the tab opens. Bound straight to + // settings — the App reads it at the page transition; no ConsoleTab static needed. + bool autoFocus = app->settings()->getConsoleAutoFocus(); + if (ImGui::Checkbox(TrId("console_auto_focus", "con_autofocus").c_str(), &autoFocus)) { + app->settings()->setConsoleAutoFocus(autoFocus); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("console_toggle_auto_focus")); ImGui::BeginDisabled(s_settingsState.low_spec_mode); } @@ -523,6 +540,79 @@ static void renderConsoleColorToggles(App* app) { // Settings Page Renderer // ============================================================================ +// A full-card-width, left-aligned, solid button (icon + label) drawn at an explicit (x,y). +// Used by the side-by-side "column card" tabs (Backup, Wallet) where content is positioned +// manually because ImGui's Indent (which GlassCardScope uses) is window-relative. +static bool renderCardButton(ImDrawList* dl, float x, float y, float w, float h, + const char* id, const char* label, const char* icon) { + using namespace material; + ImGui::SetCursorScreenPos(ImVec2(x, y)); + ImFont* lf = Type().button(); + ImFont* icf = Type().iconSmall(); + const float dpp = Layout::dpiScale(); + const float padX = 12.0f * dpp, ig = 6.0f * dpp; + const float bh = h; + const bool pressed = ImGui::InvisibleButton(id, ImVec2(w, bh)); + const bool hov = ImGui::IsItemHovered(); + if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); + const ImVec2 pmin(x, y), pmax(x + w, y + bh); + const float round = 7.0f * dpp; // match ActionButton chips (mockup .btn radius: 7px) + dl->AddRectFilled(pmin, pmax, WithAlpha(OnSurface(), hov ? 30 : 20), round); + dl->AddRect(pmin, pmax, WithAlpha(OnSurface(), 48), round, 0, 1.0f); + const ImU32 fg = ImGui::GetColorU32(OnSurface()); + dl->PushClipRect(pmin, pmax, true); + float tx = x + padX; + if (icon && icon[0] && icf) { + dl->AddText(icf, icf->LegacySize, ImVec2(tx, y + (bh - icf->LegacySize) * 0.5f), fg, icon); + tx += icf->CalcTextSizeA(icf->LegacySize, FLT_MAX, 0, icon).x + ig; + } + dl->AddText(lf, lf->LegacySize, ImVec2(tx, y + (bh - lf->LegacySize) * 0.5f), fg, label); + dl->PopClipRect(); + return pressed; +} + +// ---- Category tabs (top-level settings navigation) ------------------------- +enum SettingsTab { TAB_APPEARANCE = 0, TAB_WALLET, TAB_BACKUP, TAB_NODE, TAB_EXPLORER, TAB_CHAT, TAB_ABOUT, TAB_COUNT }; + +// Pinned horizontal category tab bar, drawn above the settings scroll region. Each category is a +// pill; the active one gets an accent fill. Advances the ImGui cursor past the bar + a divider so +// the scrollable content begins below it. +static void renderSettingsTabBar(float availWidth) { + using namespace material; + struct T { int id; const char* label; const char* idstr; }; + const T tabs[] = { + {TAB_APPEARANCE, TR("grpa_tab_appearance"), "##stabA"}, {TAB_WALLET, TR("grpa_tab_wallet"), "##stabW"}, + {TAB_BACKUP, TR("grpa_tab_backup_data"), "##stabB"}, {TAB_NODE, TR("grpa_tab_node_security"), "##stabN"}, + {TAB_EXPLORER, TR("grpa_tab_explorer"), "##stabE"}, {TAB_CHAT, TR("grpa_tab_chat"), "##stabC"}, + {TAB_ABOUT, TR("grpa_tab_about"), "##stabT"}, + }; + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImFont* f = Type().body2(); + const float dp = Layout::dpiScale(); + const float padX = 13.0f * dp, padY = 7.0f * dp, gap = 6.0f * dp, rnd = 8.0f * dp; + const float h = f->LegacySize + padY * 2.0f; + const ImVec2 origin = ImGui::GetCursorScreenPos(); + float x = origin.x, y = origin.y; + for (const T& t : tabs) { + ImVec2 ts = f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, t.label); + float w = ts.x + padX * 2.0f; + if (x > origin.x && x + w > origin.x + availWidth) { x = origin.x; y += h + gap; } // wrap + ImGui::SetCursorScreenPos(ImVec2(x, y)); + if (ImGui::InvisibleButton(t.idstr, ImVec2(w, h))) s_settingsState.current_tab = t.id; + const bool hovered = ImGui::IsItemHovered(); + const bool active = (s_settingsState.current_tab == t.id); + if (active) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + h), WithAlpha(Primary(), 34), rnd); + else if (hovered) dl->AddRectFilled(ImVec2(x, y), ImVec2(x + w, y + h), IM_COL32(255, 255, 255, 12), rnd); + dl->AddText(f, f->LegacySize, ImVec2(x + (w - ts.x) * 0.5f, y + (h - ts.y) * 0.5f), + ImGui::GetColorU32((active || hovered) ? OnSurface() : OnSurfaceMedium()), t.label); + x += w + gap; + } + const float bottom = y + h; + dl->AddLine(ImVec2(origin.x, bottom + 5.0f * dp), ImVec2(origin.x + availWidth, bottom + 5.0f * dp), + ImGui::GetColorU32(Divider()), 1.0f); + ImGui::SetCursorScreenPos(ImVec2(origin.x, bottom + 12.0f * dp)); +} + void RenderSettingsPage(App* app) { // Load settings state on first render if (!s_settingsState.initialized && app->settings()) { @@ -563,6 +653,9 @@ void RenderSettingsPage(App* app) { ImVec2 contentAvail = ImGui::GetContentRegionAvail(); float scrollbarMargin = ImGui::GetStyle().ScrollbarSize + Layout::spacingSm(); float availWidth = contentAvail.x - scrollbarMargin; + + // Settings fills the full content width (the global content-max-width cap is disabled). + float settingsLeftOffset = 0.0f; float hs = Layout::hScale(availWidth); float vs = Layout::vScale(contentAvail.y); float pad = Layout::cardInnerPadding(); @@ -588,10 +681,14 @@ void RenderSettingsPage(App* app) { } // Input field width — fill remaining space in card float inputW = std::max(S.drawElement("components.settings-page", "input-min-width").size, availWidth - labelW - pad * 2); + (void)inputW; // used by some sections; may be unused depending on active tab + + // Category tab bar — pinned above the scrollable content area (not part of the scroll). + renderSettingsTabBar(availWidth); // Scrollable content area — NoBackground matches other tabs - - ImGui::BeginChild("##SettingsPageScroll", ImVec2(0, 0), false, + if (settingsLeftOffset > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + settingsLeftOffset); + ImGui::BeginChild("##SettingsPageScroll", ImVec2(settingsLeftOffset > 0.0f ? availWidth + scrollbarMargin : 0.0f, 0), false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse); ApplySmoothScroll(); @@ -639,24 +736,17 @@ void RenderSettingsPage(App* app) { GlassPanelSpec glassSpec; glassSpec.rounding = glassRound; + glassSpec.fillAlpha = 26; // lift the settings cards off the background (closer to the mockup's flat cards) + glassSpec.borderAlpha = 50; // crisper, more defined card border (mockup uses a visible 1px line) ImFont* capFont = Type().caption(); ImFont* body2 = Type().body2(); ImFont* sub1 = Type().subtitle1(); // ==================================================================== - // THEME & LANGUAGE — card (draw-first approach; avoids ChannelsSplit - // which breaks BeginCombo popup rendering in some ImGui versions) + // APPEARANCE — two stacked cards: THEME & LANGUAGE (2x2 dropdown grid) + // then SCALE & EFFECTS (font scale + effect toggles + Advanced sliders). // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("theme_language")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); - - float contentW = availWidth - pad * 2; - float comboGap = S.drawElement("components.settings-page", "combo-row-gap").size; - float compactBP = S.drawElement("components.settings-page", "compact-breakpoint").size; - bool wideLayout = availWidth >= compactBP; + if (s_settingsState.current_tab == TAB_APPEARANCE) { float refreshBtnW = S.drawElement("components.settings-page", "refresh-btn-width").size; // --- Skin data --- @@ -671,6 +761,7 @@ void RenderSettingsPage(App* app) { break; } } + (void)active_is_custom; // --- Language data --- auto& i18n = util::I18n::instance(); @@ -688,7 +779,7 @@ void RenderSettingsPage(App* app) { if (l.id == s_settingsState.balance_layout) { balPreview = l.name; break; } } - // --- Theme combo popup (shared between wide and narrow paths) --- + // --- Theme combo popup (shared) --- auto renderThemeComboPopup = [&]() { ImGui::TextDisabled("%s", TR("settings_builtin")); ImGui::Separator(); @@ -720,7 +811,7 @@ void RenderSettingsPage(App* app) { if (!skin.valid) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); ImGui::BeginDisabled(true); - std::string lbl = skin.name + " (invalid)"; + std::string lbl = skin.name + TR("grpa_invalid_suffix"); ImGui::Selectable(lbl.c_str(), false); ImGui::EndDisabled(); ImGui::PopStyleColor(); @@ -742,111 +833,121 @@ void RenderSettingsPage(App* app) { } }; - if (wideLayout) { - // ============================================================ - // Wide: 3 combos on one row + compact 3-column effects grid - // ============================================================ + // ============================================================ + // Card 1 — THEME & LANGUAGE (2x2 grid of labeled dropdowns) + // ============================================================ + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("theme_language")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + float contentW = availWidth - pad * 2; + float cellGap = Layout::spacingLg(); + bool twoCol = contentW >= 460.0f * dp; // drop to a single column when too narrow (high font scale) + int cols = twoCol ? 2 : 1; + float colW = std::max(160.0f, twoCol ? (contentW - cellGap) * 0.5f : contentW); + float baseX = ImGui::GetCursorScreenPos().x; - // --- Combo row: Theme | Layout | Language [Refresh] --- - { - ImGui::PushFont(body2); - float lblGap = Layout::spacingXs(); - float lblThemeW = ImGui::CalcTextSize(TR("theme")).x + lblGap; - float lblLayoutW = ImGui::CalcTextSize(TR("balance_layout")).x + lblGap; - float lblLangW = ImGui::CalcTextSize(TR("language")).x + lblGap; - float totalFixed = lblThemeW + lblLayoutW + lblLangW - + comboGap * 2 + Layout::spacingSm() + refreshBtnW; - float comboW = std::max(80.0f, (contentW - totalFixed) / 3.0f); + const char* cellLabels[4] = { TR("theme"), TR("balance_layout"), TR("language"), TR("clock_format") }; + ImGui::PushFont(body2); + float lblW = 0.0f; // label column — mockup puts the label BESIDE the control (.row), not above + for (int i = 0; i < 4; ++i) lblW = std::max(lblW, ImGui::CalcTextSize(cellLabels[i]).x); + lblW += Layout::spacingMd(); + float rowTop = ImGui::GetCursorScreenPos().y; + float rowBottom = rowTop; + for (int i = 0; i < 4; ++i) { + int col = i % cols; + if (col == 0 && i > 0) rowTop = rowBottom; // start a new grid row + float cx = baseX + col * (colW + cellGap); + + // Field label on the left, control filling the rest of the cell (mockup .row layout). + ImGui::SetCursorScreenPos(ImVec2(cx, rowTop)); ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("theme")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { - renderThemeComboPopup(); - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_theme_hotkey")); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); + ImGui::TextUnformatted(cellLabels[i]); + ImGui::PopStyleColor(); + ImGui::SetCursorScreenPos(ImVec2(cx + lblW, rowTop)); + ImGui::SetNextItemWidth(colW - lblW); - ImGui::SameLine(0, comboGap); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("balance_layout")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { - for (const auto& l : layouts) { - if (!l.enabled) continue; - bool selected = (l.id == s_settingsState.balance_layout); - if (ImGui::Selectable(l.name.c_str(), selected)) { - s_settingsState.balance_layout = l.id; - if (app->settings()) { - app->settings()->setBalanceLayout(s_settingsState.balance_layout); - app->settings()->save(); + switch (i) { + case 0: // Theme + if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { renderThemeComboPopup(); ImGui::EndCombo(); } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_hotkey")); + break; + case 1: // Balance layout + if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { + for (const auto& l : layouts) { + if (!l.enabled) continue; + bool selected = (l.id == s_settingsState.balance_layout); + if (ImGui::Selectable(l.name.c_str(), selected)) { + s_settingsState.balance_layout = l.id; + if (app->settings()) { app->settings()->setBalanceLayout(l.id); app->settings()->save(); } + } + if (selected) ImGui::SetItemDefaultFocus(); } + ImGui::EndCombo(); } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_layout_hotkey")); - - ImGui::SameLine(0, comboGap); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("language")); - ImGui::SameLine(0, lblGap); - ImGui::SetNextItemWidth(comboW); - if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), - static_cast(lang_names.size()))) { - auto it = languages.begin(); - std::advance(it, s_settingsState.language_index); - i18n.loadLanguage(it->first); - if (app->settings()) { - app->settings()->setLanguage(it->first); - app->settings()->save(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_layout_hotkey")); + break; + case 2: // Language + if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), + static_cast(lang_names.size()))) { + auto it = languages.begin(); + std::advance(it, s_settingsState.language_index); + i18n.loadLanguage(it->first); + if (app->settings()) { app->settings()->setLanguage(it->first); app->settings()->save(); } + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); + break; + case 3: { // Clock format + int cf = app->settings() ? app->settings()->getTimeFormat() : 0; + const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; + if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { + app->settings()->setTimeFormat(cf); + app->settings()->save(); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); + break; } } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); - ImGui::SameLine(0, Layout::spacingSm()); - if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { - schema::SkinManager::instance().refresh(); - Notifications::instance().info("Theme list refreshed"); + float cellBottom = ImGui::GetCursorScreenPos().y; + rowBottom = (col == 0) ? cellBottom : std::max(rowBottom, cellBottom); + if (col == cols - 1 || i == 3) { + ImGui::SetCursorScreenPos(ImVec2(baseX, rowBottom + 11.0f * dp)); + rowBottom = ImGui::GetCursorScreenPos().y; } - if (ImGui::IsItemHovered()) { - material::Tooltip(TR("tt_scan_themes"), - schema::SkinManager::getUserSkinsDirectory().c_str()); - } - ImGui::PopFont(); } + ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Rescan the theme folder — minor action, tucked below the grid. + ImGui::SetCursorScreenPos(ImVec2(baseX, rowBottom)); + if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { + schema::SkinManager::instance().refresh(); + Notifications::instance().info(TR("settings_theme_refreshed")); + } + if (ImGui::IsItemHovered()) + material::Tooltip(TR("tt_scan_themes"), schema::SkinManager::getUserSkinsDirectory().c_str()); + } - // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("clock_format")); - ImGui::SameLine(0, Layout::spacingMd()); - int cf = app->settings() ? app->settings()->getTimeFormat() : 0; - const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; - ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); - if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { - app->settings()->setTimeFormat(cf); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Font Scale slider (always visible) --- + ImGui::Dummy(ImVec2(0, gap)); + + // ============================================================ + // Card 2 — SCALE & EFFECTS (font scale + effect toggles + Advanced sliders) + // ============================================================ + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("scale_effects")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + float contentW = availWidth - pad * 2; + + // --- Font Scale slider --- { ImGui::PushFont(body2); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); ImGui::TextUnformatted(TR("font_scale")); - float fontSliderW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW); + ImGui::PopStyleColor(); + float fontSliderW = std::min(std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, contentW), 360.0f * dp); ImGui::SetNextItemWidth(fontSliderW); s_settingsState.font_scale = Layout::userFontScale(); float prev_font_scale = s_settingsState.font_scale; @@ -868,17 +969,11 @@ void RenderSettingsPage(App* app) { ImGui::PopFont(); } - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - // --- Collapsible: Advanced Effects... --- - material::CollapsibleHeader(dl, "##EffectsToggle", TR("advanced_effects"), - s_settingsState.effects_expanded, contentW, - body2, OnSurfaceMedium()); - - if (s_settingsState.effects_expanded) { + // --- Effect toggles (always visible, horizontal wrapping flow) --- + { ImGui::PushFont(body2); - - // Effects checkboxes — wrap to new rows instead of overflowing on narrow windows. const float efFh = ImGui::GetFrameHeight(); const float efInner = ImGui::GetStyle().ItemInnerSpacing.x; float efX = 0.0f; bool efFirst = true; @@ -888,6 +983,7 @@ void RenderSettingsPage(App* app) { else if (efX + Layout::spacingLg() + w <= contentW) { ImGui::SameLine(0, Layout::spacingLg()); efX += Layout::spacingLg() + w; } else { efX = w; } }; + efFlow(TR("low_spec_mode")); if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) { effects::setLowSpecMode(s_settingsState.low_spec_mode); @@ -940,13 +1036,31 @@ void RenderSettingsPage(App* app) { if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); } + ImGui::EndDisabled(); // low-spec + ImGui::PopFont(); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + + // --- Collapsible: Advanced Effects... (console colors + 2x2 opacity/blur sliders) --- + material::CollapsibleHeader(dl, "##EffectsToggle", TR("advanced_effects"), + s_settingsState.effects_expanded, contentW, + body2, OnSurfaceMedium()); + + if (s_settingsState.effects_expanded) { + ImGui::PushFont(body2); + + ImGui::BeginDisabled(s_settingsState.low_spec_mode); + // Console output color toggles (own row — no GPU cost, enabled even in low-spec). + // renderConsoleColorToggles() temporarily End/BeginDisabled()s so its own checkboxes + // stay enabled — it MUST be called while exactly one BeginDisabled is active. renderConsoleColorToggles(app); // Row 1: Acrylic preset slider + Noise slider (side by side, labels above) float effCtrlMinW = S.drawElement("components.settings-page", "effects-input-min-width").size; float halfW = (contentW - Layout::spacingLg()) * 0.5f; - float ctrlW = std::max(effCtrlMinW, halfW); + float ctrlW = std::min(std::max(effCtrlMinW, halfW), 360.0f * dp); float baseX = ImGui::GetCursorScreenPos().x; float rightX = baseX + ctrlW + Layout::spacingLg(); @@ -1022,455 +1136,329 @@ void RenderSettingsPage(App* app) { ImGui::SetCursorScreenPos(ImVec2(baseX, afterRow2Y)); - ImGui::EndDisabled(); // low-spec - ImGui::PopFont(); - } // s_settingsState.effects_expanded - } else { - // ============================================================ - // Narrow: stacked combos + 2-column effects (original layout) - // ============================================================ - - // --- Theme row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("theme")); - ImGui::SameLine(labelW); - - // Reserve the real gaps (default ItemSpacing, not spacingSm) plus the - // custom-skin "*" marker so the Refresh button never spills past the edge. - float themeComboW = std::max(S.drawElement("components.settings-page", "theme-combo-min-width").size, - availWidth - pad * 2 - labelW - refreshBtnW - ImGui::GetStyle().ItemSpacing.x - - (active_is_custom ? (ImGui::GetStyle().ItemSpacing.x + ImGui::CalcTextSize("*").x) : 0.0f)); - ImGui::SetNextItemWidth(themeComboW); - if (ImGui::BeginCombo("##Theme", active_preview.c_str())) { - renderThemeComboPopup(); - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_theme_hotkey")); - if (active_is_custom) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "*"); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_theme")); - } - ImGui::SameLine(); - if (TactileButton(TR("refresh"), ImVec2(refreshBtnW, 0), S.resolveFont("button"))) { - schema::SkinManager::instance().refresh(); - Notifications::instance().info("Theme list refreshed"); - } - if (ImGui::IsItemHovered()) { - material::Tooltip(TR("tt_scan_themes"), - schema::SkinManager::getUserSkinsDirectory().c_str()); - } - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // --- Balance Layout row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("balance_layout")); - ImGui::SameLine(labelW); - ImGui::SetNextItemWidth(std::max(180.0f, inputW)); - if (ImGui::BeginCombo("##BalanceLayout", balPreview.c_str())) { - for (const auto& l : layouts) { - if (!l.enabled) continue; - bool selected = (l.id == s_settingsState.balance_layout); - if (ImGui::Selectable(l.name.c_str(), selected)) { - s_settingsState.balance_layout = l.id; - if (app->settings()) { - app->settings()->setBalanceLayout(s_settingsState.balance_layout); - app->settings()->save(); - } - } - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - if (ImGui::IsItemHovered()) - material::Tooltip("%s", TR("tt_layout_hotkey")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // --- Language row --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("language")); - ImGui::SameLine(labelW); - ImGui::SetNextItemWidth(inputW); - if (ImGui::Combo("##Language", &s_settingsState.language_index, lang_names.data(), - static_cast(lang_names.size()))) { - auto it = languages.begin(); - std::advance(it, s_settingsState.language_index); - i18n.loadLanguage(it->first); - if (app->settings()) { - app->settings()->setLanguage(it->first); - app->settings()->save(); - } - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_language")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Clock format (app-wide 24h / 12h — the Chat tab can override it in chat settings) --- - { - ImGui::PushFont(body2); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("clock_format")); - ImGui::SameLine(0, Layout::spacingMd()); - int cf = app->settings() ? app->settings()->getTimeFormat() : 0; - const char* cfItems[] = { TR("chat_ts_24h"), TR("chat_ts_12h") }; - ImGui::SetNextItemWidth(160.0f * Layout::dpiScale()); - if (ImGui::Combo("##ClockFormat", &cf, cfItems, 2) && app->settings()) { - app->settings()->setTimeFormat(cf); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clock_format")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Font Scale slider (always visible) --- - { - ImGui::PushFont(body2); - ImGui::TextUnformatted(TR("font_scale")); - float fontSliderW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, - availWidth - pad * 2); - ImGui::SetNextItemWidth(fontSliderW); - s_settingsState.font_scale = Layout::userFontScale(); - float prev_font_scale = s_settingsState.font_scale; - { - char fs_fmt[16]; - snprintf(fs_fmt, sizeof(fs_fmt), "%.2fx", s_settingsState.font_scale); - ImGui::SliderFloat("##FontScale", &s_settingsState.font_scale, 1.0f, 1.5f, fs_fmt, - ImGuiSliderFlags_AlwaysClamp); - } - s_settingsState.font_scale = std::max(1.0f, std::min(1.5f, - std::round(s_settingsState.font_scale * 20.0f) / 20.0f)); - if (s_settingsState.font_scale != prev_font_scale) - Layout::setUserFontScaleVisual(s_settingsState.font_scale); - if (ImGui::IsItemDeactivatedAfterEdit()) { - Layout::setUserFontScale(s_settingsState.font_scale); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_font_scale")); - ImGui::PopFont(); - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Collapsible: Advanced Effects... --- - { - float narrowContentW = availWidth - pad * 2; - material::CollapsibleHeader(dl, "##EffectsToggleN", TR("advanced_effects"), - s_settingsState.effects_expanded, narrowContentW, - body2, OnSurfaceMedium()); - } - - if (s_settingsState.effects_expanded) { - ImGui::PushFont(body2); - - if (ImGui::Checkbox(TrId("low_spec_mode", "low_spec").c_str(), &s_settingsState.low_spec_mode)) { - effects::setLowSpecMode(s_settingsState.low_spec_mode); - if (s_settingsState.low_spec_mode) { - enterLowSpec(true); - } else if (s_settingsState.low_spec_snapshot.valid) { - exitLowSpec(true); - } - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_low_spec")); - - if (ImGui::Checkbox(TrId("settings_gradient_bg", "gradient_bg").c_str(), &s_settingsState.gradient_background)) { - schema::SkinManager::instance().setGradientMode(s_settingsState.gradient_background); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_simple_bg_alt")); - - if (ImGui::Checkbox(TrId("reduce_motion", "reduce_motion").c_str(), &s_settingsState.reduce_motion)) { - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reduce_motion")); - - ImGui::BeginDisabled(s_settingsState.low_spec_mode); - - if (ImGui::Checkbox(TrId("console_scanline", "scanline").c_str(), &s_settingsState.scanline_enabled)) { - ConsoleTab::s_scanline_enabled = s_settingsState.scanline_enabled; - app->settings()->setScanlineEnabled(s_settingsState.scanline_enabled); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_scanline")); - - ImGui::SameLine(0, Layout::spacingLg()); - if (ImGui::Checkbox(TrId("theme_effects", "theme_fx").c_str(), &s_settingsState.theme_effects_enabled)) { - effects::ThemeEffects::instance().setEnabled(s_settingsState.theme_effects_enabled); - saveSettingsPageState(app->settings()); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); - - ImGui::SameLine(0, Layout::spacingLg()); - { - bool anim = app->settings()->getAnimateAvatars(); - if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) { - app->settings()->setAnimateAvatars(anim); - app->settings()->save(); - } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars")); - } - - // Console output color toggles (own row — no GPU cost, enabled even in low-spec). - renderConsoleColorToggles(app); - - float ctrlW = std::max(S.drawElement("components.settings-page", "effects-input-min-width").size, - availWidth - pad * 2.0f); - ImGui::TextUnformatted(TR("acrylic")); - ImGui::SetNextItemWidth(ctrlW); - { - char blur_fmt[16]; - if (s_settingsState.blur_amount < 0.01f) - snprintf(blur_fmt, sizeof(blur_fmt), "%s", TR("slider_off")); - else - snprintf(blur_fmt, sizeof(blur_fmt), "%.0f%%%%", s_settingsState.blur_amount / kAcrylicMaxBlur * 100.0f); - if (ImGui::SliderFloat("##AcrylicBlur", &s_settingsState.blur_amount, 0.0f, kAcrylicMaxBlur, blur_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - if (s_settingsState.blur_amount > 0.0f && s_settingsState.blur_amount < kAcrylicMaxBlur * 0.04f) s_settingsState.blur_amount = 0.0f; - s_settingsState.acrylic_enabled = (s_settingsState.blur_amount > 0.001f); - effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_blur")); - - ImGui::TextUnformatted(TR("noise")); - ImGui::SetNextItemWidth(ctrlW); - { - char noise_fmt[16]; - if (s_settingsState.noise_opacity < 0.01f) - snprintf(noise_fmt, sizeof(noise_fmt), "%s", TR("slider_off")); - else - snprintf(noise_fmt, sizeof(noise_fmt), "%.0f%%%%", s_settingsState.noise_opacity * 100.0f); - if (ImGui::SliderFloat("##NoiseOpacity", &s_settingsState.noise_opacity, 0.0f, 1.0f, noise_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - effects::ImGuiAcrylic::SetNoiseOpacity(s_settingsState.noise_opacity); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_noise")); - - ImGui::TextUnformatted(TR("ui_opacity")); - ImGui::SetNextItemWidth(ctrlW); - { - char uiop_fmt[16]; - snprintf(uiop_fmt, sizeof(uiop_fmt), "%.0f%%%%", s_settingsState.ui_opacity * 100.0f); - if (ImGui::SliderFloat("##UIOpacity", &s_settingsState.ui_opacity, 0.3f, 1.0f, uiop_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - effects::ImGuiAcrylic::SetUIOpacity(s_settingsState.ui_opacity); - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_ui_opacity")); - - ImGui::TextUnformatted(TR("window_opacity")); - ImGui::SetNextItemWidth(ctrlW); - { - char winop_fmt[16]; - snprintf(winop_fmt, sizeof(winop_fmt), "%.0f%%%%", s_settingsState.window_opacity * 100.0f); - if (ImGui::SliderFloat("##WindowOpacity", &s_settingsState.window_opacity, 0.3f, 1.0f, winop_fmt, - ImGuiSliderFlags_AlwaysClamp)) { - } - } - if (ImGui::IsItemDeactivatedAfterEdit()) saveSettingsPageState(app->settings()); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_window_opacity")); - ImGui::EndDisabled(); // low-spec ImGui::PopFont(); } // s_settingsState.effects_expanded } } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // WALLET — card (privacy/daemon toggles + collapsible tools) // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_WALLET) { + const bool showDaemonOptions = app->supportsFullNodeLifecycleActions(); - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + // Two side-by-side glass cards: OPTIONS (toggles) | DIAGNOSTICS (log + tools). + const float ccGap = Layout::cardGap(); + const float ccW = (availWidth - ccGap) * 0.5f; + const float cw = ccW - pad * 2; + const float ccTop = ImGui::GetCursorScreenPos().y; + const float ccBaseX = ImGui::GetCursorScreenPos().x; + float ccBottom = ccTop; - float contentW = availWidth - pad * 2; + // One foreground channel for both cards; panels painted afterwards at equal (tallest) height. + float cardBot[2] = { ccTop, ccTop }; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + auto cardHeader = [&](int col, const char* header) -> float { + const float cx = ccBaseX + col * (ccW + ccGap); + ImGui::SetCursorScreenPos(ImVec2(cx + pad, ccTop + pad)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), header); + return ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + }; + auto cardClose = [&](int col, float lastBottom) { + cardBot[col] = lastBottom + pad; + ccBottom = std::max(ccBottom, cardBot[col]); + }; - // Privacy, Network & Daemon checkboxes — wrap to new rows instead of shrinking the text. + ImGui::PushFont(body2); + const float fh = ImGui::GetFrameHeight(); // checkbox row height + const float bh = ImGui::GetFrameHeight() + 12.0f * dp; // taller, airier buttons (match mockup) + const float gp = Layout::spacingSm(); // roomier gap + + // ---- Card 0: OPTIONS (checkboxes in a 2-column grid — mockup .chks.two) ---- { - const bool showDaemonOptions = app->supportsFullNodeLifecycleActions(); - const float cbSpacing = Layout::spacingLg(); - const float fh = ImGui::GetFrameHeight(); - const float inner = ImGui::GetStyle().ItemInnerSpacing.x; - float cbX = 0.0f; bool cbFirst = true; - // Position the next checkbox: SameLine if it fits on the current row, else wrap. - auto cbFlow = [&](const char* label) { - const float w = fh + inner + ImGui::CalcTextSize(label).x; - if (cbFirst) { cbFirst = false; cbX = w; } - else if (cbX + cbSpacing + w <= contentW) { ImGui::SameLine(0, cbSpacing); cbX += cbSpacing + w; } - else { cbX = w; } + const float cx = ccBaseX + pad; + const float col2W = (cw - Layout::spacingLg()) * 0.5f; + float rowY = cardHeader(0, TR("wallet_options_hdr")); + int c = 0; float last = rowY; + auto CB = [&](const std::string& id, bool* val) -> bool { + ImGui::SetCursorScreenPos(ImVec2(cx + c * (col2W + Layout::spacingLg()), rowY)); + const bool changed = ImGui::Checkbox(id.c_str(), val); + last = rowY + fh; + if (c == 1) { rowY += fh + gp; c = 0; } else { c = 1; } + return changed; }; - cbFlow(TR("save_z_transactions")); - ImGui::Checkbox(TrId("save_z_transactions", "save_ztx").c_str(), &s_settingsState.save_ztxs); + CB(TrId("save_z_transactions", "save_ztx"), &s_settingsState.save_ztxs); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_ztx")); - cbFlow(TR("auto_shield")); - ImGui::Checkbox(TrId("auto_shield", "auto_shld").c_str(), &s_settingsState.auto_shield); + CB(TrId("auto_shield", "auto_shld"), &s_settingsState.auto_shield); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_shield")); - cbFlow(TR("use_tor")); - ImGui::Checkbox(TrId("use_tor", "tor").c_str(), &s_settingsState.use_tor); + CB(TrId("use_tor", "tor"), &s_settingsState.use_tor); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tor")); if (showDaemonOptions) { - cbFlow(TR("keep_daemon")); - if (ImGui::Checkbox(TrId("keep_daemon", "keep_dmn").c_str(), &s_settingsState.keep_daemon_running)) + if (CB(TrId("keep_daemon", "keep_dmn"), &s_settingsState.keep_daemon_running)) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_keep_daemon")); - cbFlow(TR("stop_external")); - if (ImGui::Checkbox(TrId("stop_external", "stop_ext").c_str(), &s_settingsState.stop_external_daemon)) + if (CB(TrId("stop_external", "stop_ext"), &s_settingsState.stop_external_daemon)) saveSettingsPageState(app->settings()); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stop_external")); + // (Stratum pool hosting lives in the Node & Security tab — it's a node feature.) } - cbFlow(TR("verbose_logging")); - if (ImGui::Checkbox(TrId("verbose_logging", "verbose").c_str(), &s_settingsState.verbose_logging)) { + if (CB(TrId("verbose_logging", "verbose"), &s_settingsState.verbose_logging)) { dragonx::util::Logger::instance().setVerbose(s_settingsState.verbose_logging); saveSettingsPageState(app->settings()); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_verbose")); - } - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // --- Collapsible: Tools & Actions... --- - material::CollapsibleHeader(dl, "##ToolsToggle", TR("tools_actions"), - s_settingsState.tools_expanded, contentW, - body2, OnSurfaceMedium()); - - if (s_settingsState.tools_expanded) { - float btnSpacing = Layout::spacingMd(); - int btnsPerRow = (contentW >= 600.0f) ? 3 : 2; - float bw = (contentW - btnSpacing * (btnsPerRow - 1)) / btnsPerRow; - float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(100.0f); - bw = std::max(minBtnW, bw); - - if (TactileButton(TR("settings_address_book"), ImVec2(bw, 0), S.resolveFont("button"))) - app->setCurrentPage(ui::NavPage::Contacts); // now a top-level tab - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book")); - ImGui::SameLine(0, btnSpacing); - if (TactileButton(TR("settings_validate_address"), ImVec2(bw, 0), S.resolveFont("button"))) - ValidateAddressDialog::show(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate")); - if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } - if (TactileButton(TR("settings_request_payment"), ImVec2(bw, 0), S.resolveFont("button"))) - RequestPaymentDialog::show(); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment")); - if (btnsPerRow >= 3) { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } else { ImGui::SameLine(0, btnSpacing); } - if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button"))) - ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining")); - if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); } - if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button"))) - ShieldDialog::show(ShieldDialog::Mode::MergeToAddress); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); - ImGui::SameLine(0, btnSpacing); - if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) { - s_settingsState.confirm_clear_ztx = true; + // O1: node coinbase auto-shield status (v1.3.0+). Rendered as a full-width note BELOW the + // checkbox grid (not wedged between checkboxes) with friendly wording — the daemon's raw + // technical reason is available on hover. Nothing renders on pre-1.3.0 daemons (never probed). + if (app && app->daemonAutoShieldProbed() && + (app->daemonAutoShieldActive() || !app->daemonAutoShieldDisabledReason().empty())) { + ImGui::SetCursorScreenPos(ImVec2(cx, last + gp)); + ImGui::PushTextWrapPos((cx + cw) - ImGui::GetWindowPos().x); // wrap to the card content width + if (app->daemonAutoShieldActive()) { + ImGui::TextColored(ImVec4(0.40f, 0.78f, 0.40f, 1.0f), "%s", TR("autoshield_by_node")); + if (!app->daemonAutoShieldAddress().empty()) + ImGui::TextDisabled(" %s", app->daemonAutoShieldAddress().c_str()); + } else { + // Friendly, actionable wording. The seed-not-recoverable case is the common one and has a + // clear fix (back up the seed); anything else gets a generic line. Raw daemon text on hover. + ImGui::TextDisabled("%s", app->daemonAutoShieldSeedRecoverable() + ? TR("autoshield_off_generic") + : TR("autoshield_off_backup_seed")); + if (ImGui::IsItemHovered()) + material::Tooltip("%s", app->daemonAutoShieldDisabledReason().c_str()); + } + ImGui::PopTextWrapPos(); + last = ImGui::GetCursorScreenPos().y; // grow the card to include the note } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx")); + cardClose(0, last); } - // --- Backup & Data --- - ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("backup_data")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + // ---- Card 1: DIAGNOSTICS + Tools & Actions (2-column button grids) ---- { - using AT = material::ActionTier; - const bool fullNode = app->supportsFullNodeLifecycleActions(); - // Tier-ordered, icon-labelled buttons that WRAP to new rows (no more font-scale-to-fit). - // Emphasized actions (accent) cluster on top, then common actions, then low-emphasis exports. - auto btn = [&](material::ButtonFlow& fl, const char* id, const char* label, const char* icon, - AT tier, const char* tip) -> bool { - fl.next(material::ActionButtonWidth(label, icon)); - const bool p = material::ActionButton(id, label, icon, tier); - if (ImGui::IsItemHovered() && tip && tip[0]) material::Tooltip("%s", tip); + const float cx = ccBaseX + (ccW + ccGap) + pad; + const float col2W = (cw - Layout::spacingLg()) * 0.5f; + float rowY = cardHeader(1, TR("wallet_diagnostics_hdr")); + int c = 0; float last = rowY; + auto BTN = [&](const char* id, const char* label, const char* icon) -> bool { + const float bx = cx + c * (col2W + Layout::spacingLg()); + const bool p = renderCardButton(dl, bx, rowY, col2W, bh, id, label, icon); + last = rowY + bh; + if (c == 1) { rowY += bh + gp; c = 0; } else { c = 1; } return p; }; + auto rowBreak = [&]() { if (c == 1) { rowY += bh + gp; c = 0; } }; - // Emphasized (Primary): import key + (full-node) seed / wallets / bootstrap. - material::ButtonFlow fPrim(contentW); - if (btn(fPrim, "##imp_key", TR("settings_import_key"), ICON_MD_KEY, AT::Primary, TR("tt_import_key"))) - app->showImportKeyDialog(); - if (fullNode) { - if (btn(fPrim, "##seed", TR("seed_backup_button"), ICON_MD_VPN_KEY, AT::Primary, TR("tt_seed_backup"))) - app->showSeedBackupDialog(); - if (btn(fPrim, "##wallets", TR("wallets_button"), ICON_MD_ACCOUNT_BALANCE_WALLET, AT::Primary, TR("tt_wallets_button"))) - ui::WalletsDialog::show(app); - if (btn(fPrim, "##bootstrap", TR("download_bootstrap"), ICON_MD_CLOUD_DOWNLOAD, AT::Primary, TR("tt_download_bootstrap"))) - BootstrapDownloadDialog::show(app); + if (BTN("##wlog", TR("settings_open_log_folder"), ICON_MD_FOLDER)) + dragonx::util::Platform::openFolder(dragonx::util::Platform::getObsidianDragonDir()); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_open_log_folder")); + if (BTN("##wdiag", TR("settings_copy_diagnostics"), ICON_MD_CONTENT_COPY)) { + ImGui::SetClipboardText(app->buildDiagnosticsReport().c_str()); + ui::Notifications::instance().info(TR("settings_diagnostics_copied"), 4.0f); } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_copy_diagnostics")); + rowBreak(); - // Common (Secondary): viewing-key import, backup, (full-node) migrate + setup wizard. - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - material::ButtonFlow fSec(contentW); - if (btn(fSec, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY, AT::Secondary, TR("tt_import_viewkey"))) - app->showImportViewingKeyDialog(); - if (btn(fSec, "##backup", TR("settings_backup"), ICON_MD_BACKUP, AT::Secondary, TR("tt_backup"))) - app->showBackupDialog(); + rowY += Layout::spacingSm(); + ImGui::SetCursorScreenPos(ImVec2(cx, rowY)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("tools_actions_hdr")); + rowY = ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + c = 0; + + if (BTN("##waddr", TR("settings_address_book"), ICON_MD_CONTACTS)) + app->setCurrentPage(ui::NavPage::Contacts); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_address_book")); + if (BTN("##wval", TR("settings_validate_address"), ICON_MD_CHECK_CIRCLE)) + ValidateAddressDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_validate")); + if (BTN("##wreq", TR("settings_request_payment"), ICON_MD_QR_CODE)) + RequestPaymentDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_request_payment")); + if (BTN("##wshield", TR("settings_shield_mining"), ICON_MD_SHIELD)) + ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining")); + if (BTN("##wmerge", TR("settings_merge_to_address"), ICON_MD_CALL_MERGE)) + ShieldDialog::showMerge(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); + if (BTN("##wclear", TR("settings_clear_ztx"), ICON_MD_DELETE_SWEEP)) + s_settingsState.confirm_clear_ztx = true; + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_clear_ztx")); + rowBreak(); + cardClose(1, last); + } + + // Paint both cards at the same (tallest) height, then merge the channels. + { + const float eq = std::max(cardBot[0], cardBot[1]); + dl->ChannelsSetCurrent(0); + for (int col = 0; col < 2; ++col) { + const float cx = ccBaseX + col * (ccW + ccGap); + material::DrawGlassPanel(dl, ImVec2(cx, ccTop), ImVec2(cx + ccW, eq), glassSpec); + } + dl->ChannelsMerge(); + } + + ImGui::PopFont(); + // Reserve the full multi-card footprint with a Dummy so the scroll region grows to include + // the manually-positioned cards (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(ccBaseX, ccTop)); + ImGui::Dummy(ImVec2(availWidth, (ccBottom - ccTop) + Layout::spacingSm())); + } + + // ==================================================================== + // BACKUP & DATA — card (own category tab; split out of Wallet) + // ==================================================================== + if (s_settingsState.current_tab == TAB_BACKUP) { + const bool fullNode = app->supportsFullNodeLifecycleActions(); + + // Three side-by-side glass cards, each with its header inside (mockup-style grouping). + // Content is positioned manually (SetCursorScreenPos) because ImGui's Indent — which + // GlassCardScope relies on — is window-relative and would pull offset columns back to x0. + const float ccGap = Layout::cardGap(); + const int ccN = 3; + const float ccW = (availWidth - ccGap * (ccN - 1)) / (float)ccN; + const float cw = ccW - pad * 2; + const float ccTop = ImGui::GetCursorScreenPos().y; + const float ccBaseX = ImGui::GetCursorScreenPos().x; + float ccBottom = ccTop; + + ImGui::PushFont(body2); + const float bh = ImGui::GetFrameHeight() + 12.0f * dp; // taller, airier buttons (match mockup) + const float bgp = Layout::spacingSm(); // roomier gap between buttons + + // A full-card-width, left-aligned, solid button drawn at an explicit (x,y). + auto cardBtn = [&](float x, float y, float w, const char* id, const char* label, const char* icon) -> bool { + return renderCardButton(dl, x, y, w, bh, id, label, icon); + }; + // All cards render onto one foreground channel; the glass panels are painted afterwards at a + // single equal height (the tallest card) so side-by-side cards match — mockup grid-stretch look. + float cardBot[3] = { ccTop, ccTop, ccTop }; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + auto cardHeader = [&](int col, const char* header) -> float { + const float cx = ccBaseX + col * (ccW + ccGap); + float cy = ccTop + pad; + ImGui::SetCursorScreenPos(ImVec2(cx + pad, cy)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), header); + return ImGui::GetCursorScreenPos().y + Layout::spacingMd(); + }; + auto cardClose = [&](int col, float lastBottom) { + cardBot[col] = lastBottom + pad; + ccBottom = std::max(ccBottom, cardBot[col]); + }; + + // ---- Card 0: Import & Restore ---- + { + const float cx = ccBaseX + pad; + float cy = cardHeader(0, TR("backup_col_import")); + float last = cy; + if (cardBtn(cx, cy, cw, "##imp_key", TR("settings_import_key"), ICON_MD_KEY)) app->showImportKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_key")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##imp_vk", TR("settings_import_viewkey"), ICON_MD_VISIBILITY)) app->showImportViewingKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_import_viewkey")); + last = cy + bh; if (fullNode) { + cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##wallets", TR("wallets_button"), ICON_MD_ACCOUNT_BALANCE_WALLET)) ui::WalletsDialog::show(app); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallets_button")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##bootstrap", TR("download_bootstrap"), ICON_MD_CLOUD_DOWNLOAD)) BootstrapDownloadDialog::show(app); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_download_bootstrap")); + last = cy + bh; + } + cardClose(0, last); + } + + // ---- Card 1: Backup ---- + { + const float cx = ccBaseX + (ccW + ccGap) + pad; + float cy = cardHeader(1, TR("backup_col_backup")); + float last = cy; + if (fullNode) { + if (cardBtn(cx, cy, cw, "##seed", TR("seed_backup_button"), ICON_MD_VPN_KEY)) app->showSeedBackupDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_backup")); + last = cy + bh; cy += bh + bgp; + } + if (cardBtn(cx, cy, cw, "##backup", TR("settings_backup"), ICON_MD_BACKUP)) app->showBackupDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_backup")); + last = cy + bh; + if (fullNode) { + cy += bh + bgp; const bool migrateGlow = app->isPreSeedWallet(); - if (btn(fSec, "##migrate", TR("seed_migrate_button"), ICON_MD_SWAP_HORIZ, AT::Secondary, TR("tt_seed_migrate"))) - app->showSeedMigrationDialog(); - if (migrateGlow) { // pulsing accent halo nudging a legacy wallet to migrate + if (cardBtn(cx, cy, cw, "##migrate", TR("seed_migrate_button"), ICON_MD_SWAP_HORIZ)) app->showSeedMigrationDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate")); + if (migrateGlow) { const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax(); const float gdp = Layout::dpiScale(); const float pulse = 0.5f + 0.5f * std::sin((float)ImGui::GetTime() * 3.2f); - ImDrawList* gdl = ImGui::GetWindowDrawList(); for (int g = 3; g >= 1; --g) { const float e = (float)g * 2.2f * gdp; const int a = (int)((70.0f + pulse * 95.0f) / (float)g); - gdl->AddRect(ImVec2(gmn.x - e, gmn.y - e), ImVec2(gmx.x + e, gmx.y + e), - material::WithAlpha(material::Primary(), a), 6.0f * gdp + e, 0, 1.6f * gdp); + dl->AddRect(ImVec2(gmn.x - e, gmn.y - e), ImVec2(gmx.x + e, gmx.y + e), + material::WithAlpha(material::Primary(), a), 6.0f * gdp + e, 0, 1.6f * gdp); } } - if (btn(fSec, "##wizard", TR("setup_wizard"), ICON_MD_AUTO_FIX_HIGH, AT::Secondary, TR("tt_wizard"))) - app->restartWizard(); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##wizard", TR("setup_wizard"), ICON_MD_AUTO_FIX_HIGH)) app->restartWizard(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wizard")); + last = cy + bh; } - - // Low-emphasis (Tertiary): exports. - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - material::ButtonFlow fTer(contentW); - if (btn(fTer, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT, AT::Tertiary, TR("tt_export_key"))) - app->showExportKeyDialog(); - if (btn(fTer, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE, AT::Tertiary, TR("tt_export_all"))) - ExportAllKeysDialog::show(); - if (btn(fTer, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION, AT::Tertiary, TR("tt_export_csv"))) - ExportTransactionsDialog::show(); + cardClose(1, last); } + + // ---- Card 2: Export ---- + { + const float cx = ccBaseX + (ccW + ccGap) * 2.0f + pad; + float cy = cardHeader(2, TR("backup_col_export")); + float last = cy; + if (cardBtn(cx, cy, cw, "##exp_key", TR("settings_export_key"), ICON_MD_LOGOUT)) app->showExportKeyDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_key")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##exp_all", TR("settings_export_all"), ICON_MD_ARCHIVE)) ExportAllKeysDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_all")); + last = cy + bh; cy += bh + bgp; + if (cardBtn(cx, cy, cw, "##exp_csv", TR("settings_export_csv"), ICON_MD_DESCRIPTION)) ExportTransactionsDialog::show(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_export_csv")); + last = cy + bh; + cardClose(2, last); + } + + // Paint all three cards at the same (tallest) height, then merge the channels. + { + const float eq = std::max({cardBot[0], cardBot[1], cardBot[2]}); + dl->ChannelsSetCurrent(0); + for (int col = 0; col < 3; ++col) { + const float cx = ccBaseX + col * (ccW + ccGap); + material::DrawGlassPanel(dl, ImVec2(cx, ccTop), ImVec2(cx + ccW, eq), glassSpec); + } + dl->ChannelsMerge(); + } + + ImGui::PopFont(); + // Reserve the full multi-card footprint with a Dummy so the scroll region grows to include + // the manually-positioned cards (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(ccBaseX, ccTop)); + ImGui::Dummy(ImVec2(availWidth, (ccBottom - ccTop) + Layout::spacingSm())); } - ImGui::Dummy(ImVec2(0, gap)); - - // ==================================================================== // NODE & SECURITY — card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node_security")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + if (s_settingsState.current_tab == TAB_NODE) { + // Two side-by-side glass cards (NODE/SECURITY | DAEMON BINARY), drawn manually because + // GlassCardScope's Indent is window-relative and can't offset the right card. All the + // column *content* below is unchanged; only the card wrapper differs. + const float ndTop = ImGui::GetCursorScreenPos().y; + const float ndBaseX = ImGui::GetCursorScreenPos().x; + bool ndTwoCol = false; + float ndColW = 0.0f, ndColGap = 0.0f, ndLeftBottom = 0.0f, ndRightBottom = 0.0f, ndSingleBottom = ndTop; + dl->ChannelsSplit(2); + dl->ChannelsSetCurrent(1); + ImGui::SetCursorScreenPos(ImVec2(ndBaseX, ndTop + pad)); + ImGui::Indent(pad); float contentW = availWidth - pad * 2; float minBtnW = S.drawElement("components.settings-page", "wallet-btn-min-width").sizeOr(130.0f); @@ -1568,25 +1556,20 @@ void RenderSettingsPage(App* app) { for (int i = 0; i < 6; i++) { if (timeoutValues[i] == timeout) { selTimeout = i; break; } } - // In a narrow (two-column) card the encrypt controls + auto-lock + PIN don't fit on - // one row, so wrap the auto-lock/PIN group onto its own row at the section's left edge. - const bool secWrap = includeRpcEncrypt && secNarrow; - if (includeRpcEncrypt && !secWrap) { - ImGui::SameLine(0, Layout::spacingLg()); - } else { - if (includeRpcEncrypt) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); - } + // Auto-lock gets its own full-width row (label left, dropdown filling — mockup). + (void)secNarrow; + if (includeRpcEncrypt) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); ImGui::AlignTextToFramePadding(); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("settings_auto_lock")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::PushItemWidth(comboW); + const float alLblW = ImGui::CalcTextSize(TR("settings_auto_lock")).x; + ImGui::SameLine(0, Layout::spacingMd()); + ImGui::SetNextItemWidth(std::max(comboW, secColW - alLblW - Layout::spacingMd())); if (ImGui::Combo("##autolock", &selTimeout, timeoutLabels, 6)) { app->settings()->setAutoLockTimeout(timeoutValues[selTimeout]); app->settings()->save(); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_auto_lock")); - ImGui::PopItemWidth(); // PIN unlock controls, trailing the auto-lock combo on the same row. bool isEncryptedPIN = app->state().isEncrypted(); @@ -1594,7 +1577,8 @@ void RenderSettingsPage(App* app) { bool hasPIN = app->hasPinVault(); float pinBtnW = std::min(rowBtnW({TR("settings_set_pin"), TR("settings_change_pin"), TR("settings_remove_pin")}), (secColW - Layout::spacingSm()) * 0.5f); - ImGui::SameLine(0, Layout::spacingMd()); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); if (!hasPIN) { if (TactileButton(TR("settings_set_pin"), ImVec2(pinBtnW, 0), S.resolveFont("button"))) app->showPinSetupDialog(); @@ -1617,7 +1601,8 @@ void RenderSettingsPage(App* app) { ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_pin_active")); } } else { - ImGui::SameLine(0, Layout::spacingMd()); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y)); ImGui::AlignTextToFramePadding(); ImGui::TextColored(ImVec4(1,1,1,0.3f), "%s", TR("settings_encrypt_first_pin")); } @@ -1705,7 +1690,7 @@ void RenderSettingsPage(App* app) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(TR("lite_birthday_label")); ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - ImGui::SetNextItemWidth(std::min(160.0f, liteInputW)); + ImGui::SetNextItemWidth(std::min(160.0f * dp, liteInputW)); ImGui::InputInt("##LiteRestoreBirthday", &s_settingsState.lite_restore_birthday); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_restore_birthday")); if (s_settingsState.lite_restore_birthday < 0) s_settingsState.lite_restore_birthday = 0; @@ -1717,7 +1702,7 @@ void RenderSettingsPage(App* app) { ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(TR("lite_account_label")); ImGui::SameLine(leftX - sectionOrigin.x + liteLabelW); - ImGui::SetNextItemWidth(std::min(160.0f, liteInputW)); + ImGui::SetNextItemWidth(std::min(160.0f * dp, liteInputW)); ImGui::InputInt("##LiteRestoreAccount", &s_settingsState.lite_restore_account); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_lite_restore_account")); if (s_settingsState.lite_restore_account < 0) s_settingsState.lite_restore_account = 0; @@ -1897,7 +1882,7 @@ void RenderSettingsPage(App* app) { while (!liteKey.empty() && (liteKey.front()==' '||liteKey.front()=='\t'||liteKey.front()=='\n'||liteKey.front()=='\r')) liteKey.erase(liteKey.begin()); while (!liteKey.empty() && (liteKey.back()==' '||liteKey.back()=='\t'||liteKey.back()=='\n'||liteKey.back()=='\r')) liteKey.pop_back(); if (liteKey.empty()) { - s_settingsState.lite_backup_status = "Enter a private key to import."; + s_settingsState.lite_backup_status = TR("grpa_enter_private_key_to_import"); } else { const auto r = app->liteWallet()->importKey(liteKey); sodium_memzero(s_settingsState.lite_import_key, sizeof(s_settingsState.lite_import_key)); @@ -2020,6 +2005,7 @@ void RenderSettingsPage(App* app) { // Advance to the true bottom of the single column. ImGui::SetCursorScreenPos(ImVec2(sectionOrigin.x, ImGui::GetCursorScreenPos().y)); + ndSingleBottom = ImGui::GetCursorScreenPos().y; // lite = single card } else { // ========================= FULL NODE ========================= @@ -2031,9 +2017,14 @@ void RenderSettingsPage(App* app) { // Two-column layout when wide enough: Node / RPC / Security on the left, Daemon binary on // the right (fills the empty right side + shortens the card). One column when narrow. const bool nsHasDaemon = app->supportsFullNodeLifecycleActions(); - const float nsColGap = Layout::spacingXl(); const bool nsTwoCol = nsHasDaemon && contentW > 760.0f * Layout::dpiScale(); - const float nsColW = nsTwoCol ? (contentW - nsColGap) * 0.5f : contentW; + // The two columns become two SEPARATE glass panels: left [x0, x0+cardW], + // right [x0+cardW+cardGap, x0+availWidth]. For the panels to keep a clean cardGap + // between them, the content column must be cardW-2*pad and the right-column indent + // (nsColW+nsColGap) must equal cardW+cardGap — so nsColGap = cardGap + 2*pad. + const float nsColGap = Layout::cardGap() + 2.0f * pad; + const float nsColW = nsTwoCol ? ((availWidth - Layout::cardGap()) * 0.5f - 2.0f * pad) : contentW; + ndTwoCol = nsTwoCol; ndColW = nsColW; ndColGap = nsColGap; // hoist geometry for the two-panel draw const ImVec2 nsColTop = ImGui::GetCursorScreenPos(); // Window-local anchor for the right column. We shift it with Indent() (not a one-shot // SetCursorScreenPos): ImGui resets the cursor X to the window's left indent on every @@ -2046,7 +2037,7 @@ void RenderSettingsPage(App* app) { // -------------------- NODE / DATA -------------------- Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("node")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { const std::string dirPath = util::Platform::getDragonXDataDir(); const std::string walletPath = dirPath + "wallet.dat"; @@ -2059,20 +2050,27 @@ void RenderSettingsPage(App* app) { + Layout::spacingLg(); const ImU32 metaCol = OnSurfaceMedium(); - // Row 1: Data directory — a clickable link (opens the folder) + a copy button. + // Row 1: Data directory — label left; clickable path + copy button RIGHT-aligned + // (mockup .kv space-between ledger look). The path middle-ellipsizes to fit. ImGui::AlignTextToFramePadding(); ImGui::PushStyleColor(ImGuiCol_Text, metaCol); ImGui::TextUnformatted(TR("settings_data_dir")); ImGui::PopStyleColor(); + ImFont* pathFont = ImGui::GetFont(); + const float copyW = ImGui::GetFrameHeight(); + const float pathAvailW = contentW - labelW - copyW - Layout::spacingSm() * 2.0f; + const std::string dirShown = + material::TruncateToWidth(dirPath, pathFont, pathFont->LegacySize, pathAvailW); + const float pathW = ImGui::CalcTextSize(dirShown.c_str()).x; ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(leftX + labelW); + ImGui::SetCursorPosX(leftX + contentW - copyW - Layout::spacingSm() - pathW); ImGui::AlignTextToFramePadding(); - ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirPath.c_str()); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirShown.c_str()); if (ImGui::IsItemHovered()) { const ImVec2 tmn = ImGui::GetItemRectMin(), tmx = ImGui::GetItemRectMax(); dl->AddLine(ImVec2(tmn.x, tmx.y), ImVec2(tmx.x, tmx.y), Primary()); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); - material::Tooltip("%s", TR("tt_open_dir")); + material::Tooltip("%s\n%s", dirPath.c_str(), TR("tt_open_dir")); } if (ImGui::IsItemClicked()) util::Platform::openFolder(dirPath); ImGui::SameLine(0, Layout::spacingSm()); @@ -2085,16 +2083,39 @@ void RenderSettingsPage(App* app) { ImGui::SetClipboardText(dirPath.c_str()); } - // Row 2: Wallet size. + // Row 2: Wallet size — label left, value RIGHT-aligned. ImGui::AlignTextToFramePadding(); ImGui::PushStyleColor(ImGuiCol_Text, metaCol); ImGui::TextUnformatted(TR("settings_wallet_size_label")); ImGui::PopStyleColor(); - ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(leftX + labelW); - ImGui::AlignTextToFramePadding(); - if (wallet_size > 0) ImGui::TextUnformatted(size_str.c_str()); - else ImGui::TextDisabled("%s", TR("settings_not_found")); + { + const char* wv = (wallet_size > 0) ? size_str.c_str() : TR("settings_not_found"); + const float wvW = ImGui::CalcTextSize(wv).x; + ImGui::SameLine(0, 0); + ImGui::SetCursorPosX(leftX + contentW - wvW); + ImGui::AlignTextToFramePadding(); + if (wallet_size > 0) ImGui::TextUnformatted(wv); + else ImGui::TextDisabled("%s", wv); + } + + // Large-wallet nudge: the BDB wallet.dat bloats with shielded-note witness data and + // never shrinks in place. Past a threshold, hint the user toward consolidating notes + // (Merge to Address) to curb further growth. Full-node only (lite has no wallet.dat here). + static constexpr uint64_t kWalletBloatWarnBytes = 500ull * 1024 * 1024; // 500 MB + if (app->supportsFullNodeLifecycleActions() && wallet_size > kWalletBloatWarnBytes) { + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::PushStyleColor(ImGuiCol_Text, Warning()); + ImGui::PushTextWrapPos(leftX + contentW); + ImGui::TextWrapped("%s", TR("wallet_size_warn")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallet_size_warn")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (material::ActionButton("##walletconsolidate", TR("wallet_size_consolidate"), + ICON_MD_CALL_MERGE, material::ActionTier::Secondary)) + ShieldDialog::showConsolidate(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge")); + } // Row 3: folder buttons (their own row so the path gets the full width). ImGui::Dummy(ImVec2(0, Layout::spacingXs())); @@ -2139,61 +2160,47 @@ void RenderSettingsPage(App* app) { const char* portLbl = TR("rpc_port"); const char* userLbl = TR("rpc_user"); const char* passLbl = TR("rpc_pass"); - float labelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(portLbl).x + - ImGui::CalcTextSize(userLbl).x + ImGui::CalcTextSize(passLbl).x; + // Two rows, two column-aligned cells each: Host | Port, then Username | Password. + // Each input fills to its column's right edge so the two columns line up vertically. + const float colGap = spMd; + const float colW = std::floor((contentW - colGap) * 0.5f); + const float startX = ImGui::GetCursorPosX(); + const float leftColRight = startX + colW; + const float rightColRight = startX + contentW; - const bool fourAcross = contentW >= 700.0f; - auto field = [&](const char* label, const char* id, char* buf, size_t bufSz, - float inputW, bool password) { + // Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf, + // so these fields DISPLAY the live connection (editing them here did nothing). + auto cell = [&](const char* label, const char* id, char* buf, size_t bufSz, + float cellX, float cellRight, bool password) { + ImGui::SetCursorPosX(cellX); ImGui::AlignTextToFramePadding(); ImGui::TextUnformatted(label); ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputW); - // Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf, - // so these fields DISPLAY the live connection (editing them here did nothing). + ImGui::SetNextItemWidth(std::max(60.0f, cellRight - ImGui::GetCursorPosX())); ImGui::InputText(id, buf, bufSz, ImGuiInputTextFlags_ReadOnly | (password ? ImGuiInputTextFlags_Password : 0)); }; - if (fourAcross) { - // fieldW = (contentW - labels - per-field label gaps - 3 inter-field gaps) / 4 - float inputTotal = contentW - labelsW - Layout::spacingXs() * 4 - spMd * 3; - float inputW = std::max(60.0f, std::floor(inputTotal / 4.0f)); - field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); - ImGui::SameLine(0, spMd); - field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - ImGui::SameLine(0, spMd); - field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); - ImGui::SameLine(0, spMd); - field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); - } else { - // 2x2: two fields per row. - float halfLabelsW = ImGui::CalcTextSize(hostLbl).x + ImGui::CalcTextSize(userLbl).x; - float inputW = std::max(60.0f, std::floor( - (contentW - halfLabelsW - Layout::spacingXs() * 2 - spMd) / 2.0f)); - field(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); - ImGui::SameLine(0, spMd); - field(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); + // Row 1: Host | Port + cell(hostLbl, "##RPCHost", s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), startX, leftColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_host")); + ImGui::SameLine(); + cell(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), leftColRight + colGap, rightColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - field(portLbl, "##RPCPort", s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), inputW, false); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_port")); - ImGui::SameLine(0, spMd); - field(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), inputW, true); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); - } + // Row 2: Username | Password + cell(userLbl, "##RPCUser", s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), startX, leftColRight, false); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_user")); + ImGui::SameLine(); + cell(passLbl, "##RPCPassword", s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), leftColRight + colGap, rightColRight, true); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_rpc_pass")); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("settings_auto_detected")); if (s_settingsState.rpc_plaintext_remote) { ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); ImGui::PushTextWrapPos(sectionOrigin.x + contentW); - ImGui::TextWrapped("Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS."); + ImGui::TextWrapped("%s", TR("rpc_plaintext_remote_warning")); ImGui::PopTextWrapPos(); ImGui::PopStyleColor(); } @@ -2206,6 +2213,7 @@ void RenderSettingsPage(App* app) { renderSecuritySection(sectionOrigin.x, contentW, /*includeRpcEncrypt=*/true); } // ---- end left column ---- const float nsLeftBottom = ImGui::GetCursorScreenPos().y; + ndLeftBottom = nsLeftBottom; if (nsTwoCol) { // Reset to the top, then indent so every line in the right column starts at the // column X (the indent persists across line-advances; the explicit SetCursorPosX @@ -2241,11 +2249,33 @@ void RenderSettingsPage(App* app) { return std::string(buf); }; - ImGui::Dummy(ImVec2(0, Layout::spacingLg())); - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Heading row: "DAEMON BINARY" on the left, a compact colored status right-aligned + // on the same line (moved up out of the status box, and shortened). + { + const ImVec2 hp = ImGui::GetCursorScreenPos(); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_binary")); + if (bun.available) { + const bool sameSize = inst.exists && inst.size == bun.size; + const char* stTxt = !inst.exists ? TR("daemon_status_none") + : sameSize ? TR("daemon_status_ok") + : TR("daemon_status_diff"); + const ImU32 stCol = (inst.exists && sameSize) ? Success() : Warning(); + ImFont* ov = Type().overline(); + const float stW = ov->CalcTextSizeA(ov->LegacySize, FLT_MAX, 0, stTxt).x; + dl->AddText(ov, ov->LegacySize, ImVec2(hp.x + contentW - stW, hp.y), stCol, stTxt); + } + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - // Version info (Installed / Bundled) as key/value rows, then a status chip. + const float ddp = Layout::dpiScale(); + + // --- Status, grouped in a filled box (mockup .statusbox) --- + const float boxPad = Layout::spacingMd(); // roomier inner padding (mockup ~9-11px) + const float boxLeftX = ImGui::GetCursorScreenPos().x; + ImGui::Dummy(ImVec2(0, boxPad)); + ImGui::BeginGroup(); + ImGui::Indent(boxPad); const float dLeftX = ImGui::GetCursorPosX(); const float dLabelW = std::max(ImGui::CalcTextSize(TR("daemon_installed")).x, ImGui::CalcTextSize(TR("daemon_bundled")).x) + Layout::spacingLg(); @@ -2255,7 +2285,10 @@ void RenderSettingsPage(App* app) { ImGui::TextUnformatted(label); ImGui::PopStyleColor(); ImGui::SameLine(0, 0); - ImGui::SetCursorPosX(dLeftX + dLabelW); + // Right-align the value to the box edge (mockup .kv). If it's too long to fit + // (e.g. the installed version+size+date), fall back to left-packing after the label. + const float dvW = ImGui::CalcTextSize(value.c_str()).x; + ImGui::SetCursorPosX(std::max(dLeftX + dLabelW, dLeftX + (contentW - 2.0f * boxPad) - dvW)); ImGui::AlignTextToFramePadding(); if (dim) ImGui::TextDisabled("%s", value.c_str()); else ImGui::TextUnformatted(value.c_str()); @@ -2279,30 +2312,25 @@ void RenderSettingsPage(App* app) { } else { dkv(TR("daemon_bundled"), TR("daemon_none_bundled"), true); } - if (bun.available) { - const bool sameSize = inst.exists && inst.size == bun.size; - const char* chipTxt = !inst.exists ? TR("daemon_status_missing") - : sameSize ? TR("daemon_status_match") - : TR("daemon_status_differ"); - const ImU32 chipCol = sameSize ? Success() : Warning(); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - const float dp = Layout::dpiScale(); - const ImVec2 cp = ImGui::GetCursorScreenPos(); - const float chpad = 8.0f * dp, chh = ImGui::GetFrameHeight(); - const ImVec2 cts = ImGui::CalcTextSize(chipTxt); - const float chw = cts.x + chpad * 2.0f; - dl->AddRectFilled(cp, ImVec2(cp.x + chw, cp.y + chh), material::WithAlpha(chipCol, 38), chh * 0.4f); - dl->AddRect(cp, ImVec2(cp.x + chw, cp.y + chh), material::WithAlpha(chipCol, 120), chh * 0.4f, 0, 1.0f); - dl->AddText(ImVec2(cp.x + chpad, cp.y + (chh - cts.y) * 0.5f), chipCol, chipTxt); - ImGui::Dummy(ImVec2(chw, chh)); + ImGui::Unindent(boxPad); + ImGui::EndGroup(); + { + const ImVec2 gmn = ImGui::GetItemRectMin(), gmx = ImGui::GetItemRectMax(); + const ImVec2 bmn(boxLeftX, gmn.y - boxPad), bmx(boxLeftX + contentW, gmx.y + boxPad); + // Subtle lifted fill (mockup .statusbox #26262a on the #121317 card) + near-invisible border. + dl->AddRectFilled(bmn, bmx, material::WithAlpha(material::OnSurface(), 8), 8.0f * ddp); + dl->AddRect(bmn, bmx, material::WithAlpha(material::OnSurface(), 20), 8.0f * ddp, 0, 1.0f); } + ImGui::Dummy(ImVec2(0, boxPad)); // Refresh the cached daemon info once an in-app install has completed. if (ui::DaemonUpdateDialog::consumeInstalled()) s_settingsState.daemon_info_loaded = false; - // Update actions: Check for updates (primary) | Refresh | Install bundled. - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + // --- UPDATES --- + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_updates_label")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { using AT = material::ActionTier; material::ButtonFlow uf(contentW); @@ -2322,7 +2350,9 @@ void RenderSettingsPage(App* app) { ImGui::EndDisabled(); } - // Maintenance actions: Test / Rescan / Repair | Delete blockchain (destructive). + // --- MAINTENANCE --- + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("daemon_maintenance_label")); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { using AT = material::ActionTier; @@ -2335,14 +2365,14 @@ void RenderSettingsPage(App* app) { try { rpc::RPCClient::TraceScope trace("Settings / Test connection"); rpc->call("getinfo"); - return []() { Notifications::instance().success("RPC connection OK"); }; + return []() { Notifications::instance().success(TR("settings_rpc_ok")); }; } catch (const std::exception& e) { std::string err = e.what(); - return [err]() { Notifications::instance().error("RPC error: " + err); }; + return [err]() { Notifications::instance().error(std::string(TR("settings_rpc_error_prefix")) + err); }; } }); } else { - Notifications::instance().warning("Not connected to daemon"); + Notifications::instance().warning(TR("settings_not_connected")); } } if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_test_conn")); @@ -2359,7 +2389,23 @@ void RenderSettingsPage(App* app) { if (material::ActionButton("##drepair", TR("repair_wallet"), ICON_MD_HEALING, AT::Secondary)) s_settingsState.confirm_repair_wallet = true; if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_repair_wallet")); - mf.next(material::ActionButtonWidth(TR("delete_blockchain"), ICON_MD_DELETE)); + ImGui::EndDisabled(); + } + + // --- Danger zone: Delete Blockchain, fenced off below a divider --- + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // mockup .danger margin-top: 8px + { + const ImVec2 dvp = ImGui::GetCursorScreenPos(); + // Neutral hairline (mockup .danger border-top #26262b) — not an alarming red rule. + dl->AddLine(dvp, ImVec2(dvp.x + contentW, dvp.y), + material::WithAlpha(material::OnSurface(), 22), 1.0f); + } + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // mockup .danger padding-top: 11px + { + using AT = material::ActionTier; + material::ButtonFlow df(contentW); + ImGui::BeginDisabled(!app->isUsingEmbeddedDaemon()); + df.next(material::ActionButtonWidth(TR("delete_blockchain"), ICON_MD_DELETE)); if (material::ActionButton("##ddelete", TR("delete_blockchain"), ICON_MD_DELETE, AT::Destructive)) s_settingsState.confirm_delete_blockchain = true; if (ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) material::Tooltip("%s", TR("tt_delete_blockchain")); @@ -2368,239 +2414,297 @@ void RenderSettingsPage(App* app) { } } // ---- end right column ---- const float nsRightBottom = ImGui::GetCursorScreenPos().y; + ndRightBottom = nsRightBottom; ndSingleBottom = nsRightBottom; if (nsTwoCol) ImGui::Unindent(nsColW + nsColGap); // restore indent before the rest of the page ImGui::SetCursorScreenPos(ImVec2(nsColTop.x, nsTwoCol ? std::max(nsLeftBottom, nsRightBottom) : nsRightBottom)); ImGui::PopFont(); } + + // ---- Draw the glass card(s) behind the content, then merge the channels ---- + ImGui::Unindent(pad); + dl->ChannelsSetCurrent(0); + if (ndTwoCol) { + const float ndEq = std::max(ndLeftBottom, ndRightBottom); // equal-height cards (mockup grid stretch) + material::DrawGlassPanel(dl, ImVec2(ndBaseX, ndTop), + ImVec2(ndBaseX + 2.0f * pad + ndColW, ndEq + bottomPad), glassSpec); + material::DrawGlassPanel(dl, ImVec2(ndBaseX + ndColW + ndColGap, ndTop), + ImVec2(ndBaseX + availWidth, ndEq + bottomPad), glassSpec); + } else { + material::DrawGlassPanel(dl, ImVec2(ndBaseX, ndTop), + ImVec2(ndBaseX + availWidth, ndSingleBottom + bottomPad), glassSpec); + } + dl->ChannelsMerge(); + const float ndBot = ndTwoCol ? std::max(ndLeftBottom, ndRightBottom) : ndSingleBottom; + ImGui::SetCursorScreenPos(ImVec2(ndBaseX, ndBot + bottomPad)); } } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // EXPLORER & OPTIONS — full-width card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("explorer_section")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_EXPLORER) { + // Card 1 — URLS + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + float contentW = availWidth - pad * 2; + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("explorer_urls_hdr")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + ImGui::PushFont(body2); - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + // Transaction URL and Address URL — stacked rows, label left, input filling the card (mockup .row). + const float urlLblW = std::max(ImGui::CalcTextSize(TR("transaction_url")).x, + ImGui::CalcTextSize(TR("address_url")).x) + Layout::spacingMd(); + const float urlRowX = ImGui::GetCursorPosX(); + const float urlInputW = contentW - urlLblW; - float contentW = availWidth - pad * 2; - ImGui::PushFont(body2); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("transaction_url")); + ImGui::SameLine(urlRowX + urlLblW); + ImGui::SetNextItemWidth(urlInputW); + ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer)); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url")); - // Row 1: Transaction URL | Address URL (side-by-side) - float halfW = (contentW - Layout::spacingLg()) * 0.5f; - float lblTxW = ImGui::CalcTextSize("Transaction URL").x + Layout::spacingXs(); - float lblAddrW = ImGui::CalcTextSize("Address URL").x + Layout::spacingXs(); - float inputTxW = std::max(80.0f, halfW - lblTxW); - float inputAddrW = std::max(80.0f, halfW - lblAddrW); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - // Row start X (indent-inclusive) — the Address column is placed relative - // to it, not to a fixed `pad`, so it lands correctly in the right column. - const float expRowX = ImGui::GetCursorPosX(); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("transaction_url")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputTxW); - ImGui::InputText("##TxExplorer", s_settingsState.tx_explorer, sizeof(s_settingsState.tx_explorer)); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_tx_url")); - ImGui::SameLine(expRowX + halfW + Layout::spacingLg()); - ImGui::AlignTextToFramePadding(); - ImGui::TextUnformatted(TR("address_url")); - ImGui::SameLine(0, Layout::spacingXs()); - ImGui::SetNextItemWidth(inputAddrW); - ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer)); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url")); + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(TR("address_url")); + ImGui::SameLine(urlRowX + urlLblW); + ImGui::SetNextItemWidth(urlInputW); + ImGui::InputText("##AddrExplorer", s_settingsState.addr_explorer, sizeof(s_settingsState.addr_explorer)); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_addr_url")); - ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Row 2: Checkboxes + Block Explorer button. Keep the two checkboxes - // side-by-side, but wrap the Block Explorer button onto its own row when - // it won't fit the (narrow, two-column) card — measured, so it's locale-safe. - const float expRowRight = ImGui::GetCursorScreenPos().x + contentW; - ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees")); - ImGui::SameLine(0, Layout::spacingLg()); - ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices); - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices")); - const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x - + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg(); - if (ImGui::GetItemRectMax().x + expBtnW <= expRowRight) - ImGui::SameLine(0, Layout::spacingLg()); - else - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) { - util::Platform::openUrl("https://explorer.dragonx.is"); + ImGui::PopFont(); } - if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer")); - } - ImGui::Dummy(ImVec2(0, gap)); + ImGui::Dummy(ImVec2(0, gap)); + + // Card 2 — OPTIONS + { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + float contentW = availWidth - pad * 2; + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("wallet_options_hdr")); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + ImGui::PushFont(body2); + + // Checkboxes + Block Explorer button (button wraps to its own row when it won't fit). + const float expRowRight = ImGui::GetCursorScreenPos().x + contentW; + ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees")); + ImGui::SameLine(0, Layout::spacingLg()); + ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices")); + const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg(); + if (ImGui::GetItemRectMax().x + expBtnW <= expRowRight) + ImGui::SameLine(0, Layout::spacingLg()); + else + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) { + util::Platform::openUrl("https://explorer.dragonx.is"); + } + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_block_explorer")); + + ImGui::PopFont(); + } + } // ==================================================================== // CHAT & CONTACTS — card (same controls as the Chat tab's settings notch) // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("chat_settings_section")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + if (s_settingsState.current_tab == TAB_CHAT) { + // The shared control paints its own two cards (Appearance | Messaging) when drawCards=true. ImGui::PushFont(body2); - RenderChatSettingsControls(app, availWidth - pad * 2.0f); // card inner width (GlassCard doesn't narrow it) + RenderChatSettingsControls(app, availWidth, /*drawCards=*/true); ImGui::PopFont(); } - ImGui::Dummy(ImVec2(0, gap)); - // ==================================================================== // ABOUT — card // ==================================================================== - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about")); - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (s_settingsState.current_tab == TAB_ABOUT) { + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + const float contentW = availWidth - pad * 2; + const float adp = Layout::dpiScale(); + const float baseX = ImGui::GetCursorScreenPos().x; - ImVec2 cardMin = ImGui::GetCursorScreenPos(); - dl->ChannelsSplit(2); - dl->ChannelsSetCurrent(1); - ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pad)); - ImGui::Indent(pad); - - // Logo on the left side of the about card. Deferred: reserve horizontal space - // now, but draw the image after the card's final height is known so it scales to - // the full card height (no empty space below it). + // --- Header: small logo + title / tagline / tech line --- + const ImVec2 logoTop = ImGui::GetCursorScreenPos(); + const float logoSz = 60.0f * adp; ImTextureID logoTex = app->getLogoTexture(); - float logoAreaW = 0; - ImVec2 logoPos = ImGui::GetCursorScreenPos(); - float logoAspect = (app->getLogoHeight() > 0) + const float logoAspect = (app->getLogoHeight() > 0) ? (float)app->getLogoWidth() / (float)app->getLogoHeight() : 1.0f; - float logoReserveH = schema::UI().drawElement("components.settings-page", "about-logo-size").sizeOr(150.0f); - if (logoTex != 0) { - logoAreaW = logoReserveH * logoAspect + Layout::spacingLg(); - ImGui::Indent(logoAreaW); - } + float logoAreaW = 0.0f; + if (logoTex != 0) { logoAreaW = logoSz + Layout::spacingLg(); ImGui::Indent(logoAreaW); } - float contentW = availWidth - pad * 2 - logoAreaW; - - // App name + version on same line ImGui::PushFont(sub1); ImGui::TextUnformatted(DRAGONX_APP_NAME); ImGui::PopFont(); - ImGui::SameLine(0, Layout::spacingLg()); + ImGui::SameLine(0, Layout::spacingSm()); ImGui::PushFont(body2); snprintf(buf, sizeof(buf), "v%s", DRAGONX_VERSION); - ImGui::TextUnformatted(buf); - ImGui::SameLine(0, Layout::spacingLg()); - snprintf(buf, sizeof(buf), "ImGui %s", IMGUI_VERSION); - ImGui::TextColored(ImVec4(1,1,1,0.4f), "%s", buf); + ImGui::TextColored(ImVec4(1, 1, 1, 0.5f), "%s", buf); ImGui::PopFont(); - // Daemon version - { - const auto& st = app->state(); - if (st.daemon_version > 0) { - int dmaj = st.daemon_version / 1000000; - int dmin = (st.daemon_version / 10000) % 100; - int dpat = (st.daemon_version / 100) % 100; - ImGui::PushFont(body2); - snprintf(buf, sizeof(buf), "%s: %d.%d.%d", TR("daemon_version"), dmaj, dmin, dpat); - ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", buf); - ImGui::PopFont(); - } - } - - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - ImGui::PushFont(body2); - ImGui::PushTextWrapPos(cardMin.x + availWidth - pad - logoAreaW); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::GetColorU32(OnSurfaceMedium())); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + (contentW - logoAreaW)); ImGui::TextUnformatted(TR("settings_about_text")); ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); ImGui::PopFont(); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - ImGui::PushFont(capFont); - ImGui::TextColored(ImVec4(1,1,1,0.5f), "%s", TR("settings_copyright")); + snprintf(buf, sizeof(buf), "SDL3 \xC2\xB7 Dear ImGui %s \xC2\xB7 GPL-3.0", IMGUI_VERSION); + ImGui::TextColored(ImVec4(1, 1, 1, 0.4f), "%s", buf); ImGui::PopFont(); + if (logoTex != 0) ImGui::Unindent(logoAreaW); + + // Make the header at least as tall as the logo, then draw the logo centered in it. + float headerH = ImGui::GetCursorScreenPos().y - logoTop.y; + if (headerH < logoSz) { ImGui::Dummy(ImVec2(0, logoSz - headerH)); headerH = logoSz; } + if (logoTex != 0) { + float lw = logoSz, lh = logoSz; + if (logoAspect >= 1.0f) lh = logoSz / logoAspect; else lw = logoSz * logoAspect; + const float lx = logoTop.x + (logoSz - lw) * 0.5f; + const float ly = logoTop.y + (headerH - lh) * 0.5f; + dl->AddImage(logoTex, ImVec2(lx, ly), ImVec2(lx + lw, ly + lh)); + } + + // --- Divider --- + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + { + const ImVec2 dv = ImGui::GetCursorScreenPos(); + dl->AddLine(dv, ImVec2(dv.x + contentW, dv.y), ImGui::GetColorU32(material::Divider()), 1.0f); + } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - // Top of the (full-width) buttons row — the deferred logo is clamped to end above - // this Y so the tall left-column logo never overlaps the buttons. - float aboutButtonsTopY = ImGui::GetCursorScreenPos().y; + // --- Two columns: Credits (bullets) | License (paragraph + links) --- + const float colGap = Layout::spacingLg(); + const float colW = (contentW - colGap) * 0.5f; + const float colTop = ImGui::GetCursorScreenPos().y; + const float rx = baseX + colW + colGap; - // Buttons — consistent equal-width row (full card width) - if (logoAreaW > 0) { - ImGui::Unindent(logoAreaW); - } + // Left column — Credits + ImGui::SetCursorScreenPos(ImVec2(baseX, colTop)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about_credits")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { - float fullContentW = availWidth - pad * 2; - // 2x2 grid in the narrow (two-column) card so labels don't clip; - // one 1x4 row at full width. - const bool aboutGrid = fullContentW < 720.0f * Layout::dpiScale(); - float aboutBtnW = aboutGrid ? (fullContentW - Layout::spacingMd()) / 2.0f - : (fullContentW - Layout::spacingMd() * 3) / 4.0f; + ImGui::PushFont(body2); + static const char* kCredits[] = { + "The Hush Developers", + "The DragonX Developers", + "ObsidianDragon Community", + "Dear ImGui \xE2\x80\x94 Omar Cornut", + "SDL3 \xE2\x80\x94 Sam Lantinga", + "HushChat \xC2\xB7 librustzcash \xC2\xB7 libsodium", + }; + for (size_t i = 0; i < std::size(kCredits); ++i) { + const char* c = kCredits[i]; + const ImVec2 p = ImGui::GetCursorScreenPos(); + const float r = 2.5f * adp; + dl->AddCircleFilled(ImVec2(p.x + r, p.y + ImGui::GetTextLineHeight() * 0.5f), r, + material::WithAlpha(material::Primary(), 210)); + ImGui::SetCursorScreenPos(ImVec2(p.x + r * 2.0f + 8.0f * adp, p.y)); + ImGui::TextUnformatted(c); + if (i < std::size(kCredits) - 1) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + } + ImGui::PopFont(); + } + const float leftBottom = ImGui::GetCursorScreenPos().y; - if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + // Right column — License + links + ImGui::SetCursorScreenPos(ImVec2(rx, colTop)); + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("about_license")); + { + ImGui::SetCursorScreenPos(ImVec2(rx, ImGui::GetCursorScreenPos().y + Layout::spacingSm())); + ImGui::PushFont(capFont); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1, 1, 0.6f)); + ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + colW); + ImGui::TextUnformatted(TR("about_license_text")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + ImGui::PopFont(); + } + ImGui::Dummy(ImVec2(0, Layout::spacingLg())); + { + using AT = material::ActionTier; + ImGui::SetCursorScreenPos(ImVec2(rx, ImGui::GetCursorScreenPos().y)); + material::ButtonFlow lf(colW); + lf.next(material::ActionButtonWidth(TR("website"), ICON_MD_PUBLIC)); + if (material::ActionButton("##aboutweb", TR("website"), ICON_MD_PUBLIC, AT::Secondary)) util::Platform::openUrl("https://dragonx.is"); - } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_website")); - ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("report_bug", "about_bug").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + lf.next(material::ActionButtonWidth(TR("about_source"), ICON_MD_CODE)); + if (material::ActionButton("##aboutsrc", TR("about_source"), ICON_MD_CODE, AT::Secondary)) + util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon"); + lf.next(material::ActionButtonWidth(TR("report_bug"), ICON_MD_BUG_REPORT)); + if (material::ActionButton("##aboutbug", TR("report_bug"), ICON_MD_BUG_REPORT, AT::Secondary)) util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues"); - } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug")); - if (aboutGrid) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); else ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { + lf.next(material::ActionButtonWidth(TR("faq"), ICON_MD_QUESTION_MARK)); + if (material::ActionButton("##aboutfaq", TR("faq"), ICON_MD_QUESTION_MARK, AT::Secondary)) + app->showFaqDialog(); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("faq_open_tooltip")); + } + const float rightBottom = ImGui::GetCursorScreenPos().y; + + // Reconcile the two columns, then a card-wide settings-actions row. + ImGui::SetCursorScreenPos(ImVec2(baseX, std::max(leftBottom, rightBottom))); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + { + using AT = material::ActionTier; + material::ButtonFlow af(contentW); + af.next(material::ActionButtonWidth(TR("save_settings"), ICON_MD_SAVE)); + if (material::ActionButton("##aboutsave", TR("save_settings"), ICON_MD_SAVE, AT::Secondary)) { saveSettingsPageState(app->settings()); - Notifications::instance().success("Settings saved"); + Notifications::instance().success(TR("settings_saved")); } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_save_settings")); - ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TrId("reset_to_defaults", "about_reset").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) { - if (app->settings()) { - loadSettingsPageState(app->settings()); - Notifications::instance().info("Settings reloaded from disk"); - } + af.next(material::ActionButtonWidth(TR("reset_to_defaults"), ICON_MD_RESTORE)); + if (material::ActionButton("##aboutreset", TR("reset_to_defaults"), ICON_MD_RESTORE, AT::Tertiary)) { + if (app->settings()) { loadSettingsPageState(app->settings()); Notifications::instance().info(TR("settings_reloaded")); } } if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_reset_settings")); } - - ImGui::Dummy(ImVec2(0, bottomPad)); - ImGui::Unindent(pad); - - ImVec2 cardMax(cardMin.x + availWidth, ImGui::GetCursorScreenPos().y); - - // Draw the logo now that the card height is known — aspect-preserved, capped to the - // reserved width, and clamped to end just above the buttons row so it never overlaps - // the text or the buttons. Still on the content channel (1), above the glass. - if (logoTex != 0) { - float reserveW = logoReserveH * logoAspect; - // Height available above the buttons row (the fix for the logo/buttons overlap). - float logoBottomLimit = aboutButtonsTopY - Layout::spacingSm(); - float logoH = std::max(16.0f, logoBottomLimit - logoPos.y); - float logoW = logoH * logoAspect; - if (logoW > reserveW) { logoW = reserveW; logoH = (logoAspect > 0.0f) ? logoW / logoAspect : logoH; } - dl->AddImage(logoTex, logoPos, ImVec2(logoPos.x + logoW, logoPos.y + logoH)); - } - - dl->ChannelsSetCurrent(0); - DrawGlassPanel(dl, cardMin, cardMax, glassSpec); - dl->ChannelsMerge(); - - ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); - ImGui::Dummy(ImVec2(availWidth, 0)); } - ImGui::Dummy(ImVec2(0, gap)); + if (s_settingsState.current_tab == TAB_NODE) + ImGui::Dummy(ImVec2(0, gap)); + + // ==================================================================== + // MINING POOL HOSTING — run this node's built-in RandomX stratum server so other miners can point + // at this machine. It's a node feature, so it belongs here (not the Wallet tab). Full-node only, and + // only on a daemon new enough to implement -stratum (v1.3.0+, version encoded + // major*1e6+minor*1e4+rev*100+build). Takes effect on the next daemon start/restart; a blank allow-IP + // keeps it loopback-only (safe), a subnet opens it to that LAN. + // ==================================================================== + if (app->supportsFullNodeLifecycleActions() && s_settingsState.current_tab == TAB_NODE && + app->state().daemon_version >= 1030000) { + Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("stratum_host_section")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + material::GlassCardScope card(dl, availWidth, pad, bottomPad, glassSpec); + if (ImGui::Checkbox(TR("stratum_host"), &s_settingsState.stratum_host)) + saveSettingsPageState(app->settings()); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_stratum_host")); + if (s_settingsState.stratum_host) { + ImGui::TextDisabled(" %s", TR("stratum_host_hint")); + ImGui::SetNextItemWidth(220.0f * Layout::dpiScale()); + if (ImGui::InputTextWithHint("##stratumallowip", TR("stratum_allowip_hint"), + s_settingsState.stratum_allowip, sizeof(s_settingsState.stratum_allowip))) + saveSettingsPageState(app->settings()); + if (s_settingsState.stratum_allowip[0] != '\0') + ImGui::TextColored(ImVec4(0.95f, 0.75f, 0.25f, 1.0f), " %s", + TR("stratum_expose_warn")); + } + } // ==================================================================== // DEBUG OPTIONS — collapsible card (full-node only: holds the screenshot sweep + the dragonxd - // daemon debug= categories written to DRAGONX.conf; lite has no daemon) + // daemon debug= categories written to DRAGONX.conf; lite has no daemon). Shown on the Node tab. // ==================================================================== - if (app->supportsFullNodeLifecycleActions()) { + if (app->supportsFullNodeLifecycleActions() && s_settingsState.current_tab == TAB_NODE) { // Clickable header row ImVec2 headerPos = ImGui::GetCursorScreenPos(); const char* arrow = s_settingsState.debug_expanded ? ICON_MD_EXPAND_LESS : ICON_MD_EXPAND_MORE; @@ -2658,10 +2762,18 @@ void RenderSettingsPage(App* app) { if (chat::hushChatFeatureEnabledAtBuild()) { // Populate the Chat tab with demo conversations so the sweep captures its real UI. ImGui::SameLine(); - if (TactileButton("Seed demo chat", ImVec2(0, 0), S.resolveFont("button"))) + if (TactileButton(TR("grpa_seed_demo_chat"), ImVec2(0, 0), S.resolveFont("button"))) app->seedChatDemoData(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_demo_chat")); } + // Restrict either sweep to just the active theme instead of cycling every skin. + ImGui::SameLine(); + { + bool only = app->sweepCurrentThemeOnly(); + if (ImGui::Checkbox(TR("sweep_current_theme_only"), &only)) + app->setSweepCurrentThemeOnly(only); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_sweep_current_theme_only")); + } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Separator(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); @@ -2677,29 +2789,29 @@ void RenderSettingsPage(App* app) { "paymentdisclosure", "pow", "proxy", "prune", "rand", "reindex", "rpc", "selectcoins", "tor", "zmq", "zrpc" }; - static const char* debugTips[] = { - "Peer address tracking and management", - "Alert system messages", - "Benchmark timings for operations", - "Coin database read/write operations", - "Berkeley DB operations", - "Fee estimation algorithm", - "HTTP RPC server activity", - "Libevent networking library", - "Lock contention debugging", - "Transaction memory pool activity", - "Network connections and messages", - "Payment disclosure protocol", - "Proof-of-work mining activity", - "SOCKS5 proxy connections", - "Block pruning operations", - "Random number generation", - "Blockchain reindexing progress", - "RPC command processing", - "Coin selection for transactions", - "Tor integration and circuit info", - "ZeroMQ notification system", - "Shielded (z-addr) RPC operations" + const char* debugTips[] = { + TR("grpa_dbg_addrman"), + TR("grpa_dbg_alert"), + TR("grpa_dbg_bench"), + TR("grpa_dbg_coindb"), + TR("grpa_dbg_db"), + TR("grpa_dbg_estimatefee"), + TR("grpa_dbg_http"), + TR("grpa_dbg_libevent"), + TR("grpa_dbg_lock"), + TR("grpa_dbg_mempool"), + TR("grpa_dbg_net"), + TR("grpa_dbg_paymentdisclosure"), + TR("grpa_dbg_pow"), + TR("grpa_dbg_proxy"), + TR("grpa_dbg_prune"), + TR("grpa_dbg_rand"), + TR("grpa_dbg_reindex"), + TR("grpa_dbg_rpc"), + TR("grpa_dbg_selectcoins"), + TR("grpa_dbg_tor"), + TR("grpa_dbg_zmq"), + TR("grpa_dbg_zrpc") }; constexpr int numCats = sizeof(debugCats) / sizeof(debugCats[0]); @@ -2881,9 +2993,9 @@ void RenderSettingsPage(App* app) { if (doConfirm) { std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json"; if (util::Platform::deleteFile(ztx_file)) { - Notifications::instance().success("Z-transaction history cleared"); + Notifications::instance().success(TR("settings_ztx_cleared")); } else { - Notifications::instance().info("No history file found"); + Notifications::instance().info(TR("settings_ztx_not_found")); } s_settingsState.confirm_clear_ztx = false; } @@ -2949,7 +3061,7 @@ void RenderSettingsPage(App* app) { ImGui::TextWrapped("%s", TR("rescan_bootstrapped_msg")); ImGui::Spacing(); ImGui::Text("%s", TR("rescan_from_height")); - ImGui::SetNextItemWidth(160.0f); + ImGui::SetNextItemWidth(160.0f * dp); ImGui::InputInt("##rescanHeight", &s_settingsState.rescan_start_height); if (s_settingsState.rescan_start_height < 0) s_settingsState.rescan_start_height = 0; } else { @@ -2963,12 +3075,12 @@ void RenderSettingsPage(App* app) { ImGui::Spacing(); float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; - if (material::TactileButton(TrId("cancel", "rescan_cancel").c_str(), ImVec2(btnW, 40))) { + if (material::TactileButton(TrId("cancel", "rescan_cancel").c_str(), ImVec2(btnW, 40 * dp))) { s_settingsState.confirm_rescan = false; } ImGui::SameLine(); ImGui::BeginDisabled(detecting); - if (material::TactileButton(TrId("rescan", "rescan_confirm").c_str(), ImVec2(btnW, 40))) { + if (material::TactileButton(TrId("rescan", "rescan_confirm").c_str(), ImVec2(btnW, 40 * dp))) { if (bootstrapped) { app->runtimeRescan(s_settingsState.rescan_start_height); } else { diff --git a/src/ui/sidebar.h b/src/ui/sidebar.h index 14a56d4..758c6fc 100644 --- a/src/ui/sidebar.h +++ b/src/ui/sidebar.h @@ -219,7 +219,7 @@ inline void DrawGlassCutout(ImDrawList* dl, ImVec2 mn, ImVec2 mx, float lineW = s_cc.lineW; // --- Outer glow pass: wider, softer dark edge on top-left --- - float glowExpand = s_cc.glowExpand; + float glowExpand = s_cc.glowExpand * Layout::dpiScale(); int glowA = (int)s_cc.glowAlpha; float glowLineW = s_cc.glowLineW; { @@ -289,6 +289,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx, // inner pass gives crisp bevel edge. All use AddRect with rounding so // every layer follows the rounded corners perfectly — no clip rects needed. { + const float dp = Layout::dpiScale(); float cx = (mn.x + mx.x) * 0.5f; float cy = (mn.y + mx.y) * 0.5f; @@ -304,7 +305,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx, struct BevelPass { float expand; float lineW; float fadeStart; float fadeEnd; }; BevelPass passes[] = { - { 0.5f, 0.75f, 0.30f, 0.55f }, // Outer glow (thin) + { 0.5f * dp, 0.75f, 0.30f, 0.55f }, // Outer glow (thin) { 0.0f, 0.75f, 0.38f, 0.58f }, // Inner crisp bevel }; @@ -387,7 +388,7 @@ inline void DrawGlassBevelButton(ImDrawList* dl, ImVec2 mn, ImVec2 mx, } } if (depth > s_ic.threshold) { - float baseInset = s_ic.inset; + float baseInset = s_ic.inset * Layout::dpiScale(); int shadowMax = (int)(s_ic.maxAlpha * depth); float fadeRatio = s_ic.fadeRatio; float bW = mx.x - mn.x - baseInset * 2.0f; @@ -487,7 +488,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei float fixedH = stripH; // collapse strip for (int i = 0; i < (int)NavPage::Count_; ++i) if (IsNavPageVisible(kNavItems[i].page) && kNavItems[i].section_label && showLabels) - fixedH += olFsz + 2.0f + sectionLabelPadBot; // section label + pad below + fixedH += olFsz + 2.0f * dp + sectionLabelPadBot; // section label + pad below fixedH += bottomPadding + stripH; // exit area float baseFlexH = baseNavGap; @@ -525,7 +526,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei if (showLabels) { curY += sectionGap; if (nSectionLabels < 4) sectionLabelY[nSectionLabels++] = curY; - curY += olFsz + 2.0f + sectionLabelPadBot; + curY += olFsz + 2.0f * dp + sectionLabelPadBot; } else { curY += sectionGap * 0.4f; if (nSeparators < 4) separatorY[nSeparators++] = curY; @@ -538,11 +539,6 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei float exitRelY = curY + bottomPadding; float panelH = exitRelY + stripH; - // Vertical centering — offset so panel is centered in the child window - float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f); - if (centerOffset + panelH > contentHeight) - centerOffset = std::max(0.0f, contentHeight - panelH); - // =================================================================== // PASS 2: Render using computed positions // =================================================================== @@ -552,6 +548,13 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei ImDrawList* dl = ImGui::GetWindowDrawList(); ImVec2 wp = ImGui::GetWindowPos(); + // Vertical centering — center the panel within the child. app.cpp sizes the child (contentHeight) + // to the visible area (child top -> status-bar top) using window-local geometry, so this yields + // equal top/bottom gaps at any height on every platform, no viewport dependency. + float centerOffset = std::max(glassMarginY, (contentHeight - panelH) * 0.5f); + if (centerOffset + panelH > contentHeight) + centerOffset = std::max(0.0f, contentHeight - panelH); + float panelLeft = wp.x + glassMarginL; float panelRight = wp.x + sidebarWidth - glassMarginR; float panelTopY = wp.y + centerOffset; @@ -651,7 +654,7 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei fx.drawShimmer(dl, indMin, indMax, btnRnd); fx.drawGradientBorderShift(dl, indMin, indMax, btnRnd); } - DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f); + DrawGlassCutout(dl, indMin, indMax, btnRnd, 1.5f * dp); DrawGlassBevelButton(dl, indMin, indMax, btnRnd, btnDepth, 18); buttonRects.push_back({indMin, indMax, btnRnd}); } @@ -676,10 +679,31 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei ImU32 textCol = selected ? Primary() : (pageNeedsUnlock ? OnSurfaceDisabled() : OnSurfaceMedium()); if (showLabels) { + // The badge is a fixed top-right corner overlay, so it must NOT move + // the icon+label — otherwise the text jumps sideways the moment a live + // count toggles the badge on/off. Reserve clearance from whether the + // page CAN show a badge (constant per item), never from the current + // count, and keep the icon+label centered in the FULL button width so + // the text position and size stay identical with or without a badge. + bool itemBadgeCapable = + item.page == NavPage::History || + item.page == NavPage::Mining || + item.page == NavPage::Chat; + float badgeReserve = 0.0f; + if (itemBadgeCapable) { + bool dotOnlyReserve = (item.page == NavPage::Mining); + float badgeRReserve = dotOnlyReserve ? badgeRadiusDot : badgeRadiusNumber; + float badgeInsetXReserve = sde("badge-inset-x", 6.0f); + badgeReserve = badgeRReserve * 2.0f + badgeInsetXReserve; + } + ImFont* font = selected ? Type().subtitle2() : Type().body2(); float lblFsz = ScaledFontSize(font); float btnW = indMax.x - indMin.x; - float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2; + // Clearance is symmetric (2x) because the group stays centered in the + // full width: reserving on both sides keeps the label's right edge clear + // of the right-side corner badge without shifting the center off-axis. + float maxLabelW = btnW - iconS * 2.0f - iconLabelGap - Layout::spacingXs() * 2 - badgeReserve * 2.0f; ImVec2 labelSz = font->CalcTextSizeA(lblFsz, 1000.0f, 0.0f, NavLabel(item)); if (labelSz.x > maxLabelW && maxLabelW > 0) { lblFsz *= maxLabelW / labelSz.x; @@ -714,16 +738,16 @@ inline bool RenderSidebar(NavPage& current, float sidebarWidth, float contentHei badgeCol = Warning(); badgeTextCol = OnWarning(); } else if (item.page == NavPage::Mining && status.miningActive) { dotOnly = true; badgeCol = Success(); - } else if (item.page == NavPage::Peers && status.peerCount > 0) { - badgeCount = status.peerCount; } else if (item.page == NavPage::Chat && status.chatUnreadCount > 0) { badgeCount = status.chatUnreadCount; } if (badgeCount > 0 || dotOnly) { float badgeR = dotOnly ? badgeRadiusDot : badgeRadiusNumber; - float bx = indMax.x - badgeR - 6.0f; - float by = indMin.y + badgeR + 5.0f; + float badgeInsetX = sde("badge-inset-x", 6.0f); + float badgeInsetY = sde("badge-inset-y", 5.0f); + float bx = indMax.x - badgeR - badgeInsetX; + float by = indMin.y + badgeR + badgeInsetY; dl->AddCircleFilled(ImVec2(bx, by), badgeR, badgeCol); if (!dotOnly && showLabels) { char buf[16]; diff --git a/src/ui/staleness_badge.h b/src/ui/staleness_badge.h new file mode 100644 index 0000000..5e01a34 --- /dev/null +++ b/src/ui/staleness_badge.h @@ -0,0 +1,48 @@ +#pragma once + +#include + +// 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 diff --git a/src/ui/windows/about_dialog.cpp b/src/ui/windows/about_dialog.cpp index d7c5205..f58ce5c 100644 --- a/src/ui/windows/about_dialog.cpp +++ b/src/ui/windows/about_dialog.cpp @@ -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; } diff --git a/src/ui/windows/address_label_dialog.h b/src/ui/windows/address_label_dialog.h index 9e56bb5..e8f9a17 100644 --- a/src/ui/windows/address_label_dialog.h +++ b/src/ui/windows/address_label_dialog.h @@ -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) { diff --git a/src/ui/windows/address_transfer_dialog.h b/src/ui/windows/address_transfer_dialog.h index 5c78d2a..8fed81e 100644 --- a/src/ui/windows/address_transfer_dialog.h +++ b/src/ui/windows/address_transfer_dialog.h @@ -93,7 +93,7 @@ public: // Arrow { float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f; - ImGui::SetCursorPosX(arrowCX - 8.0f); + ImGui::SetCursorPosX(arrowCX - 8.0f * dp); ImFont* iconFont = Type().iconMed(); float fsz = ScaledFontSize(iconFont); ImVec2 pos = ImGui::GetCursorScreenPos(); @@ -169,9 +169,9 @@ public: } if (amountValid && newFromBal < 1e-9) { ImGui::Spacing(); - ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); - ImGui::TextWrapped("%s", TR("sends_full_balance_warning")); - ImGui::PopStyleColor(); + // Full-balance send: same warning-icon treatment as the de-shielding header above, + // so this stakes-bearing line reads as distinct from the neutral preview text. + DialogWarningHeader(TR("sends_full_balance_warning")); } // Buttons @@ -180,16 +180,14 @@ public: const char* sendingLabel = TR("sending"); ImFont* buttonFont = Type().button(); float buttonFontSize = ScaledFontSize(buttonFont); - float minBtnW = 120.0f * dp; float confirmMinW = 160.0f * dp; float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp; - float cancelW = std::max(minBtnW, - buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW); + // Both footer buttons share one width (equal-width primary/Close pair), sized to fit the + // widest label — the "Sending…" swap label included — so nothing clips. float confirmTextW = std::max( buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x, buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x); - float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW); - float totalW = cancelW + confirmW + Layout::spacingMd(); + float btnW = std::max(confirmMinW, confirmTextW + buttonPadW); float footerH = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemSpacing.y * 3.0f; // footer divider removed ImGuiViewport* vp = ImGui::GetMainViewport(); float cardBottomY = vp->Pos.y + vp->Size.y * 0.85f; @@ -201,19 +199,18 @@ public: ImGui::Spacing(); } - ImGui::Spacing(); + // Standardized primary + Close footer (centered, no divider). The primary is the + // Confirm/"Sending…" action; the Close button dismisses the dialog. + bool outConfirm = false; + bool outClose = false; + DialogActionFooter(s_sending ? sendingLabel : confirmLabel, + amountValid && !s_sending, + cancelLabel, outConfirm, outClose, btnW); - float rowStartX = ImGui::GetCursorPosX(); - float contentW = ImGui::GetContentRegionAvail().x; - ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f)); - - if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) { + if (outClose) { s_open = false; } - ImGui::SameLine(0, Layout::spacingMd()); - - ImGui::BeginDisabled(!amountValid || s_sending); - if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) { + if (outConfirm) { s_sending = true; s_app->sendTransaction(s_info.fromAddr, s_info.toAddr, amount, s_fee, "", @@ -231,7 +228,6 @@ public: // state, and when the async callback sets s_resultMsg the in-dialog result screen shows // (with its own Close button). Previously closing here made that result screen dead code. } - ImGui::EndDisabled(); EndOverlayDialog(); } @@ -316,7 +312,8 @@ private: ImGui::Spacing(); ImGui::Spacing(); - float btnW = 120.0f; + const float dp = Layout::dpiScale(); + float btnW = 120.0f * dp; ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f); if (TactileButton(TR("close"), ImVec2(btnW, 0))) { s_open = false; diff --git a/src/ui/windows/balance_components.cpp b/src/ui/windows/balance_components.cpp index dd97827..523f7dc 100644 --- a/src/ui/windows/balance_components.cpp +++ b/src/ui/windows/balance_components.cpp @@ -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(); @@ -256,7 +264,7 @@ void RenderSharedAddressList(App* app, float listH, float availW, } else if (rows.empty()) { float cw = ImGui::GetContentRegionAvail().x; float ch = ImGui::GetContentRegionAvail().y; - if (ch < 60) ch = 60; + if (ch < 60.0f * dp) ch = 60.0f * dp; const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet"); ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg); ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f); @@ -324,12 +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); diff --git a/src/ui/windows/balance_components.h b/src/ui/windows/balance_components.h index 427f663..fb028ac 100644 --- a/src/ui/windows/balance_components.h +++ b/src/ui/windows/balance_components.h @@ -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); diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index 46c8aa6..c46deb0 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -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 #include +#include #include #include #include @@ -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); } } } @@ -298,9 +302,9 @@ static void RenderBalanceClassic(App* app) float cardPadLg = (classicPadOverride >= 0.0f) ? classicPadOverride : Layout::spacingLg(); // Card height: must fit the Market card's content (overline + price + 24h) - const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f); - const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f); - const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f); + const float ovGap = S.drawElement("tabs.balance", "overline-value-gap").sizeOr(6.0f) * dp; + const float valGap = S.drawElement("tabs.balance", "value-caption-gap").sizeOr(4.0f) * dp; + const float tickGap = S.drawElement("tabs.balance.classic", "ticker-gap").sizeOr(4.0f) * dp; float marketContentH = cardPadLg + ovFont->LegacySize + ovGap + sub1->LegacySize + 2.0f * dp @@ -319,7 +323,7 @@ static void RenderBalanceClassic(App* app) // Helper: draw accent stripe on left edge, clipped to card rounded corners. // We draw a full-size rounded rect (left corners only) and clip it to the // stripe width so the shape itself follows the card rounding. - const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f); + const float accentW = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp; auto drawAccent = [&](const ImVec2& cMin, const ImVec2& cMax, ImU32 col) { dl->PushClipRect(cMin, ImVec2(cMin.x + accentW, cMax.y), true); dl->AddRectFilled(cMin, cMax, col, cardSpec.rounding, @@ -358,8 +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(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,16 +424,39 @@ static void RenderBalanceClassic(App* app) dl->AddCircleFilled(ImVec2(cx + 4 * dp, cy + capFont->LegacySize * 0.5f), S.drawElement("tabs.balance.classic", "mining-dot-radius").sizeOr(3.0f), mineCol); double hr = state.mining.localHashrate; - snprintf(buf, sizeof(buf), " Mining %s", FormatHashrate(hr).c_str()); + // Leading indent clears the mining dot drawn at cx+4dp; the text + // itself starts at cx+12dp so keep the two spaces for spacing parity. + char mineFmt[64]; + snprintf(mineFmt, sizeof(mineFmt), " %s", TR("balance_mining_rate")); + snprintf(buf, sizeof(buf), mineFmt, FormatHashrate(hr).c_str()); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + 12 * dp, cy), WithAlpha(Success(), 200), buf); + } else { + // Refresh-staleness badge (W6-2): connected, not syncing/mining, but the balance + // hasn't refreshed in a while (a busy daemon can fail z_gettotalbalance without + // dropping the whole connection). The node banner only covers full disconnects, so + // this pill is the sole signal that the shown number may be out of date. + StalenessBadge badge = evaluateStalenessBadge( + state.last_balance_update, std::time(nullptr), state.connected); + if (badge.show) { + const bool err = (badge.severity == StalenessSeverity::Error); + ImU32 fg = err ? Error() : Warning(); + ImU32 bg = WithAlpha(fg, 38); + ImU32 bd = WithAlpha(fg, 90); + snprintf(buf, sizeof(buf), "%s %s", + TR("data_stale_prefix"), timeAgo(state.last_balance_update).c_str()); + ImVec2 pillSz = DrawPill(dl, ImVec2(cx, cy), buf, capFont, fg, bg, bd, ImVec2(4.0f * dp, 2.0f * dp)); + // Hover → explain what stale means and how old the data actually is. + if (material::IsRectHovered(ImVec2(cx, cy), ImVec2(cx + pillSz.x, cy + pillSz.y))) + Tooltip("%s", TR("data_stale_tooltip")); + } } // Hover glow if (material::IsRectHovered(cMin, cMax)) { dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)), - cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f)); + cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp); } } @@ -457,7 +487,7 @@ static void RenderBalanceClassic(App* app) { float privPct = (s_dispTotal > 1e-9) ? (float)(s_dispShielded / s_dispTotal * 100.0) : 0.0f; - snprintf(buf, sizeof(buf), "%.0f%% of total · %d Z-addr", + snprintf(buf, sizeof(buf), TR("baltab_pct_of_total_zaddr"), privPct, (int)state.z_addresses.size()); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), WithAlpha(Success(), 160), buf); @@ -468,8 +498,8 @@ static void RenderBalanceClassic(App* app) snprintf(buf, sizeof(buf), "+%.4f", state.unconfirmed_balance); ImVec2 ts = capFont->CalcTextSizeA( capFont->LegacySize, 10000, 0, buf); - float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f); - float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f); + float bp = S.drawElement("tabs.balance.classic", "unconfirmed-badge-padding").sizeOr(4.0f) * dp; + float br = S.drawElement("tabs.balance.classic", "unconfirmed-badge-rounding").sizeOr(4.0f) * dp; ImVec2 bMin(cMax.x - ts.x - bp * 3, cMin.y + cardPadLg); ImVec2 bMax(cMax.x - bp, bMin.y + ts.y + bp); @@ -483,7 +513,7 @@ static void RenderBalanceClassic(App* app) // Hover glow + click to Receive if (material::IsRectHovered(cMin, cMax)) { dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)), - cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f)); + cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(0)) app->setCurrentPage(NavPage::Receive); @@ -513,7 +543,7 @@ static void RenderBalanceClassic(App* app) OnSurfaceMedium(), DRAGONX_TICKER); cy += sub1->LegacySize + valGap; - snprintf(buf, sizeof(buf), "%d T-addresses", + snprintf(buf, sizeof(buf), TR("baltab_t_addresses_count"), (int)state.t_addresses.size()); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), buf); @@ -521,7 +551,7 @@ static void RenderBalanceClassic(App* app) // Hover glow + click to Receive if (material::IsRectHovered(cMin, cMax)) { dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)), - cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f)); + cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(0)) app->setCurrentPage(NavPage::Receive); @@ -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); - float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f); + ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, marketLabel.c_str()).x); + float sparkGap = S.drawElement("tabs.balance.classic", "sparkline-gap").sizeOr(12.0f) * dp; float sparkLeft = cx + textW + sparkGap; float sparkRight = cMax.x - cardPadLg; // Left side: label + price + 24h change dl->AddText(ovFont, ovFont->LegacySize, ImVec2(cx, cy), - OnSurfaceMedium(), "MARKET"); + OnSurfaceMedium(), marketLabel.c_str()); cy += ovFont->LegacySize + ovGap; dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), @@ -578,7 +611,7 @@ static void RenderBalanceClassic(App* app) bool pos = market.change_24h >= 0; ImU32 chgCol = pos ? Success() : Error(); - snprintf(buf, sizeof(buf), "%s%.1f%% 24h", + snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"), pos ? "+" : "", market.change_24h); dl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), chgCol, buf); @@ -600,7 +633,7 @@ static void RenderBalanceClassic(App* app) // Hover glow + click to Market if (material::IsRectHovered(cMin, cMax)) { dl->AddRect(cMin, cMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)), - cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f)); + cardSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(0)) app->setCurrentPage(NavPage::Market); @@ -628,7 +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(); @@ -757,20 +790,20 @@ static void RenderBalanceDonut(App* app) { ImFont* capFont = Type().caption(); ImFont* body2 = Type().body2(); - float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f); - float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f); - float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f); - float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f); + float legendDotR = S.drawElement("tabs.balance.donut", "legend-dot-radius").sizeOr(4.0f) * dp; + float legendXOff = S.drawElement("tabs.balance.donut", "legend-x-offset").sizeOr(14.0f) * dp; + float legendLineGap = S.drawElement("tabs.balance.donut", "legend-line-gap").sizeOr(6.0f) * dp; + float legendSectionGap = S.drawElement("tabs.balance.donut", "legend-section-gap").sizeOr(10.0f) * dp; // Shielded legend dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Success()); - snprintf(buf, sizeof(buf), "Shielded %.8f", s_dispShielded); + snprintf(buf, sizeof(buf), TR("baltab_shielded_amount"), s_dispShielded); dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Success(), buf); legendY += capFont->LegacySize + legendLineGap; // Transparent legend dl->AddCircleFilled(ImVec2(legendX + 5 * dp, legendY + capFont->LegacySize * 0.5f), legendDotR, Warning()); - snprintf(buf, sizeof(buf), "Transparent %.8f", s_dispTransparent); + snprintf(buf, sizeof(buf), TR("baltab_transparent_amount"), s_dispTransparent); dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), Warning(), buf); legendY += capFont->LegacySize + legendSectionGap; @@ -778,15 +811,15 @@ static void RenderBalanceDonut(App* app) { const auto& market = state.market; if (market.price_usd > 0) { if (market.price_usd >= 0.01) - snprintf(buf, sizeof(buf), "Market: $%.4f", market.price_usd); + snprintf(buf, sizeof(buf), TR("baltab_market_price_4dp"), market.price_usd); else - snprintf(buf, sizeof(buf), "Market: $%.8f", market.price_usd); + snprintf(buf, sizeof(buf), TR("baltab_market_price_8dp"), market.price_usd); dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), OnSurfaceMedium(), buf); legendY += capFont->LegacySize + 4 * dp; bool pos = market.change_24h >= 0; - snprintf(buf, sizeof(buf), "%s%.1f%% 24h", pos ? "+" : "", market.change_24h); + snprintf(buf, sizeof(buf), TR("baltab_pct_change_24h"), pos ? "+" : "", market.change_24h); dl->AddText(capFont, capFont->LegacySize, ImVec2(legendX + legendXOff, legendY), pos ? Success() : Error(), buf); } @@ -802,7 +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); } @@ -913,7 +946,7 @@ static void RenderBalanceConsolidated(App* app) { float divY = cardMin.y + cardH * S.drawElement("tabs.balance.consolidated", "divider-y-ratio").sizeOr(0.55f); dl->AddLine(ImVec2(cardMin.x + pad, divY), ImVec2(cardMax.x - pad, divY), IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance.consolidated", "divider-alpha").sizeOr(20.0f)), - S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f)); + S.drawElement("tabs.balance.consolidated", "divider-thickness").sizeOr(1.0f) * dp); // Bottom half: proportion bars float barY = divY + Layout::spacingSm(); @@ -927,10 +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++) { @@ -1074,7 +1122,7 @@ static void RenderBalanceDashboard(App* app) { // Accent stripe — clipped to tile rounded corners { - float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f); + float aw = S.drawElement("tabs.balance", "accent-width").sizeOr(4.0f) * dp; dl->PushClipRect(tMin, ImVec2(tMin.x + aw, tMax.y), true); dl->AddRectFilled(tMin, tMax, tiles[i].accent, tileSpec.rounding, ImDrawFlags_RoundCornersLeft); @@ -1105,13 +1153,13 @@ static void RenderBalanceDashboard(App* app) { tiles[i].accent, tiles[i].value); } else { dl->AddText(capFont, capFont->LegacySize, ImVec2(tMin.x + tilePad, py), - OnSurfaceMedium(), "Click to open"); + OnSurfaceMedium(), TR("tile_click_to_open")); } // Click if (material::IsRectHovered(tMin, tMax)) { dl->AddRect(tMin, tMax, IM_COL32(255, 255, 255, (int)S.drawElement("tabs.balance", "hover-glow-alpha").sizeOr(40.0f)), - tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f)); + tileSpec.rounding, 0, S.drawElement("tabs.balance", "hover-glow-thickness").sizeOr(1.5f) * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsMouseClicked(0)) app->setCurrentPage(tiles[i].nav); @@ -1127,7 +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); } @@ -1160,7 +1208,7 @@ static void RenderBalanceVerticalStack(App* app) { // Font-content floor per row: icon + label + value must fit float vstackRowFontFloor = std::max(body2->LegacySize, capFont->LegacySize) + Layout::spacingSm() * 2; - float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f); + float rowGap = S.drawElement("tabs.balance.vertical-stack", "row-gap").sizeOr(2.0f) * dp; float vstackFontFloor = vstackRowFontFloor * 4 + rowGap * 3; float vstackCardH = S.drawElement("tabs.balance.vertical-stack", "card-height").size; float stackH; @@ -1190,10 +1238,10 @@ static void RenderBalanceVerticalStack(App* app) { }; RowInfo rowInfos[4] = { - {"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f}, - {"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio}, - {"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio}, - {"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f}, + {TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f}, + {TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio}, + {TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio}, + {TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f}, }; for (int i = 0; i < 4; i++) { @@ -1248,7 +1296,7 @@ static void RenderBalanceVerticalStack(App* app) { // Proportion bar (for shielded/transparent rows — fills gap between label and amount) if (i == 1 || i == 2) { ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label); - float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f); + float barGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp; float barPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f); float barH = std::max( S.drawElement("tabs.balance.vertical-stack", "bar-min-height").sizeOr(3.0f), @@ -1259,7 +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); @@ -1278,8 +1326,8 @@ static void RenderBalanceVerticalStack(App* app) { // Sparkline in the gap between label and 24h change if (state.market.price_history.size() >= 2) { ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, rowInfos[i].label); - float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f); - float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f); + float sparkGap = S.drawElement("tabs.balance.vertical-stack", "sparkline-gap").sizeOr(12.0f) * dp; + float sparkPad = S.drawElement("tabs.balance.vertical-stack", "sparkline-pad").sizeOr(4.0f) * dp; float sparkLeft = px + labelSz.x + sparkGap; float sparkRight = chgX - sparkGap; if (sparkLeft < sparkRight) { @@ -1306,7 +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); } @@ -1339,8 +1387,8 @@ static void RenderBalanceVertical2x2(App* app) { ImFont* iconFont = Type().iconSmall(); // Font-content floor per row: caption text + vertical padding float v2x2RowFontFloor = capFont->LegacySize + Layout::spacingSm() * 2; - float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f); - float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f); + float rowGap = S.drawElement(cfgSec, "row-gap").sizeOr(2.0f) * dp; + float colGap = S.drawElement(cfgSec, "col-gap").sizeOr(8.0f) * dp; float v2x2FontFloor = v2x2RowFontFloor * 2 + rowGap; float cardHOverride = S.drawElement(cfgSec, "card-height").size; float stackH; @@ -1383,13 +1431,13 @@ static void RenderBalanceVertical2x2(App* app) { CellInfo cells[2][2] = { // Row 0: Total Balance (left), Shielded (right) { - {"Total Balance", ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false}, - {"Shielded", ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true}, + {TR("baltab_total_balance"), ICON_MD_ACCOUNT_BALANCE_WALLET, S.resolveColor("var(--accent-total)", OnSurface()), s_dispTotal, 1.0f, false, false}, + {TR("baltab_shielded"), ICON_MD_SHIELD, S.resolveColor("var(--accent-shielded)", Success()), s_dispShielded, shieldRatio, false, true}, }, // Row 1: Market (left), Transparent (right) { - {"Market", ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false}, - {"Transparent", ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true}, + {TR("baltab_market"), ICON_MD_TRENDING_UP, S.resolveColor("var(--accent-action)", Primary()), state.market.price_usd, 0.0f, true, false}, + {TR("baltab_transparent"), ICON_MD_CIRCLE, S.resolveColor("var(--accent-transparent)", Warning()), s_dispTransparent, transRatio, false, true}, }, }; @@ -1453,7 +1501,7 @@ static void RenderBalanceVertical2x2(App* app) { float barX = cellMax.x - amtSz.x - rowPad - barW - Layout::spacingSm(); float barY = cellMin.y + (rowH - barH) * 0.5f; dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH), - IM_COL32(255, 255, 255, 15), barH * 0.5f); + SurfaceOverlay(15), barH * 0.5f); dl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW * cell.ratio, barY + barH), WithAlpha(cell.accent, 180), barH * 0.5f); @@ -1471,8 +1519,8 @@ static void RenderBalanceVertical2x2(App* app) { // Sparkline between label and 24h change if (state.market.price_history.size() >= 2) { ImVec2 labelSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, cell.label); - float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f); - float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f); + float sparkGap = S.drawElement(cfgSec, "sparkline-gap").sizeOr(12.0f) * dp; + float sparkPad = S.drawElement(cfgSec, "sparkline-pad").sizeOr(4.0f) * dp; float sparkLeft = px + labelSz.x + sparkGap; float sparkRight = chgX - sparkGap; if (sparkLeft < sparkRight) { @@ -1501,7 +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(); @@ -1617,7 +1665,7 @@ static void RenderBalanceShield(App* app) { ImVec2 needleTip(gaugeCx + cosf(needleAngle) * needleLen, gaugeCy + sinf(needleAngle) * needleLen); dl->AddLine(ImVec2(gaugeCx, gaugeCy), needleTip, gaugeCol, - S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f)); + S.drawElement("tabs.balance.shield", "needle-thickness").sizeOr(2.0f) * dp); // Center text: percentage ImFont* sub1 = Type().subtitle1(); @@ -1645,13 +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,32 +1927,36 @@ static void RenderBalanceTwoRow(App* app) { ImFont* capFont = Type().caption(); if (state.sync.syncing && state.sync.headers > 0) { float pct = static_cast(state.sync.verification_progress) * 100.0f; - snprintf(buf, sizeof(buf), "Syncing %.1f%%", pct); + snprintf(buf, sizeof(buf), TR("balance_syncing_pct"), pct); Type().textColored(TypeStyle::Caption, Warning(), buf); ImGui::SameLine(); } if (state.mining.generate) { double hr = state.mining.localHashrate; - snprintf(buf, sizeof(buf), "Mining %s", FormatHashrate(hr).c_str()); + snprintf(buf, sizeof(buf), TR("balance_mining_rate"), FormatHashrate(hr).c_str()); Type().textColored(TypeStyle::Caption, WithAlpha(Success(), 200), buf); ImGui::SameLine(); } // Action buttons right-aligned - float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f); + float btnW = S.drawElement("tabs.balance.two-row", "action-btn-width").sizeOr(80.0f) * dp; float rightEdge = ImGui::GetWindowWidth() - Layout::spacingLg(); ImGui::SameLine(rightEdge - btnW * 2 - Layout::spacingSm()); - if (TactileButton("Send", ImVec2(btnW, 0), S.resolveFont("button"))) { + // Stable ## ids keep the button identity fixed across translations. + char sendBtn[64], recvBtn[64]; + snprintf(sendBtn, sizeof(sendBtn), "%s##tworow-send", TR("send")); + snprintf(recvBtn, sizeof(recvBtn), "%s##tworow-receive", TR("receive")); + if (TactileButton(sendBtn, ImVec2(btnW, 0), S.resolveFont("button"))) { app->setCurrentPage(NavPage::Send); } ImGui::SameLine(); - if (TactileButton("Receive", ImVec2(btnW, 0), S.resolveFont("button"))) { + if (TactileButton(recvBtn, ImVec2(btnW, 0), S.resolveFont("button"))) { app->setCurrentPage(NavPage::Receive); } } RenderSyncBar(app, dl, vs); - ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f))); + ImGui::Dummy(ImVec2(0, S.drawElement("tabs.balance.two-row", "sync-gap").sizeOr(2.0f) * dp)); // Row 2: 3 mini-cards inline { @@ -1915,7 +1979,7 @@ static void RenderBalanceTwoRow(App* app) { S.drawElement("tabs.balance.two-row", "mini-rounding-min").sizeOr(4.0f), glassRound * S.drawElement("tabs.balance.two-row", "mini-rounding-ratio").sizeOr(0.5f)); ImFont* capFont = Type().caption(); - float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f); + float indicatorR = S.drawElement("tabs.balance.two-row", "indicator-radius").sizeOr(3.0f) * dp; int balDecimals = (int)S.drawElement("tabs.balance.two-row", "balance-decimals").sizeOr(4.0f); float twoRowPadOverride = S.drawElement("tabs.balance.two-row", "card-padding").size; float miniPad = (twoRowPadOverride >= 0.0f) ? twoRowPadOverride : Layout::spacingSm(); @@ -1990,8 +2054,8 @@ static void RenderBalanceTwoRow(App* app) { // Sparkline between price and percentage if (market.price_history.size() >= 2) { - float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f); - float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f); + float sparkGap = S.drawElement("tabs.balance.two-row", "sparkline-gap").sizeOr(6.0f) * dp; + float sparkPad = S.drawElement("tabs.balance.two-row", "sparkline-pad").sizeOr(4.0f) * dp; float sparkLeft = cx + priceSz.x + sparkGap; float sparkRightEdge = sparkRight - sparkGap; if (sparkLeft < sparkRightEdge) { @@ -2017,7 +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); } @@ -2089,10 +2153,10 @@ static void RenderBalanceMinimal(App* app) { { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImVec2 sepPos = ImGui::GetCursorScreenPos(); - float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f); - float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f); + float dashLen = S.drawElement("tabs.balance.minimal", "dash-length").sizeOr(6.0f) * dp; + float gapLen = S.drawElement("tabs.balance.minimal", "dash-gap").sizeOr(4.0f) * dp; float sepAlpha = S.drawElement("tabs.balance.minimal", "separator-alpha").sizeOr(25.0f); - float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f); + float sepThick = S.drawElement("tabs.balance.minimal", "separator-thickness").sizeOr(1.0f) * dp; float x = sepPos.x; float endX = sepPos.x + availW; while (x < endX) { @@ -2110,7 +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); } diff --git a/src/ui/windows/block_info_dialog.cpp b/src/ui/windows/block_info_dialog.cpp index a38f3b2..2a661ca 100644 --- a/src/ui/windows/block_info_dialog.cpp +++ b/src/ui/windows/block_info_dialog.cpp @@ -59,7 +59,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er s_loading = false; if (!error.empty()) { - s_error = "Error: " + error; + s_error = std::string(TR("grpa_error_prefix")) + error; return; } @@ -84,7 +84,7 @@ static void handleBlockResponseUnified(const json& result, const std::string& er s_has_data = true; } else { - s_error = "Invalid response from daemon"; + s_error = TR("grpa_invalid_response_from_daemon"); } } @@ -112,7 +112,7 @@ void BlockInfoDialog::render(App* app) // Height input ImGui::Text("%s", TR("block_height")); - ImGui::SetNextItemWidth(heightInput.width); + ImGui::SetNextItemWidth(heightInput.width * Layout::dpiScale()); ImGui::InputInt("##Height", &s_height); if (s_height < 1) s_height = 1; // Clamp to the chain tip so navigation/typing can't request a height @@ -125,7 +125,7 @@ void BlockInfoDialog::render(App* app) // Current block info if (state.sync.blocks > 0) { - ImGui::TextDisabled("(Current: %d)", state.sync.blocks); + ImGui::TextDisabled(TR("grpa_current_block_paren"), state.sync.blocks); } ImGui::SameLine(); @@ -135,7 +135,7 @@ void BlockInfoDialog::render(App* app) ImGui::BeginDisabled(); } - if (material::StyledButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) { + if (material::TactileButton(TR("block_get_info"), ImVec2(0,0), S.resolveFont(closeBtn.font))) { if (rpc && rpc->isConnected() && app->worker()) { s_loading = true; s_error.clear(); @@ -152,7 +152,7 @@ void BlockInfoDialog::render(App* app) rpc::RPCClient::TraceScope trace("Explorer / Block info"); auto hashResult = rpc->call("getblockhash", {height}); if (!hashResult.is_string()) { - error = "unexpected getblockhash result"; + error = TR("grpa_unexpected_getblockhash_result"); } else { block = rpc->call("getblock", {hashResult.get()}); } @@ -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(); diff --git a/src/ui/windows/bootstrap_download_dialog.h b/src/ui/windows/bootstrap_download_dialog.h index 251341a..bed3f97 100644 --- a/src/ui/windows/bootstrap_download_dialog.h +++ b/src/ui/windows/bootstrap_download_dialog.h @@ -144,7 +144,7 @@ private: if (!s_bootstrap) { s_state = State::Failed; - s_errorMsg = "Bootstrap not initialized"; + s_errorMsg = TR("grpc_bootstrap_not_initialized"); return; } @@ -227,7 +227,7 @@ private: s_state = State::Done; } else { s_errorMsg = finalProg.error; - if (s_errorMsg.empty()) s_errorMsg = "Bootstrap failed"; + if (s_errorMsg.empty()) s_errorMsg = TR("grpc_bootstrap_failed"); s_state = State::Failed; } s_bootstrap.reset(); diff --git a/src/ui/windows/chat_tab.cpp b/src/ui/windows/chat_tab.cpp index 3539252..400833c 100644 --- a/src/ui/windows/chat_tab.cpp +++ b/src/ui/windows/chat_tab.cpp @@ -10,6 +10,7 @@ #include "../../data/address_book.h" #include "../../chat/chat_service.h" #include "../../util/i18n.h" +#include "../../util/address_validation.h" // isShieldedAddress — chat requires a z-address recipient #include "../../util/platform.h" // getConfigDir + writeFileAtomically — conversation export (Q11) #include "../../config/settings.h" // per-conversation mute (Q10) #include "../material/colors.h" @@ -59,6 +60,10 @@ bool s_msgsel_dragging = false; // Composer + new-conversation UI state. char s_compose[512] = ""; std::string s_compose_cid; // the conversation s_compose is a draft for; draft is wiped when it changes +// Live byte offset of the composer's text caret, kept in sync by composeInputCallback while the composer +// is active (the callback only fires then). The emoji picker uses it to splice a glyph at the cursor +// instead of always appending. -1 = unknown/never-focused => append at the end. +int s_composeCursor = -1; // On-chain chat body cap in bytes = (512 − len("utf8:"))/2 − secretstream ABYTES (see chat_outgoing.cpp). // The composer hard-caps input to this; the emoji picker respects it too. constexpr int kChatBodyMaxBytes = (512 - 5) / 2 - 17; // = 236 @@ -70,11 +75,16 @@ float s_composerTargetH = 0.0f; // target height measured in the composer block // neither the plain-Enter (submit) nor the Ctrl+Enter shortcut — ImGui does nothing with it. We insert the // newline ourselves here (running under CallbackAlways), respecting the on-chain byte cap. int composeInputCallback(ImGuiInputTextCallbackData* data) { + // Track the live caret so the emoji picker can insert at the cursor. This callback runs under + // CallbackAlways, which ImGui only invokes while the field is active — so when the composer loses + // focus (e.g. to the emoji picker) s_composeCursor keeps the last edit position. + s_composeCursor = data->CursorPos; ImGuiIO& io = ImGui::GetIO(); if (io.KeyShift && (ImGui::IsKeyPressed(ImGuiKey_Enter) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter)) && data->BufTextLen < kChatBodyMaxBytes) { data->InsertChars(data->CursorPos, "\n"); + s_composeCursor = data->CursorPos; // InsertChars advanced the caret past the newline } return 0; } @@ -83,6 +93,10 @@ char s_new_zaddr[128] = ""; char s_new_msg[256] = ""; char s_search[80] = ""; // conversation-list filter (Q8) bool s_show_hidden = false; // when on, the list also shows hidden conversations (with an Unhide action) +bool s_show_delete_confirm = false; // "Delete conversation?" confirm overlay (revive vs block) +std::string s_delete_cid; // conversation targeted by the delete confirm +std::string s_delete_name; // its peer name (for the confirm copy / block-list label) +bool s_show_blocked = false; // blocked-conversations manager overlay (unblock) bool s_show_emoji_picker = false; // emoji picker overlay — fills the conversation-list pane while open char s_emoji_search[48] = ""; // emoji picker keyword filter @@ -415,9 +429,9 @@ void RenderChatSettingsPreview(App* app, float width) { struct PMsg { const char* body; bool outgoing; bool startGroup; bool lastInGroup; std::string meta; }; const std::string peer = "Ava"; const PMsg msgs[] = { - { u8"Did the payment go through? \U0001F642", false, true, true, peer + " " + t1 }, - { u8"Yep — just confirmed ✅", true, true, false, std::string(TR("chat_you")) + " " + t2 }, - { u8"Sending the rest now \U0001F44D", true, false, true, std::string() }, + { TR("grpb_preview_msg_payment_through"), false, true, true, peer + " " + t1 }, + { TR("grpb_preview_msg_yep_confirmed"), true, true, false, std::string(TR("chat_you")) + " " + t2 }, + { TR("grpb_preview_msg_sending_rest"), true, false, true, std::string() }, }; const int N = 3; @@ -441,7 +455,7 @@ void RenderChatSettingsPreview(App* app, float width) { totalH += padIn; const ImVec2 origin = ImGui::GetCursorScreenPos(); - material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 16; g.borderAlpha = 36; + material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 36; material::DrawGlassPanel(dl, origin, ImVec2(origin.x + width, origin.y + totalH), g); const float leftX = origin.x + padIn; @@ -513,6 +527,16 @@ struct ConvSummary { bool hidden = false; // shown only while "Show hidden" is on }; +// Per-frame memoization of the conversation-list build and the open-thread message list (both otherwise +// rescan + copy + sort the whole chat history every frame). File-scope so ResetChatTab() can reset them +// on a wallet switch; the hide/unhide/rename handlers reset s_convsKey directly to force a rebuild. +std::vector s_convs; +int s_convsHidden = 0; +std::uint64_t s_convsKey = ~0ull; +std::string s_threadCid; +std::uint64_t s_threadRev = ~0ull; +std::vector s_threadMsgs; + // Centered, muted, wrapped hint for the empty states. void centeredHint(const char* text) { ImVec2 avail = ImGui::GetContentRegionAvail(); @@ -537,7 +561,7 @@ void centeredEmptyState(const char* icon, const char* title, const char* hint) { ImFont* titleF = material::Type().subtitle1(); ImFont* hintF = material::Type().body2(); const float gap = 8.0f * Layout::dpiScale(); - const float wrap = std::min(avail.x - 40.0f, 360.0f); + const float wrap = std::min(avail.x - 40.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale()); const float iconSz = iconF ? scaledSize(iconF) : 40.0f; const float iconH = iconF ? iconF->CalcTextSizeA(iconSz, FLT_MAX, 0.0f, icon).y : 0.0f; const float titleH = titleF->CalcTextSizeA(scaledSize(titleF), FLT_MAX, 0.0f, title).y; @@ -625,16 +649,22 @@ static const EmojiEntry kEmoji[] = { // Emoji picker overlay: fills the conversation-list pane (cancel + keyword search at the top, then a // grid). Clicking an emoji appends its UTF-8 bytes to `buf` (the composer), respecting the buffer. void renderEmojiPickerOverlay(char* buf, std::size_t bufSize, ImTextureID drgxTex) { - // Insert a token (emoji glyph or the ":drgx:" shortcode) at the end of the draft, prepending a space - // when the draft isn't empty and doesn't already end in whitespace. Respects the on-chain byte cap. + // Insert a token (emoji glyph or the ":drgx:" shortcode) at the composer's caret (s_composeCursor, + // kept live by composeInputCallback; -1 => end of draft), prepending a space when the char before the + // caret is a non-space word char so the emoji doesn't fuse onto it. Respects the on-chain byte cap. + // The composer is inactive whenever the picker is open, so it renders straight from buf — splicing + // here shows immediately. auto insertToken = [&](const char* tok) { const std::size_t cur = std::strlen(buf), add = std::strlen(tok); - const bool needsSpace = cur > 0 && static_cast(buf[cur - 1]) > ' '; + const std::size_t pos = (s_composeCursor < 0) + ? cur : std::min(static_cast(s_composeCursor), cur); + const bool needsSpace = pos > 0 && static_cast(buf[pos - 1]) > ' '; const std::size_t pad = needsSpace ? 1 : 0; if (cur + pad + add <= static_cast(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(pos + pad + add); // keep the caret after the inserted token } }; if (ImGui::SmallButton(TR("chat_cancel"))) { s_show_emoji_picker = false; s_emoji_search[0] = '\0'; return; } @@ -709,32 +739,46 @@ void RenderChatTab(App* app) } // Build conversation summaries (single scan per conversation), sorted by most-recent activity. - std::vector convs; - int hiddenCount = 0; - for (const auto& cid : store.conversationIds()) { - const bool hidden = app->settings() && app->settings()->isChatHidden(cid); - if (hidden) ++hiddenCount; - if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on - const auto messages = store.conversation(cid); - if (messages.empty()) continue; - ConvSummary c; - c.cid = cid; - c.hidden = hidden; - c.count = static_cast(messages.size()); - for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the - if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides - if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD) + // MEMOIZED: this previously rescanned + copied + sorted the ENTIRE chat history every frame. Rebuild + // only when the store changed (revision), the show-hidden toggle flipped, or the contact list grew + // (peerName resolution). Hide/unhide and rename don't move any of those, so those handlers force a + // rebuild by resetting s_convsKey (delete/block already bump the store revision). s_convsKey is reset + // in ResetChatTab on wallet switch. + const std::uint64_t convsKey = + store.revision() * 1000003ull + + static_cast(s_show_hidden ? 1 : 0) + + (book.revision() << 20); // book.revision() catches in-place contact edits (rename) that keep size() + if (convsKey != s_convsKey) { + s_convs.clear(); + s_convsHidden = 0; + for (const auto& cid : store.conversationIds()) { + const bool hidden = app->settings() && app->settings()->isChatHidden(cid); + if (hidden) ++s_convsHidden; + if (hidden && !s_show_hidden) continue; // filtered out unless "Show hidden" is on + const auto messages = store.conversation(cid); + if (messages.empty()) continue; + ConvSummary c; + c.cid = cid; + c.hidden = hidden; + c.count = static_cast(messages.size()); + for (const auto& m : messages) { // pin to the EARLIEST (establishing) peer z-addr / key (B2 — the + if (c.peerZaddr.empty() && !m.peer_zaddr.empty()) c.peerZaddr = m.peer_zaddr; // memo header rides + if (c.peerPubKey.empty() && !m.peer_public_key_hex.empty()) c.peerPubKey = m.peer_public_key_hex; // outside the AEAD) + } + const auto& last = messages.back(); + c.lastBody = last.body; + c.lastTs = last.timestamp; + const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr); + c.peerName = (idx >= 0) ? book.entries()[idx].label + : shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid); + s_convs.push_back(std::move(c)); } - const auto& last = messages.back(); - c.lastBody = last.body; - c.lastTs = last.timestamp; - const int idx = c.peerZaddr.empty() ? -1 : book.findByAddress(c.peerZaddr); - c.peerName = (idx >= 0) ? book.entries()[idx].label - : shorten(!c.peerZaddr.empty() ? c.peerZaddr : cid); - convs.push_back(std::move(c)); + std::sort(s_convs.begin(), s_convs.end(), + [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; }); + s_convsKey = convsKey; } - std::sort(convs.begin(), convs.end(), - [](const ConvSummary& a, const ConvSummary& b) { return a.lastTs > b.lastTs; }); + std::vector& convs = s_convs; + int hiddenCount = s_convsHidden; // Keep the selection valid (only when there is something to select). if (!convs.empty() && @@ -747,6 +791,7 @@ void RenderChatTab(App* app) // for one contact can't be sent to another (B5). if (s_selected_cid != s_compose_cid) { sodium_memzero(s_compose, sizeof(s_compose)); + s_composeCursor = -1; // fresh draft — next emoji appends until the caret is known again s_compose_cid = s_selected_cid; s_composerAnimH = 0.0f; // re-arm the first-frame snap so the box doesn't animate-collapse on switch } @@ -759,7 +804,7 @@ void RenderChatTab(App* app) } const ImVec2 avail = ImGui::GetContentRegionAvail(); - const float listW = std::clamp(avail.x * 0.32f, 220.0f, 360.0f); + const float listW = std::clamp(avail.x * 0.32f, 220.0f * Layout::dpiScale(), 360.0f * Layout::dpiScale()); // Row geometry is logical px — scale by dpiScale() so rows/padding grow with the (DPI-scaled) // fonts. Left raw, at higher DPI the row was too short for the enlarged text and the preview's // right margin (rowW - pad) shrank to ~zero, clipping the last glyph mid-word. @@ -778,7 +823,7 @@ void RenderChatTab(App* app) { ImDrawList* paneDL = ImGui::GetWindowDrawList(); const ImVec2 pMin = ImGui::GetCursorScreenPos(); - material::GlassPanelSpec g; g.rounding = 12.0f * Layout::dpiScale(); g.fillAlpha = 20; g.borderAlpha = 34; + material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 20; g.borderAlpha = 34; material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + listW, pMin.y + avail.y), g); } // Inner padding so the list content (buttons, search, conversation cards) doesn't hug the glass @@ -828,6 +873,15 @@ void RenderChatTab(App* app) ImGui::SetNextItemWidth(-FLT_MIN); ImGui::InputTextWithHint("##chatsearch", TR("chat_search"), s_search, sizeof(s_search)); } + // Blocked-conversations manager opener — only when at least one is blocked. Blocked convs have no + // stored messages (deleted), so they can't appear in the list; this opens a small manager to unblock. + const int blockedCount = app->settings() ? (int)app->settings()->blockedChatConversations().size() : 0; + if (blockedCount > 0) { + const std::string bl = std::string(TR("chat_blocked_manage")) + " (" + std::to_string(blockedCount) + ")"; + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + if (ImGui::SmallButton(bl.c_str())) s_show_blocked = true; + ImGui::PopStyleColor(); + } const std::string search = s_search; ImGui::Separator(); if (convs.empty()) { @@ -878,16 +932,22 @@ void RenderChatTab(App* app) } const float textX = avC.x + avR + pad; - // Name (top). Hidden conversations (shown via "Show hidden") render dimmed. - dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), - c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str()); - // Time (top-right, muted) — compact relative form (Q5). + // Time (top-right, muted) — compact relative form (Q5). Measure/draw it FIRST so the name can be + // clipped to the column left of it — otherwise a long peer name overruns the timestamp (worse at + // HiDPI, where the fixed-length name grows ~1.5x). const std::string when = relativeTime(c.lastTs); + float nameRight = mx.x - pad; if (!when.empty()) { const ImVec2 wsz = metaFont->CalcTextSizeA(metaSz, FLT_MAX, 0.0f, when.c_str()); dl->AddText(metaFont, metaSz, ImVec2(mx.x - pad - wsz.x, p.y + pad + 1.0f), material::OnSurfaceMedium(), when.c_str()); + nameRight = mx.x - pad - wsz.x - pad; // reserve the timestamp column + a gap } + // Name (top), clipped to the space left of the timestamp. Hidden conversations render dimmed. + dl->PushClipRect(ImVec2(textX, p.y), ImVec2(std::max(textX, nameRight), mx.y), true); + dl->AddText(nameFont, nameSz, ImVec2(textX, p.y + pad), + c.hidden ? material::OnSurfaceMedium() : material::OnSurface(), c.peerName.c_str()); + dl->PopClipRect(); // Preview (bottom, clipped to the text column, muted). const std::string preview = previewOf(c.lastBody); dl->PushClipRect(ImVec2(textX, p.y), ImVec2(mx.x - pad, mx.y), true); @@ -950,7 +1010,7 @@ void RenderChatTab(App* app) ImDrawList* paneDL = ImGui::GetWindowDrawList(); const ImVec2 pMin = ImGui::GetCursorScreenPos(); const float pW = ImGui::GetContentRegionAvail().x; - material::GlassPanelSpec g; g.rounding = 12.0f * tdp; g.fillAlpha = 12; g.borderAlpha = 34; + material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 12; g.borderAlpha = 34; material::DrawGlassPanel(paneDL, pMin, ImVec2(pMin.x + pW, pMin.y + (avail.y - composerAreaH)), g); } // Inner padding so the header + messages don't hug the glass card's edges (the message child @@ -995,7 +1055,7 @@ void RenderChatTab(App* app) // The toolbar's left edge is known up front (from the button count). A rename (edit) icon is // shown whenever there's an address to save the contact under; the settings "notch" gear is // always the rightmost icon. - const int nBtns = 4 + (hasAddr ? 1 : 0); + const int nBtns = 5 + (hasAddr ? 1 : 0); // export, mute, hide, delete, settings (+rename) const float toolbarLeft = rightX - (nBtns * ib + (nBtns - 1) * gap); // Compact address + lock (or waiting-chip) metrics, reserved to the right of the name. @@ -1046,6 +1106,7 @@ void RenderChatTab(App* app) } else { Notifications::instance().error(TR("address_book_exists")); } + s_convsKey = ~0ull; // peerName changed — force the conversation-list memo to rebuild } s_rename_cid.clear(); } else if (cancel) { @@ -1147,6 +1208,21 @@ void RenderChatTab(App* app) s_selected_cid.clear(); Notifications::instance().info(TR("chat_hidden_toast")); } + s_convsKey = ~0ull; // hidden-state changed — force the conversation-list memo to rebuild + } + bx += ib + gap; + } + // Delete — clears this conversation's LOCAL history. Destructive (and offers a + // "delete & block" variant), so it opens a confirm dialog rather than acting inline. + { + ImGui::SetCursorScreenPos(ImVec2(bx, by)); + material::IconButtonStyle a = base; + a.tooltip = TR("chat_delete"); + a.hoverColor = material::Error(); + if (material::IconButton("##hdr_delete", ICON_MD_DELETE_OUTLINE, ifont, ImVec2(ib, ib), a)) { + s_delete_cid = sel->cid; + s_delete_name = sel->peerName; + s_show_delete_confirm = true; } bx += ib + gap; } @@ -1255,7 +1331,15 @@ void RenderChatTab(App* app) const float groupGap = (compact ? 4.0f : 7.0f) * dp; const float msgGap = (compact ? 2.0f : 3.0f) * dp; const ImU32 accentBase = bubbleAccentColor(cs ? cs->getChatBubbleAccent() : 0); - const auto messages = store.conversation(s_selected_cid); + // MEMOIZED: store.conversation() linear-scans ALL messages across ALL conversations and + // copies+sorts the match every call. Rebuild only when the open thread or the store changes, + // not every frame while the thread is simply being read/scrolled. + if (s_selected_cid != s_threadCid || store.revision() != s_threadRev) { + s_threadMsgs = store.conversation(s_selected_cid); + s_threadCid = s_selected_cid; + s_threadRev = store.revision(); + } + const auto& messages = s_threadMsgs; // Grouping + per-day separators (Tier 1). Same-sender messages within kGroupWindow share // one meta header and stack tightly; a date pill is drawn once per calendar day. const std::int64_t nowTs = static_cast(std::time(nullptr)); @@ -1290,7 +1374,7 @@ void RenderChatTab(App* app) } const float availW = ImGui::GetContentRegionAvail().x; - const float maxBubbleW = std::max(140.0f * dp, availW * 0.72f); + const float maxBubbleW = std::clamp(availW * 0.72f, 140.0f * dp, 560.0f * dp); const float innerW = maxBubbleW - 2.0f * bpad; // ── Date separator (once per calendar day): a centered pill. @@ -1576,7 +1660,8 @@ void RenderChatTab(App* app) const float ringR = std::max(7.0f, lineH * 0.42f); const float ringPad = 9.0f * tdp; const float ringSlot = 2.0f * ringR + ringPad * 1.6f; - const float inputW = std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW); + const float inputW = std::min(720.0f * tdp, + std::max(ringSlot + 48.0f * tdp, cw - emojiBtn - 2.0f * inGap - sendW)); const float sendX = inputX + inputW + inGap; const float textW = std::max(40.0f * tdp, inputW - ringSlot); // input area, left of the ring const ImVec2 ringC(inputX + inputW - ringPad - ringR, rowY + composerBoxH - ringPad - ringR); @@ -1598,7 +1683,7 @@ void RenderChatTab(App* app) // FrameBg); a flat FrameBg showed the sharp texture. { ImDrawList* cdl = ImGui::GetWindowDrawList(); - material::GlassPanelSpec g; g.rounding = 8.0f * tdp; g.fillAlpha = 16; g.borderAlpha = 34; + material::GlassPanelSpec g; g.rounding = Layout::glassRounding(); g.fillAlpha = 16; g.borderAlpha = 34; material::DrawGlassPanel(cdl, ImVec2(inputX, rowY), ImVec2(inputX + inputW, rowY + composerBoxH), g); } @@ -1705,6 +1790,7 @@ void RenderChatTab(App* app) if (submit && s_compose[0] != '\0' && !overCap) { app->sendChatMessage(sel->cid, s_compose); sodium_memzero(s_compose, sizeof(s_compose)); + s_composeCursor = -1; s_scroll_to_cid = sel->cid; s_composerAnimH = 0.0f; // snap back to collapsed instead of animating while unfocused // Sending closes the emoji picker (it takes over the conversation-list pane) so the list @@ -1726,6 +1812,17 @@ void RenderChatTab(App* app) if (material::BeginOverlayDialog(ov)) { const float fieldW = ImGui::GetContentRegionAvail().x; material::LabeledInput(TR("chat_new_zaddr"), "##newz", s_new_zaddr, sizeof(s_new_zaddr), fieldW); + // Chat rides on encrypted memos, which only shielded (z) addresses carry — a transparent (t) + // address can't receive one. Contacts can hold t-addresses, so guard the manual field too: + // warn when the entry isn't a valid z-address and keep Send disabled below. + const bool newAddrIsZ = dragonx::util::isShieldedAddress(s_new_zaddr); + if (s_new_zaddr[0] != '\0' && !newAddrIsZ) { + ImGui::PushStyleColor(ImGuiCol_Text, material::Warning()); + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted(TR("chat_new_needs_zaddr")); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + } // Or pick from contacts — chat needs a shielded z-address, so only z-addr contacts are listed. // Selecting one fills the field above (manual paste still works). ImGui::SetNextItemWidth(fieldW); @@ -1757,7 +1854,7 @@ void RenderChatTab(App* app) material::LabeledInput(TR("chat_new_message"), "##newm", s_new_msg, sizeof(s_new_msg), fieldW); ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - const bool canSend = s_new_zaddr[0] != '\0' && s_new_msg[0] != '\0'; + const bool canSend = newAddrIsZ && s_new_msg[0] != '\0'; const float actionW = std::max(130.0f * dp, ImGui::CalcTextSize(TR("chat_new_send")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp); const float actionGap = Layout::spacingSm(); @@ -1784,6 +1881,122 @@ void RenderChatTab(App* app) } } + // ---- Delete-conversation confirm (revive-on-new-message vs delete & block) ---- + if (s_show_delete_confirm) { + const float dp = Layout::dpiScale(); + material::OverlayDialogSpec ov; + ov.title = TR("chat_delete_title"); + ov.p_open = &s_show_delete_confirm; // X / backdrop closes it (no-op) + ov.style = material::OverlayStyle::BlurFloat; + ov.cardWidth = 500.0f; ov.idSuffix = "chatdelete"; + if (material::BeginOverlayDialog(ov)) { + ImGui::PushTextWrapPos(0.0f); + ImGui::TextUnformatted((std::string(TR("chat_delete_body_prefix")) + s_delete_name + + TR("chat_delete_body_suffix")).c_str()); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(TR("chat_delete_revive_note")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + ImGui::TextUnformatted(TR("chat_delete_local_note")); + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + ImGui::Dummy(ImVec2(0, Layout::spacingMd())); + + auto doDelete = [&](bool block) { + // Delete first — if the persisted rows can't be removed, change nothing else (no block, + // no toast) so the store and DB can't diverge. + if (!app->chatService().deleteConversation(s_delete_cid, block)) { + Notifications::instance().error(TR("chat_delete_failed")); + s_show_delete_confirm = false; + s_delete_cid.clear(); s_delete_name.clear(); + return; + } + if (app->settings()) { + if (block) app->settings()->setChatBlocked(s_delete_cid, s_delete_name, true); + app->settings()->setChatHidden(s_delete_cid, false); // clear any prior hide flag + app->settings()->save(); + } + // Revive mode: forget the seen-watermark so a re-imported message badges as unread even if + // its stamped time predates the deleted thread. Block mode keeps it, so an unblock-restored + // history doesn't all re-badge. + if (!block) app->forgetChatConversationSeen(s_delete_cid); + if (s_selected_cid == s_delete_cid) s_selected_cid.clear(); + Notifications::instance().info(block ? TR("chat_blocked_toast") : TR("chat_deleted_toast")); + s_show_delete_confirm = false; + s_delete_cid.clear(); s_delete_name.clear(); + }; + + auto textW = [&](const char* t) { + return ImGui::CalcTextSize(t).x + ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp; + }; + const float gap2 = Layout::spacingSm(); + const float wDel = std::max(100.0f * dp, textW(TR("chat_delete_confirm"))); + const float wBlk = std::max(130.0f * dp, textW(TR("chat_delete_block"))); + const float wCan = std::max(90.0f * dp, textW(TR("chat_cancel"))); + material::BeginOverlayDialogFooter(wDel + wBlk + wCan + gap2 * 2.0f, /*drawSeparator=*/false); + + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 205))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Error())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Error(), 235))); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnError())); + const bool doDel = material::TactileButton(TR("chat_delete_confirm"), ImVec2(wDel, 0)); + ImGui::SameLine(0, gap2); + const bool doBlk = material::TactileButton(TR("chat_delete_block"), ImVec2(wBlk, 0)); + ImGui::PopStyleColor(4); + ImGui::SameLine(0, gap2); + const bool doCancel = material::TactileButton(TR("chat_cancel"), ImVec2(wCan, 0)); + + if (doDel) doDelete(false); + if (doBlk) doDelete(true); + if (doCancel) { s_show_delete_confirm = false; s_delete_cid.clear(); s_delete_name.clear(); } + + material::EndOverlayDialog(); + } + } + + // ---- Blocked-conversations manager (unblock) ---- + if (s_show_blocked) { + const float dp = Layout::dpiScale(); + material::OverlayDialogSpec ov; + ov.title = TR("chat_blocked_title"); + ov.p_open = &s_show_blocked; + ov.style = material::OverlayStyle::BlurFloat; + ov.cardWidth = 500.0f; ov.idSuffix = "chatblocked"; + if (material::BeginOverlayDialog(ov)) { + ImGui::PushTextWrapPos(0.0f); + ImGui::PushStyleColor(ImGuiCol_Text, material::OnSurfaceMedium()); + ImGui::TextUnformatted(TR("chat_blocked_desc")); + ImGui::PopStyleColor(); + ImGui::PopTextWrapPos(); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + if (app->settings()) { + // Copy so unblocking (which mutates the settings vector) during iteration is safe. + const auto blocked = app->settings()->blockedChatConversations(); + std::string unblockCid; + for (const auto& b : blocked) { + ImGui::PushID(b.cid.c_str()); + const std::string label = b.name.empty() ? shorten(b.cid, 10, 6) : b.name; + const float bw = ImGui::CalcTextSize(TR("chat_unblock")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 16.0f * dp; + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label.c_str()); + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ImGui::GetContentRegionAvail().x - bw); + if (material::TactileButton(TR("chat_unblock"), ImVec2(bw, 0))) unblockCid = b.cid; + ImGui::PopID(); + } + if (!unblockCid.empty()) { + app->settings()->setChatBlocked(unblockCid, "", false); + app->settings()->save(); + Notifications::instance().info(TR("chat_unblocked_toast")); // re-imports on the next chat scan + if (app->settings()->blockedChatConversations().empty()) s_show_blocked = false; + } + } + material::EndOverlayDialog(); + } + } + // ---- Chat customization modal (opened by the header settings "notch") — house BlurFloat overlay ---- if (s_show_chat_settings) { material::OverlayDialogSpec ov; @@ -1847,22 +2060,51 @@ void ResetChatTab() s_rename_focus = false; s_show_new_convo = false; s_show_chat_settings = false; + s_show_delete_confirm = false; + s_delete_cid.clear(); + s_delete_name.clear(); + s_show_blocked = false; + // Drop the per-frame memoization caches so the next wallet doesn't briefly render the previous one's + // conversations/thread (store.revision() is monotonic and would rebuild anyway, but be explicit). + s_convs.clear(); + s_convsHidden = 0; + s_convsKey = ~0ull; + s_threadCid.clear(); + s_threadRev = ~0ull; + s_threadMsgs.clear(); } -void RenderChatSettingsControls(App* app, float contentWidth) +void RenderChatSettingsControls(App* app, float contentWidth, bool drawCards) { auto* st = app ? app->settings() : nullptr; if (!st) return; const float dp = Layout::dpiScale(); const float ctrlW = 250.0f * dp; // control column width (fits a 3-segment control comfortably) - const float rowGap = 5.0f * dp; + const float rowGap = 10.0f * dp; + + // Optionally paint two glass cards (Appearance | Messaging) around our own two columns so the + // Settings tab matches the mockup's card-per-group layout. The chat modal passes drawCards=false + // and keeps its plain single-surface layout — the controls themselves are identical either way. + ImDrawList* cardDL = ImGui::GetWindowDrawList(); + const float cardPad = drawCards ? Layout::cardInnerPadding() : 0.0f; + material::GlassPanelSpec cardSpec; cardSpec.rounding = Layout::glassRounding(); + float cardTopScr = 0.0f, cardBaseXScr = 0.0f, cardLeftBotScr = 0.0f; + if (drawCards) { + cardTopScr = ImGui::GetCursorScreenPos().y; + cardBaseXScr = ImGui::GetCursorScreenPos().x; + cardDL->ChannelsSplit(2); + cardDL->ChannelsSetCurrent(1); + ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr + cardPad)); + ImGui::Indent(cardPad); + } // Right-align controls to the row's true right edge. The Settings tab renders us inside a GlassCard // whose content region isn't narrowed to the card padding, so it passes an explicit contentWidth; // the chat modal's dialog content region is correct, so it passes 0 (auto). // leftX/rowW define the current column the rows lay out in; retargeted below // to split Appearance | Messaging into two columns when the card is wide. float leftX = ImGui::GetCursorPosX(); - float rowW = (contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x; + float rowW = drawCards ? (contentWidth - 2.0f * cardPad) + : ((contentWidth > 0.0f) ? contentWidth : ImGui::GetContentRegionAvail().x); // Label left, control right-aligned within [leftX, leftX+rowW]. Leaves the cursor at the control origin. auto beginRow = [&](const char* label) { @@ -1930,7 +2172,7 @@ void RenderChatSettingsControls(App* app, float contentWidth) // Two internal columns when the card is wide enough: Appearance on the left, // Messaging on the right — fills the width and roughly halves the height. // (Mirrors the Node & Security card.) Narrow (the chat modal) stays single-column. - const float chatColGap = 24.0f * dp; + const float chatColGap = drawCards ? (Layout::cardGap() + 2.0f * cardPad) : (24.0f * dp); const bool chatTwoCol = rowW > 760.0f * dp; const float chatColW = chatTwoCol ? (rowW - chatColGap) * 0.5f : rowW; const float chatBaseLeftX = leftX; @@ -1987,6 +2229,7 @@ void RenderChatSettingsControls(App* app, float contentWidth) // line-start holds the column; retarget leftX so controls right-align in it). if (chatTwoCol) { chatLeftBottomY = ImGui::GetCursorPosY(); + cardLeftBotScr = ImGui::GetCursorScreenPos().y; // left column bottom (screen), for its card panel ImGui::SetCursorPosY(chatTopY); ImGui::Indent(chatColW + chatColGap); leftX = chatBaseLeftX + chatColW + chatColGap; @@ -2020,12 +2263,41 @@ void RenderChatSettingsControls(App* app, float contentWidth) } // Close the two-column band: un-indent and drop below the taller column. + const float cardRightBotScr = ImGui::GetCursorScreenPos().y; // right (or only) column bottom, screen if (chatTwoCol) { ImGui::Unindent(chatColW + chatColGap); const float chatRightBottomY = ImGui::GetCursorPosY(); ImGui::SetCursorPosX(chatBaseLeftX); ImGui::SetCursorPosY(std::max(chatLeftBottomY, chatRightBottomY)); } + + // Paint the glass card(s) behind the content, then merge the channels. + if (drawCards) { + ImGui::Unindent(cardPad); + cardDL->ChannelsSetCurrent(0); + const float cardW = (contentWidth - Layout::cardGap()) * 0.5f; + if (chatTwoCol) { + const float eqBot = std::max(cardLeftBotScr, cardRightBotScr); // equal-height cards (mockup grid stretch) + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr), + ImVec2(cardBaseXScr + cardW, eqBot + cardPad), cardSpec); + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr + cardW + Layout::cardGap(), cardTopScr), + ImVec2(cardBaseXScr + contentWidth, eqBot + cardPad), cardSpec); + } else { + material::DrawGlassPanel(cardDL, ImVec2(cardBaseXScr, cardTopScr), + ImVec2(cardBaseXScr + contentWidth, cardRightBotScr + cardPad), cardSpec); + } + cardDL->ChannelsMerge(); + const float botScr = chatTwoCol ? std::max(cardLeftBotScr, cardRightBotScr) : cardRightBotScr; + // Reserve the card footprint with a Dummy so the parent scroll region grows to include it + // (a bare SetCursorScreenPos past content warns in ImGui). + ImGui::SetCursorScreenPos(ImVec2(cardBaseXScr, cardTopScr)); + ImGui::Dummy(ImVec2(contentWidth, (botScr - cardTopScr) + cardPad)); + + // Live conversation preview below the two cards (Settings tab only — the chat modal renders + // its own preview column beside these controls, so it passes drawCards=false and skips this). + ImGui::Dummy(ImVec2(0.0f, Layout::spacingMd())); + RenderChatSettingsPreview(app, contentWidth); + } } } // namespace ui diff --git a/src/ui/windows/chat_tab.h b/src/ui/windows/chat_tab.h index 0e673ee..71fe390 100644 --- a/src/ui/windows/chat_tab.h +++ b/src/ui/windows/chat_tab.h @@ -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 diff --git a/src/ui/windows/console_command_reference.cpp b/src/ui/windows/console_command_reference.cpp index 9ec3ded..575f666 100644 --- a/src/ui/windows/console_command_reference.cpp +++ b/src/ui/windows/console_command_reference.cpp @@ -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"}, diff --git a/src/ui/windows/console_model.cpp b/src/ui/windows/console_model.cpp index c4de6dc..a17d085 100644 --- a/src/ui/windows/console_model.cpp +++ b/src/ui/windows/console_model.cpp @@ -37,18 +37,21 @@ ConsoleModel::DrainResult ConsoleModel::drain() lines_.pop_front(); ++result.popped; } + ++revision_; // deque changed — lets the view memoize its filter/layout passes return result; } void ConsoleModel::clear() { lines_.clear(); + ++revision_; } bool ConsoleModel::toggleCollapsed(std::size_t i) { if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false; lines_[i].collapsed = !lines_[i].collapsed; + ++revision_; // fold change alters which lines are visible return lines_[i].collapsed; } diff --git a/src/ui/windows/console_model.h b/src/ui/windows/console_model.h index ba25497..aa402f2 100644 --- a/src/ui/windows/console_model.h +++ b/src/ui/windows/console_model.h @@ -22,6 +22,7 @@ #include "console_channel.h" #include +#include #include #include #include @@ -76,11 +77,16 @@ public: const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; } const ConsoleModelLine& back() const { return lines_.back(); } + // Monotonic counter bumped whenever the visible deque changes (drain added/evicted lines, clear, + // fold toggle). The view memoizes its per-frame filter + text-layout passes against this. + std::uint64_t revision() const { return revision_; } + private: const std::size_t max_lines_; std::deque lines_; // visible model — main thread only std::vector pending_; // guarded by ingest_mutex_ std::mutex ingest_mutex_; + std::uint64_t revision_ = 0; }; } // namespace ui diff --git a/src/ui/windows/console_tab.cpp b/src/ui/windows/console_tab.cpp index 630fffa..88c7c62 100644 --- a/src/ui/windows/console_tab.cpp +++ b/src/ui/windows/console_tab.cpp @@ -337,7 +337,7 @@ void ConsoleTab::render(ConsoleCommandExecutor& exec) float outputH = ComputeConsoleOutputHeight( availHeight, input_height, - schema::UI().drawElement("tabs.console", "output-min-height").size, + schema::UI().drawElement("tabs.console", "output-min-height").size * Layout::dpiScale(), schema::UI().drawElement("tabs.console", "output-min-height-ratio").size); ImDrawList* dlOut = ImGui::GetWindowDrawList(); @@ -564,12 +564,27 @@ void ConsoleTab::renderToolbar(ConsoleCommandExecutor& exec) ImGui::SameLine(); } - // Line count - ImGui::TextDisabled(TR("console_line_count"), model_.size()); + // Line count — the least-critical trailing element. When the row is too narrow to fit the + // filter box (at its placeholder-sized minimum) AND its trailing controls, drop the line count + // rather than starve/hide the filter (worst at 1024px). Mirror the reservation formula in + // drawFilterInput(): trailing = 4 frame-height buttons + the group spacers, and the filter's + // hard floor = its placeholder width + frame padding. + { + char lineCountBuf[64]; + snprintf(lineCountBuf, sizeof(lineCountBuf), TR("console_line_count"), model_.size()); + float lineCountW = ImGui::CalcTextSize(lineCountBuf).x + Layout::spacingSm() * 2.0f; // text + its trailing spacer + float trailingW = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f; + float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale(); + bool showLineCount = ImGui::GetContentRegionAvail().x >= lineCountW + trailingW + filterMinW; - ImGui::SameLine(); - ImGui::Spacing(); - ImGui::SameLine(); + if (showLineCount) { + ImGui::TextDisabled(TR("console_line_count"), model_.size()); + ImGui::SameLine(); + ImGui::Spacing(); + ImGui::SameLine(); + } + } // Output filter input drawFilterInput(); @@ -617,7 +632,7 @@ void ConsoleTab::drawToolbarStatus(ConsoleCommandExecutor& exec) ConsoleStatusLine st = exec.toolbarStatus(); if (!st.text.empty()) { ImVec2 cp = ImGui::GetCursorScreenPos(); - float dotR = schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size * Layout::hScale(); + float dotR = (schema::UI().drawElement("tabs.console", "status-dot-radius-base").size + schema::UI().drawElement("tabs.console", "status-dot-radius-scale").size) * Layout::hScale(); float dotY = cp.y + ImGui::GetTextLineHeight() * 0.5f; float dotX = cp.x + dotR + 2.0f * Layout::dpiScale(); @@ -687,9 +702,21 @@ void ConsoleTab::drawLogFilterToggles(const ConsoleLogFilterCaps& caps) void ConsoleTab::drawFilterInput() { using namespace material; - float zoomBtnSpace = ImGui::GetFrameHeight() * 2.0f + Layout::spacingSm() * 3.0f; - float filterAvail = ImGui::GetContentRegionAvail().x - zoomBtnSpace; - float filterW = std::min(schema::UI().drawElement("tabs.console", "filter-max-width").size, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size); + // Reserve room for EVERY trailing same-line control drawn AFTER this filter on the toolbar + // row: the two icon toggles (accent-fill + text-color) and the two zoom buttons, plus the + // group spacers between them. Otherwise the filter eats the row and the trailing controls + // run off-window (worst at 1024px / font_scale 1.5). All four are GetFrameHeight() wide. + float trailingBtnSpace = ImGui::GetFrameHeight() * 4.0f + Layout::spacingSm() * 7.0f; + float filterAvail = ImGui::GetContentRegionAvail().x - trailingBtnSpace; + float filterMaxW = schema::UI().drawElement("tabs.console", "filter-max-width").size * Layout::dpiScale(); + float filterW = std::min(filterMaxW, filterAvail * schema::UI().drawElement("tabs.console", "filter-width-ratio").size); + // Never shrink below the placeholder — otherwise the hint clips to "Filter outp" (or the box + // vanishes) at narrow widths. Floor = placeholder text + frame padding + a little breathing room. + // (drawToolbar() drops the "NNN lines" count when even this floor won't fit alongside the row's + // trailing controls, so this max() doesn't push the zoom/color buttons off-window.) + float filterMinW = ImGui::CalcTextSize(TR("console_filter_hint")).x + + ImGui::GetStyle().FramePadding.x * 2.0f + 8.0f * Layout::dpiScale(); + filterW = std::max(filterMinW, filterW); ImGui::SetNextItemWidth(filterW); ImGui::InputTextWithHint("##ConsoleFilter", TR("console_filter_hint"), filter_text_, sizeof(filter_text_)); if (filter_text_[0] != '\0') { @@ -768,7 +795,10 @@ void ConsoleTab::renderOutput() // height. The inter-line gap is added explicitly to layout_.heights // so that layout_.cumulativeY stays perfectly in sync with actual // cursor positions (avoiding selection-offset drift). - float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f); + // Raw logical px from the schema; scale it so the inter-line gap grows at font_scale 1.5 + // (it is added to the already-DPI-scaled GetTextLineHeight in BuildConsoleLayout — scale the + // gap only, never the line height). + float interLineGap = S.drawElement("tabs.console", "output").getFloat("line-spacing", 0.0f) * Layout::dpiScale(); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0)); // Inner padding for glass panel @@ -790,11 +820,23 @@ void ConsoleTab::renderOutput() // segment records which bytes of the source text appear on that visual row, so // hit-testing and selection highlight can map screen positions to exact char offsets. float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX); - ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); - layout_ = BuildConsoleLayout( - static_cast(visible_indices_.size()), - [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, - wrap_width, line_height, interLineGap, measure); + // Memoize the text-shaping pass: rebuild only when the visible set, wrap width, line height, gap or + // zoom changed. Otherwise this re-wrapped and re-measured every visible line (glyph-by-glyph) every + // frame, even while idle/scrolled. layout_ is a member, so the cached geometry stays valid for + // drawVisibleLines / screenToTextPos when we skip the rebuild. + std::uint64_t layoutKey = vis_generation_ * 1000003ull; + layoutKey = layoutKey * 131ull + static_cast(wrap_width * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(line_height * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(interLineGap * 16.0f); + layoutKey = layoutKey * 131ull + static_cast(s_console_zoom * 1000.0f); + if (layoutKey != layout_key_) { + ImFontConsoleMeasure measure(ImGui::GetFont(), ImGui::GetFontSize()); + layout_ = BuildConsoleLayout( + static_cast(visible_indices_.size()), + [this](int vi) -> const std::string& { return model_[visible_indices_[vi]].text; }, + wrap_width, line_height, interLineGap, measure); + layout_key_ = layoutKey; + } // Mouse/keyboard interaction (wheel-up detach, selection drag, Ctrl+C/A). Raw IO bypasses // the child window's event consumption. @@ -861,6 +903,20 @@ void ConsoleTab::renderOutput() void ConsoleTab::computeVisibleLines(bool& hasTextFilter, std::string& filterLower) { + // Memoize: rebuild the visible set only when the model changed or the filter state changed. + // Otherwise this scanned the entire (up to 10k-line) model with a per-line filter predicate every + // frame. The out-params + filter_match_count_/folding_active_ are members that stay valid until the + // key moves, so an early return leaves last frame's (still-correct) results in place. + std::uint64_t visKey = model_.revision() * 1000003ull; + for (const char* p = filter_text_; *p; ++p) visKey = visKey * 131ull + static_cast(*p); + visKey = visKey * 2ull + (s_daemon_messages_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_errors_only_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_rpc_trace_enabled ? 1u : 0u); + visKey = visKey * 2ull + (s_app_messages_enabled ? 1u : 0u); + if (visKey == vis_key_) return; // nothing that affects the visible set changed + vis_key_ = visKey; + ++vis_generation_; // the layout pass keys off this + ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled, s_errors_only_enabled, s_rpc_trace_enabled, s_app_messages_enabled}; @@ -1050,8 +1106,11 @@ void ConsoleTab::drawVisibleLines(float padX, float lineHeight, bool hasTextFilt ImVec2(cx, cy + sz * 0.7f), triCol); // ▼ expanded } // Click anywhere in the gutter cell for this line's first row toggles the fold. + // Guard against a click that is actually dismissing the ConsoleContextMenu (or any + // popup) — the same popup guard the text-selection path uses (see mouse_in_output). ImVec2 mp = ImGui::GetIO().MousePos; if (ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + !ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopup) && mp.x >= output_origin_.x - padX && mp.x < output_origin_.x && mp.y >= lineOrigin.y && mp.y < lineOrigin.y + lineHeight) { pendingFoldToggle = i; @@ -1406,18 +1465,24 @@ void ConsoleTab::renderInput(ConsoleCommandExecutor& exec) ImGui::PopItemWidth(); ImGui::PopFont(); - // Auto-focus on input - if (reclaim_focus) { + // Auto-focus on input — after submitting a command (reclaim), or once when the Console tab is opened + // (focus_input_pending_, set by requestInputFocus() and gated on the console_auto_focus setting). + // Skip while a command is running: SetKeyboardFocusHere can't focus the disabled field anyway. + if ((reclaim_focus || focus_input_pending_) && !busy) { ImGui::SetKeyboardFocusHere(-1); } + focus_input_pending_ = false; } bool ConsoleTab::submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd) { if (cmd.empty()) return false; - addLine("> " + cmd, ConsoleChannel::Command); - AppendConsoleHistory(command_history_, cmd, 100); + // Redact secret-bearing commands (walletpassphrase, z_importkey, …) before they reach the visible + // log and the recall history. The real `cmd` below is still executed unredacted. + const std::string display = RedactConsoleCommand(cmd); + addLine("> " + display, ConsoleChannel::Command); + AppendConsoleHistory(command_history_, display, 100); history_index_ = -1; // First token, lowercased, for built-in interception. @@ -1885,7 +1950,7 @@ void ConsoleTab::renderCommandsPopup(ConsoleCommandExecutor& exec) // Both panes sit on soft Material glass surfaces (no hard 1px child border) with inner padding. GlassPanelSpec paneGlass; - paneGlass.rounding = 14.0f; + paneGlass.rounding = 14.0f * dp; paneGlass.fillAlpha = 30; paneGlass.borderAlpha = 30; @@ -2040,6 +2105,12 @@ void ConsoleTab::clear() // line indices) here to avoid an out-of-bounds crash. computeVisibleLines() rebuilds them next frame. visible_indices_.clear(); selection_.clear(); + // Force both memoized passes to rebuild: renderOutput() runs later THIS frame against the now-empty + // visible_indices_, and computeVisibleLines() recomputes next frame — without these resets the layout + // memo would skip the rebuild and keep stale geometry for the just-emptied set. + vis_key_ = ~0ull; + layout_key_ = ~0ull; + ++vis_generation_; stop_confirm_pending_ = false; // a pending 'stop' confirmation is cancelled by clearing addLine(TR("console_cleared"), ConsoleChannel::Info); } diff --git a/src/ui/windows/console_tab.h b/src/ui/windows/console_tab.h index b3ba243..f7b8607 100644 --- a/src/ui/windows/console_tab.h +++ b/src/ui/windows/console_tab.h @@ -15,6 +15,7 @@ #include "../../rpc/rpc_client.h" #include "../../rpc/rpc_worker.h" +#include #include #include #include @@ -65,6 +66,10 @@ public: */ void clear(); + // Ask the console to place the keyboard focus in the command input on the next render (consumed once). + // Called when the user switches to the Console tab, gated by the console_auto_focus setting. + void requestInputFocus() { focus_input_pending_ = true; } + // Scanline effect toggle (set from settings) static bool s_scanline_enabled; @@ -156,6 +161,7 @@ private: int history_index_ = -1; char input_buffer_[4096] = {0}; bool stop_confirm_pending_ = false; // 'stop' typed once, awaiting a confirming second 'stop' + bool focus_input_pending_ = false; // one-shot: focus the command input next render (tab-open auto-focus) // (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor) // Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count). @@ -183,6 +189,14 @@ private: bool has_text_filter_ = false; // computed once per frame (before the toolbar draws it) std::string filter_lower_; // lowercased filter needle for match highlighting + // Memoization keys so the two expensive per-frame passes rebuild only on change (not every frame): + // computeVisibleLines (filter scan over the whole model) is keyed on the model revision + filter + // state; BuildConsoleLayout (glyph-by-glyph text shaping of every visible line) is keyed on the + // resulting visible-set generation + wrap width + line height/zoom. + std::uint64_t vis_key_ = ~0ull; // key of the last computeVisibleLines + std::uint64_t vis_generation_ = 0; // bumped whenever visible_indices_ is rebuilt + std::uint64_t layout_key_ = ~0ull; // key of the last BuildConsoleLayout + // Wrap layout for the visible lines (segments + per-line heights + cumulative Y), // recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and // consumed by the renderer + hit-testing. diff --git a/src/ui/windows/console_tab_helpers.cpp b/src/ui/windows/console_tab_helpers.cpp index 50228f5..a79b346 100644 --- a/src/ui/windows/console_tab_helpers.cpp +++ b/src/ui/windows/console_tab_helpers.cpp @@ -1,10 +1,34 @@ #include "console_tab_helpers.h" #include +#include 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(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 diff --git a/src/ui/windows/console_tab_helpers.h b/src/ui/windows/console_tab_helpers.h index 27f2d13..2cb691c 100644 --- a/src/ui/windows/console_tab_helpers.h +++ b/src/ui/windows/console_tab_helpers.h @@ -1,5 +1,7 @@ #pragma once +#include + 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 diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 4cf5078..43d9770 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -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(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; diff --git a/src/ui/windows/daemon_download_dialog.h b/src/ui/windows/daemon_download_dialog.h index ebfb9ce..3a7d15d 100644 --- a/src/ui/windows/daemon_download_dialog.h +++ b/src/ui/windows/daemon_download_dialog.h @@ -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(); diff --git a/src/ui/windows/explorer_tab.cpp b/src/ui/windows/explorer_tab.cpp index 7c45215..9b67e92 100644 --- a/src/ui/windows/explorer_tab.cpp +++ b/src/ui/windows/explorer_tab.cpp @@ -108,13 +108,13 @@ static const char* relativeTime(int64_t timestamp) { int64_t diff = now - timestamp; if (diff < 0) diff = 0; if (diff < 60) - snprintf(buf, sizeof(buf), "%lld sec ago", (long long)diff); + snprintf(buf, sizeof(buf), TR("grpa_sec_ago"), (long long)diff); else if (diff < 3600) - snprintf(buf, sizeof(buf), "%lld min ago", (long long)(diff / 60)); + snprintf(buf, sizeof(buf), TR("grpa_min_ago"), (long long)(diff / 60)); else if (diff < 86400) - snprintf(buf, sizeof(buf), "%lld hr ago", (long long)(diff / 3600)); + snprintf(buf, sizeof(buf), TR("grpa_hr_ago"), (long long)(diff / 3600)); else - snprintf(buf, sizeof(buf), "%lld days ago", (long long)(diff / 86400)); + snprintf(buf, sizeof(buf), TR("grpa_days_ago"), (long long)(diff / 86400)); return buf; } @@ -446,10 +446,10 @@ static void renderSearchBar(App* app, float availWidth) { float navW = navBtnSz * 2.0f + pageW + navGap * 2.0f; float inputW = std::min( - S.drawElement("tabs.explorer", "search-input-width").size, + S.drawElement("tabs.explorer", "search-input-width").size * Layout::dpiScale(), availWidth * 0.65f); - float btnW = S.drawElement("tabs.explorer", "search-button-width").size; - float barH = S.drawElement("tabs.explorer", "search-bar-height").size; + float btnW = S.drawElement("tabs.explorer", "search-button-width").size * Layout::dpiScale(); + float barH = S.drawElement("tabs.explorer", "search-bar-height").size * Layout::dpiScale(); // Clamp so search bar never overflows float maxInputW = availWidth - btnW - navW - pad * 4 - Type().iconMed()->LegacySize; @@ -620,7 +620,16 @@ static void renderChainStats(App* app, float availWidth) { ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats")); drawStatusPill(cardMin, cardW); - float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg(); + // Distribute the two stat blocks (Height, Best Block) evenly through the + // content region so the card matches the density of the sibling 2x2 metric + // grid instead of pinning Height to the top and Best Block to the bottom + // edge with a large empty gap between them. + float contentTop = cardMin.y + pad * 0.5f + headerH; + float contentBottom = cardMax.y - pad; + float blockGap = std::max(Layout::spacingMd(), + (contentBottom - contentTop - heroLineH - hashLineH) / 3.0f); + + float labelY = contentTop + blockGap; dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height")); @@ -640,7 +649,7 @@ static void renderChainStats(App* app, float availWidth) { ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f); } - float hashLabelY = cardMax.y - pad - hashLineH; + float hashLabelY = labelY + heroLineH + blockGap; dl->AddText(capFont, capFont->LegacySize, ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block")); @@ -755,8 +764,8 @@ static void renderRecentBlocks(App* app, float availWidth) { ImFont* body2 = Type().body2(); ImFont* sub1 = Type().subtitle1(); - float baseRowH = S.drawElement("tabs.explorer", "row-height").size; - float rowRound = S.drawElement("tabs.explorer", "row-rounding").size; + float baseRowH = S.drawElement("tabs.explorer", "row-height").size * dp; + float rowRound = S.drawElement("tabs.explorer", "row-rounding").size * dp; float headerH = ovFont->LegacySize + Layout::spacingSm() + pad * 0.5f; // Stretch card to fill the remaining tab height; rows scroll inside. @@ -989,7 +998,7 @@ static void renderRecentBlocks(App* app, float availWidth) { ImGui::EndChild(); - float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size; + float fadeZone = S.drawElement("tabs.explorer", "scroll-fade-zone").size * dp; ApplyScrollEdgeMask(dl, parentVtx, childDL, childVtx, rowAreaTop, rowAreaTop + rowAreaH, fadeZone, scrollY, scrollMaxY); @@ -1116,8 +1125,9 @@ static void renderBlockDetailModal(App* app) { // ── Info grid ── ImDrawList* dl = ImGui::GetWindowDrawList(); + float dp = Layout::dpiScale(); float rowH = capFont->LegacySize + Layout::spacingXs() + sub1->LegacySize; - float labelW = S.drawElement("tabs.explorer", "label-column").size; + float labelW = S.drawElement("tabs.explorer", "label-column").size * dp; { ImVec2 gridPos = ImGui::GetCursorScreenPos(); float gx = gridPos.x; @@ -1178,7 +1188,7 @@ static void renderBlockDetailModal(App* app) { ImGui::Spacing(); - float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size; + float txRowH = S.drawElement("tabs.explorer", "tx-row-height").size * dp; ImU32 linkCol = schema::UI().resolveColor("var(--secondary-light)"); for (int i = 0; i < (int)s_detail_txids.size(); i++) { @@ -1206,7 +1216,7 @@ static void renderBlockDetailModal(App* app) { txDL->AddRectFilled(rowStart, ImVec2(rowStart.x + txContentW, rowStart.y + txRowH), WithAlpha(OnSurface(), 10), - S.drawElement("tabs.explorer", "row-rounding").size); + S.drawElement("tabs.explorer", "row-rounding").size * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", txid.c_str()); } @@ -1305,7 +1315,7 @@ static void renderBlockDetailModal(App* app) { } if (s_detail_txids.size() > 100) { - snprintf(buf, sizeof(buf), "... showing first 100 of %d", (int)s_detail_txids.size()); + snprintf(buf, sizeof(buf), TR("grpa_showing_first_100_of"), (int)s_detail_txids.size()); ImGui::TextDisabled("%s", buf); } } diff --git a/src/ui/windows/faq_content.cpp b/src/ui/windows/faq_content.cpp new file mode 100644 index 0000000..b2f4661 --- /dev/null +++ b/src/ui/windows/faq_content.cpp @@ -0,0 +1,135 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "faq_content.h" + +namespace dragonx { +namespace ui { +namespace faq { + +// ── Wallet group ─────────────────────────────────────────────────────────── +// Variant-aware: gs_1 (what the app IS) and sec_1 (where encryption lives) differ between the +// full-node and Lite builds; migrate-to-seed (seed_2) is full-node only; and the Lite build gets a +// trailing "Lite Wallet" subcategory that stands in for the (hidden) Daemon group. The remaining +// answers are worded neutrally so one string serves both variants. +static std::vector buildWalletFaq(bool fullNode) +{ + std::vector w; + w.push_back({ "faq_w_gs_title", { + { fullNode ? "faq_w_gs_1_q" : "faq_l_gs_1_q", fullNode ? "faq_w_gs_1_a" : "faq_l_gs_1_a" }, + { "faq_w_gs_2_q", "faq_w_gs_2_a" }, + { "faq_w_gs_3_q", "faq_w_gs_3_a" }, + { "faq_w_gs_4_q", "faq_w_gs_4_a" }, + }}); + w.push_back({ "faq_w_addr_title", { + { "faq_w_addr_1_q", "faq_w_addr_1_a" }, + { "faq_w_addr_2_q", "faq_w_addr_2_a" }, + { "faq_w_addr_3_q", "faq_w_addr_3_a" }, + { "faq_w_addr_4_q", "faq_w_addr_4_a" }, + }}); + w.push_back({ "faq_w_send_title", { + { "faq_w_send_1_q", "faq_w_send_1_a" }, + { "faq_w_send_2_q", "faq_w_send_2_a" }, + { "faq_w_send_3_q", "faq_w_send_3_a" }, + { "faq_w_send_4_q", "faq_w_send_4_a" }, + }}); + w.push_back({ "faq_w_bal_title", { + { "faq_w_bal_1_q", "faq_w_bal_1_a" }, + { "faq_w_bal_2_q", "faq_w_bal_2_a" }, + { "faq_w_bal_3_q", "faq_w_bal_3_a" }, + }}); + w.push_back({ "faq_w_sec_title", { + { "faq_w_sec_1_q", fullNode ? "faq_w_sec_1_a" : "faq_l_sec_1_a" }, + { "faq_w_sec_2_q", "faq_w_sec_2_a" }, + { "faq_w_sec_3_q", "faq_w_sec_3_a" }, + }}); + { + std::vector seed = { { "faq_w_seed_1_q", "faq_w_seed_1_a" } }; + if (fullNode) seed.push_back({ "faq_w_seed_2_q", "faq_w_seed_2_a" }); // migrate-to-seed is full-node only + seed.push_back({ "faq_w_seed_3_q", "faq_w_seed_3_a" }); + seed.push_back({ "faq_w_seed_4_q", "faq_w_seed_4_a" }); + w.push_back({ "faq_w_seed_title", std::move(seed) }); + } + w.push_back({ "faq_w_chat_title", { + { "faq_w_chat_1_q", "faq_w_chat_1_a" }, + { "faq_w_chat_2_q", "faq_w_chat_2_a" }, + { "faq_w_chat_3_q", "faq_w_chat_3_a" }, + { "faq_w_chat_4_q", "faq_w_chat_4_a" }, + }}); + w.push_back({ "faq_w_set_title", { + { "faq_w_set_1_q", "faq_w_set_1_a" }, + { "faq_w_set_2_q", "faq_w_set_2_a" }, + { "faq_w_set_3_q", "faq_w_set_3_a" }, + { "faq_w_set_4_q", "faq_w_set_4_a" }, + }}); + // Lite-only: explain the server model (stands in for the hidden Daemon group). + if (!fullNode) { + w.push_back({ "faq_l_lite_title", { + { "faq_l_lite_1_q", "faq_l_lite_1_a" }, + { "faq_l_lite_2_q", "faq_l_lite_2_a" }, + { "faq_l_lite_3_q", "faq_l_lite_3_a" }, + }}); + } + return w; +} + +const std::vector& walletFaq(bool fullNode) +{ + static const std::vector kFull = buildWalletFaq(true); + static const std::vector kLite = buildWalletFaq(false); + return fullNode ? kFull : kLite; +} + +// ── Daemon group (full-node only) ────────────────────────────────────────── +const std::vector& daemonFaq() +{ + static const std::vector kDaemon = { + { "faq_d_node_title", { + { "faq_d_node_1_q", "faq_d_node_1_a" }, + { "faq_d_node_2_q", "faq_d_node_2_a" }, + { "faq_d_node_3_q", "faq_d_node_3_a" }, + }}, + { "faq_d_sync_title", { + { "faq_d_sync_1_q", "faq_d_sync_1_a" }, + { "faq_d_sync_2_q", "faq_d_sync_2_a" }, + { "faq_d_sync_3_q", "faq_d_sync_3_a" }, + { "faq_d_sync_4_q", "faq_d_sync_4_a" }, + }}, + { "faq_d_mgmt_title", { + { "faq_d_mgmt_1_q", "faq_d_mgmt_1_a" }, + { "faq_d_mgmt_2_q", "faq_d_mgmt_2_a" }, + { "faq_d_mgmt_3_q", "faq_d_mgmt_3_a" }, + }}, + { "faq_d_upd_title", { + { "faq_d_upd_1_q", "faq_d_upd_1_a" }, + { "faq_d_upd_2_q", "faq_d_upd_2_a" }, + { "faq_d_upd_3_q", "faq_d_upd_3_a" }, + }}, + { "faq_d_mine_title", { + { "faq_d_mine_1_q", "faq_d_mine_1_a" }, + { "faq_d_mine_2_q", "faq_d_mine_2_a" }, + { "faq_d_mine_3_q", "faq_d_mine_3_a" }, + { "faq_d_mine_4_q", "faq_d_mine_4_a" }, + }}, + { "faq_d_net_title", { + { "faq_d_net_1_q", "faq_d_net_1_a" }, + { "faq_d_net_2_q", "faq_d_net_2_a" }, + }}, + { "faq_d_perf_title", { + { "faq_d_perf_1_q", "faq_d_perf_1_a" }, + { "faq_d_perf_2_q", "faq_d_perf_2_a" }, + }}, + { "faq_d_trbl_title", { + { "faq_d_trbl_1_q", "faq_d_trbl_1_a" }, + { "faq_d_trbl_2_q", "faq_d_trbl_2_a" }, + { "faq_d_trbl_3_q", "faq_d_trbl_3_a" }, + { "faq_d_trbl_4_q", "faq_d_trbl_4_a" }, + }}, + }; + return kDaemon; +} + +} // namespace faq +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/faq_content.h b/src/ui/windows/faq_content.h new file mode 100644 index 0000000..81499d8 --- /dev/null +++ b/src/ui/windows/faq_content.h @@ -0,0 +1,44 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// FAQ content model. The FAQ screen is data-driven: this header exposes the two +// top-level groups (Wallet, Daemon) as ordered lists of subcategories, each a list +// of question/answer entries. Every string is an i18n KEY (looked up with TR at +// render time), so wording + translations live in src/util/i18n.cpp + res/lang/*.json +// and never require touching UI code. Add a Q&A by appending a {qKey,aKey} pair here +// and its two strings to loadBuiltinEnglish(). + +#pragma once + +#include + +namespace dragonx { +namespace ui { +namespace faq { + +// One question and its answer, both i18n keys. The answer may contain "\n\n" +// paragraph breaks; it is rendered wrapped. Keep answers free of printf specifiers +// (%d/%s/…) — the i18n layer rejects translations whose format signature drifts. +struct FaqEntry { + const char* questionKey; + const char* answerKey; +}; + +// A named group of questions. titleKey is an i18n key for the subcategory header. +struct FaqSubcategory { + const char* titleKey; + std::vector entries; +}; + +// The two top-level groups. daemonFaq() is full-node material and is only shown when +// the build supports full-node lifecycle actions (see App::supportsFullNodeLifecycleActions()). +// walletFaq() is variant-aware: pass fullNode=false for the Lite variant, which swaps in +// lite-appropriate answers (no local node / daemon), drops full-node-only entries +// (e.g. migrate-to-seed), and appends a "Lite Wallet" subcategory explaining the server model. +const std::vector& walletFaq(bool fullNode); +const std::vector& daemonFaq(); + +} // namespace faq +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/faq_dialog.cpp b/src/ui/windows/faq_dialog.cpp new file mode 100644 index 0000000..aa916ca --- /dev/null +++ b/src/ui/windows/faq_dialog.cpp @@ -0,0 +1,203 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "faq_dialog.h" +#include "faq_content.h" +#include "../../app.h" +#include "../../util/i18n.h" +#include "../../util/text_format.h" +#include "../../embedded/IconsMaterialDesign.h" +#include "../schema/ui_schema.h" +#include "../layout.h" +#include "../material/type.h" +#include "../material/colors.h" +#include "../material/draw_helpers.h" +#include "imgui.h" + +#include +#include +#include +#include + +namespace dragonx { +namespace ui { + +namespace { + +// Persists across frames (the dialog is re-entered each frame while open). Group 0 = Wallet, +// 1 = Daemon. `expanded` keys are FaqEntry::questionKey (stable string literals from faq_content). +struct FaqDialogState { + int group = 0; + char search[128] = ""; + std::unordered_map expanded; +}; +FaqDialogState s_faq; + +bool entryMatches(const faq::FaqEntry& e, const char* query) +{ + return util::containsIgnoreCase(TR(e.questionKey), query) || + util::containsIgnoreCase(TR(e.answerKey), query); +} + +// One answer body: wrapped, muted. Shared by the collapsible (non-search) and search paths. +void renderAnswer(const char* answerKey) +{ + ImGui::Indent(Layout::spacingMd()); + ImGui::PushFont(material::Type().body2()); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium())); + ImGui::TextWrapped("%s", TR(answerKey)); + ImGui::PopStyleColor(); + ImGui::PopFont(); + ImGui::Unindent(Layout::spacingMd()); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); +} + +} // namespace + +void RenderFaqDialog(App* app, bool* p_open) +{ + auto& S = schema::UI(); + auto win = S.window("dialogs.faq"); + const float dp = Layout::dpiScale(); + + const bool daemonAvailable = app && app->supportsFullNodeLifecycleActions(); + if (!daemonAvailable) s_faq.group = 0; // no Daemon tab in lite builds + + // Floating "BlurFloat" modal, matching the Wallets dialog: live-blur backdrop, no boxed card, a + // plain heading (no ✕ — a Close button sits at the bottom). Roomy fixed width; height capped to the + // viewport so the content flexes + scrolls on small / HiDPI screens. + const float vpH = ImGui::GetMainViewport()->Size.y; + const float wantH = (win.height > 0 ? win.height : 640.0f) * dp; + material::OverlayDialogSpec spec; + spec.title = TR("faq_title"); + spec.p_open = p_open; + spec.style = material::OverlayStyle::BlurFloat; + spec.cardWidth = (win.width > 0 ? win.width : 860.0f); + spec.cardHeight = std::min(wantH, vpH * 0.86f) / dp; + spec.idSuffix = "faq"; + if (!material::BeginOverlayDialog(spec)) { + return; + } + // Esc closes (ImGui consumes Esc itself while the search box is being edited, so this only + // fires when the field isn't capturing it). + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) *p_open = false; + + // Subtitle under the plain heading, matching the Wallets dialog's intro caption. + material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), + TR(daemonAvailable ? "faq_intro" : "faq_intro_lite")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + const float contentW = ImGui::GetContentRegionAvail().x; + + // ── Group tabs: Wallet | Daemon — only when there's a choice. Lite has just the Wallet group, so a + // lone "Wallet" selector is redundant; skip it entirely (the group is already pinned to 0 above). + if (daemonAvailable) { + const float gap = ImGui::GetStyle().ItemSpacing.x; + const float tabW = (contentW - gap) / 2.0f; + auto tab = [&](const char* label, int idx) { + const bool active = (s_faq.group == idx); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::Primary())); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnPrimary())); + } + if (material::TactileButton(label, ImVec2(tabW, 0))) s_faq.group = idx; + if (active) ImGui::PopStyleColor(2); + }; + tab(TR("faq_group_wallet"), 0); + ImGui::SameLine(); + tab(TR("faq_group_daemon"), 1); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + + // ── Search ── + ImGui::SetNextItemWidth(contentW); + ImGui::InputTextWithHint("##FaqSearch", TR("faq_search_hint"), s_faq.search, sizeof(s_faq.search)); + const bool searching = s_faq.search[0] != '\0'; + + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + // ── Scrollable Q&A body ── (reserve room for the Close button footer below) + const float footerH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingMd(); + float bodyH = ImGui::GetContentRegionAvail().y - footerH; + if (bodyH < 80.0f * dp) bodyH = 80.0f * dp; + // Inner padding gives the content breathing room and, on the right, a clear gap to the LEFT of the + // scrollbar (WindowPadding.x is exactly that gap); the scrollbar itself is made chunkier than the + // app default. NoScrollWithMouse + ApplySmoothScroll gives the wheel eased scrolling, matching the + // Wallets dialog / Settings page (ApplySmoothScroll owns the wheel input and lerps ScrollY). + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingXs())); + ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 16.0f * dp); + ImGui::BeginChild("##FaqScroll", ImVec2(0, bodyH), false, + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollWithMouse); + material::ApplySmoothScroll(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + const auto& groups = (s_faq.group == 1 && daemonAvailable) ? faq::daemonFaq() : faq::walletFaq(daemonAvailable); + bool anyShown = false; + + bool firstSection = true; + for (const auto& subcat : groups) { + // Collect the entries visible under the current search. + std::vector visible; + for (const auto& e : subcat.entries) { + if (!searching || entryMatches(e, s_faq.search)) visible.push_back(&e); + } + if (visible.empty()) continue; + + // Section break: generous space above every section after the first, so topic groups read as + // clearly separated bands rather than one uniform list. + if (!firstSection) ImGui::Dummy(ImVec2(0, Layout::spacingLg())); + firstSection = false; + anyShown = true; + + // Section header — accent overline + a thin full-width rule beneath it, anchoring the group + // above its (brighter, normal-case) question rows. + material::Type().textColored(material::TypeStyle::Overline, + material::Primary(), TR(subcat.titleKey)); + { + const ImVec2 rp = ImGui::GetCursorScreenPos(); + const float rw = ImGui::GetContentRegionAvail().x; + dl->AddLine(ImVec2(rp.x, rp.y + dp), ImVec2(rp.x + rw, rp.y + dp), + material::Divider(), 1.0f * dp); + } + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + for (const auto* e : visible) { + const float rowW = ImGui::GetContentRegionAvail().x; + if (searching) { + // Search results: show question + answer directly (no collapsing). + ImGui::PushFont(material::Type().subtitle2()); + ImGui::TextWrapped("%s", TR(e->questionKey)); + ImGui::PopFont(); + renderAnswer(e->answerKey); + } else { + bool& exp = s_faq.expanded[e->questionKey]; + std::string id = std::string("##faq_") + e->questionKey; + material::CollapsibleHeader(dl, id.c_str(), TR(e->questionKey), exp, rowW, + material::Type().subtitle2(), material::OnSurface()); + if (exp) renderAnswer(e->answerKey); + } + } + } + + if (!anyShown) { + ImGui::Dummy(ImVec2(0, Layout::spacingLg())); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium())); + ImGui::TextWrapped("%s", TR("faq_no_results")); + ImGui::PopStyleColor(); + } + + ImGui::EndChild(); + ImGui::PopStyleVar(2); // ScrollbarSize, WindowPadding + + // Close button footer (BlurFloat has no ✕ in the heading). + const float closeW = 120.0f * dp; + material::BeginOverlayDialogFooter(closeW, false); + if (material::TactileButton(TR("close"), ImVec2(closeW, 0))) *p_open = false; + + material::EndOverlayDialog(); +} + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/faq_dialog.h b/src/ui/windows/faq_dialog.h new file mode 100644 index 0000000..99f07dd --- /dev/null +++ b/src/ui/windows/faq_dialog.h @@ -0,0 +1,18 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +namespace dragonx { +class App; +namespace ui { + +// Renders the Help & FAQ modal. Call every frame while *p_open is true; the dialog +// clears *p_open itself on close (✕ / click-outside / Esc). Content is data-driven +// (see faq_content.h) and grouped into Wallet and Daemon tabs; the Daemon tab is +// hidden on builds without full-node lifecycle support. +void RenderFaqDialog(App* app, bool* p_open); + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/key_export_dialog.cpp b/src/ui/windows/key_export_dialog.cpp index fa3f01c..b0c2104 100644 --- a/src/ui/windows/key_export_dialog.cpp +++ b/src/ui/windows/key_export_dialog.cpp @@ -173,7 +173,7 @@ void KeyExportDialog::render(App* app) s_key = found; s_show_key = wantViewing; // viewing keys are less sensitive } else { - s_error = r.ok ? std::string("Key not available for this address") : r.error; + s_error = r.ok ? std::string(TR("grpc_key_not_available")) : r.error; } wallet::secureWipeLiteSecret(found); s_fetching = false; @@ -240,14 +240,15 @@ void KeyExportDialog::render(App* app) ImGui::TextDisabled("%s", TR("key_export_click_retrieve")); } else { // Key fetched. Layout: [ key text (click-to-copy) + Show/Hide below ] [ QR | square ]. - const float gap = 12.0f; + const float dp = Layout::dpiScale(); + const float gap = 12.0f * dp; const float avail = ImGui::GetContentRegionAvail().x; // Larger, responsive QR: ~30% of the content width, clamped to a comfortable range. float qrSize = avail * 0.30f; - if (qrSize < 200.0f) qrSize = 200.0f; - if (qrSize > 340.0f) qrSize = 340.0f; + if (qrSize < 200.0f * dp) qrSize = 200.0f * dp; + if (qrSize > 340.0f * dp) qrSize = 340.0f * dp; float keyW = avail - qrSize - gap; - const bool sideBySide = keyW >= 200.0f; // too narrow -> stack the QR under the key + const bool sideBySide = keyW >= 200.0f * dp; // too narrow -> stack the QR under the key if (!sideBySide) keyW = avail; // Chunk for readability, but keep a Bech32 HRP (e.g. "secret-extended-key-main") intact @@ -309,12 +310,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; diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 844efdd..d4b8a2c 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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& 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(); @@ -1556,7 +1580,7 @@ static void mktDrawPriceHero(const MktCtx& cx) } } else { const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable"); - DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy + 10), OnSurfaceDisabled(), status); + DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx0, cy + 10 * dp), OnSurfaceDisabled(), status); if (!market.price_loading && !market.price_error.empty()) { std::string errorText = market.price_error; float maxErrorW = cardMax.x - cx0 - Layout::spacingLg(); @@ -1566,7 +1590,7 @@ static void mktDrawPriceHero(const MktCtx& cx) } if (errorText.size() < market.price_error.size()) errorText += "..."; dl->AddText(capFont, capFont->LegacySize, - ImVec2(cx0, cy + 10 + sub1->LegacySize + Layout::spacingXs()), + ImVec2(cx0, cy + 10 * dp + sub1->LegacySize + Layout::spacingXs()), Warning(), errorText.c_str()); } } @@ -1850,7 +1874,7 @@ static void mktDrawPriceChart(const MktCtx& cx) : (tk == ticks - 1) ? plotRight - lblSz.x : xpos - lblSz.x * 0.5f; dl->AddText(capFont, capFont->LegacySize, - ImVec2(lx, plotBottom + 4), OnSurfaceDisabled(), tlbl); + ImVec2(lx, plotBottom + 4 * mktDp), OnSurfaceDisabled(), tlbl); } } @@ -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. diff --git a/src/ui/windows/mining_controls.cpp b/src/ui/windows/mining_controls.cpp index 133db30..0a318b6 100644 --- a/src/ui/windows/mining_controls.cpp +++ b/src/ui/windows/mining_controls.cpp @@ -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 #include #include +#include // 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 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,92 @@ 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(); + // Match the -/+ button height to the InputInt's actual frame height (GetFrameHeight == + // fontSize + 2*FramePadding.y, evaluated with the same font/style the input renders with, + // since nothing pushes a font between here and the InputInt below). This keeps the buttons + // the SAME height as the number box and top-aligned with it at sy — previously fieldH was + // capFont+6px, shorter than the box, so they sat misaligned. + const float fieldH = ImGui::GetFrameHeight(); + 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 +298,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 +337,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 +376,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)); @@ -442,7 +549,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo& s_benchConfirm = true; char msg[128]; snprintf(msg, sizeof(msg), - "Benchmark takes ~%ds and interrupts mining. Click again to start.", + TR("grpc_benchmark_takes_secs"), (int)(s_benchmark.totalEstimatedSecs() + 0.5f)); Notifications::instance().warning(msg); } else { @@ -474,8 +581,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 " when an update is available, neutral when either version is unknown. + auto verNum = [](std::string s) -> std::vector { + 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 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 +636,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 +654,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 +687,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 +705,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 +726,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 +735,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 +763,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 +807,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 +819,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 +957,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")); } diff --git a/src/ui/windows/mining_earnings.cpp b/src/ui/windows/mining_earnings.cpp index 8c10471..eba9ad6 100644 --- a/src/ui/windows/mining_earnings.cpp +++ b/src/ui/windows/mining_earnings.cpp @@ -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; @@ -217,13 +220,16 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& if (estActive) snprintf(estVal, sizeof(estVal), "~%.4f", estDaily); else - snprintf(estVal, sizeof(estVal), "N/A"); + snprintf(estVal, sizeof(estVal), "%s", TR("grpc_na")); + // 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++) { @@ -469,7 +475,7 @@ void RenderMiningEarnings(App* app, const WalletState& state, const MiningInfo& else if (totalRAM > 0) snprintf(sysBuf, sizeof(sysBuf), "-- / %.0f GB", totalRAM / 1024.0); else - snprintf(sysBuf, sizeof(sysBuf), "N/A"); + snprintf(sysBuf, sizeof(sysBuf), "%s", TR("grpc_na")); float sysTextW = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, sysBuf).x; float sysTextX = barX + barW - textPadX - sysTextW; @@ -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(); diff --git a/src/ui/windows/mining_mode_toggle.cpp b/src/ui/windows/mining_mode_toggle.cpp index 862fd36..198a640 100644 --- a/src/ui/windows/mining_mode_toggle.cpp +++ b/src/ui/windows/mining_mode_toggle.cpp @@ -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(); diff --git a/src/ui/windows/mining_pool_panel.cpp b/src/ui/windows/mining_pool_panel.cpp index 2082e9e..9d3b9c8 100644 --- a/src/ui/windows/mining_pool_panel.cpp +++ b/src/ui/windows/mining_pool_panel.cpp @@ -20,6 +20,17 @@ std::string defaultPoolWorkerAddress(const std::vector& 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& savedValues, const std::string& value) { diff --git a/src/ui/windows/mining_pool_panel.h b/src/ui/windows/mining_pool_panel.h index f4e7b73..705606f 100644 --- a/src/ui/windows/mining_pool_panel.h +++ b/src/ui/windows/mining_pool_panel.h @@ -10,6 +10,14 @@ namespace ui { bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted); std::string defaultPoolWorkerAddress(const std::vector& 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& savedValues, const std::string& value); const char* defaultPoolUrl(); diff --git a/src/ui/windows/mining_stats.cpp b/src/ui/windows/mining_stats.cpp index ae734e8..d700837 100644 --- a/src/ui/windows/mining_stats.cpp +++ b/src/ui/windows/mining_stats.cpp @@ -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 " 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), TR("grpc_hashrate_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 " 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,16 @@ 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; + // Fill the panel's content width so the chart extends across the whole panel on a wide window. + // (Previously capped at ~1000dp and centered, which left large empty margins either side.) + const float chartBandW = rightContentW; + const float chartBandX = rightMin.x + pad; + // Right panel: live log (if toggled + available) else the sparkline. if (showLogView) { float logPad = pad * 0.5f; @@ -398,14 +434,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); } diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 49b8774..b6368f8 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -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. @@ -250,8 +263,7 @@ static void RenderMiningTabContent(App* app) } if (benchmarkUpdate.inconclusive) { Notifications::instance().warning( - "Benchmark inconclusive: no hashrate samples were recorded. " - "Check the pool connection and try again."); + TR("grpc_benchmark_inconclusive")); } } diff --git a/src/ui/windows/mining_tab.h b/src/ui/windows/mining_tab.h index 2f55762..0515925 100644 --- a/src/ui/windows/mining_tab.h +++ b/src/ui/windows/mining_tab.h @@ -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 diff --git a/src/ui/windows/mining_tab_helpers.cpp b/src/ui/windows/mining_tab_helpers.cpp index 40efe61..801e1b7 100644 --- a/src/ui/windows/mining_tab_helpers.cpp +++ b/src/ui/windows/mining_tab_helpers.cpp @@ -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); diff --git a/src/ui/windows/mining_tab_helpers.h b/src/ui/windows/mining_tab_helpers.h index 4a7d1cf..60add69 100644 --- a/src/ui/windows/mining_tab_helpers.h +++ b/src/ui/windows/mining_tab_helpers.h @@ -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); diff --git a/src/ui/windows/network_tab.cpp b/src/ui/windows/network_tab.cpp index 8ad4e2e..b12de3a 100644 --- a/src/ui/windows/network_tab.cpp +++ b/src/ui/windows/network_tab.cpp @@ -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 ──────────────────────────────────────────────────────── diff --git a/src/ui/windows/peers_tab.cpp b/src/ui/windows/peers_tab.cpp index c823a74..ac06880 100644 --- a/src/ui/windows/peers_tab.cpp +++ b/src/ui/windows/peers_tab.cpp @@ -380,7 +380,7 @@ void RenderPeersTab(App* app) if (tlsCount == totalPeers) { ImFont* iconFont = Type().iconSmall(); ImVec2 txtSize = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf); - dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx + txtSize.x + 4, valY), Success(), ICON_MD_CHECK); + dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx + txtSize.x + 4 * dp, valY), Success(), ICON_MD_CHECK); } } else { dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, valY), OnSurfaceDisabled(), "\xE2\x80\x94"); @@ -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,10 +692,10 @@ 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); - dl->AddText(capFont, capFont->LegacySize, ImVec2(dirX, cy + 2), dirFg, dirLabel); + 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 * dp), 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 tlsMax(tlsMin.x + tlsBadgeW, tlsMin.y + capFont->LegacySize + 2); - dl->AddRectFilled(tlsMin, tlsMax, tlsBg, S.drawElement("tabs.peers", "tls-badge-rounding").size); - dl->AddText(capFont, capFont->LegacySize, ImVec2(tlsMin.x + 4, cy2 + 1), tlsFg, "TLS"); + ImVec2 tlsMin(badgeX, cy2); + ImVec2 tlsMax(tlsMin.x + tlsBadgeW, tlsMin.y + capFont->LegacySize + 2 * dp); + dl->AddRectFilled(tlsMin, tlsMax, tlsBg, S.drawElement("tabs.peers", "tls-badge-rounding").size * dp); + dl->AddText(capFont, capFont->LegacySize, ImVec2(tlsMin.x + 4 * dp, cy2 + 1 * dp), 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(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)); } diff --git a/src/ui/windows/qr_popup_dialog.cpp b/src/ui/windows/qr_popup_dialog.cpp index 7e40ea3..809b852 100644 --- a/src/ui/windows/qr_popup_dialog.cpp +++ b/src/ui/windows/qr_popup_dialog.cpp @@ -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(); diff --git a/src/ui/windows/receive_tab.cpp b/src/ui/windows/receive_tab.cpp index 2269850..7e8bbef 100644 --- a/src/ui/windows/receive_tab.cpp +++ b/src/ui/windows/receive_tab.cpp @@ -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(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())) { @@ -245,11 +259,11 @@ static void RenderAddressDropdown(App* app, float width) { snprintf(buf, sizeof(buf), "%s %s (%s) \xe2\x80\x94 %.8f %s%s", tag, lblIt->second.c_str(), trunc.c_str(), addr.balance, DRAGONX_TICKER, - isNew ? " [NEW]" : ""); + isNew ? TR("grpb_new_badge_suffix") : ""); } else { snprintf(buf, sizeof(buf), "%s %s \xe2\x80\x94 %.8f %s%s", tag, trunc.c_str(), addr.balance, DRAGONX_TICKER, - isNew ? " [NEW]" : ""); + isNew ? TR("grpb_new_badge_suffix") : ""); } ImGui::PushID(static_cast(i)); @@ -258,9 +272,9 @@ static void RenderAddressDropdown(App* app, float width) { s_cached_qr_data.clear(); // Force QR regeneration } if (ImGui::IsItemHovered()) { - material::Tooltip("%s\nBalance: %.8f %s%s", + material::Tooltip(TR("grpb_tooltip_address_balance"), addr.address.c_str(), addr.balance, DRAGONX_TICKER, - isCurrent ? "\n(selected)" : ""); + isCurrent ? TR("grpb_selected_suffix") : ""); } ImGui::PopID(); } @@ -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), @@ -367,15 +370,25 @@ static void RenderRecentReceived(const AddressInfo& /* addr */, S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); ImU32 recvCol = Success(); - // Collect matching transactions + // 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. + // Compute maxRows BEFORE the collect loop so the scan can stop early (below). + float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); + size_t maxRows = std::max(4, (size_t)std::floor(listH / rowH)); + + // Collect matching transactions. state.transactions is newest-first, so the first + // maxRows matches ARE exactly the rows we render — stop scanning once we have them + // instead of filtering the entire history every frame (mirrors RenderSharedRecentTx + // in balance_components.cpp). std::vector recvs; + recvs.reserve(maxRows); for (const auto& tx : state.transactions) { if (tx.type != "receive" && tx.type != "mined") continue; recvs.push_back(&tx); + if (recvs.size() >= maxRows) break; // newest-first: prefix is all we show } - if (recvs.size() > 4) recvs.resize(4); // show only the 4 most recent (newest-first) - - float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false, ImGuiWindowFlags_NoBackground); @@ -383,8 +396,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 +418,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 +509,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 +545,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 +602,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 +649,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,25 +713,25 @@ 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; ImU32 iconCol = ImGui::GetColorU32(ImGuiCol_Text); float ss = iconW * 0.5f; float cx = startX + ss; - float ag = S.drawElement("tabs.receive", "swap-icon-arrow-gap").size; + float ag = S.drawElement("tabs.receive", "swap-icon-arrow-gap").size * cardDp; float al = ss * S.drawElement("tabs.receive", "swap-icon-arrow-length-ratio").size; - float hss = S.drawElement("tabs.receive", "swap-icon-arrowhead-size").size; + float hss = S.drawElement("tabs.receive", "swap-icon-arrowhead-size").size * cardDp; float ay1 = cy - ag; - dl->AddLine(ImVec2(cx - al, ay1), ImVec2(cx + al, ay1), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size); + dl->AddLine(ImVec2(cx - al, ay1), ImVec2(cx + al, ay1), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp); dl->AddTriangleFilled( ImVec2(cx + al, ay1), ImVec2(cx + al - hss, ay1 - hss), ImVec2(cx + al - hss, ay1 + hss), iconCol); float ay2 = cy + ag; - dl->AddLine(ImVec2(cx + al, ay2), ImVec2(cx - al, ay2), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size); + dl->AddLine(ImVec2(cx + al, ay2), ImVec2(cx - al, ay2), iconCol, S.drawElement("tabs.receive", "swap-icon-line-thickness").size * cardDp); dl->AddTriangleFilled( ImVec2(cx - al, ay2), ImVec2(cx - al + hss, ay2 - hss), @@ -699,8 +749,8 @@ void RenderReceiveTab(App* app) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); float chipRound = schema::UI().drawElement("tabs.receive", "chip-rounding").size; - float chipGap = schema::UI().drawElement("tabs.receive", "chip-gap").size; - float chipH = schema::UI().drawElement("tabs.receive", "chip-height").size; + float chipGap = schema::UI().drawElement("tabs.receive", "chip-gap").size * cardDp; + float chipH = schema::UI().drawElement("tabs.receive", "chip-height").size * cardDp; struct Preset { const char* label; double amount; }; Preset presets[] = { @@ -778,7 +828,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); } @@ -828,7 +878,7 @@ void RenderReceiveTab(App* app) RenderQRCode(s_qr_texture, qrSize); } else { ImGui::Dummy(ImVec2(qrSize, qrSize)); - ImVec2 textPos(qrPanelMin.x + totalQrSize * 0.5f - S.drawElement("tabs.receive", "qr-unavailable-text-offset").size, + ImVec2 textPos(qrPanelMin.x + totalQrSize * 0.5f - S.drawElement("tabs.receive", "qr-unavailable-text-offset").size * cardDp, qrPanelMin.y + totalQrSize * 0.5f); dl->AddText(capFont, capFont->LegacySize, textPos, OnSurfaceDisabled(), TR("qr_unavailable")); @@ -862,7 +912,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 +923,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 +932,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 +1009,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())); } diff --git a/src/ui/windows/request_payment_dialog.cpp b/src/ui/windows/request_payment_dialog.cpp index d61bbbe..f63293c 100644 --- a/src/ui/windows/request_payment_dialog.cpp +++ b/src/ui/windows/request_payment_dialog.cpp @@ -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(); } diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 11cc73f..8766eb4 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -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(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(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 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 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(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(); } @@ -336,7 +364,8 @@ static void RenderAddressSuggestions(const WalletState& state, float width, cons if (suggestions.empty()) return; ImGui::PushStyleColor(ImGuiCol_ChildBg, ImGui::ColorConvertU32ToFloat4(schema::UI().resolveColor(schema::UI().drawElement("tabs.send", "suggestion-bg-color").color))); - float sugH = std::min((float)suggestions.size() * schema::UI().drawElement("tabs.send", "suggestion-row-height").size + schema::UI().drawElement("tabs.send", "suggestion-list-padding").size, schema::UI().drawElement("tabs.send", "suggestion-max-height").size); + const float dp = Layout::dpiScale(); + float sugH = std::min((float)suggestions.size() * schema::UI().drawElement("tabs.send", "suggestion-row-height").size * dp + schema::UI().drawElement("tabs.send", "suggestion-list-padding").size * dp, schema::UI().drawElement("tabs.send", "suggestion-max-height").size * dp); ImGui::BeginChild(childId, ImVec2(width, sugH), true); for (size_t si = 0; si < suggestions.size(); si++) { int sugTrunc = (int)schema::UI().drawElement("tabs.send", "suggestion-trunc-len").size; @@ -359,13 +388,14 @@ static void RenderAddressSuggestions(const WalletState& state, float width, cons // ============================================================================ static void RenderFeeTierSelector(const char* suffix = "") { auto& S = schema::UI(); + const float dp = Layout::dpiScale(); const char* feeLabels[] = { TR("send_fee_low"), TR("send_fee_normal"), TR("send_fee_high") }; const double feeValues[] = { FEE_LOW, FEE_NORMAL, FEE_HIGH }; ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0, 0, 0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, schema::UI().drawElement("tabs.send", "fee-rounding").size); for (int fi = 0; fi < 3; fi++) { - if (fi > 0) ImGui::SameLine(0, S.drawElement("tabs.send", "fee-tier-gap").size); + if (fi > 0) ImGui::SameLine(0, S.drawElement("tabs.send", "fee-tier-gap").size * dp); bool active = (s_fee_tier == fi); if (active) { ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(IM_COL32(255, 255, 255, (int)S.drawElement("tabs.send", "fee-tier-active-bg-alpha").size))); @@ -396,11 +426,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(); @@ -485,7 +517,7 @@ static void RenderAmountBar(ImDrawList* dl, double available, float innerW, // Max button — use caption font to fit bar height ImGui::SameLine(0, gap); char maxId[32]; - snprintf(maxId, sizeof(maxId), "Max%s", suffix); + snprintf(maxId, sizeof(maxId), "%s%s", TR("grpb_max"), suffix); if (TactileButton(maxId, ImVec2(maxBtnW, barH), capFont)) { s_amount = maxAmount; s_send_max = true; @@ -509,6 +541,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 +551,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 +576,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 +587,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 +610,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 +633,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 +643,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 +658,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 +666,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 +730,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 +802,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 +862,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 +946,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 +963,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())); @@ -927,6 +978,7 @@ static void RenderActionButtons(App* app, float width, float vScale, bool is_valid_address, double available, const char* suffix = "") { auto& S = schema::UI(); + const float dp = Layout::dpiScale(); const auto& state = app->getWalletState(); double total = s_amount + s_fee; // Block spending from a view-only source (imported viewing key, no spending key) — it would only @@ -962,7 +1014,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 +1034,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 +1047,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; @@ -1016,12 +1068,12 @@ static void RenderActionButtons(App* app, float width, float vScale, if (ImGui::BeginPopup(confirmClearId)) { ImGui::Text("%s", TR("send_clear_fields")); ImGui::Spacing(); - if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size, 0), S.resolveFont("button"))) { + if (TactileButton(TR("send_yes_clear"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-yes-width").size * dp, 0), S.resolveFont("button"))) { ClearFormWithUndo(); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); - if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size, 0), S.resolveFont("button"))) { + if (TactileButton(TR("send_keep"), ImVec2(schema::UI().drawElement("tabs.send", "clear-confirm-keep-width").size * dp, 0), S.resolveFont("button"))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); @@ -1037,7 +1089,7 @@ static void RenderActionButtons(App* app, float width, float vScale, ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(WithAlpha(Warning(), (int)S.drawElement("tabs.send", "undo-btn-bg-alpha").size))); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(WithAlpha(Warning(), (int)S.drawElement("tabs.send", "undo-btn-hover-alpha").size))); char undoId[32]; - snprintf(undoId, sizeof(undoId), "Undo Clear%s", suffix); + snprintf(undoId, sizeof(undoId), "%s%s", TR("grpb_undo_clear"), suffix); if (TactileButton(undoId, ImVec2(width, btnH), S.resolveFont("button"))) { RestoreFormSnapshot(); Notifications::instance().info(TR("send_form_restored")); @@ -1068,15 +1120,25 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap S.drawElement("tabs.balance", "recent-tx-icon-size").size * hs); ImU32 sendCol = Error(); - // Collect matching transactions + // 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. + // Row budget is hoisted above the collect loop (it only needs the remaining region + // height + row height, no per-row state) so the scan can early-exit. + float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); + size_t maxRows = std::max(4, (size_t)std::floor(listH / rowH)); + + // Collect matching transactions. state.transactions is newest-first, so scan only a + // bounded prefix and stop once maxRows matches are gathered (mirrors the early-exit in + // balance_components.cpp:RenderSharedRecentTx) instead of filtering the whole history. std::vector sends; + sends.reserve(maxRows); for (const auto& tx : state.transactions) { if (tx.type != "send" && tx.type != "shield") continue; sends.push_back(&tx); + if (sends.size() == maxRows) break; // newest-first: enough rows to fill the region } - if (sends.size() > 4) sends.resize(4); // show only the 4 most recent (newest-first) - - float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false, ImGuiWindowFlags_NoBackground); @@ -1084,8 +1146,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 +1168,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 +1187,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 +1265,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 +1280,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 +1301,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 +1322,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 +1376,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 +1405,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 +1426,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 +1437,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 +1469,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 +1542,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 +1599,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 +1630,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 +1663,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(); diff --git a/src/ui/windows/settings_window.cpp b/src/ui/windows/settings_window.cpp index 4e40782..21c255c 100644 --- a/src/ui/windows/settings_window.cpp +++ b/src/ui/windows/settings_window.cpp @@ -216,7 +216,7 @@ void RenderSettingsWindow(App* app, bool* p_open) if (!skin.valid) { ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.3f, 0.3f, 1.0f)); ImGui::BeginDisabled(true); - std::string label = skin.name + " (invalid)"; + std::string label = skin.name + TR("swin_invalid_suffix"); ImGui::Selectable(label.c_str(), false); ImGui::EndDisabled(); ImGui::PopStyleColor(); @@ -261,7 +261,7 @@ void RenderSettingsWindow(App* app, bool* p_open) ImGui::PushFont(material::Type().iconSmall()); if (material::StyledButton(ICON_REFRESH_THEMES, ImVec2(0, 0))) { skinMgr.refresh(); - Notifications::instance().info("Theme list refreshed"); + Notifications::instance().info(TR("swin_theme_list_refreshed")); } ImGui::PopFont(); if (ImGui::IsItemHovered()) { @@ -425,14 +425,14 @@ void RenderSettingsWindow(App* app, bool* p_open) app->rpc()->getInfo([](const nlohmann::json& result, const std::string& error) { if (error.empty()) { std::string version = result.value("version", "unknown"); - std::string msg = "Connection successful!\ndragonxd version: " + version; + std::string msg = std::string(TR("swin_connection_successful")) + version; Notifications::instance().success(msg); } else { - Notifications::instance().error("Connection failed: " + error); + Notifications::instance().error(std::string(TR("swin_connection_failed")) + error); } }); } else { - Notifications::instance().error("RPC client not initialized"); + Notifications::instance().error(TR("swin_rpc_client_not_initialized")); } } @@ -455,15 +455,15 @@ void RenderSettingsWindow(App* app, bool* p_open) if (error.empty()) { int start = result.value("start_height", 0); int end = result.value("stop_height", 0); - std::string msg = "Rescan started from block " + std::to_string(start) + - " to " + std::to_string(end); + std::string msg = std::string(TR("swin_rescan_started_from_block")) + std::to_string(start) + + TR("swin_rescan_to") + std::to_string(end); Notifications::instance().success(msg); } else { - Notifications::instance().error("Rescan failed: " + error); + Notifications::instance().error(std::string(TR("swin_rescan_failed")) + error); } }); } else { - Notifications::instance().error("RPC client not initialized"); + Notifications::instance().error(TR("swin_rpc_client_not_initialized")); } } ImGui::TextDisabled(" %s", TR("settings_rescan_desc")); @@ -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(); @@ -498,9 +503,9 @@ void RenderSettingsWindow(App* app, bool* p_open) if (doConfirm) { std::string ztx_file = util::Platform::getDragonXDataDir() + "ztx_history.json"; if (util::Platform::deleteFile(ztx_file)) { - Notifications::instance().success("Z-transaction history cleared"); + Notifications::instance().success(TR("swin_ztx_history_cleared")); } else { - Notifications::instance().info("No history file found"); + Notifications::instance().info(TR("swin_no_history_file_found")); } s_confirm_clear_ztx = false; } @@ -563,7 +568,7 @@ void RenderSettingsWindow(App* app, bool* p_open) // Save/Cancel buttons if (material::StyledButton(TR("save"), ImVec2(saveBtn.width, 0), S.resolveFont(saveBtn.font))) { saveSettingsFromUI(app->settings()); - Notifications::instance().success("Settings saved"); + Notifications::instance().success(TR("swin_settings_saved")); *p_open = false; } ImGui::SameLine(); diff --git a/src/ui/windows/shield_dialog.cpp b/src/ui/windows/shield_dialog.cpp index 50a0699..f7961fc 100644 --- a/src/ui/windows/shield_dialog.cpp +++ b/src/ui/windows/shield_dialog.cpp @@ -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 #include +#include 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 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(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(addrLbl.truncate)) { - label = label.substr(0, addrFrontLbl.truncate) + "..." + label.substr(label.length() - addrBackLbl.truncate); - } - - bool selected = (s_selected_zaddr_idx == static_cast(i)); - if (ImGui::Selectable(label.c_str(), selected)) { - s_selected_zaddr_idx = static_cast(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 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(); + } 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(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(addrLbl.truncate)) + label = label.substr(0, addrFront.truncate) + "..." + label.substr(label.length() - addrBack.truncate); + bool selected = (s_selected_zaddr_idx == static_cast(i)); + if (ImGui::Selectable(label.c_str(), selected)) { + s_selected_zaddr_idx = static_cast(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 diff --git a/src/ui/windows/shield_dialog.h b/src/ui/windows/shield_dialog.h index 296c32c..e37cd86 100644 --- a/src/ui/windows/shield_dialog.h +++ b/src/ui/windows/shield_dialog.h @@ -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) */ diff --git a/src/ui/windows/transaction_details_dialog.cpp b/src/ui/windows/transaction_details_dialog.cpp index 031b337..c533f41 100644 --- a/src/ui/windows/transaction_details_dialog.cpp +++ b/src/ui/windows/transaction_details_dialog.cpp @@ -121,10 +121,10 @@ 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))) { + if (material::StyledButton((std::string(TR("grpb_copy")) + "##TxID").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) { ImGui::SetClipboardText(tx.txid.c_str()); } @@ -139,17 +139,18 @@ 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(); - if (material::StyledButton("Copy##Addr", ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) { + if (material::StyledButton((std::string(TR("grpb_copy")) + "##Addr").c_str(), ImVec2(copyBtn.width, 0), S.resolveFont(copyBtn.font))) { ImGui::SetClipboardText(tx.address.c_str()); } } @@ -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(); diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index 6f57f83..3312cc7 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -186,53 +187,106 @@ void RenderTransactionsTab(App* app) // Summary Cards — Received | Sent | Mined // ================================================================ { - int recvCount = 0, sendCount = 0, minedCount = 0; - double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0; - - // Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg): - // that pair is a single internal shielding move — shown as one "Shield" row in the - // list below — not income or spending. Counting both legs double-counts the amount - // into BOTH the Sent and Received totals, so the cards disagree with the list. - // Mirror the list's pairing logic and exclude both legs from the totals. - std::unordered_map> summaryTxidMap; - for (size_t i = 0; i < state.transactions.size(); i++) - summaryTxidMap[state.transactions[i].txid].push_back(i); - std::vector isShieldLeg(state.transactions.size(), false); - for (const auto& kv : summaryTxidMap) { - if (kv.second.size() < 2) continue; - int send_i = -1, recv_i = -1; - for (size_t si : kv.second) { - const auto& stx = state.transactions[si]; - if (stx.type == "send" && send_i < 0) send_i = (int)si; - else if (stx.type == "receive" && recv_i < 0 && - !stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si; - } - if (send_i >= 0 && recv_i >= 0) { - isShieldLeg[send_i] = true; - isShieldLeg[recv_i] = true; - // The list shows the merged shield under the "Sent" filter, so count it there too — - // otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded - // (receive-leg) amount, which is what the merged row displays. - sendCount++; - sendTotal += std::abs(state.transactions[recv_i].amount); + // Content fingerprint over state.transactions, shared by the summary-card totals (below) and + // the display-list memoization (further down). This is a cheap, allocation-free FNV-1a hash + // computed ONCE per frame and folds in every field the *totals* and the *displayed rows* + // depend on: tx count, last update time, and per-tx txid (drives autoshield pairing), + // amount (drives the totals — bit-exact), confirmations, timestamp, type, and address. It is + // sort-independent (s_sort_mode is NOT folded here); the display key mixes s_sort_mode on top + // so a sort change re-sorts the display cache without invalidating the totals (which don't + // depend on order). NB vs. the old display key: this additionally folds full txid + amount + + // full type/address (not just first char), which the summary totals require — the old key + // omitted them, so it could not have gated the totals correctly. + std::uint64_t contentKey; + { + std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis + auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; }; + auto mixBytes = [&mix](const std::string& s) { + mix(s.size()); + for (unsigned char c : s) mix(static_cast(c)); + }; + mix(state.transactions.size()); + mix(static_cast(state.last_tx_update)); + for (const auto& t : state.transactions) { + mixBytes(t.txid); + std::uint64_t amtBits = 0; + std::memcpy(&amtBits, &t.amount, sizeof(double)); // bit-exact fold of the double + mix(amtBits); + mix(static_cast(t.confirmations)); + mix(static_cast(t.timestamp)); + mixBytes(t.type); + mixBytes(t.address); } + contentKey = h; } - for (size_t i = 0; i < state.transactions.size(); i++) { - if (isShieldLeg[i]) continue; // internal shielding move — not received or sent - const auto& tx = state.transactions[i]; - if (tx.type == "receive") { - recvCount++; - recvTotal += std::abs(tx.amount); - } else if (tx.type == "send") { - sendCount++; - sendTotal += std::abs(tx.amount); - } else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") { - minedCount++; - minedTotal += std::abs(tx.amount); + // Summary-card totals: Received / Sent / Mined (counts + amounts). These are three static + // numbers that only change when the tx list changes, so memoize them behind contentKey and + // recompute only when it moves. File-scope-style function statics persist across wallet + // switches, but a switch/refresh mutates state.transactions (count, txids, amounts …), which + // bumps contentKey, so the sentinel initial key + change detection covers that for free. + static int s_recvCount = 0, s_sendCount = 0, s_minedCount = 0; + static double s_recvTotal = 0.0, s_sendTotal = 0.0, s_minedTotal = 0.0; + static std::uint64_t s_summaryKey = 0; // sentinel; first frame always recomputes + + if (contentKey != s_summaryKey) { + int recvCount = 0, sendCount = 0, minedCount = 0; + double recvTotal = 0.0, sendTotal = 0.0, minedTotal = 0.0; + + // Identify autoshield legs (same txid with a "send" leg and a "receive"-to-z leg): + // that pair is a single internal shielding move — shown as one "Shield" row in the + // list below — not income or spending. Counting both legs double-counts the amount + // into BOTH the Sent and Received totals, so the cards disagree with the list. + // Mirror the list's pairing logic and exclude both legs from the totals. + std::unordered_map> summaryTxidMap; + for (size_t i = 0; i < state.transactions.size(); i++) + summaryTxidMap[state.transactions[i].txid].push_back(i); + std::vector isShieldLeg(state.transactions.size(), false); + for (const auto& kv : summaryTxidMap) { + if (kv.second.size() < 2) continue; + int send_i = -1, recv_i = -1; + for (size_t si : kv.second) { + const auto& stx = state.transactions[si]; + if (stx.type == "send" && send_i < 0) send_i = (int)si; + else if (stx.type == "receive" && recv_i < 0 && + !stx.address.empty() && stx.address[0] == 'z') recv_i = (int)si; + } + if (send_i >= 0 && recv_i >= 0) { + isShieldLeg[send_i] = true; + isShieldLeg[recv_i] = true; + // The list shows the merged shield under the "Sent" filter, so count it there too — + // otherwise the Sent card reads 0 while the Sent list is non-empty. Use the shielded + // (receive-leg) amount, which is what the merged row displays. + sendCount++; + sendTotal += std::abs(state.transactions[recv_i].amount); + } } + + for (size_t i = 0; i < state.transactions.size(); i++) { + if (isShieldLeg[i]) continue; // internal shielding move — not received or sent + const auto& tx = state.transactions[i]; + if (tx.type == "receive") { + recvCount++; + recvTotal += std::abs(tx.amount); + } else if (tx.type == "send") { + sendCount++; + sendTotal += std::abs(tx.amount); + } else if (tx.type == "generate" || tx.type == "immature" || tx.type == "mined") { + minedCount++; + minedTotal += std::abs(tx.amount); + } + } + + s_recvCount = recvCount; s_recvTotal = recvTotal; + s_sendCount = sendCount; s_sendTotal = sendTotal; + s_minedCount = minedCount; s_minedTotal = minedTotal; + s_summaryKey = contentKey; } + // Render from the cached totals (identical values — only the recomputation is skipped). + const int recvCount = s_recvCount, sendCount = s_sendCount, minedCount = s_minedCount; + const double recvTotal = s_recvTotal, sendTotal = s_sendTotal, minedTotal = s_minedTotal; + float availWidth = ImGui::GetContentRegionAvail().x; float cardGap = cGap; float cardW = (availWidth - 2 * cardGap) / 3.0f; @@ -263,10 +317,10 @@ void RenderTransactionsTab(App* app) int idx_map[] = {-1, 1, 0, 2}; int idx = idx_map[type_filter]; float xOff = idx * (cardW + cardGap); - ImVec2 acMin(origin.x + xOff, origin.y + cardH - 3); + ImVec2 acMin(origin.x + xOff, origin.y + cardH - 3 * vs); ImVec2 acMax(origin.x + xOff + cardW, origin.y + cardH); ImU32 acCol = (type_filter == 1) ? redCol : (type_filter == 2) ? greenCol : goldCol; - dl->AddRectFilled(acMin, acMax, acCol, 2.0f); + dl->AddRectFilled(acMin, acMax, acCol, 2.0f * hs); } ImGui::Dummy(ImVec2(availWidth, cardH)); @@ -293,11 +347,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); @@ -336,31 +400,24 @@ void RenderTransactionsTab(App* app) // // This merge + sort is O(N log N) with several heap allocations, so it is MEMOIZED: it only // rebuilds when the underlying transactions actually change, not every frame. The cache key - // is a cheap, allocation-free FNV-1a fingerprint over the fields that affect the displayed - // rows (count, last update time, and each tx's confirmations / timestamp / type+address - // first char). A new block bumps every confirmation, so the key changes and we rebuild; - // between changes (the common case while the user reads/scrolls) we reuse the cache. The - // result is already sorted newest-first by the refresh service, but we re-sort here to apply - // the "pending first" ordering — also folded into the memoized build. + // reuses the shared per-frame contentKey computed above (a cheap, allocation-free FNV-1a + // fingerprint over count / last update time / every tx's txid+amount+confirmations+timestamp+ + // type+address) and mixes in s_sort_mode on top, so the display cache also rebuilds on a sort + // change. contentKey already folds in more than the display list strictly needs (amount, + // full txid/type/address) — a superset, so any change that would have flipped the old + // per-first-char key still flips this one. A new block bumps every confirmation, so the key + // changes and we rebuild; between changes (the common case while the user reads/scrolls) we + // reuse the cache. The result is already sorted newest-first by the refresh service, but we + // re-sort here to apply the "pending first" ordering — also folded into the memoized build. static std::vector s_display_cache; static std::uint64_t s_display_cache_key = 0; static bool s_display_cache_valid = false; - std::uint64_t displayKey; - { - std::uint64_t h = 1469598103934665603ULL; // FNV-1a offset basis - auto mix = [&h](std::uint64_t v) { h = (h ^ v) * 1099511628211ULL; }; - mix(state.transactions.size()); - mix(static_cast(state.last_tx_update)); - mix(static_cast(s_sort_mode)); // re-sort the cache when the mode changes - for (const auto& t : state.transactions) { - mix(static_cast(t.confirmations)); - mix(static_cast(t.timestamp)); - mix(t.type.empty() ? 0u : static_cast(t.type[0])); - mix(t.address.empty() ? 0u : static_cast(t.address[0])); - } - displayKey = h; - } + // Derive the display key from the shared contentKey (avoids a second full pass over the + // transactions) plus the sort mode — the only display-affecting input contentKey omits. + std::uint64_t displayKey = + (contentKey ^ (static_cast(s_sort_mode) + 0x9E3779B97F4A7C15ULL)) + * 1099511628211ULL; if (!s_display_cache_valid || displayKey != s_display_cache_key) { s_display_cache.clear(); @@ -531,32 +588,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 +627,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 +671,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; diff --git a/src/ui/windows/validate_address_dialog.cpp b/src/ui/windows/validate_address_dialog.cpp index 5a17f96..c0228a2 100644 --- a/src/ui/windows/validate_address_dialog.cpp +++ b/src/ui/windows/validate_address_dialog.cpp @@ -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; } diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h index 87e7768..eb1f27c 100644 --- a/src/ui/windows/wallets_dialog.h +++ b/src/ui/windows/wallets_dialog.h @@ -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 @@ -97,7 +100,7 @@ public: const float headH = Type().h6()->LegacySize + Layout::spacingXs() // framework h6 title + Type().caption()->LegacySize + Layout::spacingSm() // intro + gap + ctrlRow + Layout::spacingSm(); // sort control row + gap - const float padV = 48.0f; // content-child padding (top+bottom) + margin + const float padV = 48.0f * dp; // content-child padding (top+bottom) + margin // Size to content, but cap at a viewport fraction: with many wallets on a small / HiDPI // screen the content-sized card could exceed the window (the framework would clip a too-tall // card, footer and all). When capped, the wallet list flexes + scrolls and the controls @@ -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= 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)); } } diff --git a/src/ui/windows/xmrig_download_dialog.h b/src/ui/windows/xmrig_download_dialog.h index 06a7668..4f815a0 100644 --- a/src/ui/windows/xmrig_download_dialog.h +++ b/src/ui/windows/xmrig_download_dialog.h @@ -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)); diff --git a/src/util/address_validation.cpp b/src/util/address_validation.cpp index cba64f1..6f3d6b6 100644 --- a/src/util/address_validation.cpp +++ b/src/util/address_validation.cpp @@ -76,7 +76,7 @@ std::vector bech32HrpExpand(const std::string& hrp) } // namespace -bool isValidBase58Check(const std::string& s) +bool decodeBase58Check(const std::string& s, std::vector& payloadOut) { if (s.size() < 5 || s.size() > 256) return false; std::vector 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 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(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 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 diff --git a/src/util/address_validation.h b/src/util/address_validation.h index bc17a33..ee3b365 100644 --- a/src/util/address_validation.h +++ b/src/util/address_validation.h @@ -11,7 +11,9 @@ #pragma once +#include #include +#include namespace dragonx { namespace util { @@ -20,9 +22,31 @@ namespace util { // (transparent R-addresses). Version-byte agnostic by design. bool isValidBase58Check(const std::string& s); +// Decodes `s` as Base58Check; on success returns true and fills `payloadOut` with the +// decoded bytes EXCLUDING the trailing 4-byte checksum (i.e. version byte + data). Lets +// callers inspect the version byte / payload length (e.g. to tell a WIF from an address). +bool decodeBase58Check(const std::string& s, std::vector& payloadOut); + // True if `s` is a valid Bech32 string (Sapling zs-addresses). The HRP is taken // from the string itself and folded into the checksum, so no HRP is hardcoded. bool isValidBech32(const std::string& s); +// Returns the (lower-cased) human-readable prefix of a valid Bech32 string, or "" if +// `s` is not valid Bech32. The HRP identifies the key/address type (e.g. "zivks"). +std::string bech32Hrp(const std::string& s); + +// True if `s` is a transparent (Base58Check) address — P2PKH *or* P2SH/multisig. Accepts any +// address whose payload is a 21-byte version+hash160, so it covers both the 'R…' (v60) and 'b…' +// (v85 script) forms on every DragonX network and rejects WIF keys / typos by checksum. Version-byte +// agnostic by design — a bare prefix check ('R' only) silently drops valid P2SH recipients. +bool isTransparentAddress(const std::string& s); + +// True if `s` is a shielded Sapling payment address (HRP "zs" / "ztestsapling" / "zregtestsapling"), +// with a valid Bech32 checksum. Distinguishes a payment address from a viewing key (e.g. "zivks…"). +bool isShieldedAddress(const std::string& s); + +// True if `s` is any address a payment can be sent to (transparent or shielded). +bool isValidRecipientAddress(const std::string& s); + } // namespace util } // namespace dragonx diff --git a/src/util/connect_stall.h b/src/util/connect_stall.h new file mode 100644 index 0000000..9824f80 --- /dev/null +++ b/src/util/connect_stall.h @@ -0,0 +1,27 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +namespace dragonx { +namespace util { + +// Default "taking longer than expected" threshold (seconds) for the daemon connect loop, +// overridable via ui.toml [screens.loading].stall-timeout-sec. Kept as a free function with +// no ImGui/App dependency so it is directly unit-testable from tests/test_phase4.cpp. +constexpr float kConnectStallDefaultSeconds = 45.0f; + +// True once a daemon that is reachable-but-not-ready has stayed that way past the threshold. +// stallSince : timestamp (same clock as `now`) when the stall began; <= 0 means "not stalling". +// now : current time in the same units as stallSince. +// thresholdSec: how long to wait before considering it stalled; <= 0 disables the feature. +inline bool connectHasStalled(double stallSince, double now, float thresholdSec) +{ + if (stallSince <= 0.0) return false; // not currently in a stall-tracked state + if (thresholdSec <= 0.0f) return false; // 0/negative disables the notice defensively + return (now - stallSince) >= static_cast(thresholdSec); +} + +} // namespace util +} // namespace dragonx diff --git a/src/util/daemon_updater.cpp b/src/util/daemon_updater.cpp index c8a9cc8..4dd905e 100644 --- a/src/util/daemon_updater.cpp +++ b/src/util/daemon_updater.cpp @@ -277,6 +277,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe // checksum and (b) verify a detached ed25519 signature over the archive bytes against the // pinned key, so a checksum rewritten in a tampered release body is not sufficient to install. setProgress(State::Verifying, "Verifying download…"); + std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01) { std::ifstream f(zipPath, std::ios::binary); if (!f) { @@ -284,7 +285,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe setProgress(State::Failed, "Could not read the downloaded archive."); return; } - const std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bytes.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); if (f.bad()) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not read the downloaded archive."); @@ -342,7 +343,9 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe const std::string daemonName = wanted.front(); // "dragonxd" / "dragonxd.exe" mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) { + // Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast + // local attacker could swap the file on disk between the hash/signature check and extraction. (I-01) + if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not open the downloaded archive."); return; @@ -351,6 +354,7 @@ void DaemonUpdater::installResolved(const std::string& targetDir, const DaemonRe bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue; diff --git a/src/util/daemon_updater_core.cpp b/src/util/daemon_updater_core.cpp index 6968804..fb8bae9 100644 --- a/src/util/daemon_updater_core.cpp +++ b/src/util/daemon_updater_core.cpp @@ -140,15 +140,16 @@ std::map parseDaemonChecksums(const std::string& body) // | File | SHA-256 | // |------|---------| // | dragonx-1.0.2-linux-amd64.zip | `85f1dd…16` | - // Per line: blank out the table/code delimiters ('|' and '`'), then find the 64-hex token (the - // hash) and a token ending in ".zip" (the archive name). Header/separator/prose rows lack one - // or the other and are skipped, so this is robust to surrounding text and column order. + // Per line: blank out the table/code/emphasis delimiters ('|', '`', and markdown '*'/'_' so a + // bolded **archive.zip** still tokenizes), then find the 64-hex token (the hash) and a token + // ending in ".zip" (the archive name). Header/separator/prose rows lack one or the other and are + // skipped, so this is robust to surrounding text and column order. std::map out; std::istringstream in(body); std::string line; while (std::getline(in, line)) { for (char& c : line) - if (c == '|' || c == '`') c = ' '; + if (c == '|' || c == '`' || c == '*' || c == '_') c = ' '; std::istringstream ls(line); std::string tok, hash, name; while (ls >> tok) { diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b49b19e..8780f1d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -223,6 +223,7 @@ void I18n::loadBuiltinEnglish() strings_["chat_send"] = "Send"; strings_["chat_new_title"] = "New chat"; strings_["chat_new_zaddr"] = "Recipient z-address"; + strings_["chat_new_needs_zaddr"] = "Chat needs a shielded (z) address — transparent (t) addresses can't receive encrypted messages."; strings_["chat_new_message"] = "Message"; strings_["chat_new_send"] = "Send request"; strings_["chat_cancel"] = "Cancel"; @@ -258,6 +259,23 @@ void I18n::loadBuiltinEnglish() strings_["chat_emoji_search"] = "Search emoji"; strings_["chat_hide_hidden"] = "Hide hidden"; strings_["chat_hidden_toast"] = "Conversation hidden — a new message brings it back"; + // Delete conversation (local cache) — revive-on-new-message vs delete & block + strings_["chat_delete"] = "Delete conversation"; + strings_["chat_delete_title"] = "Delete conversation?"; + strings_["chat_delete_body_prefix"] = "Delete your local copy of the conversation with "; + strings_["chat_delete_body_suffix"] = "?"; + strings_["chat_delete_revive_note"] = "\"Delete\" clears the history on this device. If they message you again, the conversation comes back."; + strings_["chat_delete_local_note"] = "This only affects this device — the messages stay on the blockchain and the other person keeps their copy."; + strings_["chat_delete_confirm"] = "Delete"; + strings_["chat_delete_block"] = "Delete & block"; + strings_["chat_deleted_toast"] = "Conversation deleted"; + strings_["chat_delete_failed"] = "Couldn't delete the conversation — nothing was changed."; + strings_["chat_blocked_toast"] = "Conversation deleted & blocked"; + strings_["chat_blocked_manage"] = "Blocked"; + strings_["chat_blocked_title"] = "Blocked conversations"; + strings_["chat_blocked_desc"] = "Blocked conversations are removed and their messages are dropped — old and new — until you unblock. Unblocking re-imports the conversation from the chain."; + strings_["chat_unblock"] = "Unblock"; + strings_["chat_unblocked_toast"] = "Conversation unblocked"; strings_["chat_pick_contact"] = "Choose from contacts\xE2\x80\xA6"; strings_["chat_no_z_contacts"] = "No shielded-address contacts yet"; strings_["chat_copy_address_tip"] = "Click to copy address"; @@ -320,7 +338,7 @@ void I18n::loadBuiltinEnglish() strings_["seed_backup_load_failed"] = "Could not load the seed phrase."; strings_["seed_backup_copy"] = "Copy"; strings_["seed_backup_save"] = "Save to file…"; - strings_["seed_backup_saved"] = "Saved to "; + strings_["seed_backup_saved"] = "Saved an UNENCRYPTED seed file — move it to secure offline storage and delete this copy: "; strings_["seed_backup_save_failed"] = "Could not write "; strings_["seed_backup_close"] = "Close"; strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security."; @@ -459,15 +477,23 @@ void I18n::loadBuiltinEnglish() // Settings sections strings_["appearance"] = "APPEARANCE"; strings_["theme_language"] = "THEME & LANGUAGE"; + strings_["scale_effects"] = "SCALE & EFFECTS"; strings_["advanced_effects"] = "Advanced Effects..."; strings_["tools_actions"] = "Tools & Actions..."; + strings_["tools_actions_hdr"] = "TOOLS & ACTIONS"; + strings_["wallet_options_hdr"] = "OPTIONS"; + strings_["wallet_diagnostics_hdr"] = "DIAGNOSTICS"; strings_["wallet"] = "WALLET"; strings_["node_security"] = "NODE & SECURITY"; strings_["node"] = "NODE"; strings_["security"] = "SECURITY"; strings_["explorer_section"] = "EXPLORER"; + strings_["explorer_urls_hdr"] = "URLS"; strings_["about"] = "About"; strings_["backup_data"] = "BACKUP & DATA"; + strings_["backup_col_import"] = "IMPORT & RESTORE"; + strings_["backup_col_backup"] = "BACKUP"; + strings_["backup_col_export"] = "EXPORT"; strings_["balance_layout"] = "Balance Layout"; strings_["low_spec_mode"] = "Low-spec mode"; strings_["simple_background"] = "Simple background"; @@ -495,9 +521,11 @@ void I18n::loadBuiltinEnglish() strings_["screenshot_sweep"] = "Run screenshot sweep"; strings_["screenshot_sweep_full"] = "Full UI sweep"; strings_["screenshot_open_dir"] = "Open location"; + strings_["sweep_current_theme_only"] = "Current theme only"; + strings_["tt_sweep_current_theme_only"] = "Sweep only the active theme instead of cycling every theme"; strings_["screenshot_sweep_desc"] = "Cycles every theme across every tab and saves a screenshot of each into per-tab subfolders under the config directory's screenshots folder (overwriting the previous sweep). Runs for a few seconds."; strings_["mine_when_idle"] = "Mine when idle"; - strings_["setup_wizard"] = "Run Setup Wizard..."; + strings_["setup_wizard"] = "Run Setup Wizard…"; // RPC / Explorer settings strings_["rpc_connection"] = "RPC Connection..."; @@ -511,21 +539,21 @@ void I18n::loadBuiltinEnglish() strings_["fetch_prices"] = "Fetch price data from CoinGecko"; strings_["block_explorer"] = "Block Explorer"; strings_["test_connection"] = "Test Connection"; - strings_["rescan"] = "Rescan Blockchain"; + strings_["rescan"] = "Rescan"; // Settings: buttons - strings_["settings_address_book"] = "Address Book..."; - strings_["settings_validate_address"] = "Validate Address..."; - strings_["settings_request_payment"] = "Request Payment..."; - strings_["settings_shield_mining"] = "Shield Mining..."; - strings_["settings_merge_to_address"] = "Merge to Address..."; + strings_["settings_address_book"] = "Address Book…"; + strings_["settings_validate_address"] = "Validate Address…"; + strings_["settings_request_payment"] = "Request Payment…"; + strings_["settings_shield_mining"] = "Shield Mining…"; + strings_["settings_merge_to_address"] = "Merge to Address…"; strings_["settings_clear_ztx"] = "Clear Z-Tx History"; - strings_["settings_import_key"] = "Import Private Key..."; - strings_["settings_import_viewkey"] = "Import Viewing Key..."; - strings_["settings_export_key"] = "Export Key..."; - strings_["settings_export_all"] = "Export All..."; - strings_["settings_backup"] = "Backup..."; - strings_["settings_export_csv"] = "Export CSV..."; + strings_["settings_import_key"] = "Import Private Key…"; + strings_["settings_import_viewkey"] = "Import Viewing Key…"; + strings_["settings_export_key"] = "Export Key…"; + strings_["settings_export_all"] = "Export All…"; + strings_["settings_backup"] = "Backup…"; + strings_["settings_export_csv"] = "Export CSV…"; strings_["settings_encrypt_wallet"] = "Encrypt Wallet"; strings_["settings_change_passphrase"] = "Change Passphrase"; strings_["settings_lock_now"] = "Lock Now"; @@ -654,8 +682,11 @@ void I18n::loadBuiltinEnglish() strings_["wiz_pin_confirm"] = "Confirm PIN:"; strings_["wiz_pin_invalid"] = "PIN must be 4-8 digits"; strings_["wiz_pin_mismatch"] = "PINs do not match"; - strings_["settings_data_dir"] = "Data Dir:"; - strings_["settings_wallet_size_label"] = "Wallet Size:"; + strings_["settings_data_dir"] = "Data Dir"; + strings_["settings_wallet_size_label"] = "Wallet Size"; + strings_["wallet_size_warn"] = "This wallet file is large. Consolidating your notes can curb further growth."; + strings_["tt_wallet_size_warn"] = "Shielded wallets grow with each note's witness data — merging many notes into one address reduces it. Back up first."; + strings_["wallet_size_consolidate"] = "Consolidate notes\xE2\x80\xA6"; strings_["settings_debug_changed"] = "Debug categories changed \xe2\x80\x94 restart daemon to apply"; strings_["settings_auto_detected"] = "Auto-detected from DRAGONX.conf"; strings_["settings_visual_effects"] = "Visual Effects"; @@ -669,7 +700,7 @@ void I18n::loadBuiltinEnglish() strings_["settings_wallet_info"] = "Wallet Info"; strings_["settings_block_explorer_urls"] = "Block Explorer URLs"; strings_["settings_configure_explorer"] = "Configure external block explorer links"; - strings_["settings_auto_lock"] = "AUTO-LOCK"; + strings_["settings_auto_lock"] = "Auto-lock"; strings_["timeout_off"] = "Off"; strings_["timeout_1min"] = "1 min"; strings_["timeout_5min"] = "5 min"; @@ -699,9 +730,9 @@ void I18n::loadBuiltinEnglish() strings_["tt_scanline"] = "CRT scanline effect in console"; strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme"; strings_["tt_animate_avatars"] = "Play animated (GIF / WebP) contact avatars; off shows the first frame only"; - strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)"; - strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)"; - strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)"; + strings_["tt_blur"] = "Blur amount (0% = off, 100% = maximum)"; + strings_["tt_noise"] = "Grain texture intensity (0% = off, 100% = maximum)"; + strings_["tt_ui_opacity"] = "Card and sidebar opacity (100% = fully opaque, lower = more see-through)"; strings_["tt_window_opacity"] = "Background opacity (lower = desktop visible through window)"; strings_["tt_font_scale"] = "Scale all text and UI (1.0x = default, up to 1.5x). Hotkey: Alt + Scroll Wheel"; strings_["tt_custom_theme"] = "Custom theme active"; @@ -716,6 +747,12 @@ void I18n::loadBuiltinEnglish() strings_["tt_tor"] = "Route daemon connections through the Tor network for anonymity"; strings_["tt_keep_daemon"] = "Daemon will still stop when running the setup wizard"; strings_["tt_stop_external"] = "Applies when connecting to a daemon\nyou started outside this wallet"; + strings_["stratum_host_section"] = "MINING POOL HOSTING"; + strings_["stratum_host"] = "Host a mining pool (stratum)"; + strings_["tt_stratum_host"] = "Run a RandomX stratum pool server on this node so other RandomX miners can point at this computer. Requires a v1.3.0+ node and a daemon restart to apply."; + strings_["stratum_host_hint"] = "Miners connect to this computer on port 22769 (RPC port + 1000) with a RandomX stratum miner. Restart the daemon to apply."; + strings_["stratum_allowip_hint"] = "Allow miners from IP or CIDR (blank = this computer only)"; + strings_["stratum_expose_warn"] = "Opens a mining port to the network you allow — only use on a trusted LAN."; strings_["tt_verbose"] = "Log detailed connection diagnostics,\ndaemon state, and port owner info\nto the Console tab"; strings_["tt_mine_idle"] = "Automatically start mining when the\nsystem is idle (no keyboard/mouse input)"; strings_["tt_idle_delay"] = "How long to wait before starting mining"; @@ -804,12 +841,14 @@ void I18n::loadBuiltinEnglish() strings_["rescan_detecting"] = "Checking which blocks your node has on disk…"; strings_["rescan_bootstrapped_msg"] = "Your node was bootstrapped, so blocks below the snapshot aren't on disk and a rescan from genesis would fail. Rescan from a height your snapshot includes to reconcile your wallet's spent balance. Your wallet.dat and chain data are not deleted."; strings_["rescan_from_height"] = "Rescan from block height:"; - strings_["repair_wallet"] = "Repair Wallet"; + strings_["repair_wallet"] = "Repair"; strings_["tt_repair_wallet"] = "Wipe and rebuild the wallet's transaction records from the blockchain (fixes notes that fail to send after a rescan)"; strings_["confirm_repair_wallet_title"] = "Repair Wallet"; strings_["confirm_repair_wallet_msg"] = "This restarts the daemon with -zapwallettxes=2: it deletes all of the wallet's transaction and note records, then rebuilds them from the blockchain. Use this when transactions fail to build (\"Invalid sapling spend proof\" / \"shielded requirements not met\") even after a full rescan. It takes a long time and the wallet stays offline until it finishes."; strings_["confirm_repair_wallet_safe"] = "Your keys, addresses and balance are preserved — only the cached transaction records are rebuilt."; strings_["daemon_binary"] = "Daemon binary"; + strings_["daemon_updates_label"] = "UPDATES"; + strings_["daemon_maintenance_label"] = "MAINTENANCE"; strings_["daemon_installed"] = "Installed"; strings_["daemon_bundled"] = "Bundled"; strings_["daemon_not_installed"] = "not installed"; @@ -817,12 +856,16 @@ void I18n::loadBuiltinEnglish() strings_["daemon_status_match"] = "Installed binary matches the bundled version."; strings_["daemon_status_differ"] = "Installed binary differs from the bundled version."; strings_["daemon_status_missing"] = "No daemon installed — install the bundled version."; + // Compact status shown right-aligned on the DAEMON BINARY heading row. + strings_["daemon_status_ok"] = "Up to date"; + strings_["daemon_status_diff"] = "Version differs"; + strings_["daemon_status_none"] = "Not installed"; strings_["daemon_install_bundled"] = "Install bundled"; strings_["tt_daemon_install_bundled"] = "Stop the node, overwrite the installed dragonxd with the version bundled in this wallet build, then restart"; strings_["confirm_reinstall_daemon_title"] = "Install Bundled Daemon"; strings_["confirm_reinstall_daemon_msg"] = "This stops the daemon, overwrites the installed dragonxd (and dragonx-cli/dragonx-tx) with the versions bundled in this wallet build, then restarts the node. Use this to recover or update the node binary."; strings_["confirm_reinstall_daemon_safe"] = "Your wallet, keys and blockchain data are not touched — only the daemon program files are replaced."; - strings_["daemon_update_title"] = "Update the node daemon?"; + strings_["daemon_update_prompt_title"] = "Update the node daemon?"; strings_["daemon_update_body"] = "This wallet build bundles a newer DragonX node than the one currently installed. Updating replaces the installed dragonxd (and dragonx-cli/dragonx-tx), then stops and restarts the node so the new version takes effect. Recommended — a newer node can add features (e.g. seed-phrase support) the old one lacks."; strings_["daemon_update_safe"] = "Your wallet, keys and blockchain data are not touched — only the daemon program files are replaced. If you deliberately run a custom node, choose Keep current."; strings_["daemon_update_now"] = "Update now"; @@ -854,6 +897,10 @@ void I18n::loadBuiltinEnglish() strings_["lite_passphrase_label"] = "Passphrase"; strings_["lite_validate"] = "Validate"; strings_["lite_working"] = "Working…"; + strings_["lite_enter_wallet_path"] = "Enter a wallet path"; + strings_["lite_enter_all_seed_words"] = "Enter all 24 seed words to restore (got %d)"; + strings_["lite_could_not_start"] = "Could not start the operation"; + strings_["lite_backend_unavailable"] = "Lite wallet backend unavailable"; strings_["lite_wallet_ready"] = "Wallet ready"; strings_["lite_backup_keys"] = "Backup & keys"; strings_["lite_show_seed"] = "Show seed"; @@ -946,7 +993,7 @@ void I18n::loadBuiltinEnglish() strings_["filter"] = "Filter..."; strings_["no_addresses_yet"] = "No addresses yet"; strings_["showing_x_of_y"] = "Showing %d of %d addresses"; - strings_["set_label"] = "Set Label..."; + strings_["set_label"] = "Set Label"; strings_["copied"] = "Copied!"; strings_["hidden_tag"] = " (hidden)"; strings_["z_address"] = "Z-Address"; @@ -1100,6 +1147,14 @@ void I18n::loadBuiltinEnglish() strings_["balance_history_collecting"] = "Balance history — collecting data..."; strings_["balance_shielded_fmt"] = "Shielded: %.8f"; strings_["balance_transparent_fmt"] = "Transparent: %.8f"; + strings_["total_balance_label"] = "Total Balance"; + strings_["balance_syncing_pct"] = "Syncing %.1f%%"; + strings_["balance_mining_rate"] = "Mining %s"; + strings_["balance_layout_switched"] = "Layout: %s"; + strings_["byte_count_fmt"] = "%zu / %zu bytes"; + strings_["quick_send"] = "Quick Send"; + strings_["quick_receive"] = "Quick Receive"; + strings_["tile_click_to_open"] = "Click to open"; strings_["your_addresses"] = "Your Addresses"; strings_["z_addresses"] = "Z-Addresses"; strings_["t_addresses"] = "T-Addresses"; @@ -1185,6 +1240,94 @@ void I18n::loadBuiltinEnglish() strings_["switch_corrupt_body"] = "This wallet appears corrupt — the node couldn't open it. Restore it from a backup, re-create it, or try to repair it."; strings_["switch_corrupt_repair"] = "Try to repair (salvage)"; + // Block-database recovery (offered when the node aborts on an unreadable/format-mismatched block DB). + strings_["block_db_reindex_title"] = "Rebuild block database?"; + strings_["block_db_reindex_warn"] = "The node can't read its block database."; + strings_["block_db_reindex_body"] = "This usually happens after a daemon update changes the on-disk format, or if the block index is damaged. Your wallet and coins are safe — the node just can't load the chain, so balances show as zero.\n\nRebuilding re-reads your existing block files and can take a while (it also rescans your wallet). Nothing is downloaded."; + strings_["block_db_reindex_confirm"] = "Rebuild block database"; + strings_["block_db_reindex_notify"] = "The node can't read its block database (often after a daemon update). Rebuild it to restore your balance — see the prompt, or Settings › Node."; + strings_["block_db_reindex_started"] = "Rebuilding the block database from your blocks — this can take a while."; + + // Wallet auto-recovery warning (the node moved wallet.dat aside and loaded a salvaged copy). + // Empty-active-wallet-with-funded-sibling warning (App::renderEmptyWalletWarningDialog). + strings_["empty_wallet_warning_title"] = "This wallet is empty"; + strings_["empty_wallet_warning_headline"] = "You may have opened the wrong wallet."; + strings_["empty_wallet_warning_body"] = "This wallet has no addresses and no funds, but another wallet file in your DragonX folder holds keys. Your coins are most likely in it, not lost. Open the wallet manager to switch to the wallet that holds your funds."; + strings_["empty_wallet_keys_suffix"] = "keys"; + strings_["empty_wallet_open_manager"] = "Open wallet manager"; + strings_["empty_wallet_warning_dismiss"] = "Don't warn again for this wallet"; + strings_["empty_wallet_warning_dismiss_tip"] = "Stops this warning for the current wallet file only. If you switch to a different empty wallet later, it can warn again."; + // Salvage-backup variant of the same modal (a funded wallet..bak from an earlier auto-repair). + strings_["empty_wallet_salvage_title"] = "Your wallet may have been repaired"; + strings_["empty_wallet_salvage_headline"] = "Your coins are safe in a backup file."; + strings_["empty_wallet_salvage_body"] = "This wallet is empty because an earlier automatic repair set your original wallet aside as a backup. Your coins are almost certainly in that backup, not lost. Restore it to load your funds again — nothing is deleted; the current file is kept aside first."; + strings_["empty_wallet_restore"] = "Restore my wallet"; + strings_["wallet_recovered_title"] = "Your wallet file needs a quick repair"; + strings_["wallet_recovered_safety"] = "Your coins are safe."; + strings_["wallet_recovered_warn"] = "When the app started, it found that your wallet file didn't pass its consistency check — this usually happens after an app update or an unclean shutdown. The app already protected your data: it set the old file aside and loaded a repaired copy so you're not stuck."; + strings_["wallet_recovered_body"] = "Nothing has been deleted. Your original wallet is still saved on your computer as a dated backup file, and every option below only copies or renames files — it never erases one. Your keys are never regenerated, only re-read.\n\nThe repaired copy that's loaded now may be missing a few recent transactions, so your balance can look a little low until you finish below and it re-scans."; + strings_["wallet_recovered_open_folder"] = "Show me the files"; + strings_["wallet_recovered_open_folder_sub"] = "Opens the wallet data folder so you can inspect the backup files yourself — nothing is changed."; + strings_["wallet_recovered_dismiss"] = "Not now — keep the repaired copy"; + strings_["wallet_recovered_dismiss_sub"] = "No files change. You can reopen this anytime from the status bar; your original stays safely backed up either way."; + strings_["wallet_recovered_restore"] = "Restore the original file instead"; + strings_["wallet_recovered_restore_sub"] = "Puts your largest untouched backup back in place, verbatim, then re-scans — slightly faster, but only as complete as that one file was. Your current file is kept as a dated backup either way."; + strings_["wallet_recovered_notify"] = "Your wallet file needed a repair — your original was safely backed up. Open the app to review your options."; + strings_["wallet_degraded_notify"] = "Your wallet opened in reduced-function mode: existing funds are safe and spendable, but creating new addresses and shielding are disabled. Back up your seed phrase and restore it to fully repair the wallet."; + strings_["autoshield_by_node"] = "Your node auto-shields mined coinbase for you."; + strings_["autoshield_off_backup_seed"] = "Mined coinbase isn't being auto-shielded yet. Back up your seed phrase (Settings \xE2\x86\x92 Backup & Data) so your node can safely turn it on."; + strings_["autoshield_off_generic"] = "Your node isn't auto-shielding mined coinbase right now."; + // In-dialog recovery lifecycle (Offer → Working → Done/Failed) + disclosures. + strings_["wallet_recovery_working_label"] = "Working"; + strings_["wallet_recovery_done"] = "Done"; + strings_["wallet_recovery_other_options"] = "Other options"; + strings_["wallet_recovery_details_label"] = "Show technical details"; + strings_["wallet_recovery_try_other"] = "Try the other option"; + strings_["wallet_recovery_success_body"] = "The app loaded your repaired wallet and is re-scanning to total your balance — this can take a few minutes. It reads every record it can, but on rare damaged files it may recover slightly fewer — or occasionally more — addresses than before. Once the re-scan finishes, check your balance and history look right."; + strings_["wallet_recovery_success_restore"] = "Your largest backup file is back in place and loading now, and the app is re-scanning. It's restored verbatim, so it's exactly as complete as that file was — check your balance once the re-scan finishes."; + strings_["wallet_recovery_failure_title"] = "The repair didn't go through"; + strings_["wallet_recovery_failure_body"] = "The repair ran, but its result didn't pass verification (it couldn't be read, or had no addresses), so it was discarded automatically before it ever replaced anything. Your wallet is exactly as it was before you clicked — nothing on disk changed."; + strings_["wallet_recovery_whats_happened"] = "What happened?"; + // Choice-card layout: two side-by-side cards (recommended one highlighted) + quiet footer links. + strings_["wallet_recovery_rebuild_card"] = "Repair automatically"; + strings_["wallet_recovery_restore_card"] = "Restore original"; + strings_["wallet_recovery_rebuild_card_desc"] = "Reads every recoverable record into a clean file, then restarts. The most thorough option."; + strings_["wallet_recovery_restore_card_desc"] = "Puts your largest untouched backup back, verbatim \xE2\x80\x94 only as complete as that file was."; + strings_["wallet_recovery_recommended"] = "RECOMMENDED"; + strings_["wallet_recovery_repair_go"] = "Repair"; + strings_["wallet_recovery_restore_go"] = "Restore"; + strings_["wallet_recovery_notnow_short"] = "Not now"; + strings_["wallet_recovery_decide_later"] = "Decide later"; + strings_["wallet_recovery_decide_later_tip"] = "Closes this and keeps the copy that's loaded now. Nothing is changed, and you can repair anytime \xE2\x80\x94 the status bar keeps a \xE2\x80\x9CWallet repair available\xE2\x80\x9D link."; + strings_["wallet_recovery_files_label"] = "What happens to my files?"; + strings_["wallet_recovery_files_detail"] = "Your original wallet is still here \xE2\x80\x94 the app renamed it to a dated backup (wallet..bak) in your data folder and hasn't deleted anything. \xE2\x80\x9CRepair\xE2\x80\x9D reads every record from your fullest wallet into a brand-new clean file. \xE2\x80\x9CRestore\xE2\x80\x9D copies your largest backup back exactly as it is. The copy loaded right now is kept as a backup too."; + // Post-repair rescan screen (shown while the node restarts + re-scans, before it answers RPC). + strings_["wallet_recovery_rescan_title"] = "Finishing your wallet repair"; + strings_["wallet_recovery_rescan_body"] = "Re-scanning the blockchain to rebuild your balance and transaction history. This can take several minutes — you don't need to do anything, and please don't restart the node while it's running."; + strings_["wallet_recovery_rescan_elapsed"] = "Working for %s"; + strings_["wallet_recovery_rescan_size"] = "Rebuilt wallet is now %s and still filling in"; + strings_["wallet_recovery_rescan_slow"] = "This is taking longer than usual, which is normal for a large wallet. It's safe to leave it running — you can watch progress under Advanced \xE2\x96\xB8 Console."; + strings_["sb_finishing_repair"] = "Finishing wallet repair — re-scanning…"; + // One-click "Restore original wallet" flow. + strings_["wallet_restore_started"] = "Restoring your original file — please don't close this window."; + strings_["wallet_restore_busy"] = "The node is busy restarting — try again in a moment."; + strings_["wallet_restore_ok"] = "Original wallet restored. The node is loading it now."; + strings_["wallet_restore_no_backup"] = "Couldn't find a wallet..bak to restore. Nothing was changed."; + strings_["wallet_restore_bad_backup"] = "The backup wallet file looks unreadable, so it was NOT restored — your current wallet is unchanged. Restore from your own backup instead."; + strings_["wallet_restore_stop_failed"] = "The node didn't stop in time, so nothing was changed. Try again."; + strings_["wallet_restore_move_failed"] = "Couldn't set the current wallet aside — nothing was changed."; + strings_["wallet_restore_copy_failed"] = "Couldn't install the backup wallet; your current wallet was left in place."; + strings_["wallet_restore_no_restart"] = "Your original wallet was restored, but the node didn't restart — start it from Settings."; + // One-click "Rebuild wallet database" flow (fixes a BDB-inconsistent wallet that keeps getting salvaged). + strings_["wallet_recovered_rebuild"] = "Repair automatically (recommended)"; + strings_["wallet_recovered_rebuild_sub"] = "Re-reads every recoverable record from your fullest wallet file and writes a clean new one, then restarts. The most thorough option — your current file is kept as a dated backup either way."; + strings_["wallet_rebuild_started"] = "Repairing your wallet file — please don't close this window."; + strings_["wallet_rebuild_ok"] = "Wallet database rebuilt — the node is loading it and rescanning for your balance."; + strings_["wallet_rebuild_no_helper"] = "The wallet-rebuild helper isn't available in this build. Use Restore, or rebuild manually."; + strings_["wallet_rebuild_no_source"] = "Couldn't find a readable wallet to rebuild. Nothing was changed."; + strings_["wallet_rebuild_failed"] = "The rebuild didn't produce a valid wallet, so nothing was changed. Your wallet is untouched."; + strings_["wallet_rebuild_install_failed"] = "Couldn't install the rebuilt wallet; your current wallet was left in place."; + // Receive Tab strings_["receiving_addresses"] = "Your Receiving Addresses"; strings_["new_z_shielded"] = "New z-Address (Shielded)"; @@ -1211,6 +1354,9 @@ void I18n::loadBuiltinEnglish() strings_["start_mining"] = "Start Mining"; strings_["stop_mining"] = "Stop Mining"; strings_["mining_threads"] = "Mining Threads"; + strings_["mining_threads_input_tooltip"] = "Type an exact thread count (press Enter to apply)"; + strings_["mining_threads_minus_tooltip"] = "Fewer threads"; + strings_["mining_threads_plus_tooltip"] = "More threads"; strings_["mining_statistics"] = "Mining Statistics"; strings_["local_hashrate"] = "Local Hashrate"; strings_["network_hashrate"] = "Network Hashrate"; @@ -1309,6 +1455,24 @@ void I18n::loadBuiltinEnglish() strings_["sb_connecting_err"] = "Connecting to daemon — %s"; strings_["sb_daemon_crashed"] = "Daemon crashed %d times"; strings_["sb_daemon_start_failed"] = "Couldn't start dragonxd"; + strings_["sb_block_db_unreadable"] = "Block database unreadable — rebuild required"; + strings_["sb_wallet_needs_recovery"] = "Wallet repair available"; + // Persistent node-status banner (App::renderNodeStatusBanner). + strings_["node_banner_offline_title"] = "Not connected to the DragonX node"; + strings_["node_banner_crashed_title"] = "The node stopped unexpectedly"; + strings_["node_banner_lite_open_failed"] = "Couldn't open your wallet"; + strings_["node_banner_reconnect"] = "Reconnect"; + strings_["node_banner_restart"] = "Restart node"; + // Refresh-staleness badge (W6-2) on the Total Balance card. + strings_["data_stale_prefix"] = "Updated"; + strings_["data_stale_tooltip"] = + "Balance may be out of date — the wallet hasn't received a fresh update recently. " + "Check your node connection."; + // Persistent alert-history panel (status-bar bell). + strings_["alerts_history_tooltip"] = "Recent alerts"; + strings_["alerts_recent"] = "RECENT ALERTS"; + strings_["alerts_none"] = "No alerts yet"; + strings_["alerts_clear"] = "Clear alert history"; strings_["daemon_port_busy_warn"] = "Port " DRAGONX_DEFAULT_RPC_PORT " is in use but isn't responding as a DragonX node. " "Close the program using it (or free the port), then restart — the wallet can't start " @@ -1316,6 +1480,26 @@ void I18n::loadBuiltinEnglish() strings_["sb_extracting_sapling"] = "Extracting Sapling parameters..."; strings_["sb_sapling_failed"] = "Failed to extract Sapling parameters."; strings_["sb_sapling_not_found"] = "Sapling parameters not found."; + strings_["sb_daemon_extract_failed"] = "Failed to write daemon files — check free disk space and permissions."; + strings_["sb_daemon_files_failed"] = "Failed to write daemon files to %s — check free disk space and permissions."; + strings_["loading_stall_title"] = "Taking longer than expected"; + strings_["loading_stall_body"] = "Initializing for %.0fs — normal after an update or first launch. Connects automatically when ready."; + strings_["loading_stall_hint"] = "Stuck? Settings → Restart Daemon, or check the Console."; + strings_["sb_plaintext_remote_blocked"] = "Refusing to send RPC credentials over plaintext to a remote host. Add rpcallowplaintext=1 to DRAGONX.conf to allow it, or enable TLS with rpctls=1."; + strings_["rpc_plaintext_remote_warning"] = "Remote RPC is using plaintext HTTP. Add rpctls=1 to DRAGONX.conf if your daemon supports TLS."; + strings_["settings_open_log_folder"] = "Open log folder"; + strings_["settings_copy_diagnostics"] = "Copy diagnostics"; + strings_["settings_diagnostics_copied"] = "Diagnostics copied to clipboard"; + strings_["settings_rpc_ok"] = "RPC connection OK"; + strings_["settings_rpc_error_prefix"] = "RPC error: "; + strings_["settings_not_connected"] = "Not connected to daemon"; + strings_["settings_theme_refreshed"] = "Theme list refreshed"; + strings_["settings_saved"] = "Settings saved"; + strings_["settings_reloaded"] = "Settings reloaded from disk"; + strings_["settings_ztx_cleared"] = "Z-transaction history cleared"; + strings_["settings_ztx_not_found"] = "No history file found"; + strings_["tt_open_log_folder"] = "Open the folder containing the debug and crash logs"; + strings_["tt_copy_diagnostics"] = "Copy a support snapshot (version, daemon/wallet/log state — no secrets) to the clipboard"; strings_["sb_dragonxd_running"] = "dragonxd running"; strings_["sb_dragonxd_stopping"] = "Stopping dragonxd..."; strings_["sb_dragonxd_stopped"] = "dragonxd stopped"; @@ -1362,6 +1546,7 @@ void I18n::loadBuiltinEnglish() strings_["about_chain"] = "Chain:"; strings_["about_connections"] = "Connections:"; strings_["about_credits"] = "Credits"; + strings_["about_source"] = "Source"; strings_["about_daemon"] = "Daemon:"; strings_["about_debug"] = "Debug"; strings_["about_edition"] = "ImGui Edition"; @@ -1375,6 +1560,189 @@ void I18n::loadBuiltinEnglish() strings_["about_version"] = "Version:"; strings_["about_website"] = "Website"; + // --- Help & FAQ --- + strings_["faq"] = "FAQ"; + strings_["faq_title"] = "Help & FAQ"; + strings_["faq_intro"] = "Answers about your wallet and the DragonX node. Search, or browse by topic below."; + strings_["faq_intro_lite"] = "Answers about your Lite wallet. Search, or browse by topic below."; + strings_["faq_search_hint"] = "Search the FAQ\xE2\x80\xA6"; + strings_["faq_open_tooltip"] = "Help & FAQ"; + strings_["faq_no_results"] = "No results. Try a different search term."; + strings_["faq_group_wallet"] = "Wallet"; + strings_["faq_group_daemon"] = "Daemon"; + + // Wallet subcategory titles + strings_["faq_w_gs_title"] = "Getting Started"; + strings_["faq_w_addr_title"] = "Addresses & Privacy"; + strings_["faq_w_send_title"] = "Sending & Receiving"; + strings_["faq_w_bal_title"] = "Balance & Sync"; + strings_["faq_w_sec_title"] = "Security & Encryption"; + strings_["faq_w_seed_title"] = "Seed Phrase & Backup"; + strings_["faq_w_chat_title"] = "Chat & Contacts"; + strings_["faq_w_set_title"] = "Settings & Appearance"; + // Daemon subcategory titles + strings_["faq_d_node_title"] = "The Full Node"; + strings_["faq_d_sync_title"] = "Sync & Blockchain"; + strings_["faq_d_mgmt_title"] = "Node Management"; + strings_["faq_d_upd_title"] = "Updating the Node"; + strings_["faq_d_mine_title"] = "Mining & Pools"; + strings_["faq_d_net_title"] = "Peers & Network"; + strings_["faq_d_perf_title"] = "Storage & Performance"; + strings_["faq_d_trbl_title"] = "Troubleshooting"; + + // Wallet > Getting Started + strings_["faq_w_gs_1_q"] = "What is ObsidianDragon?"; + strings_["faq_w_gs_1_a"] = "ObsidianDragon is a full-node wallet for DragonX (DRGX). It manages your coins and, on the desktop build, runs a DragonX full node (the daemon) in the background so your wallet verifies the blockchain itself instead of trusting a third-party server.\n\nThink of it as two parts working together: the wallet (keys, balances, sending) and the node (the blockchain and network). The questions here are split the same way \xE2\x80\x94 see the Wallet and Daemon tabs above."; + strings_["faq_w_gs_2_q"] = "How do I create a new wallet?"; + strings_["faq_w_gs_2_a"] = "On first launch the setup wizard creates a wallet for you automatically. New wallets are backed by a secret recovery phrase (a list of words). Write that phrase down and keep it offline \xE2\x80\x94 it is the only way to restore your funds if this computer is lost.\n\nYou can review or back up the phrase any time from Settings > Backup & Data > Seed phrase."; + strings_["faq_w_gs_3_q"] = "How do I restore a wallet I already have?"; + strings_["faq_w_gs_3_a"] = "Use the seed-phrase restore flow if you have a recovery phrase. (On the full-node build you can also copy an existing wallet.dat file into the wallet's data directory before launch.)\n\nAfter restoring, the wallet re-scans to find your past transactions, so your balance and history may take a while to appear the first time."; + strings_["faq_w_gs_4_q"] = "What does the first-run setup do?"; + strings_["faq_w_gs_4_a"] = "The wizard lets you pick an appearance/theme, set up encryption and a PIN, and \xE2\x80\x94 on the full-node build \xE2\x80\x94 optionally download a bootstrap to speed up the first sync. You can skip any step and change all of these later in Settings."; + + // Wallet > Addresses & Privacy + strings_["faq_w_addr_1_q"] = "What's the difference between transparent and shielded addresses?"; + strings_["faq_w_addr_1_a"] = "Transparent addresses (they start with R or t) work like most coins: the amounts and addresses are public on the blockchain.\n\nShielded addresses (z-addresses, starting with zs) use zero-knowledge cryptography \xE2\x80\x94 the amount, sender, and receiver are encrypted on-chain. Balances held in shielded addresses are private."; + strings_["faq_w_addr_2_q"] = "Which address type should I use?"; + strings_["faq_w_addr_2_a"] = "Prefer shielded (z) addresses whenever possible \xE2\x80\x94 they keep your balance and payment history private. DragonX is a privacy coin and shielded is the default for received funds.\n\nUse a transparent address only when a service you interact with cannot handle shielded addresses."; + strings_["faq_w_addr_3_q"] = "Should I reuse an address or make a new one?"; + strings_["faq_w_addr_3_a"] = "For shielded addresses, reuse is fine and does not leak history. For transparent addresses, using a fresh address per payment improves privacy. Create new addresses from the Receive tab."; + strings_["faq_w_addr_4_q"] = "What is 'shielding'?"; + strings_["faq_w_addr_4_a"] = "Shielding moves coins from a transparent address into a shielded one, making them private. Newly mined coins arrive transparent and are shielded for you automatically.\n\nYou can also shield manually from the wallet; a shield is just a special transaction, so it takes a normal confirmation time to complete."; + + // Wallet > Sending & Receiving + strings_["faq_w_send_1_q"] = "How do I send funds?"; + strings_["faq_w_send_1_a"] = "Open the Send tab, paste the recipient's address, enter an amount, and confirm. If the wallet is encrypted you'll be asked to unlock it for the transaction.\n\nSends to shielded addresses are private; sends to transparent addresses are public."; + strings_["faq_w_send_2_q"] = "What fee do I pay?"; + strings_["faq_w_send_2_a"] = "DragonX fees are very low and are set automatically. The fee is shown before you confirm. You do not normally need to change it."; + strings_["faq_w_send_3_q"] = "Can I attach a message to a payment?"; + strings_["faq_w_send_3_a"] = "Yes \xE2\x80\x94 when sending to a shielded (z) address you can include an encrypted memo. Only the recipient can read it. Transparent addresses do not support memos."; + strings_["faq_w_send_4_q"] = "I sent a payment but it says pending \xE2\x80\x94 why?"; + strings_["faq_w_send_4_a"] = "A transaction is 'pending' until it is mined into a block and gains confirmations. This usually takes a minute or two. Shielded transactions also need your wallet to be synced to build the proof.\n\nIf a send stays pending unusually long, check that your wallet is connected and synced (see the status bar at the bottom of the window)."; + + // Wallet > Balance & Sync + strings_["faq_w_bal_1_q"] = "Why is my balance 0 or not updating?"; + strings_["faq_w_bal_1_a"] = "While your wallet is still syncing, your balance is incomplete and may read 0 \xE2\x80\x94 it hasn't scanned all of your transactions yet. It fills in once syncing finishes.\n\nWatch the status bar at the bottom: it shows the connection state and block height. Once syncing is complete, your balance is accurate."; + strings_["faq_w_bal_2_q"] = "What are confirmations?"; + strings_["faq_w_bal_2_a"] = "Each new block mined on top of the block containing your transaction adds one confirmation. More confirmations mean the payment is more firmly settled. Received funds become spendable after the first confirmation."; + strings_["faq_w_bal_3_q"] = "What's the difference between total and spendable balance?"; + strings_["faq_w_bal_3_a"] = "Total includes funds that are still confirming or are temporarily locked (for example, coins in the middle of being shielded). Spendable is what you can send right now. They converge as transactions confirm."; + + // Wallet > Security & Encryption + strings_["faq_w_sec_1_q"] = "How do I encrypt my wallet?"; + strings_["faq_w_sec_1_a"] = "Open Settings > Node & Security and set a passphrase (the SECURITY section). Encryption protects your private keys on disk, so someone with access to the files still cannot spend your coins.\n\nChoose a strong passphrase and don't lose it \xE2\x80\x94 there is no way to recover an encrypted wallet without it."; + strings_["faq_w_sec_2_q"] = "What is the PIN / lock screen?"; + strings_["faq_w_sec_2_a"] = "The PIN locks the app's screen so balances and actions are hidden when you step away. It's a convenience lock on top of encryption; you set it up during the wizard or in Settings, after encrypting the wallet."; + strings_["faq_w_sec_3_q"] = "I forgot my passphrase \xE2\x80\x94 can it be recovered?"; + strings_["faq_w_sec_3_a"] = "No. Wallet encryption cannot be bypassed. If you still have your seed recovery phrase, you can restore the wallet from it into a fresh wallet and set a new passphrase. Without either the passphrase or the seed phrase, the funds cannot be recovered."; + + // Wallet > Seed Phrase & Backup + strings_["faq_w_seed_1_q"] = "How do I back up my recovery phrase?"; + strings_["faq_w_seed_1_a"] = "Go to Settings > Backup & Data > Seed phrase. Write the words down on paper in order and store them somewhere safe and offline. Anyone with the phrase can spend your coins, so never store it in a photo, email, or cloud note."; + strings_["faq_w_seed_2_q"] = "My wallet has no recovery phrase \xE2\x80\x94 can I add one?"; + strings_["faq_w_seed_2_a"] = "Older (legacy) wallets weren't seed-based. The wallet can migrate a legacy wallet into a modern seed-backed one: it creates a new seed wallet and sweeps your funds into it. Look for 'Migrate to seed\xE2\x80\xA6' in Settings > Backup & Data. This feature needs the current daemon version."; + strings_["faq_w_seed_3_q"] = "How do I restore from my recovery phrase?"; + strings_["faq_w_seed_3_a"] = "Use the seed-restore flow when setting up a wallet and enter your words in order. Your wallet then re-scans to rebuild your balance and history, which can take a while the first time."; + strings_["faq_w_seed_4_q"] = "Should I also back up wallet.dat?"; + strings_["faq_w_seed_4_a"] = "Your seed phrase is the primary backup and is enough to restore everything. A copy of your wallet file is a convenient secondary backup that also preserves labels and settings. Keep any backup offline and private."; + + // Wallet > Chat & Contacts + strings_["faq_w_chat_1_q"] = "What is the Chat feature?"; + strings_["faq_w_chat_1_a"] = "Chat is an encrypted, on-chain messenger built into the wallet. Messages are sent as private shielded memos, so only you and your contact can read them."; + strings_["faq_w_chat_2_q"] = "How does my chat identity work?"; + strings_["faq_w_chat_2_a"] = "Your chat identity is derived from your wallet's recovery phrase, so it travels with your wallet \xE2\x80\x94 restore the wallet and your identity comes back. You don't create a separate account or password."; + strings_["faq_w_chat_3_q"] = "How do contacts work?"; + strings_["faq_w_chat_3_a"] = "Add a contact by their address in the Contacts tab, optionally with a name and avatar. Contacts can be kept private to the current wallet or shared across all wallets you open on this computer."; + strings_["faq_w_chat_4_q"] = "How do I hide, delete, or block a conversation?"; + strings_["faq_w_chat_4_a"] = "Open a conversation and use the icons in its header. Hide (the eye) drops it from the list but keeps every message \xE2\x80\x94 a new message un-hides it. Delete (the trash) opens a dialog: 'Delete' clears the history on this device (if that person messages you again, the conversation comes back), while 'Delete & block' also stops their future messages until you unblock them from the 'Blocked' list above the conversation list. Everything here is local to this device \xE2\x80\x94 the messages stay on the blockchain and the other person keeps their own copy, so it isn't an 'unsend'."; + // ---- Lite-variant FAQ (ObsidianDragonLite): swapped-in answers + a Lite Wallet subcategory ---- + strings_["faq_l_gs_1_q"] = "What is ObsidianDragonLite?"; + strings_["faq_l_gs_1_a"] = "ObsidianDragonLite is the lightweight DragonX wallet. Instead of running a full node, it connects to a DragonX lite server, so it starts fast and doesn't download the whole blockchain.\n\nIt manages your keys, balances, and sending just like the full wallet \xE2\x80\x94 see the 'Lite Wallet' section below for how the server connection works and the privacy tradeoff."; + strings_["faq_l_sec_1_a"] = "Open Settings > Wallet and set a passphrase in the security section. Encryption protects your private keys on disk, so someone with access to the files still cannot spend your coins.\n\nChoose a strong passphrase and don't lose it \xE2\x80\x94 there is no way to recover an encrypted wallet without it."; + strings_["faq_l_lite_title"] = "Lite Wallet"; + strings_["faq_l_lite_1_q"] = "How is the Lite wallet different from the full wallet?"; + strings_["faq_l_lite_1_a"] = "The Lite wallet talks to a DragonX lite server instead of running its own full node. That makes it fast to start and light on disk and bandwidth, because it doesn't download or verify the entire blockchain itself. Your private keys always stay on your device \xE2\x80\x94 they are never sent to the server."; + strings_["faq_l_lite_2_q"] = "How do I choose or change the server?"; + strings_["faq_l_lite_2_a"] = "Open Settings and pick a server. You can switch servers at any time and the wallet re-syncs from the new one. If a server is slow or unreachable, try another."; + strings_["faq_l_lite_3_q"] = "Is the Lite wallet as private as the full node?"; + strings_["faq_l_lite_3_a"] = "Your keys never leave your device, and shielded amounts stay encrypted. But the lite server does see which addresses your wallet asks about and your IP address, so it can link your addresses together \xE2\x80\x94 a convenience-for-privacy tradeoff. For the most privacy, use the full-node wallet, which verifies the chain itself and doesn't reveal your addresses to a server."; + + // Wallet > Settings & Appearance + strings_["faq_w_set_1_q"] = "How do I change the theme?"; + strings_["faq_w_set_1_a"] = "Open Settings > Appearance to pick a theme and accent. Several light and dark skins are included, and effects like blur can be toggled off for lower-powered machines."; + strings_["faq_w_set_2_q"] = "Can I change the language?"; + strings_["faq_w_set_2_a"] = "Yes \xE2\x80\x94 Settings > Appearance has a language selector. The wallet ships with several translations; anything not yet translated falls back to English."; + strings_["faq_w_set_3_q"] = "The text is too small (or too large) \xE2\x80\x94 can I scale it?"; + strings_["faq_w_set_3_a"] = "Use the font-scale option in Settings > Appearance. It scales the whole interface, which is handy on high-resolution (HiDPI) displays where the app might otherwise render small."; + strings_["faq_w_set_4_q"] = "How do I show fiat prices?"; + strings_["faq_w_set_4_a"] = "Enable price fetching in Settings. The wallet then shows an approximate fiat value alongside balances and can display a small market chart. Prices are informational only."; + + // Daemon > The Full Node + strings_["faq_d_node_1_q"] = "What is the daemon (dragonxd)?"; + strings_["faq_d_node_1_a"] = "The daemon, dragonxd, is the DragonX full node. It downloads and verifies the entire blockchain, relays transactions to the network, and answers the wallet's queries. Running your own node means you don't have to trust anyone else's server."; + strings_["faq_d_node_2_q"] = "Embedded vs external node \xE2\x80\x94 what's the difference?"; + strings_["faq_d_node_2_a"] = "By default the wallet launches and manages its own bundled node (embedded) \xE2\x80\x94 you don't have to do anything. If you already run dragonxd yourself, the wallet can connect to that external node instead. The wallet detects which situation it's in and behaves accordingly."; + strings_["faq_d_node_3_q"] = "Why run a full node at all?"; + strings_["faq_d_node_3_a"] = "A full node validates every rule of the blockchain independently, so your wallet trusts math instead of a third party. It also strengthens the network. The trade-off is disk space and an initial sync."; + + // Daemon > Sync & Blockchain + strings_["faq_d_sync_1_q"] = "What does 'Processing blocks' at startup mean?"; + strings_["faq_d_sync_1_a"] = "On launch the node loads and validates blocks before it can serve the wallet. The 'Processing blocks / Applying blocks to build the current chain state' screen is that warm-up. It's normal; the wallet becomes usable once the node finishes and reports its height."; + strings_["faq_d_sync_2_q"] = "What is 'Rebuilding witness cache'?"; + strings_["faq_d_sync_2_a"] = "For your shielded funds the node keeps cryptographic 'witnesses' that let you spend privately. After certain restarts or updates it rebuilds this cache by re-scanning recent blocks. On a wallet with many transactions this can take several minutes and use extra memory.\n\nIt is not stuck \xE2\x80\x94 let it finish. Force-quitting in the middle just makes it restart the rebuild next time."; + strings_["faq_d_sync_3_q"] = "Why is syncing slow when I'm almost caught up?"; + strings_["faq_d_sync_3_a"] = "Near the chain tip, work that scans your wallet (checking balances and notes) competes with the node for the same internal lock, which can slow down connecting the final blocks on a large wallet. Recent versions throttle that scanning so the node can catch up. Staying on a lighter tab (or just waiting) lets it finish faster."; + strings_["faq_d_sync_4_q"] = "How long does the initial sync take?"; + strings_["faq_d_sync_4_a"] = "The first sync downloads and verifies the whole chain and can take a while depending on your connection and disk. You can speed it up dramatically by enabling the bootstrap download in the setup wizard, which fetches a recent verified copy of the chain data."; + + // Daemon > Node Management + strings_["faq_d_mgmt_1_q"] = "How do I start or stop the node?"; + strings_["faq_d_mgmt_1_a"] = "The embedded node starts automatically with the wallet and stops when appropriate, so you normally don't manage it by hand. Advanced controls (including restarting the daemon) live in Settings > Node & Security."; + strings_["faq_d_mgmt_2_q"] = "What does 'close external daemon on exit' do?"; + strings_["faq_d_mgmt_2_a"] = "If you connect the wallet to a node you started yourself, this option decides whether closing the wallet also shuts that node down. Leave it off if you want your node to keep running after you close the wallet."; + strings_["faq_d_mgmt_3_q"] = "Where is the blockchain and wallet data stored?"; + strings_["faq_d_mgmt_3_a"] = "Node and wallet data live in the DragonX data directory under your user profile (for example, in AppData on Windows or your home folder on Linux/macOS). The blockchain is the large part; keep enough free disk space for it to grow."; + + // Daemon > Updating the Node + strings_["faq_d_upd_1_q"] = "How do I update the node?"; + strings_["faq_d_upd_1_a"] = "Open Settings > Node & Security > Daemon binary and use 'Check for updates\xE2\x80\xA6'. The wallet downloads the latest verified node build and installs it; the new version takes effect the next time the daemon starts."; + strings_["faq_d_upd_2_q"] = "Can I install a specific node version?"; + strings_["faq_d_upd_2_a"] = "Yes \xE2\x80\x94 the update dialog lists every release so you can pick a specific or older build. Be cautious downgrading: an older node may not accept blockchain data written by a newer one and could need a re-index."; + strings_["faq_d_upd_3_q"] = "Is the update safe?"; + strings_["faq_d_upd_3_a"] = "Every downloaded node archive is verified against a checksum and a cryptographic signature before it's installed. If verification fails, the wallet refuses the update."; + + // Daemon > Mining & Pools + strings_["faq_d_mine_1_q"] = "How do I mine DragonX?"; + strings_["faq_d_mine_1_a"] = "The Mining tab lets you mine with your CPU. DragonX uses the RandomX algorithm, which is designed for regular processors. Set the number of threads and start \xE2\x80\x94 rewards accrue to your wallet."; + strings_["faq_d_mine_2_q"] = "Solo vs pool mining \xE2\x80\x94 which should I choose?"; + strings_["faq_d_mine_2_a"] = "Solo mining sends rewards straight to you but pays only when you find a block, which is infrequent unless you have a lot of hashrate. Pool mining shares work with others for smaller, steadier payouts. The Mining tab supports both."; + strings_["faq_d_mine_3_q"] = "How do I update the miner?"; + strings_["faq_d_mine_3_a"] = "The Mining tab's pool section has an 'Update miner\xE2\x80\xA6' button that downloads, verifies, and installs the latest optimized DragonX miner build. Like the node updater, each download is checksum- and signature-verified before install."; + strings_["faq_d_mine_4_q"] = "Can I host a mining pool?"; + strings_["faq_d_mine_4_a"] = "The current node can run a built-in stratum server so other miners can point at your machine. There's a toggle for it under Settings > Node & Security. By default it only listens locally; exposing it to other computers requires opening it up deliberately."; + + // Daemon > Peers & Network + strings_["faq_d_net_1_q"] = "The wallet shows no peers or connections \xE2\x80\x94 what's wrong?"; + strings_["faq_d_net_1_a"] = "Right after launch the node needs a moment to find peers, so a brief '0 peers' is normal. If it persists, check your internet connection and that a firewall isn't blocking the node. The node finds peers automatically through built-in seeds."; + strings_["faq_d_net_2_q"] = "Is my connection to the network encrypted?"; + strings_["faq_d_net_2_a"] = "Yes \xE2\x80\x94 DragonX nodes talk to each other over TLS, so peer connections are encrypted. The wallet also talks to its own node over a secure local channel."; + + // Daemon > Storage & Performance + strings_["faq_d_perf_1_q"] = "How much disk does the node use, and can I tune memory?"; + strings_["faq_d_perf_1_a"] = "The blockchain is the large item and grows over time, so keep several gigabytes free. The node uses a database cache for speed; the bundled defaults are tuned for typical machines. Advanced users can adjust the node's database cache by editing dbcache in its config file (DRAGONX.conf)."; + strings_["faq_d_perf_2_q"] = "How do I make the initial sync faster?"; + strings_["faq_d_perf_2_a"] = "Enable the bootstrap download (offered in the setup wizard) to fetch a recent, verified copy of the chain instead of validating every block from scratch. A fast disk (SSD) also helps a lot."; + + // Daemon > Troubleshooting + strings_["faq_d_trbl_1_q"] = "The node seems stuck on 'Activating best chain' \xE2\x80\x94 is it frozen?"; + strings_["faq_d_trbl_1_a"] = "Usually not. On a large wallet the node can spend several minutes rebuilding shielded witnesses or applying blocks, during which it looks paused and may be slow to answer. Give it time \xE2\x80\x94 the debug log (Settings) shows steady progress if it's working.\n\nAvoid force-quitting during this phase; it restarts the work from scratch next launch."; + strings_["faq_d_trbl_2_q"] = "What is 'degraded mode'?"; + strings_["faq_d_trbl_2_a"] = "If a wallet file is recovered but part of its key data is missing, the node may open it in a limited 'degraded' mode where it can't derive new addresses or shield funds. The safest fix is to restore from your seed recovery phrase into a fresh wallet."; + strings_["faq_d_trbl_3_q"] = "When should I re-index or re-scan?"; + strings_["faq_d_trbl_3_a"] = "A re-scan makes the wallet re-read the chain to rediscover your transactions (useful after importing keys). A re-index rebuilds the node's blockchain database and is only needed if that database is corrupted or incompatible after a version change. Both can take a while; start them from Settings > Node & Security."; + strings_["faq_d_trbl_4_q"] = "The node is using a lot of memory \xE2\x80\x94 is that normal?"; + strings_["faq_d_trbl_4_a"] = "Memory use spikes during heavy work like a witness rebuild or an initial sync, then drops back down once it finishes. Sustained high memory at idle is unusual \xE2\x80\x94 restarting the wallet clears it. On a low-RAM machine, make sure other memory-heavy apps aren't competing."; + // --- Address Book Dialog --- strings_["address_book_add"] = "Add Address"; strings_["address_book_add_new"] = "Add New"; @@ -1516,6 +1884,8 @@ void I18n::loadBuiltinEnglish() strings_["console_zoom_out"] = "Zoom out"; strings_["console_toggle_accents"] = "Toggle line color accents"; strings_["console_toggle_text_color"] = "Toggle line text colors"; + strings_["console_auto_focus"] = "Focus input on open"; + strings_["console_toggle_auto_focus"] = "Place the cursor in the command box when you open the Console tab"; strings_["console_accents"] = "Color accents"; strings_["console_text_colors"] = "Text colors"; strings_["console_cat_control"] = "Control"; @@ -1764,7 +2134,10 @@ void I18n::loadBuiltinEnglish() strings_["mining_open_in_explorer"] = "Open in explorer"; strings_["mining_payout_address"] = "Payout Address"; strings_["mining_payout_tooltip"] = "Address to receive mining rewards"; + strings_["mining_payout_invalid"] = "Not a valid DragonX address — fix it before starting, or mining rewards are lost."; + strings_["mining_est_daily_pool_sub"] = "rough solo-equivalent, before pool fee"; strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address"; + strings_["mining_pool_needs_payout_tooltip"] = "Enter a payout address first (generate a Z address)"; strings_["mining_pool"] = "Pool"; strings_["mining_payout_foreign"] = "⚠ This payout address isn't in your current wallet — mined rewards would go to a different wallet. Update it if you switched wallets."; strings_["mining_pool_hashrate"] = "Pool Hashrate"; @@ -1815,6 +2188,7 @@ void I18n::loadBuiltinEnglish() // --- Miner (xmrig) updater --- strings_["xmrig_update_button"] = "Update miner…"; strings_["xmrig_update_short"] = "Update"; + strings_["xmrig_releases"] = "xmrig releases"; strings_["xmrig_current"] = "Current:"; strings_["xmrig_none"] = "none"; strings_["xmrig_update_title"] = "Update Miner"; @@ -2023,6 +2397,7 @@ void I18n::loadBuiltinEnglish() strings_["send_recipient"] = "RECIPIENT"; strings_["send_select_source"] = "Select a source address..."; strings_["send_sending_from"] = "SENDING FROM"; + strings_["send_available_note"] = "available"; strings_["send_submitting"] = "Submitting transaction..."; strings_["send_switch_to_receive"] = "Switch to Receive to get your address and start receiving funds."; strings_["send_tooltip_enter_amount"] = "Enter an amount to send"; @@ -2032,6 +2407,7 @@ void I18n::loadBuiltinEnglish() strings_["send_tooltip_not_connected"] = "Not connected to daemon"; strings_["send_tooltip_select_source"] = "Select a source address first"; strings_["send_tooltip_syncing"] = "Wait for blockchain to sync"; + strings_["send_tooltip_view_only"] = "View-only address — no spending key, cannot send"; strings_["send_total"] = "Total"; strings_["send_tx_failed"] = "Transaction failed"; strings_["send_tx_sent"] = "Transaction sent!"; @@ -2075,6 +2451,29 @@ void I18n::loadBuiltinEnglish() strings_["merge_funds"] = "Merge Funds"; strings_["merge_started"] = "Merge operation started"; strings_["merge_title"] = "Merge to Address"; + // Consolidate-funds flow (rich merge modal + wallet-bloat preset). + strings_["consolidate_title"] = "Consolidate funds"; + strings_["consolidate_desc"] = "Combine many small inputs into a single shielded note. Fewer notes means a smaller wallet file and better privacy."; + strings_["consolidate_funds_btn"] = "Consolidate"; + strings_["merge_scope_loading"] = "Checking your inputs\xE2\x80\xA6"; + strings_["merge_scope_fmt"] = "%d transparent + %d shielded inputs \xC2\xB7 ~%s DRGX spendable"; + strings_["merge_source"] = "Consolidate"; + strings_["merge_src_transparent"] = "Transparent"; + strings_["merge_src_shielded"] = "Shielded"; + strings_["merge_src_both"] = "Both"; + strings_["merge_batch_fmt"] = "Merges up to %d inputs per run \xE2\x80\x94 repeat to finish the rest."; + strings_["merge_advanced"] = "Advanced"; + strings_["merge_max_inputs"] = "Max inputs per batch"; + strings_["merge_fee_hint"] = "Network fee for this transaction."; + strings_["merge_create_zaddr"] = "Create shielded address"; + strings_["merge_creating"] = "Creating address\xE2\x80\xA6"; + strings_["merge_addr_created"] = "Shielded address created."; + strings_["merge_confirm_fmt"] = "Consolidate ~%s DRGX from %d input(s) into %s?"; + strings_["merge_confirm_btn"] = "Confirm"; + strings_["merge_back"] = "Back"; + strings_["merge_progress"] = "Consolidating\xE2\x80\xA6 this can take a few minutes. You can close this window."; + strings_["merge_no_spendable"] = "No spendable inputs to consolidate yet."; + strings_["done"] = "Done"; // --- Transaction Details Dialog --- strings_["tx_confirmations"] = "%d confirmations"; @@ -2096,6 +2495,7 @@ void I18n::loadBuiltinEnglish() strings_["validate_not_mine"] = "Not owned by this wallet"; strings_["validate_ownership"] = "Ownership:"; strings_["validate_results"] = "Results:"; + strings_["validate_results_placeholder"] = "Results will appear here"; strings_["validate_shielded_type"] = "Shielded (z-address)"; strings_["validate_status"] = "Status:"; strings_["validate_title"] = "Validate Address"; @@ -2157,6 +2557,217 @@ void I18n::loadBuiltinEnglish() strings_["explorer_hash_not_found"] = "No block or transaction found for this hash"; strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash"; strings_["explorer_no_results"] = "No matching cached blocks"; + + // ---- i18n audit 2026-09: wrap previously-hardcoded UI strings ---- + // app_security.cpp — encryption / PIN / lock flow + strings_["sec_restart_daemon_for_encryption"] = "Please restart your daemon for encryption to take effect."; + strings_["sec_encrypting_wallet"] = "Encrypting wallet..."; + strings_["sec_wallet_encrypted_restarting_daemon"] = "Wallet encrypted. Restarting daemon..."; + strings_["sec_wallet_encrypted_successfully"] = "Wallet encrypted successfully"; + strings_["sec_encryption_failed_prefix"] = "Encryption failed: "; + strings_["sec_wallet_encrypted_and_pin_set"] = "Wallet encrypted & PIN set"; + strings_["sec_wallet_encrypted_but_pin_vault_failed"] = "Wallet encrypted but PIN vault failed"; + strings_["sec_couldnt_lock_wallet"] = "Couldn't lock the wallet — it is still unlocked. Check the daemon connection."; + strings_["sec_changing_passphrase"] = "Changing passphrase..."; + strings_["sec_passphrase_changed_successfully"] = "Passphrase changed successfully"; + strings_["sec_failed_prefix"] = "Failed: "; + strings_["sec_encryption_did_not_complete"] = "Wallet encryption did not complete — your wallet is NOT encrypted. Open Settings to finish encrypting it."; + strings_["sec_wallet_locked_title"] = "Wallet Locked"; + strings_["sec_too_many_attempts_wait"] = "Too many attempts. Wait %.0f seconds..."; + strings_["sec_mode_passphrase"] = " Passphrase"; + strings_["sec_use_passphrase_instead"] = "Use passphrase instead"; + strings_["sec_use_pin_instead"] = "Use PIN instead"; + strings_["sec_unlocking_fmt"] = "Unlocking%s"; + strings_["sec_unlock_button"] = "Unlock"; + strings_["sec_not_connected_to_daemon"] = "Not connected to daemon"; + strings_["sec_unlock_failed_prefix"] = "Unlock failed: "; + strings_["sec_incorrect_passphrase_decrypt"] = "Incorrect passphrase"; + strings_["sec_importing_keys_rescanning"] = "Importing keys & rescanning blockchain — wallet is usable while this runs"; + strings_["sec_encrypted_backup_suffix"] = "\nEncrypted backup: wallet.dat.encrypted.bak"; + strings_["sec_wallet_decrypted_all_keys_imported"] = "Wallet decrypted successfully! All keys imported."; + strings_["sec_total_elapsed_fmt"] = "Total elapsed: %dm %02ds"; + strings_["sec_verifying_passphrase"] = "Verifying passphrase..."; + strings_["sec_incorrect_passphrase_pin_setup"] = "Incorrect passphrase"; + strings_["sec_pin_set_successfully"] = "PIN set successfully"; + strings_["sec_failed_to_create_vault"] = "Failed to create vault"; + strings_["sec_not_connected_to_daemon_pin"] = "Not connected to daemon"; + strings_["sec_changing_pin"] = "Changing PIN..."; + strings_["sec_pin_changed_successfully"] = "PIN changed successfully"; + strings_["sec_incorrect_current_pin"] = "Incorrect current PIN"; + strings_["sec_internal_error_change_pin"] = "Internal error"; + strings_["sec_verifying_pin"] = "Verifying PIN..."; + strings_["sec_pin_removed"] = "PIN removed"; + strings_["sec_incorrect_pin_remove"] = "Incorrect PIN"; + strings_["sec_internal_error_remove_pin"] = "Internal error"; + + // app.cpp — wizard, seed backup, shutdown, daemon/miner lifecycle + strings_["appx_creating_your_wallet"] = "Creating your wallet…"; + strings_["appx_create_failed_prefix"] = "Create failed: "; + strings_["appx_back_up_seed_phrase_title"] = "Back up your seed phrase"; + strings_["appx_seed_backup_warning"] = "These 24 words are the ONLY way to restore your wallet. Write them down in order, store them offline, and never share them. If you lose them, your funds are gone forever."; + strings_["appx_birthday_block_height"] = "Birthday (block height): %llu — back this up too."; + strings_["appx_ive_written_it_down"] = "I've written it down"; + strings_["appx_copy"] = "Copy"; + strings_["appx_skip_anyway"] = "Skip anyway"; + strings_["appx_skip"] = "Skip"; + strings_["appx_seed_not_backed_up_warning"] = "You have not backed up your seed — funds could be lost. Skip anyway?"; + strings_["appx_confirm_your_backup"] = "Confirm your backup"; + strings_["appx_tap_words_in_order"] = "Tap the words in the correct order to confirm you saved them."; + strings_["appx_progress_n_of_n"] = "Progress: %d / %d"; + strings_["appx_not_next_word"] = " — that's not the next word"; + strings_["appx_done"] = "Done"; + strings_["appx_wallet_created_and_backed_up"] = "Wallet created and backed up."; + strings_["appx_back"] = "Back"; + strings_["appx_restoring_your_wallet"] = "Restoring your wallet…"; + strings_["appx_recovery_phrase_word_count"] = "Recovery phrase should be 24 words — you have %d."; + strings_["appx_could_not_start_restore"] = "Could not start restore"; + strings_["appx_node_rebuilding_witness_cache"] = "Node is rebuilding its witness cache"; + strings_["appx_stopping_node_discards_rebuild"] = "Stopping the node now discards the in-progress rebuild and restarts it (several minutes) the next time you open the wallet. You can keep the node running instead."; + strings_["appx_keep_node_running_and_quit"] = "Keep node running & quit"; + strings_["appx_stop_anyway_and_quit"] = "Stop anyway & quit"; + strings_["appx_cancel"] = "Cancel"; + strings_["appx_last_used_wallet_not_found_prefix"] = "Your last-used wallet file ("; + strings_["appx_last_used_wallet_not_found_suffix"] = ") was not found — opened the default wallet instead. If you moved it, restore it and switch back from the wallet list."; + strings_["appx_wallet_open_failed_prefix"] = "Wallet open failed: "; + strings_["appx_blockchain_rescan_complete"] = "Blockchain rescan complete"; + strings_["appx_miner_stopped_unexpectedly"] = "Miner stopped unexpectedly."; + strings_["appx_miner_stopped_prefix"] = "Miner stopped: "; + strings_["appx_pool_miner_connected_and_hashing"] = "Pool miner connected and hashing."; + strings_["appx_bootstrap_complete_reconciling"] = "Bootstrap complete — reconciling your wallet with the new chain data."; + strings_["appx_blockchain_data_deleted"] = "Blockchain data deleted (%d items). The daemon is restarting to re-sync from the network."; + strings_["appx_invalid_payment_uri_prefix"] = "Invalid payment URI: "; + strings_["appx_payment_request_loaded"] = "Payment request loaded"; + strings_["appx_fullnode_lifecycle_unavailable_lite"] = "Full-node lifecycle actions are unavailable in lite build"; + strings_["appx_blockchain_maintenance_in_progress"] = "A blockchain maintenance operation is already in progress."; + strings_["appx_node_busy_restarting"] = "The node is busy restarting — try again in a moment."; + strings_["appx_restarting_daemon_rescan_flag"] = "Restarting daemon with -rescan flag..."; + strings_["appx_restarting_daemon_zapwallettxes"] = "Restarting daemon with -zapwallettxes=2 (wallet repair)..."; + strings_["appx_no_bundled_daemon_to_install"] = "This build has no bundled daemon to install"; + strings_["appx_no_embedded_daemon_to_install"] = "This build has no embedded daemon to install"; + strings_["appx_daemon_reinstall_in_progress"] = "The daemon reinstall is already in progress."; + strings_["appx_installing_bundled_daemon"] = "Installing bundled daemon — the node will stop, update, and restart..."; + strings_["appx_stopping_daemon_deleting_blockchain"] = "Stopping daemon and deleting blockchain data..."; + strings_["appx_stopping_pool_miner"] = "Stopping pool miner..."; + strings_["appx_disconnecting"] = "Disconnecting..."; + strings_["appx_sending_stop_command_to_daemon"] = "Sending stop command to daemon..."; + strings_["appx_cleaning_up"] = "Cleaning up..."; + strings_["appx_shutdown_complete"] = "Shutdown complete"; + strings_["appx_n_seconds"] = "%d seconds"; + strings_["appx_n_min_n_sec"] = "%d min %d sec"; + strings_["appx_still_status_prefix"] = "Still \""; + strings_["appx_still_status_suffix"] = "\" — force quitting now may corrupt chain data."; + strings_["appx_rebuilding_witness_cache_blocks_left"] = "Rebuilding witness cache %.0f%% — %d blocks left"; + strings_["appx_rebuilding_witness_cache_pct"] = "Rebuilding witness cache %.0f%%"; + strings_["appx_setting_initial_sapling_witnesses"] = "Setting initial Sapling witnesses %.0f%%"; + strings_["appx_rebuilding_sapling_note_witnesses"] = "Rebuilding Sapling note witnesses…"; + strings_["appx_syncing_pct_block_n_of_n"] = "Syncing %.1f%% — Block %d / %d"; + strings_["appx_last_block_n"] = "Last block: %d"; + strings_["appx_encrypting_wallet"] = "Encrypting wallet..."; + strings_["appx_waiting_for_daemon_to_encrypt_wallet"] = "Waiting for daemon to encrypt wallet..."; + strings_["appx_daemon_error"] = "Daemon Error"; + strings_["appx_use_settings_restart_daemon_hint"] = "Use Settings > Restart Daemon to try again"; + strings_["appx_copied_clipboard_autoclears"] = "Copied — clipboard auto-clears in 45s"; + strings_["appx_dragonxd_output"] = "dragonxd output"; + strings_["appx_theme_prefix"] = "Theme: "; + strings_["appx_low_spec_mode_enabled"] = "Low-spec mode enabled"; + strings_["appx_low_spec_mode_disabled"] = "Low-spec mode disabled"; + strings_["appx_theme_effects_enabled"] = "Theme effects enabled"; + strings_["appx_theme_effects_disabled"] = "Theme effects disabled"; + strings_["appx_simple_background_enabled"] = "Simple background enabled"; + strings_["appx_simple_background_disabled"] = "Simple background disabled"; + + // balance_tab.cpp + strings_["baltab_shielded_amount"] = "Shielded %.8f"; + strings_["baltab_transparent_amount"] = "Transparent %.8f"; + strings_["baltab_market_price_4dp"] = "Market: $%.4f"; + strings_["baltab_market_price_8dp"] = "Market: $%.8f"; + strings_["baltab_pct_of_total_zaddr"] = "%.0f%% of total · %d Z-addr"; + strings_["baltab_t_addresses_count"] = "%d T-addresses"; + strings_["baltab_pct_change_24h"] = "%s%.1f%% 24h"; + strings_["baltab_total_balance"] = "Total Balance"; + strings_["baltab_shielded"] = "Shielded"; + strings_["baltab_transparent"] = "Transparent"; + strings_["baltab_market"] = "Market"; + + // settings_window.cpp + strings_["swin_invalid_suffix"] = " (invalid)"; + strings_["swin_theme_list_refreshed"] = "Theme list refreshed"; + strings_["swin_connection_successful"] = "Connection successful!\ndragonxd version: "; + strings_["swin_connection_failed"] = "Connection failed: "; + strings_["swin_rpc_client_not_initialized"] = "RPC client not initialized"; + strings_["swin_rescan_started_from_block"] = "Rescan started from block "; + strings_["swin_rescan_to"] = " to "; + strings_["swin_rescan_failed"] = "Rescan failed: "; + strings_["swin_ztx_history_cleared"] = "Z-transaction history cleared"; + strings_["swin_no_history_file_found"] = "No history file found"; + strings_["swin_settings_saved"] = "Settings saved"; + + // settings_page.cpp / explorer_tab.cpp / block_info_dialog.cpp + strings_["grpa_tab_appearance"] = "Appearance"; + strings_["grpa_tab_wallet"] = "Wallet"; + strings_["grpa_tab_backup_data"] = "Backup & Data"; + strings_["grpa_tab_node_security"] = "Node & Security"; + strings_["grpa_tab_explorer"] = "Explorer"; + strings_["grpa_tab_chat"] = "Chat"; + strings_["grpa_tab_about"] = "About"; + strings_["grpa_enter_private_key_to_import"] = "Enter a private key to import."; + strings_["grpa_invalid_suffix"] = " (invalid)"; + strings_["grpa_seed_demo_chat"] = "Seed demo chat"; + strings_["grpa_dbg_addrman"] = "Peer address tracking and management"; + strings_["grpa_dbg_alert"] = "Alert system messages"; + strings_["grpa_dbg_bench"] = "Benchmark timings for operations"; + strings_["grpa_dbg_coindb"] = "Coin database read/write operations"; + strings_["grpa_dbg_db"] = "Berkeley DB operations"; + strings_["grpa_dbg_estimatefee"] = "Fee estimation algorithm"; + strings_["grpa_dbg_http"] = "HTTP RPC server activity"; + strings_["grpa_dbg_libevent"] = "Libevent networking library"; + strings_["grpa_dbg_lock"] = "Lock contention debugging"; + strings_["grpa_dbg_mempool"] = "Transaction memory pool activity"; + strings_["grpa_dbg_net"] = "Network connections and messages"; + strings_["grpa_dbg_paymentdisclosure"] = "Payment disclosure protocol"; + strings_["grpa_dbg_pow"] = "Proof-of-work mining activity"; + strings_["grpa_dbg_proxy"] = "SOCKS5 proxy connections"; + strings_["grpa_dbg_prune"] = "Block pruning operations"; + strings_["grpa_dbg_rand"] = "Random number generation"; + strings_["grpa_dbg_reindex"] = "Blockchain reindexing progress"; + strings_["grpa_dbg_rpc"] = "RPC command processing"; + strings_["grpa_dbg_selectcoins"] = "Coin selection for transactions"; + strings_["grpa_dbg_tor"] = "Tor integration and circuit info"; + strings_["grpa_dbg_zmq"] = "ZeroMQ notification system"; + strings_["grpa_dbg_zrpc"] = "Shielded (z-addr) RPC operations"; + strings_["grpa_sec_ago"] = "%lld sec ago"; + strings_["grpa_min_ago"] = "%lld min ago"; + strings_["grpa_hr_ago"] = "%lld hr ago"; + strings_["grpa_days_ago"] = "%lld days ago"; + strings_["grpa_showing_first_100_of"] = "... showing first 100 of %d"; + strings_["grpa_error_prefix"] = "Error: "; + strings_["grpa_invalid_response_from_daemon"] = "Invalid response from daemon"; + strings_["grpa_current_block_paren"] = "(Current: %d)"; + strings_["grpa_unexpected_getblockhash_result"] = "unexpected getblockhash result"; + + // receive / chat / send / transaction_details + strings_["grpb_new_badge_suffix"] = " [NEW]"; + strings_["grpb_tooltip_address_balance"] = "%s\nBalance: %.8f %s%s"; + strings_["grpb_selected_suffix"] = "\n(selected)"; + strings_["grpb_preview_msg_payment_through"] = "Did the payment go through? 🙂"; + strings_["grpb_preview_msg_yep_confirmed"] = "Yep — just confirmed ✅"; + strings_["grpb_preview_msg_sending_rest"] = "Sending the rest now 👍"; + strings_["grpb_max"] = "Max"; + strings_["grpb_undo_clear"] = "Undo Clear"; + strings_["grpb_copy"] = "Copy"; + + // bootstrap / mining_* / key_export + strings_["grpc_bootstrap_not_initialized"] = "Bootstrap not initialized"; + strings_["grpc_bootstrap_failed"] = "Bootstrap failed"; + strings_["grpc_na"] = "N/A"; + strings_["grpc_key_not_available"] = "Key not available for this address"; + strings_["grpc_benchmark_takes_secs"] = "Benchmark takes ~%ds and interrupts mining. Click again to start."; + strings_["grpc_hashrate_fee"] = "%s %s%% fee"; + strings_["grpc_benchmark_inconclusive"] = "Benchmark inconclusive: no hashrate samples were recorded. Check the pool connection and try again."; + + // misc pre-existing missing keys + strings_["copied_to_clipboard"] = "Copied to clipboard"; + } const char* I18n::translate(const char* key) const diff --git a/src/util/logger.cpp b/src/util/logger.cpp index a912767..ba7b493 100644 --- a/src/util/logger.cpp +++ b/src/util/logger.cpp @@ -5,10 +5,12 @@ #include "logger.h" #include +#include #include #include #include #include +#include namespace dragonx { namespace util { @@ -35,14 +37,30 @@ bool Logger::init(const std::string& path) if (file_.is_open()) { file_.close(); } - + + // W7-4: cap the log's growth — if the existing file is already large, rotate it to a single .1 + // backup before reopening in append mode, so a long-lived or verbose session can't grow it + // without bound. + { + std::error_code ec; + const auto sz = std::filesystem::file_size(path, ec); + constexpr std::uintmax_t kMaxLogBytes = 10ull * 1024ull * 1024ull; // 10 MB + if (!ec && sz > kMaxLogBytes) { + std::filesystem::rename(path, path + ".1", ec); // replaces any previous .1 backup + if (ec) std::filesystem::remove(path, ec); // fall back to truncation if rename fails + } + } + file_.open(path, std::ios::out | std::ios::app); initialized_ = file_.is_open(); - + if (initialized_) { - write("=== Logger initialized ==="); + // Write the banner directly, NOT via write(): write() re-locks the non-recursive mutex_ we + // already hold here, which would deadlock (latent — init() was previously never called, W7-2). + file_ << "=== Logger initialized ===" << std::endl; + file_.flush(); } - + return initialized_; } diff --git a/src/util/payment_uri.cpp b/src/util/payment_uri.cpp index f7ef8bb..bb835d3 100644 --- a/src/util/payment_uri.cpp +++ b/src/util/payment_uri.cpp @@ -3,6 +3,7 @@ // Released under the GPLv3 #include "payment_uri.h" +#include "address_validation.h" #include #include @@ -161,20 +162,11 @@ PaymentURI parsePaymentURI(const std::string& uri) return result; } - // Basic address format check. NOTE: this is format-only by design — the send flow - // checksum-validates the recipient (isValidBase58Check / shielded check) before broadcasting, - // so an invalid-checksum address parsed here can never actually be sent to. - bool validFormat = false; - - // z-address: starts with 'zs' and is 78+ chars - if (result.address[0] == 'z' && result.address.size() >= 78) { - validFormat = true; - } - // t-address: starts with 'R' (DragonX) or 't' (HUSH) and is ~34 chars - else if ((result.address[0] == 'R' || result.address[0] == 't') && - result.address.size() >= 26 && result.address.size() <= 36) { - validFormat = true; - } + // Address format check via the shared, structure-based recognizers (checksum + real DragonX + // address types). This accepts shielded ("zs…"), P2PKH ("R…") and P2SH/multisig ("b…") forms — + // the old prefix/length heuristic rejected P2SH and hardcoded a 't' prefix DragonX never emits. + const bool validFormat = isShieldedAddress(result.address) || + isTransparentAddress(result.address); if (!validFormat) { result.error = "Invalid address format"; diff --git a/src/util/platform.cpp b/src/util/platform.cpp index 6ebe8bd..b177f65 100644 --- a/src/util/platform.cpp +++ b/src/util/platform.cpp @@ -126,6 +126,27 @@ bool Platform::openUrl(const std::string& url) #endif } +bool Platform::ensureDirectory(const std::string& dir, std::string* outError) +{ + if (dir.empty()) { + if (outError) *outError = "Cannot create directory: empty path."; + return false; + } + 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."; + } + DEBUG_LOGF("[ERROR] ensureDirectory failed for %s: %s\n", dir.c_str(), ec.message().c_str()); + return false; + } + return true; +} + bool Platform::openFolder(const std::string& path, bool createIfMissing) { if (path.empty()) return false; @@ -846,6 +867,70 @@ int Platform::getSystemIdleSeconds() // GPU utilization detection // ============================================================================ +std::string Platform::runHiddenCapture(const std::string& cmdLine, bool mergeStderr, int* exitCode) +{ + if (exitCode) *exitCode = -1; +#ifdef _WIN32 + SECURITY_ATTRIBUTES sa; + ZeroMemory(&sa, sizeof(sa)); + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + HANDLE hRead = NULL, hWrite = NULL; + if (!CreatePipe(&hRead, &hWrite, &sa, 0)) return {}; + SetHandleInformation(hRead, HANDLE_FLAG_INHERIT, 0); // parent's read end stays private + + HANDLE hNul = INVALID_HANDLE_VALUE; + if (!mergeStderr) { + hNul = CreateFileA("NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + } + + STARTUPINFOA si; + ZeroMemory(&si, sizeof(si)); + si.cb = sizeof(si); + si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + si.hStdOutput = hWrite; + si.hStdError = mergeStderr ? hWrite : hNul; + + PROCESS_INFORMATION pi; + ZeroMemory(&pi, sizeof(pi)); + std::string cl = cmdLine; // CreateProcessA may modify lpCommandLine → needs a mutable buffer + std::string out; + if (CreateProcessA(NULL, cl.empty() ? NULL : &cl[0], NULL, NULL, TRUE, + CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) { + CloseHandle(hWrite); hWrite = NULL; // close our copy so ReadFile hits EOF when the child exits + if (hNul != INVALID_HANDLE_VALUE) { CloseHandle(hNul); hNul = INVALID_HANDLE_VALUE; } + char buf[4096]; + DWORD n = 0; + while (ReadFile(hRead, buf, sizeof(buf), &n, NULL) && n > 0) out.append(buf, n); + WaitForSingleObject(pi.hProcess, INFINITE); + if (exitCode) { + DWORD code = 0; + if (GetExitCodeProcess(pi.hProcess, &code)) *exitCode = static_cast(code); + } + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + } + if (hWrite != NULL) CloseHandle(hWrite); + if (hNul != INVALID_HANDLE_VALUE) CloseHandle(hNul); + CloseHandle(hRead); + return out; +#else + const std::string full = cmdLine + (mergeStderr ? " 2>&1" : " 2>/dev/null"); + std::string out; + FILE* f = popen(full.c_str(), "r"); + if (!f) return out; + char buf[512]; + size_t n; + while ((n = fread(buf, 1, sizeof(buf), f)) > 0) out.append(buf, n); + const int st = pclose(f); + if (exitCode) *exitCode = st; // raw status (matches prior pclose-based rc checks) + return out; +#endif +} + int Platform::getGpuUtilization() { #ifdef _WIN32 @@ -856,23 +941,16 @@ int Platform::getGpuUtilization() static bool s_has_nvidia = false; if (!s_tried_nvidia) { s_tried_nvidia = true; - FILE* f = _popen("where nvidia-smi 2>nul", "r"); - if (f) { - char buf[256]; - s_has_nvidia = (fgets(buf, sizeof(buf), f) != nullptr); - _pclose(f); - } + // Windowless (runHiddenCapture) so GPU-aware idle detection never flashes a cmd.exe console. + const std::string w = runHiddenCapture("where nvidia-smi"); + s_has_nvidia = (w.find_first_not_of(" \t\r\n") != std::string::npos); } if (s_has_nvidia) { - FILE* f = _popen("nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits 2>nul", "r"); - if (f) { - char buf[64]; - int util = -1; - if (fgets(buf, sizeof(buf), f)) { - util = atoi(buf); - if (util < 0 || util > 100) util = -1; - } - _pclose(f); + const std::string o = runHiddenCapture( + "nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits"); + if (!o.empty()) { + int util = atoi(o.c_str()); + if (util < 0 || util > 100) util = -1; return util; } } diff --git a/src/util/platform.h b/src/util/platform.h index 35ea134..baad2ce 100644 --- a/src/util/platform.h +++ b/src/util/platform.h @@ -128,6 +128,17 @@ public: */ static void ensureObsidianDragonSetup(); + /** + * @brief Create a directory (and parents) if missing, with a clear error on failure. + * + * Uses the non-throwing std::error_code overload internally. On failure sets *outError + * (when non-null) to one consistent, user-facing message: + * "Cannot create : . Check permissions / free space." + * + * @return true if the directory exists (already did, or was just created). + */ + static bool ensureDirectory(const std::string& dir, std::string* outError = nullptr); + /** * @brief Get total system RAM in megabytes * @return Total physical RAM in MB, or 0 on failure @@ -168,6 +179,15 @@ public: * @return GPU busy percent, or -1 if unavailable. */ static int getGpuUtilization(); + + // Run a command line and capture its stdout WITHOUT ever popping a console window: Windows uses + // CreateProcess + CREATE_NO_WINDOW (a plain popen()/_popen() flashes a cmd.exe console), POSIX uses + // popen(). Use this instead of _popen for anything run while the GUI is up. `mergeStderr` folds the + // child's stderr into the result (like "2>&1"); otherwise stderr is discarded. `exitCode`, if given, + // receives the child's exit status (raw pclose() status on POSIX, GetExitCodeProcess on Windows; -1 + // if the process could not be launched). + static std::string runHiddenCapture(const std::string& cmdLine, bool mergeStderr = false, + int* exitCode = nullptr); }; /** diff --git a/src/util/pool_registry.h b/src/util/pool_registry.h index e86aa63..f5a63f1 100644 --- a/src/util/pool_registry.h +++ b/src/util/pool_registry.h @@ -45,16 +45,30 @@ struct PoolHashrate { std::string id; double hashrateHs = 0.0; bool ok = false; + // Live pool fee (%) read from the same stats JSON. <0 means "not available" — + // callers fall back to the compile-time KnownPool.feePercent. + double feePercent = -1.0; }; // The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is // meaningless to balance against). Stable order. const std::vector& knownPools(); -// The known pool whose stratum matches `url` (host, and port when both specify one), -// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path. +// The pool in `pools` whose stratum matches `url` (host, and port when both specify +// one), or nullptr. `url` may be a bare host, host:port, or carry a scheme/path. +const KnownPool* findPoolByUrl(const std::vector& pools, const std::string& url); + +// Same, over the built-in official pools only. const KnownPool* findKnownPoolByUrl(const std::string& url); +// The full list the UI should show: the official knownPools(), plus a row for every +// user-saved pool URL and for `currentPoolUrl` when it isn't one of those — so a +// custom/bookmarked pool is a first-class, selectable row. Synthetic (user) rows are +// official=false and carry no statsUrl (feePercent<0, no live hashrate), and endpoints +// are de-duplicated so a saved URL that equals an official pool isn't listed twice. +std::vector effectivePools(const std::string& currentPoolUrl, + const std::vector& savedPoolUrls); + // The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`. std::string resolvePoolAlgo(const std::string& url, const std::string& fallback); @@ -64,6 +78,13 @@ std::string resolvePoolAlgo(const std::string& url, const std::string& fallback) double parsePoolHashrate(PoolStatsSchema schema, const std::string& json, const std::string& miningcorePoolId, bool& ok); +// Parse a pool's advertised fee (%) out of the same stats JSON (DragonXIs: +// pools..poolFee; Miningcore: pools[id].poolFeePercent). Selects the same +// pool entry as parsePoolHashrate. Sets ok=false and returns 0 when the field is +// absent / malformed, so the caller keeps the compile-time fallback. +double parsePoolFee(PoolStatsSchema schema, const std::string& json, + const std::string& miningcorePoolId, bool& ok); + // Weighted-random pick among the usable (ok==true) pools: probability is inversely // proportional to hashrate (smaller pools favored), so miners spread out instead of // all stampeding to the single lowest pool. The current pool (`currentId`, may be diff --git a/src/util/pool_registry_core.cpp b/src/util/pool_registry_core.cpp index 619cd76..2a8271d 100644 --- a/src/util/pool_registry_core.cpp +++ b/src/util/pool_registry_core.cpp @@ -28,7 +28,7 @@ const std::vector& knownPools() KnownPool{ "dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush", "https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs, - /*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true, + /*miningcorePoolId=*/"", /*feePercent=*/1.0, /*official=*/true, }, }; return pools; @@ -83,13 +83,62 @@ bool sameEndpoint(const std::string& a, const std::string& b) return pa == pb; } +// Build a synthetic, selectable pool row for a user-supplied URL (a saved favorite +// or the current custom pool). We don't know its stats API, so it carries no +// statsUrl / live hashrate and an unknown (<0) fee — the UI falls back to "—". +KnownPool makeUserPool(const std::string& url) +{ + KnownPool p; + const std::string hp = hostPortOf(url); + std::string host, port; + splitHostPort(hp, host, port); + p.id = "user:" + trimmed(url); // stable + unique (used as the ImGui id) + p.label = host.empty() ? hp : host; + p.stratum = trimmed(url); // what the miner connects to / a row-click restores + p.algo = ""; // unknown; xmrig resolves via resolvePoolAlgo's fallback + p.statsUrl = ""; // no known stats endpoint -> no live hashrate/fee + p.schema = PoolStatsSchema::DragonXIs; + p.miningcorePoolId = ""; + p.feePercent = -1.0; // unknown fee + p.official = false; + return p; +} + } // namespace +const KnownPool* findPoolByUrl(const std::vector& pools, const std::string& url) +{ + for (const auto& p : pools) + if (sameEndpoint(p.stratum, url)) return &p; + return nullptr; +} + const KnownPool* findKnownPoolByUrl(const std::string& url) { - for (const auto& p : knownPools()) - if (sameEndpoint(p.stratum, url)) return &p; - return nullptr; + return findPoolByUrl(knownPools(), url); +} + +std::vector effectivePools(const std::string& currentPoolUrl, + const std::vector& savedPoolUrls) +{ + std::vector pools = knownPools(); + + // Skip anything whose endpoint already appears (official or an earlier user row). + auto listed = [&](const std::string& url) { + return findPoolByUrl(pools, url) != nullptr; + }; + + for (const auto& url : savedPoolUrls) { + if (trimmed(url).empty() || listed(url)) continue; + pools.push_back(makeUserPool(url)); + } + + // The pool currently being mined, if not already shown, so the active pool is + // always visible even before it's bookmarked. + if (!trimmed(currentPoolUrl).empty() && !listed(currentPoolUrl)) + pools.push_back(makeUserPool(currentPoolUrl)); + + return pools; } std::string resolvePoolAlgo(const std::string& url, const std::string& fallback) @@ -159,6 +208,64 @@ double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr, return 0.0; } +double parsePoolFee(PoolStatsSchema schema, const std::string& jsonStr, + const std::string& miningcorePoolId, bool& ok) +{ + ok = false; + try { + const json j = json::parse(jsonStr); + + if (schema == PoolStatsSchema::DragonXIs) { + // { "pools": { "dragonx": { "poolFee": , ... }, ... } } + if (j.contains("pools") && j["pools"].is_object()) { + const auto& pools = j["pools"]; + auto readFee = [&](const json& pool, double& out) -> bool { + if (pool.is_object() && pool.contains("poolFee") && + pool["poolFee"].is_number()) { + out = pool["poolFee"].get(); + return true; + } + return false; + }; + double fee = 0.0; + if (pools.contains("dragonx") && readFee(pools["dragonx"], fee)) { + ok = true; + return fee; + } + for (auto it = pools.begin(); it != pools.end(); ++it) { + if (readFee(it.value(), fee)) { + ok = true; + return fee; + } + } + } + } else { // Miningcore: pools[id].poolFeePercent + if (j.contains("pools") && j["pools"].is_array()) { + const json* chosen = nullptr; + for (const auto& pool : j["pools"]) { + if (!pool.is_object()) continue; + if (!miningcorePoolId.empty()) { + if (pool.value("id", std::string{}) == miningcorePoolId) { + chosen = &pool; + break; + } + } else if (!chosen) { + chosen = &pool; // first pool when no id requested + } + } + if (chosen && chosen->contains("poolFeePercent") && + (*chosen)["poolFeePercent"].is_number()) { + ok = true; + return (*chosen)["poolFeePercent"].get(); + } + } + } + } catch (...) { + // fall through — ok stays false + } + return 0.0; +} + std::string chooseWeightedPool(const std::vector& pools, const std::string& currentId, std::mt19937& rng) diff --git a/src/util/pool_stats_service.cpp b/src/util/pool_stats_service.cpp index 117751a..0be6a34 100644 --- a/src/util/pool_stats_service.cpp +++ b/src/util/pool_stats_service.cpp @@ -13,8 +13,14 @@ namespace { size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp) { - static_cast(userp)->append(static_cast(contents), size * nmemb); - return size * nmemb; + auto* s = static_cast(userp); + const size_t add = size * nmemb; + // Pool stats JSON is tiny; refuse an unbounded body from a hostile/MITM'd endpoint (returning < add + // aborts the transfer) so it can't grow this string until OOM. (M-02) + constexpr size_t kMaxPoolStatsBytes = 1u << 20; // 1 MiB + if (s->size() + add > kMaxPoolStatsBytes) return 0; + s->append(static_cast(contents), add); + return add; } // Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't @@ -94,6 +100,12 @@ void PoolStatsService::run(std::vector pools) const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok); hr.ok = ok; hr.hashrateHs = ok ? v : 0.0; + + bool feeOk = false; + const double fee = parsePoolFee(p.schema, body, p.miningcorePoolId, feeOk); + // Only trust a sane fee; anything else leaves feePercent < 0 so the UI + // falls back to the compile-time KnownPool.feePercent. + if (feeOk && fee >= 0.0 && fee <= 100.0) hr.feePercent = fee; } results[p.id] = hr; } diff --git a/src/util/seed_phrase.cpp b/src/util/seed_phrase.cpp new file mode 100644 index 0000000..660ca1e --- /dev/null +++ b/src/util/seed_phrase.cpp @@ -0,0 +1,77 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "seed_phrase.h" + +#include + +namespace dragonx { +namespace util { + +std::string normalizeSeedPhrase(const std::string& raw) +{ + // Unicode whitespace encoded as UTF-8, each mapped to a single ASCII space; and zero-width marks + // to strip. We substitute only these EXACT byte sequences, so ordinary (ASCII) word bytes are + // never touched — the common all-ASCII phrase just gets its spacing collapsed and trimmed. + static const char* const kSpaces[] = { + "\xC2\xA0", // U+00A0 NBSP + "\xC2\x85", // U+0085 NEL + "\xE1\x9A\x80", // U+1680 ogham space + "\xE2\x80\x80", "\xE2\x80\x81", "\xE2\x80\x82", "\xE2\x80\x83", // U+2000–2003 + "\xE2\x80\x84", "\xE2\x80\x85", "\xE2\x80\x86", "\xE2\x80\x87", // U+2004–2007 + "\xE2\x80\x88", "\xE2\x80\x89", "\xE2\x80\x8A", // U+2008–200A + "\xE2\x80\xAF", // U+202F narrow NBSP + "\xE2\x81\x9F", // U+205F math space + "\xE3\x80\x80", // U+3000 ideographic + }; + static const char* const kZeroWidth[] = { + "\xE2\x80\x8B", "\xE2\x80\x8C", "\xE2\x80\x8D", // U+200B/C/D + "\xEF\xBB\xBF", // U+FEFF BOM / ZWNBSP + }; + + std::string s = raw; + auto replaceAll = [&s](const std::string& from, const std::string& to) { + if (from.empty()) return; + std::size_t pos = 0; + while ((pos = s.find(from, pos)) != std::string::npos) { + s.replace(pos, from.size(), to); + pos += to.size(); + } + }; + for (const char* zw : kZeroWidth) replaceAll(zw, ""); + for (const char* sp : kSpaces) replaceAll(sp, " "); + + // Collapse ASCII whitespace runs to a single space and trim ends. + std::string out; + out.reserve(s.size()); + bool pendingSpace = false; + bool sawWord = false; + for (unsigned char c : s) { + if (std::isspace(c)) { pendingSpace = sawWord; continue; } + if (pendingSpace) { out.push_back(' '); pendingSpace = false; } + out.push_back(static_cast(c)); + sawWord = true; + } + return out; +} + +int seedPhraseWordCount(const std::string& phrase) +{ + int words = 0; + bool inWord = false; + for (unsigned char c : phrase) { + const bool space = std::isspace(c) != 0; + if (space) inWord = false; + else if (!inWord) { inWord = true; ++words; } + } + return words; +} + +bool isCompleteRecoveryPhrase(int words) +{ + return words == 24; +} + +} // namespace util +} // namespace dragonx diff --git a/src/util/seed_phrase.h b/src/util/seed_phrase.h new file mode 100644 index 0000000..d13ce05 --- /dev/null +++ b/src/util/seed_phrase.h @@ -0,0 +1,38 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// seed_phrase.h — shared, pure helpers for validating a pasted BIP39 recovery phrase. +// One source of truth for the seed-length contract so the lite-restore gates (first-run +// wizard + Settings) cannot drift apart. No I/O, no secrets retained — safe for both variants. + +#pragma once + +#include + +namespace dragonx { +namespace util { + +// Normalize a pasted recovery phrase for consistent word counting AND backend submission: +// - every run of Unicode/ASCII whitespace (incl. NBSP U+00A0, en/em spaces U+2000–200A, +// U+202F, U+205F, ideographic U+3000, NEL, tab/newline) collapses to a single ASCII space, +// - zero-width marks (U+200B/C/D, U+FEFF BOM) are stripped, +// - leading/trailing space is trimmed. +// Word bytes are copied verbatim — only these exact whitespace byte-sequences are substituted. +// The lite backend (tiny-bip39) splits on the literal ASCII space and does NO Unicode folding, so +// a phrase pasted with NBSPs (common from PDFs/note apps) is otherwise unrestorable; normalizing +// before submit makes the words space-separated and recoverable. +std::string normalizeSeedPhrase(const std::string& raw); + +// Count ASCII-whitespace-separated words. Pair with normalizeSeedPhrase so exotic spacing counts right. +int seedPhraseWordCount(const std::string& phrase); + +// True if `words` is a complete recovery phrase the DragonX backends accept. DragonX seeds are +// 24-word / 256-bit / 32-byte-entropy ONLY: the SDXL lite backend's LightWallet::new copies the +// phrase entropy into a fixed [u8;32] (a shorter valid-BIP39 phrase — 12/15/18/21 words — makes it +// panic, uncaught, across the restore FFI), and the full-node daemon likewise generates 24 words. +// Both lite-restore gates MUST use this so a crash-inducing length is refused client-side. +bool isCompleteRecoveryPhrase(int words); + +} // namespace util +} // namespace dragonx diff --git a/src/util/wallet_file_probe.h b/src/util/wallet_file_probe.h index 9f3b100..b9c5266 100644 --- a/src/util/wallet_file_probe.h +++ b/src/util/wallet_file_probe.h @@ -18,14 +18,57 @@ #include #include #include +#include #include #include +#include #include #include namespace dragonx { namespace util { +// A reserved bare-filename PREFIX for the datadir "in-place link" wallets: an out-of-datadir wallet the +// user opens gets a stable symlink/hardlink under this name in the datadir so the daemon can load it by +// bare -wallet=. These are plumbing, not standalone wallet files, so wallet enumeration hides them. +// Single source of truth shared with the wallets UI (ui/windows/wallets_dialog.h). +constexpr const char* kInPlaceLinkPrefix = "wallet-ip-"; +inline bool isInPlaceLinkName(const std::string& name) { return name.rfind(kInPlaceLinkPrefix, 0) == 0; } + +// Enumerate the standalone wallet-bearing files in a DragonX datadir (TOP-LEVEL only): bare "wallet*.dat" +// files, excluding in-place links and (optionally) one active filename. The "wallet" prefix already excludes +// node artifacts (peers.dat / blk*.dat / asmap.dat / …). With includeSalvageBaks, also returns "wallet*.bak" +// files — the daemon's salvage backups (wallet..bak) that hold the pre-salvage keys. Returns full paths. +// Exception-safe (error_code iteration); never descends into subdirectories. This is the lightweight +// datadir-only counterpart to the wallets dialog's richer scan (which also walks user-added external folders +// and de-dups by canonical path); the default (.dat only, no baks) matches that dialog's semantics. +inline std::vector enumerateDatadirWalletFiles(const std::string& datadir, + const std::string& excludeActiveName = "", + bool includeSalvageBaks = false) { + namespace fs = std::filesystem; + std::vector out; + std::error_code ec; + fs::directory_iterator it(datadir, ec), end; + if (ec) return out; + for (; it != end; it.increment(ec)) { + if (ec) break; + const fs::path p = it->path(); + const std::string name = p.filename().string(); + if (name.size() <= 4) continue; + const std::string ext = name.substr(name.size() - 4); + const bool isDat = (ext == ".dat"); + const bool isBak = includeSalvageBaks && (ext == ".bak"); + if (!isDat && !isBak) continue; // *.dat (+ *.bak) only + if (name.rfind("wallet", 0) != 0) continue; // wallet-prefixed only + if (isInPlaceLinkName(name)) continue; // hide in-place links + if (!excludeActiveName.empty() && name == excludeActiveName) continue; // skip the active wallet + std::error_code fec; + if (!fs::is_regular_file(p, fec)) continue; + out.push_back(p.string()); + } + return out; +} + struct WalletFileProbe { bool isBerkeleyDB = false; ///< file has a valid BDB btree metapage magic (looks like a real wallet.dat) bool encrypted = false; ///< has an "mkey" master-key record → passphrase-encrypted @@ -339,5 +382,155 @@ inline WalletBtreeStats parseWalletBtree(const std::string& path, return st; } +// ───────────────────────────────────────────────────────────────────────────────────────────────── +// Tier 3: collect the raw (key,value) record BYTES — the read half of the offline wallet REBUILD that +// recovers a BDB-inconsistent wallet.dat (stale extent metadata: our tolerant walk reads records the +// daemon's Berkeley DB verify rejects and auto-salvages). A helper then writes these verbatim into a +// fresh, consistent BDB so the daemon loads it cleanly. Records are copied byte-for-byte — encrypted +// key material (ckey/csapzkey/mkey) passes through as opaque ciphertext, so no passphrase is needed. +// Values that live in BDB OVERFLOW pages (only large `tx` history records) are NOT captured (skipped + +// counted); they are irrelevant to funds — a rescan rebuilds transaction history. Same bounds-checked, +// subdb-aware, visited-set-capped walk as parseWalletBtree. +struct WalletRawRecords { + bool parsed = false; ///< the btree walked cleanly + bool complete = false; ///< the whole file was read (not cap-truncated) + std::vector> records; ///< inline (key,value) bytes, verbatim + int keyRecords = 0; ///< fund-critical key-type records captured (key/wkey/ckey/z*/sap*/hdseed) + int skippedOverflow = 0; ///< records whose value spilled to overflow pages (tx history) — not captured + std::size_t bytesRead = 0; +}; + +inline WalletRawRecords extractWalletBtreeRecords(const std::string& path, + std::size_t maxBytes = 512u * 1024u * 1024u) { + WalletRawRecords out; + std::ifstream f(path, std::ios::binary); + if (!f) return out; + std::string buf; + { + f.seekg(0, std::ios::end); + std::streamoff sz = f.tellg(); + if (sz < 512) return out; + const std::size_t want = std::min(static_cast(sz), maxBytes); + f.seekg(0, std::ios::beg); + buf.resize(want); + f.read(&buf[0], static_cast(want)); + buf.resize(static_cast(std::max(0, f.gcount()))); + if (buf.size() < 512) return out; + out.bytesRead = buf.size(); + out.complete = (buf.size() == static_cast(sz)); + } + const unsigned char* B = reinterpret_cast(buf.data()); + const std::size_t N = buf.size(); + + auto rd32at = [&](std::size_t o, bool le) -> uint32_t { + return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) | ((uint32_t)B[o+2]<<16) | ((uint32_t)B[o+3]<<24) + : (uint32_t)B[o+3] | ((uint32_t)B[o+2]<<8) | ((uint32_t)B[o+1]<<16) | ((uint32_t)B[o]<<24); + }; + constexpr uint32_t kBtreeMagic = 0x00053162u; + bool le; + if (rd32at(12, true) == kBtreeMagic) le = true; + else if (rd32at(12, false) == kBtreeMagic) le = false; + else return out; + auto r32 = [&](std::size_t o) { return o + 4 <= N ? rd32at(o, le) : 0u; }; + auto r16 = [&](std::size_t o) -> uint32_t { + if (o + 2 > N) return 0; + return le ? (uint32_t)B[o] | ((uint32_t)B[o+1]<<8) : (uint32_t)B[o+1] | ((uint32_t)B[o]<<8); + }; + const uint32_t pagesize = r32(20); + if (pagesize < 512 || pagesize > 65536 || (pagesize & (pagesize - 1)) != 0) return out; + const uint32_t npages = static_cast(N / pagesize); + const uint32_t root = r32(88); + if (npages == 0 || root == 0 || root >= npages) return out; + if (B[24] != 0 || (B[26] & 0x01)) return out; // page checksum/encryption — offsets shift; bail + + constexpr uint8_t P_IBTREE = 3, P_LBTREE = 5, P_BTREEMETA = 9, B_KEYDATA = 1; + constexpr std::size_t kMaxPagesVisited = 600000; + constexpr int kMaxKeys = 4000000; + std::vector visited(npages, false); + std::size_t pagesVisited = 0; + int keys = 0; + bool aborted = false; + auto rdpgno = [&](const unsigned char* p) -> uint32_t { + return le ? (uint32_t)p[0] | ((uint32_t)p[1]<<8) | ((uint32_t)p[2]<<16) | ((uint32_t)p[3]<<24) + : (uint32_t)p[3] | ((uint32_t)p[2]<<8) | ((uint32_t)p[1]<<16) | ((uint32_t)p[0]<<24); + }; + auto traverse = [&](uint32_t rootPg, auto&& fn) { + std::fill(visited.begin(), visited.end(), false); + std::vector stack; + if (rootPg < npages && !visited[rootPg]) { visited[rootPg] = true; stack.push_back(rootPg); } + while (!stack.empty()) { + const uint32_t pg = stack.back(); stack.pop_back(); + if (pg >= npages) continue; + if (++pagesVisited > kMaxPagesVisited) { aborted = true; return; } + const std::size_t base = static_cast(pg) * pagesize; + if (base + 26 > N) continue; + const uint8_t type = B[base + 25]; + const uint32_t entries = r16(base + 20); + if (26 + static_cast(entries) * 2 > pagesize) continue; + if (type == P_IBTREE) { + for (uint32_t i = 0; i < entries; ++i) { + const uint32_t off = r16(base + 26 + i * 2); + if (off + 8 > pagesize) continue; + const uint32_t child = r32(base + off + 4); + if (child > 0 && child < npages && !visited[child]) { visited[child] = true; stack.push_back(child); } + } + } else if (type == P_LBTREE) { + for (uint32_t i = 0; i + 1 < entries; i += 2) { + if (++keys > kMaxKeys) { aborted = true; return; } + const uint32_t ko = r16(base + 26 + i * 2); + const uint32_t dO = r16(base + 26 + (i + 1) * 2); + if (ko + 3 > pagesize || dO + 3 > pagesize) continue; + if (B[base + ko + 2] != B_KEYDATA) continue; // overflow/dup key — never a record name + const uint32_t kl = r16(base + ko); + if (kl < 1 || ko + 3 + kl > pagesize) continue; + const uint8_t dtype = B[base + dO + 2]; + const uint32_t dl = r16(base + dO); + const unsigned char* dp = (dO + 3 + dl <= pagesize) ? B + base + dO + 3 : nullptr; + fn(B + base + ko + 3, kl, dp, dl, dtype); + } + } + } + }; + + // Master DB: subdb-name → subdb meta/root pgno (big-endian value; native fallback), same as parseWalletBtree. + std::vector subRoots; + traverse(root, [&](const unsigned char*, uint32_t, const unsigned char* dp, uint32_t dl, uint8_t dtype) { + if (dtype != B_KEYDATA || dl != 4 || !dp) return; + const uint32_t cand[2] = { + (uint32_t)dp[3] | ((uint32_t)dp[2]<<8) | ((uint32_t)dp[1]<<16) | ((uint32_t)dp[0]<<24), + rdpgno(dp), + }; + for (const uint32_t pgno : cand) { + if (pgno == 0 || pgno >= npages) continue; + const uint8_t pt = B[static_cast(pgno) * pagesize + 25]; + if (pt == P_BTREEMETA) { + const uint32_t sr = r32(static_cast(pgno) * pagesize + 88); + if (sr > 0 && sr < npages) { subRoots.push_back(sr); break; } + } else if (pt == P_LBTREE || pt == P_IBTREE) { subRoots.push_back(pgno); break; } + } + }); + if (aborted) return out; + if (subRoots.empty()) subRoots.push_back(root); + + auto isKeyType = [](const char* nm, uint32_t nl) { + auto is = [&](const char* s) { return std::strlen(s) == nl && std::memcmp(nm, s, nl) == 0; }; + return is("key") || is("wkey") || is("ckey") || is("zkey") || is("czkey") + || is("sapzkey") || is("csapzkey") || is("hdseed") || is("chdseed"); + }; + for (const uint32_t sr : subRoots) { + traverse(sr, [&](const unsigned char* kp, uint32_t kl, const unsigned char* dp, uint32_t dl, uint8_t dt) { + if (dt != B_KEYDATA || !dp) { out.skippedOverflow++; return; } // overflow value (tx history) — skip + out.records.emplace_back(std::string(reinterpret_cast(kp), kl), + std::string(reinterpret_cast(dp), dl)); + const uint32_t nlen = kp[0]; + if (nlen >= 2 && nlen <= 20 && 1u + nlen <= kl && isKeyType(reinterpret_cast(kp + 1), nlen)) + out.keyRecords++; + }); + if (aborted) return out; + } + out.parsed = true; + return out; +} + } // namespace util } // namespace dragonx diff --git a/src/util/xmrig_updater.cpp b/src/util/xmrig_updater.cpp index 25e8343..67453a6 100644 --- a/src/util/xmrig_updater.cpp +++ b/src/util/xmrig_updater.cpp @@ -265,6 +265,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele // the archive bytes against that key, so a checksum rewritten in a tampered release body is // not sufficient to install. setProgress(State::Verifying, "Verifying download…"); + std::string bytes; // kept in scope through extraction so we extract the VERIFIED buffer (I-01) { std::ifstream f(zipPath, std::ios::binary); if (!f) { @@ -272,7 +273,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele setProgress(State::Failed, "Could not read the downloaded archive."); return; } - const std::string bytes((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + bytes.assign(std::istreambuf_iterator(f), std::istreambuf_iterator()); if (f.bad()) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not read the downloaded archive."); @@ -333,7 +334,9 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele const std::string minerName = wanted.front(); // "xmrig" / "xmrig.exe" mz_zip_archive zip{}; - if (!mz_zip_reader_init_file(&zip, zipPath.c_str(), 0)) { + // Extract from the ALREADY-VERIFIED in-memory buffer, not by reopening zipPath — otherwise a fast + // local attacker could swap the file on disk between the hash/signature check and extraction. (I-01) + if (!mz_zip_reader_init_mem(&zip, bytes.data(), bytes.size(), 0)) { fs::remove(zipPath, ec); setProgress(State::Failed, "Could not open the downloaded archive."); return; @@ -342,6 +345,7 @@ void XmrigUpdater::installResolved(const std::string& targetDir, const XmrigRele bool failed = false; const int numFiles = static_cast(mz_zip_reader_get_num_files(&zip)); for (int i = 0; i < numFiles && !failed; ++i) { + if (cancel_requested_) { failed = true; break; } // honor cancel mid-extraction so the dialog's join returns promptly (L-07) mz_zip_archive_file_stat st; if (!mz_zip_reader_file_stat(&zip, i, &st)) continue; if (mz_zip_reader_is_file_a_directory(&zip, i)) continue; diff --git a/src/wallet/lite_wallet_controller.cpp b/src/wallet/lite_wallet_controller.cpp index 839781c..f5971e0 100644 --- a/src/wallet/lite_wallet_controller.cpp +++ b/src/wallet/lite_wallet_controller.cpp @@ -80,8 +80,10 @@ bool persistAfterBroadcast(LiteClientBridge& bridge) for (int attempt = 0; attempt < 2; ++attempt) { if (bridge.execute("save", "").ok) return true; } - // Persistent failure: the spent note will be re-derived from the chain on the next sync, - // so this is a robustness gap, not fund loss. (Retry handles the common transient case.) + // Persistent failure: the spent note will be re-derived from the chain on the next sync, so this + // is a robustness gap, not fund loss. Log it (W5-1) — both callers discard this return, so the + // failure was previously completely silent. + liteLog("save failed after send/shield — the wallet will re-derive it on the next sync"); return false; } @@ -137,6 +139,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, state.transparentBalance = static_cast(model.balance.transparentZatoshis) / kZatoshisPerCoin; state.totalBalance = static_cast(model.balance.totalZatoshis) / kZatoshisPerCoin; state.unconfirmedBalance = static_cast(model.balance.unconfirmedZatoshis) / kZatoshisPerCoin; + // Lite already tracks confirmed/unconfirmed itself and its per-address balances are confirmed + // (spendable outputs only). Mirror the aggregate spendable fields so full-node-shaped spend + // validation isn't zeroed on lite (the actual lite spend gate is per-address, set below). + state.spendablePrivateBalance = state.privateBalance; + state.spendableTransparentBalance = state.transparentBalance; + state.spendableTotalBalance = state.totalBalance; } if (model.hasAddresses) { @@ -176,6 +184,7 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model, } else { info.balance = 0.0; // notes succeeded and address has no spendable outputs } + info.spendableBalance = info.balance; // lite per-address balance is already confirmed/spendable info.type = (addr.kind == LiteWalletAppAddressKind::Shielded) ? "shielded" : "transparent"; info.has_spending_key = addr.spendabilityKnown ? addr.spendable : true; if (addr.kind == LiteWalletAppAddressKind::Shielded) { @@ -600,7 +609,8 @@ void LiteWalletController::startSync() // The backend does NOT auto-save after a sync, so persist the freshly-scanned wallet; // otherwise the next launch re-scans from the checkpoint (~30 min). Set `done` only // after the save so a syncComplete() observer sees a fully-persisted wallet. - bridge->execute("save", ""); + if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-sync save silent + liteLog("save failed after sync — the next launch will re-scan from the checkpoint"); } done->store(true); }); @@ -631,7 +641,8 @@ bool LiteWalletController::startRescan() // `rescan` clears the wallet's synced block cache and re-downloads/re-scans from the // birthday height — a blocking, uninterruptible full scan, same as `sync`. bridge->execute("rescan", ""); - bridge->execute("save", ""); // backend doesn't auto-save after a rescan + if (!bridge->execute("save", "").ok) // W5-2: don't leave a failed post-rescan save silent + liteLog("save failed after rescan — the next launch will re-scan from the checkpoint"); } done->store(true); }); @@ -845,6 +856,13 @@ bool LiteWalletController::runConsoleCommand(std::string commandLine) r.ok = call.ok; r.response = call.ok ? call.value : (call.error.empty() ? "command failed" : call.error); + // send/shield/import mutate wallet state and the backend does NOT auto-save (same reason + // doSend/doShield call persistAfterBroadcast). Persist so a console-driven tx survives a + // restart instead of only being re-derived on the next full sync. (M-02) + if (call.ok && (command == "send" || command == "shield" || + command == "import" || command == "timport")) { + persistAfterBroadcast(*bridge); + } } else { r.response = "lite backend unavailable"; } @@ -883,6 +901,11 @@ LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKe } // Transparent WIFs begin with U/5/K/L (TImportCommand); shielded keys begin with // "secret-..." / viewing keys "zxview...", so this prefix check usually won't collide. + // NB: the lite/SDXL backend's viewing key is an *extended full* viewing key ("zxviews…", + // hrp_sapling_viewing_key), which is genuinely correct here — do NOT "harmonize" this with the + // full node, whose z_importviewingkey takes an *incoming* viewing key ("zivks…") instead. The + // two variants accept different viewing-key forms; a VK is not portable between them. Regardless, + // the two-command fallback below means a mis-guessed prefix never rejects an otherwise-valid key. const char first = spendingOrViewingKey[0]; const bool transparentFirst = (first == 'U' || first == '5' || first == 'K' || first == 'L'); @@ -1172,25 +1195,47 @@ void LiteWalletController::workerLoop() LiteWalletLifecycleResult LiteWalletController::createWallet(LiteWalletCreateRequest request) { auto result = lifecycle_.createWallet(request); - secureWipeLiteSecret(request.passphrase); onLifecycleResult(result); + // If the user supplied a passphrase, encrypt the brand-new wallet with it now that it's open + // (the backend encrypts + locks + saves). Previously this passphrase was collected but never + // used (W5-3) — a passphrase field that silently did nothing. encryptWallet() takes its own + // copy and wipes it. + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto enc = encryptWallet(request.passphrase); + if (!enc.ok) liteLog("wallet created but encryption failed: " + enc.error); + } + secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::openWallet(LiteWalletOpenRequest request) { auto result = lifecycle_.openWallet(request); - secureWipeLiteSecret(request.passphrase); onLifecycleResult(result); + // An existing wallet may be encrypted + locked — use the supplied passphrase to unlock it so it + // opens ready to use. Only meaningful when the wallet is actually locked (W5-3). + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto encStatus = encryptionStatus(); + if (encStatus.ok && encStatus.encrypted && encStatus.locked) { + if (!unlockWallet(request.passphrase)) + liteLog("wallet opened but unlock failed (wrong passphrase?)"); + } + } + secureWipeLiteSecret(request.passphrase); return result; } LiteWalletLifecycleResult LiteWalletController::restoreWallet(LiteWalletRestoreRequest request) { auto result = lifecycle_.restoreWallet(request); + onLifecycleResult(result); + // If the user supplied a passphrase, encrypt the restored wallet with it now that it's open (W5-3). + if (walletOpen_.load() && !request.passphrase.empty()) { + const auto enc = encryptWallet(request.passphrase); + if (!enc.ok) liteLog("wallet restored but encryption failed: " + enc.error); + } secureWipeLiteSecret(request.seedPhrase); secureWipeLiteSecret(request.passphrase); - onLifecycleResult(result); return result; } diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 6925049..e909b23 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -3,6 +3,9 @@ #include "chat/chat_service.h" #include "chat/chat_database.h" #include "daemon/daemon_controller.h" +#include "daemon/embedded_daemon.h" +#include "util/connect_stall.h" +#include "util/logger.h" #include "data/transaction_history_cache.h" #include "data/address_book.h" #include "data/wallet_index.h" @@ -29,7 +32,13 @@ #include "ui/windows/mining_benchmark.h" #include "ui/windows/mining_pool_panel.h" #include "ui/windows/mining_tab_helpers.h" +#include "ui/node_status_banner.h" +#include "ui/staleness_badge.h" +#include "ui/notifications.h" +#include "data/seed_migration_resume.h" #include "util/address_validation.h" +#include "util/seed_phrase.h" +#include "daemon/daemon_startup_diagnosis.h" #include "util/amount_format.h" #include "util/payment_uri.h" #include "util/platform.h" @@ -719,7 +728,9 @@ void testConnectionConfig() void testPaymentUri() { - std::string taddr = "R" + std::string(33, 'a'); + // Real checksummed addresses — the parser now checksum-validates the recipient (not a bare + // prefix/length filter), so it accepts P2PKH / P2SH / shielded and rejects transcription errors. + std::string taddr = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // P2PKH (v60) auto parsed = dragonx::util::parsePaymentURI( "drgx:" + taddr + "?amount=1.25000000&label=Main+Wallet&memo=hello%20there&message=thanks"); @@ -730,11 +741,19 @@ void testPaymentUri() EXPECT_EQ(parsed.memo, std::string("hello there")); EXPECT_EQ(parsed.message, std::string("thanks")); - std::string zaddr = "zs" + std::string(76, 'b'); + std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5"; auto zparsed = dragonx::util::parsePaymentURI("hush://" + zaddr + "?amt=0.5"); EXPECT_TRUE(zparsed.valid); EXPECT_NEAR(zparsed.amount, 0.5, 0.00000001); + // Regression: a P2SH/multisig recipient ("b…", v85) must parse — the old 'R'/'t'-only filter dropped it. + auto p2sh = dragonx::util::parsePaymentURI("drgx:bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C?amount=1"); + EXPECT_TRUE(p2sh.valid); + + // A transcription error (flipped checksum char) is now rejected at parse time. + auto typo = dragonx::util::parsePaymentURI("drgx:R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX?amount=1"); + EXPECT_FALSE(typo.valid); + auto invalid = dragonx::util::parsePaymentURI("drgx:" + taddr + "?amount=-1"); EXPECT_FALSE(invalid.valid); EXPECT_EQ(invalid.error, std::string("Invalid negative amount")); @@ -826,6 +845,18 @@ void testWalletFileProbe() EXPECT_EQ(s.txCount, 1); EXPECT_EQ(s.transparentKeys, 1); EXPECT_EQ(s.addresses(), 1); EXPECT_EQ(s.createdEpoch, kCreated); EXPECT_FALSE(dragonx::util::parseWalletBtree((dir / "junk.dat").string()).parsed); + + // Byte-collecting reader (the wallet-REBUILD read half): same walk, but collects (key,value) bytes. + auto ex = dragonx::util::extractWalletBtreeRecords((dir / "btree.dat").string()); + EXPECT_TRUE(ex.parsed); + EXPECT_TRUE(ex.records.size() >= static_cast(3)); // tx + key + keymeta captured verbatim + EXPECT_EQ(ex.keyRecords, 1); // the "key" record is fund-critical + bool foundKeyRec = false; // value copied byte-for-byte, name intact + for (const auto& kv : ex.records) + if (kv.first.size() >= 4 && (unsigned char)kv.first[0] == 3 && kv.first.compare(1, 3, "key") == 0 + && !kv.second.empty()) foundKeyRec = true; + EXPECT_TRUE(foundKeyRec); + EXPECT_FALSE(dragonx::util::extractWalletBtreeRecords((dir / "junk.dat").string()).parsed); } // 8) Mnemonic-flag decode (hdChainMnemonicFlag): the fMnemonicSeed byte lives at offset 52 of the @@ -903,6 +934,7 @@ void testSpendableFiltering() addresses.push_back({"zs-low", 2.0, "shielded", true}); addresses.push_back({"R-zero", 0.0, "transparent", true}); addresses.push_back({"R-high", 5.0, "transparent", true}); + for (auto& a : addresses) a.spendableBalance = a.balance; // selection now ranks by CONFIRMED balance EXPECT_EQ(dragonx::bestSpendableAddressIndex(addresses), 3); @@ -1385,31 +1417,51 @@ void testNetworkRefreshRpcCollectors() }); coreRpc.addResponse("getblockchaininfo", json{ {"blocks", 150}, - {"headers", 155}, + {"headers", 150}, {"bestblockhash", "core-best-150"}, - {"verificationprogress", 0.80}, - {"longestchain", 160}, + {"verificationprogress", 1.0}, + {"longestchain", 150}, // caught up → balance scan runs {"notarized", 145} }); auto core = Refresh::collectCoreRefreshResult(coreRpc); + // getblockchaininfo is issued FIRST — sync detection gates (and must not be delayed behind) the + // O(mapWallet) balance scan. When caught up, the balance scan follows: display (minconf=0) + + // spendable (minconf=1). EXPECT_TRUE(coreRpc.methodNames() == std::vector({ - "z_gettotalbalance", "getblockchaininfo" + "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance" })); - EXPECT_EQ(coreRpc.calls[0].params, json::array()); - EXPECT_EQ(coreRpc.calls[1].params, json::array()); + EXPECT_EQ(coreRpc.calls[1].params, json::array({0})); + EXPECT_EQ(coreRpc.calls[2].params, json::array({1})); EXPECT_TRUE(core.balanceOk); EXPECT_TRUE(core.blockchainOk); EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001); EXPECT_EQ(*core.blocks, 150); EXPECT_EQ(*core.bestBlockHash, std::string("core-best-150")); - EXPECT_EQ(*core.longestChain, 160); + EXPECT_EQ(*core.longestChain, 150); + + // When getblockchaininfo shows the node is behind (blocks < longestchain - 2), the balance scan is + // skipped this cycle regardless of includeBalance — so sync-state (and hence kSyncProfile) updates + // promptly instead of after the multi-second scan. Only getblockchaininfo is issued. + MockRefreshRpc coreBehindRpc; + coreBehindRpc.addResponse("z_gettotalbalance", json{{"total", "4.25000000"}}); + coreBehindRpc.addResponse("getblockchaininfo", json{ + {"blocks", 150}, {"headers", 160}, {"longestchain", 160} + }); + auto coreBehind = Refresh::collectCoreRefreshResult(coreBehindRpc); + EXPECT_TRUE(coreBehindRpc.methodNames() == std::vector({"getblockchaininfo"})); + EXPECT_FALSE(coreBehind.balanceOk); + EXPECT_TRUE(coreBehind.blockchainOk); + EXPECT_EQ(*coreBehind.blocks, 150); + EXPECT_EQ(*coreBehind.longestChain, 160); MockRefreshRpc coreFallbackRpc; coreFallbackRpc.addFailure("z_gettotalbalance", "wallet warming up"); coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}}); auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc); + // No longestchain in the response → not classified as "behind" → balance is still attempted (here it + // fails). getblockchaininfo is still issued first. EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector({ - "z_gettotalbalance", "getblockchaininfo" + "getblockchaininfo", "z_gettotalbalance", "z_gettotalbalance" })); EXPECT_FALSE(partialCore.balanceOk); EXPECT_TRUE(partialCore.blockchainOk); @@ -1535,7 +1587,7 @@ void testNetworkRefreshRpcCollectors() auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc); EXPECT_TRUE(fallbackRpc.methodNames() == std::vector({ "z_listaddresses", "z_validateaddress", "z_listunspent", - "z_getbalance", "getaddressesbyaccount", "listunspent" + "z_getbalance", "z_getbalance", "getaddressesbyaccount", "listunspent" // display (minconf=0) + spendable (minconf=1) })); EXPECT_TRUE(fallbackAddresses.shieldedAddresses[0].has_spending_key); EXPECT_NEAR(fallbackAddresses.shieldedAddresses[0].balance, 4.75, 0.00000001); @@ -1932,7 +1984,8 @@ void testNetworkRefreshResultModels() dragonx::WalletState state; auto core = Refresh::parseCoreRefreshResult( - json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, + json{{"private", "1.25000000"}, {"transparent", "0.50000000"}, {"total", "1.75000000"}}, // display (minconf=0) + json{{"private", "1.00000000"}, {"transparent", "0.50000000"}, {"total", "1.50000000"}}, // spendable (minconf=1) true, json{{"blocks", 100}, {"headers", 105}, {"bestblockhash", "apply-best-100"}, {"verificationprogress", 0.75}, {"longestchain", 110}, {"notarized", 90}}, @@ -1941,6 +1994,10 @@ void testNetworkRefreshResultModels() EXPECT_NEAR(state.shielded_balance, 1.25, 0.00000001); EXPECT_NEAR(state.transparent_balance, 0.5, 0.00000001); EXPECT_NEAR(state.total_balance, 1.75, 0.00000001); + // Confirmed/spendable stays at minconf=1; unconfirmed = display total - spendable total (the pending change). + EXPECT_NEAR(state.spendablePrivateBalance, 1.0, 0.00000001); + EXPECT_NEAR(state.spendableTotalBalance, 1.5, 0.00000001); + EXPECT_NEAR(state.unconfirmedBalance, 0.25, 0.00000001); EXPECT_EQ(state.sync.blocks, 100); EXPECT_EQ(state.sync.headers, 105); EXPECT_EQ(state.sync.best_blockhash, std::string("apply-best-100")); @@ -2014,6 +2071,20 @@ void testNetworkRefreshResultModels() EXPECT_EQ(state.market.price_history.size(), static_cast(1)); } + // Regression: CoinGecko emits JSON null (not an omitted key) for fields it can't compute — + // commonly usd_24h_change on illiquid tokens like DRGX — while still returning a valid spot + // price. The parser must keep the valid usd/btc, not discard the whole update on the null. + auto priceNull = Refresh::parseCoinGeckoPriceResponse( + R"({"dragonx-2":{"usd":0.42,"btc":0.000009,"usd_24h_change":null,"usd_24h_vol":null,"usd_market_cap":50000}})", + 0); + EXPECT_TRUE(priceNull.has_value()); + if (priceNull) { + EXPECT_NEAR(priceNull->market.price_usd, 0.42, 0.00000001); + EXPECT_NEAR(priceNull->market.price_btc, 0.000009, 0.00000001); + EXPECT_NEAR(priceNull->market.change_24h, 0.0, 0.0001); // null -> default, not a throw + EXPECT_NEAR(priceNull->market.market_cap, 50000.0, 0.0001); + } + Refresh::markPriceRefreshStarted(state); Refresh::applyPriceRefreshFailure(state, "timeout"); EXPECT_FALSE(state.market.price_loading); @@ -2195,6 +2266,20 @@ void testOperationStatusPollParsing() EXPECT_FALSE(malformed.anySuccess); EXPECT_TRUE(malformed.doneOpids.empty()); EXPECT_TRUE(malformed.staleOpids.empty()); + + // Regression: a type-anomalous element (non-string "id"/"status") must be skipped, not throw — + // a throw here would escape the parser and permanently wedge opid polling for the session. A + // valid tracked opid alongside the anomaly must still be processed. + auto typeSafe = Refresh::parseOperationStatusPoll(json::array({ + json{{"id", 12345}, {"status", "failed"}}, // non-string id -> skipped, no throw + json{{"id", "op-ok"}, {"status", 7}}, // non-string status -> "" (not done) + json{{"id", "op-good"}, {"status", "success"}, {"result", json{{"txid", "tx-good"}}}} + }), {"op-ok", "op-good", "op-x"}); + EXPECT_TRUE(typeSafe.anySuccess); + EXPECT_EQ(typeSafe.successTxidsByOpid.at("op-good"), std::string("tx-good")); + EXPECT_TRUE(typeSafe.failureMessages.empty()); // the non-string-id "failed" was skipped + EXPECT_EQ(typeSafe.staleOpids.size(), static_cast(1)); // op-x absent; op-ok was seen (not stale) + EXPECT_EQ(typeSafe.staleOpids[0], std::string("op-x")); } void testSecureVaultScope() @@ -2477,6 +2562,451 @@ void testDaemonShutdownPolicy() EXPECT_TRUE(bootstrap.disconnectRpc); } +void testIsLocalHost() +{ + using dragonx::rpc::Connection; + // Genuine loopback / local hosts. + EXPECT_TRUE(Connection::isLocalHost("127.0.0.1")); + EXPECT_TRUE(Connection::isLocalHost("127.1.2.3")); + EXPECT_TRUE(Connection::isLocalHost("localhost")); + EXPECT_TRUE(Connection::isLocalHost("LocalHost")); + EXPECT_TRUE(Connection::isLocalHost("::1")); + EXPECT_TRUE(Connection::isLocalHost("[::1]")); + // The regression this fix targets: a hostname merely starting "127." is NOT loopback. + EXPECT_TRUE(!Connection::isLocalHost("127.evil.com")); + EXPECT_TRUE(!Connection::isLocalHost("127.0.0.1.attacker.example")); + EXPECT_TRUE(!Connection::isLocalHost("127.300.0.1")); + EXPECT_TRUE(!Connection::isLocalHost("1270.0.0.1")); + EXPECT_TRUE(!Connection::isLocalHost("10.0.0.5")); + EXPECT_TRUE(!Connection::isLocalHost("example.com")); +} + +void testAllowsPlaintextRemote() +{ + using dragonx::rpc::Connection; + using dragonx::rpc::ConnectionConfig; + + ConnectionConfig local; + local.host = "127.0.0.1"; + local.use_tls = false; + EXPECT_TRUE(!Connection::usesPlaintextRemote(local)); // local is never "plaintext remote" + + ConnectionConfig remote; + remote.host = "10.0.0.5"; + remote.use_tls = false; + EXPECT_TRUE(Connection::usesPlaintextRemote(remote)); // remote + no TLS + EXPECT_TRUE(!Connection::allowsPlaintextRemote(remote)); // blocked by default → connect refused + + remote.allow_plaintext_remote = true; + EXPECT_TRUE(Connection::allowsPlaintextRemote(remote)); // explicit opt-in + + ConnectionConfig remoteTls; + remoteTls.host = "10.0.0.5"; + remoteTls.use_tls = true; + EXPECT_TRUE(!Connection::usesPlaintextRemote(remoteTls)); // TLS → not plaintext, never refused +} + +void testConsoleSecretRedaction() +{ + using dragonx::ui::RedactConsoleCommand; + using dragonx::ui::ConsoleCommandCarriesSecret; + + // Secret-bearing commands are recognized (case- and whitespace-insensitive on the name). + EXPECT_TRUE(ConsoleCommandCarriesSecret("walletpassphrase myPass 60")); + EXPECT_TRUE(ConsoleCommandCarriesSecret("z_importkey SK-secret")); + EXPECT_TRUE(ConsoleCommandCarriesSecret(" ENCRYPTWALLET topsecret")); + EXPECT_TRUE(!ConsoleCommandCarriesSecret("getinfo")); + EXPECT_TRUE(!ConsoleCommandCarriesSecret("getwalletinfo")); // not a false-positive substring match + + // Redaction replaces the arguments with **** but preserves the (original-case) command name. + EXPECT_EQ(RedactConsoleCommand("walletpassphrase myPass 60"), std::string("walletpassphrase ****")); + EXPECT_EQ(RedactConsoleCommand("z_importkey SK-secret-key"), std::string("z_importkey ****")); + EXPECT_EQ(RedactConsoleCommand("ENCRYPTWALLET topsecret"), std::string("ENCRYPTWALLET ****")); + // A bare secret command with no argument is left unchanged (nothing to hide). + EXPECT_EQ(RedactConsoleCommand("walletpassphrase"), std::string("walletpassphrase")); + // Non-secret commands pass through untouched. + EXPECT_EQ(RedactConsoleCommand("sendtoaddress addr 1.0"), std::string("sendtoaddress addr 1.0")); + EXPECT_EQ(RedactConsoleCommand("getwalletinfo"), std::string("getwalletinfo")); +} + +void testNodeStatusBanner() +{ + using namespace dragonx::ui; + + // Connected full node → no banner. + { + NodeBannerInputs in; in.connected = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + } + // Expected startup phases own the screen (loading/warmup overlay) → no banner. + { + NodeBannerInputs in; in.warming_up = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in).show); + NodeBannerInputs in2; in2.daemon_initializing = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in2).show); + NodeBannerInputs in3; in3.connection_in_progress = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(in3).show); + } + // Genuinely offline full node → amber, reconnect offered, detail passed through. + { + NodeBannerInputs in; + in.connection_status = "Lost connection to daemon"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Warning); + EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); + EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); + EXPECT_EQ(s.detail, std::string("Lost connection to daemon")); + } + // Embedded daemon crashed and auto-restart gave up → red, restart offered, lastError preferred. + { + NodeBannerInputs in; + in.using_embedded_daemon = true; + in.has_daemon_controller = true; + in.daemon_running = false; + in.daemon_crash_count = kNodeBannerCrashGiveUpCount; + in.daemon_last_error = "exit code 134"; + in.connection_status = "Daemon crashed 3 times"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); + EXPECT_TRUE(s.reason == NodeBannerReason::DaemonCrashed); + EXPECT_TRUE(s.action == NodeBannerAction::RestartNode); + EXPECT_EQ(s.detail, std::string("exit code 134")); + } + // Below the give-up threshold it's still just an offline/reconnect banner, not the crash one. + { + NodeBannerInputs in; + in.using_embedded_daemon = true; + in.has_daemon_controller = true; + in.daemon_running = false; + in.daemon_crash_count = kNodeBannerCrashGiveUpCount - 1; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.reason == NodeBannerReason::FullNodeOffline); + EXPECT_TRUE(s.action == NodeBannerAction::Reconnect); + } + // Lite: an open failure shows a red, action-less banner; no failure → nothing. + { + NodeBannerInputs in; in.lite = true; in.connected = false; + in.lite_open_error = "wallet.dat is corrupt"; + NodeBannerState s = evaluateNodeStatusBanner(in); + EXPECT_TRUE(s.show); + EXPECT_TRUE(s.severity == NodeBannerSeverity::Error); + EXPECT_TRUE(s.reason == NodeBannerReason::LiteOpenFailed); + EXPECT_TRUE(s.action == NodeBannerAction::None); + EXPECT_EQ(s.detail, std::string("wallet.dat is corrupt")); + + NodeBannerInputs clean; clean.lite = true; clean.connected = false; // no error yet + EXPECT_TRUE(!evaluateNodeStatusBanner(clean).show); + NodeBannerInputs open; open.lite = true; open.connected = true; + EXPECT_TRUE(!evaluateNodeStatusBanner(open).show); + } +} + +void testStalenessBadge() +{ + using namespace dragonx::ui; + const int64_t now = 1'000'000; + + // Disconnected → banner's job, never a badge. + EXPECT_TRUE(!evaluateStalenessBadge(now - 999, now, /*connected=*/false).show); + // Never updated this session (0 stamp, e.g. fresh start / reset on disconnect) → nothing. + EXPECT_TRUE(!evaluateStalenessBadge(0, now, true).show); + // Fresh (just under the threshold) → no badge. + EXPECT_TRUE(!evaluateStalenessBadge(now - (kStaleAfterSeconds - 1), now, true).show); + // At the threshold → amber badge, age reported. + { + StalenessBadge b = evaluateStalenessBadge(now - kStaleAfterSeconds, now, true); + EXPECT_TRUE(b.show); + EXPECT_TRUE(b.severity == StalenessSeverity::Warning); + EXPECT_EQ(b.seconds_old, (int64_t)kStaleAfterSeconds); + } + // Past the very-stale threshold → red. + { + StalenessBadge b = evaluateStalenessBadge(now - kVeryStaleAfterSeconds, now, true); + EXPECT_TRUE(b.show); + EXPECT_TRUE(b.severity == StalenessSeverity::Error); + } + // Clock skew (future timestamp) is clamped to age 0 → no badge, no negative age. + { + StalenessBadge b = evaluateStalenessBadge(now + 100, now, true); + EXPECT_TRUE(!b.show); + } +} + +void testNotificationHistory() +{ + using dragonx::ui::Notifications; + using dragonx::ui::NotificationType; + auto& n = Notifications::instance(); + n.clearHistory(); + auto base = n.totalPushed(); // monotonic counter is NOT reset by clearHistory() + + n.push("first", NotificationType::Info, 5.0f); + n.push("second", NotificationType::Error, 5.0f); + EXPECT_EQ((int)n.history().size(), 2); + EXPECT_TRUE(n.hasHistory()); + // Oldest first, newest last; type + wall-clock stamp retained. + EXPECT_EQ(n.history().front().message, std::string("first")); + EXPECT_EQ(n.history().back().message, std::string("second")); + EXPECT_TRUE(n.history().back().type == NotificationType::Error); + EXPECT_TRUE(n.history().back().epoch > 0); + EXPECT_EQ((int)(n.totalPushed() - base), 2); + + // Cap at 100: push past it → size caps, oldest entries drop, totalPushed keeps counting. + for (int i = 0; i < 150; ++i) n.push("bulk", NotificationType::Info, 5.0f); + EXPECT_EQ((int)n.history().size(), 100); + EXPECT_EQ((int)(n.totalPushed() - base), 152); + EXPECT_EQ(n.history().front().message, std::string("bulk")); // the two originals fell off + + n.clearHistory(); + EXPECT_TRUE(!n.hasHistory()); + EXPECT_EQ((int)n.history().size(), 0); + EXPECT_EQ((int)(n.totalPushed() - base), 152); // clearing doesn't rewind the counter +} + +void testSeedMigrationResume() +{ + using dragonx::decideSeedMigrationResume; + using dragonx::MigrationResume; + + // No pending migration (or missing dest) → start fresh at the intro. + EXPECT_TRUE(decideSeedMigrationResume(false, false, "", "", true) == MigrationResume::Intro); + EXPECT_TRUE(decideSeedMigrationResume(false, true, "tx", "op", true) == MigrationResume::Intro); + EXPECT_TRUE(decideSeedMigrationResume(true, false, "tx", "op", true) == MigrationResume::Intro); + + // A persisted txid outranks everything → resume at the confirm/adopt gate (txid-first invariant). + EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "", true) == MigrationResume::Confirming); + EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", true) == MigrationResume::Confirming); + EXPECT_TRUE(decideSeedMigrationResume(true, true, "tx", "op", false) == MigrationResume::Confirming); + + // Opid but no txid, AND connected → re-track the opid (recover the txid / detect stale). + EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", true) == MigrationResume::RetrackOpid); + + // W3-3 connectivity gate: opid but NOT connected → the dismissable Sweep gate, NOT the buttonless + // Sweeping spinner (whose only exit is the opid poller, which needs a connection). This is the + // trap the review caught. + EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "op", false) == MigrationResume::SweepGate); + + // No txid and no opid → the Sweep gate (whether or not connected). + EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", true) == MigrationResume::SweepGate); + EXPECT_TRUE(decideSeedMigrationResume(true, true, "", "", false) == MigrationResume::SweepGate); +} + +void testLoggerFileSink() +{ + using dragonx::util::Logger; + namespace fsn = std::filesystem; + fsn::path logPath = fsn::temp_directory_path() / "od_logger_test.log"; + std::error_code ec; + fsn::remove(logPath, ec); + fsn::remove(logPath.string() + ".1", ec); + + // W7-2: init() opens the file sink and must NOT deadlock — it writes the banner under the same + // non-recursive lock it holds (this test would hang if that regressed). + Logger& lg = Logger::instance(); + EXPECT_TRUE(lg.init(logPath.string())); + lg.write("hello-w7-2-sink"); + EXPECT_TRUE(fsn::exists(logPath)); + + std::ifstream f(logPath.string()); + std::string all, line; + while (std::getline(f, line)) all += line + "\n"; + f.close(); + EXPECT_TRUE(all.find("hello-w7-2-sink") != std::string::npos); + EXPECT_TRUE(all.find("Logger initialized") != std::string::npos); + + fsn::remove(logPath, ec); + fsn::remove(logPath.string() + ".1", ec); +} + +void testConnectHasStalled() +{ + using dragonx::util::connectHasStalled; + EXPECT_TRUE(connectHasStalled(100.0, 145.0, 45.0f)); // exactly at threshold + EXPECT_TRUE(connectHasStalled(100.0, 300.0, 45.0f)); // well over + EXPECT_TRUE(!connectHasStalled(100.0, 144.0, 45.0f)); // just under + EXPECT_TRUE(!connectHasStalled(0.0, 1000.0, 45.0f)); // sentinel: not stalling + EXPECT_TRUE(!connectHasStalled(-1.0, 1000.0, 45.0f)); // sentinel: not stalling + EXPECT_TRUE(!connectHasStalled(10.0, 20.0, 0.0f)); // disabled: threshold 0 + EXPECT_TRUE(!connectHasStalled(10.0, 20.0, -5.0f)); // disabled: negative threshold +} + +void testVerifySaplingParams() +{ + using dragonx::rpc::Connection; + namespace fsn = std::filesystem; + + fsn::path dir = fsn::temp_directory_path() / "od_sapling_test"; + std::error_code rmec; + fsn::remove_all(dir, rmec); + fsn::create_directories(dir); + + auto writeFile = [](const fsn::path& p, const std::string& content) { + std::ofstream(p.string(), std::ios::binary) << content; + }; + const std::string spendContent = "fake-spend-params-contents"; + const std::string outputContent = "fake-output-params-contents"; + writeFile(dir / "sapling-spend.params", spendContent); + writeFile(dir / "sapling-output.params", outputContent); + + const std::string spendHash = dragonx::util::sha256Hex(spendContent.data(), spendContent.size()); + const std::string outputHash = dragonx::util::sha256Hex(outputContent.data(), outputContent.size()); + const std::vector> good = { + { "sapling-spend.params", spendHash }, + { "sapling-output.params", outputHash }, + }; + + // Valid params → pass, and a verification marker is written. + EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good)); + EXPECT_TRUE(fsn::exists(dir / ".sapling_verified")); + + // Second call → marker fast-path, still true (round-trips the cache). + EXPECT_TRUE(Connection::verifySaplingParamsIn(dir.string(), good)); + + // Wrong expected hash → integrity failure (fresh dir so no marker can short-circuit it). + fsn::path dir2 = fsn::temp_directory_path() / "od_sapling_test2"; + fsn::remove_all(dir2, rmec); + fsn::create_directories(dir2); + writeFile(dir2 / "sapling-spend.params", spendContent); + writeFile(dir2 / "sapling-output.params", outputContent); + const std::vector> wrong = { + { "sapling-spend.params", std::string(64, 'a') }, + { "sapling-output.params", outputHash }, + }; + EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir2.string(), wrong)); + + // Truncated content (size change) invalidates the marker AND fails the hash. + writeFile(dir / "sapling-spend.params", std::string("x")); + EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good)); + + // A missing param → fail. + fsn::remove(dir / "sapling-output.params", rmec); + EXPECT_TRUE(!Connection::verifySaplingParamsIn(dir.string(), good)); + + fsn::remove_all(dir, rmec); + fsn::remove_all(dir2, rmec); +} + +void testPlatformEnsureDirectory() +{ + using dragonx::util::Platform; + + // An existing directory → true (temp_directory_path always exists). + { + std::string err = "sentinel"; + EXPECT_TRUE(Platform::ensureDirectory(std::filesystem::temp_directory_path().string(), &err)); + } + + // A fresh nested path → created, no error. + { + std::filesystem::path base = std::filesystem::temp_directory_path() / "od_ensuredir_test"; + std::error_code rmec; std::filesystem::remove_all(base, rmec); + std::filesystem::path nested = base / "a" / "b" / "c"; + std::string err; + EXPECT_TRUE(Platform::ensureDirectory(nested.string(), &err)); + EXPECT_TRUE(std::filesystem::is_directory(nested)); + EXPECT_TRUE(err.empty()); + std::filesystem::remove_all(base, rmec); + } + + // Empty path → false with a message. + { + std::string err; + EXPECT_TRUE(!Platform::ensureDirectory("", &err)); + EXPECT_TRUE(!err.empty()); + } + + // A path whose parent component is a regular file cannot be created. This fails the + // same way for root and non-root, so it's a stable negative case across environments. + { + std::filesystem::path f = std::filesystem::temp_directory_path() / "od_ensuredir_file"; + std::error_code rmec; std::filesystem::remove_all(f, rmec); + { std::ofstream(f.string()) << "x"; } + std::string err; + bool ok = Platform::ensureDirectory((f / "child").string(), &err); + std::filesystem::remove_all(f, rmec); + EXPECT_TRUE(!ok); + EXPECT_TRUE(err.find("Cannot create") != std::string::npos); + } +} + +#ifndef _WIN32 +// Integration tests that drive the REAL EmbeddedDaemon fork/exec/waitpid paths (POSIX only). +void testExecFailureReported() +{ + using dragonx::daemon::EmbeddedDaemon; + namespace fsn = std::filesystem; + + // A present-but-non-executable file: execv() must fail, and the F2 self-pipe handshake + // must report it as a start FAILURE with a precise reason — not a transient "Running". + fsn::path bin = fsn::temp_directory_path() / "od_fake_daemon_bin"; + { std::ofstream(bin.string(), std::ios::binary) << "this is not an executable"; } + fsn::permissions(bin, fsn::perms::owner_read, fsn::perm_options::replace); // 0400, no +x + + EmbeddedDaemon d; + d.setSkipPortCheck(true); // bypass the port + datadir-lock gates so we reach startProcess() + EXPECT_TRUE(!d.start(bin.string())); + EXPECT_TRUE(d.getLastError().find("not executable or wrong architecture") != std::string::npos); + EXPECT_TRUE(!d.isRunning()); + + std::error_code ec; fsn::remove(bin, ec); +} + +void testDaemonCrashDetected() +{ + using dragonx::daemon::EmbeddedDaemon; + namespace fsn = std::filesystem; + + // A tiny script that ignores the injected daemon args, lives briefly, then exits abnormally + // — standing in for a daemon that crashes. is_script detection runs it via /bin/bash. + fsn::path script = fsn::temp_directory_path() / "od_fake_daemon.sh"; + { std::ofstream(script.string()) << "#!/bin/bash\nsleep 0.2\nexit 7\n"; } + fsn::permissions(script, fsn::perms::owner_all, fsn::perm_options::replace); // +x + + EmbeddedDaemon d; + d.setSkipPortCheck(true); + EXPECT_TRUE(d.start(script.string())); + EXPECT_TRUE(d.isRunning()); // reads the atomic state_, not a racy waitpid() + + // Hammer isRunning() the way the UI thread does while the child exits and monitorProcess() + // reaps it. Pre-fix (F1), isRunning()'s own waitpid() could steal the reap and hide the + // crash; with the fix the monitor is the sole reaper and always sees it. + for (int i = 0; i < 400 && d.getCrashCount() == 0; ++i) { + (void)d.isRunning(); + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + EXPECT_TRUE(d.getCrashCount() >= 1); // the unexpected exit was detected and counted + EXPECT_TRUE(!d.isRunning()); // state_ flipped to Error + + d.stop(); // join the monitor thread cleanly + std::error_code ec; fsn::remove(script, ec); +} +#endif // !_WIN32 + +void testDatadirLockGate() +{ + using dragonx::daemon::EmbeddedDaemon; + + // Normal start, no lingering daemon after the bounded wait → proceed. + auto clear = EmbeddedDaemon::evaluateDatadirLockGate(false, false, false); + EXPECT_TRUE(clear.proceed); + + // A previous dragonxd still alive after the wait → bail with a distinct, non-crash msg. + auto locked = EmbeddedDaemon::evaluateDatadirLockGate(false, false, true); + EXPECT_TRUE(!locked.proceed); + EXPECT_TRUE(std::string(locked.errorMessage).find("data directory lock") != std::string::npos); + + // Isolated instance via skip_port_check_ is exempt even if a sibling dragonxd is running. + auto skipPort = EmbeddedDaemon::evaluateDatadirLockGate(true, false, true); + EXPECT_TRUE(skipPort.proceed); + + // Isolated instance via -datadir override is exempt even if a sibling is running. + auto isolated = EmbeddedDaemon::evaluateDatadirLockGate(false, true, true); + EXPECT_TRUE(isolated.proceed); +} + void testDaemonLifecycleExecution() { using dragonx::daemon::DaemonController; @@ -3231,6 +3761,19 @@ void testRendererHelpers() EXPECT_EQ(dragonx::ui::defaultPoolWorkerAddress(poolAddresses), std::string("zs-default-worker")); EXPECT_TRUE(dragonx::ui::miningValueAlreadySaved({"pool-a", "pool-b"}, "pool-b")); EXPECT_FALSE(dragonx::ui::miningValueAlreadySaved({"pool-a"}, "")); + + // resolveMiningUserAddress: the configured payout address is the xmrig "user" + // (where rewards go) and must win over the wallet's own addresses. + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("zs-payout", "zs-own", "R-own"), + std::string("zs-payout")); // explicit payout wins + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "zs-own", "R-own"), + std::string("zs-own")); // unset -> own shielded + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "zs-own", "R-own"), + std::string("zs-own")); // "x" placeholder counts as unset + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("x", "", "R-own"), + std::string("R-own")); // no shielded -> transparent + EXPECT_EQ(dragonx::ui::resolveMiningUserAddress("", "", ""), + std::string("")); // nothing anywhere -> caller errors EXPECT_EQ(std::string(dragonx::ui::defaultPoolUrl()), std::string("pool.dragonx.is:3433")); dragonx::TransactionInfo tx; @@ -4132,7 +4675,6 @@ void testLiteWalletControllerLifecycle() EXPECT_FALSE(controller.walletOpen()); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; const auto result = controller.createWallet(req); EXPECT_TRUE(result.ok); EXPECT_TRUE(result.walletReady); @@ -4149,7 +4691,6 @@ void testLiteWalletControllerLifecycle() dragonx::test::g_liteFakeWalletExists = true; LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletOpenRequest req; - req.passphrase = "hunter2"; const auto result = controller.openWallet(req); EXPECT_TRUE(result.ok); EXPECT_TRUE(result.walletReady); @@ -4224,7 +4765,6 @@ void testLiteWalletControllerM4() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4352,7 +4892,6 @@ void testLiteWalletControllerM5Persistence() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4434,7 +4973,6 @@ void testLiteWalletControllerEncryption() auto c = std::make_unique( liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; (void)c->createWallet(req); return c; }; @@ -4665,6 +5203,33 @@ void testLiteWalletControllerConsoleCommand() // Async FULL lifecycle (Settings-page create/open/restore WITH passphrase/restore params) also // fails over: the request runs off the UI thread against the preferred server, then the other // usable defaults, finalized by pumpLifecycleResult() on the main thread. +// W5-3: a create-time passphrase now actually encrypts (and locks) the new lite wallet, and it +// unlocks with the same passphrase — previously the field was collected but ignored. +void testLiteWalletControllerCreateEncryptsWithPassphrase() +{ + using namespace dragonx::wallet; + const auto liteCaps = makeWalletCapabilities(WalletBuildKind::Lite, false, true); + const LiteConnectionSettings conn = defaultLiteConnectionSettings(); + + dragonx::test::g_liteFakeEncrypted = false; + dragonx::test::g_liteFakeLocked = false; + auto c = std::make_unique( + liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); + + LiteWalletCreateRequest req; + req.passphrase = "hunter2"; + (void)c->createWallet(req); + + const auto s = c->encryptionStatus(); + EXPECT_TRUE(s.ok); + EXPECT_TRUE(s.encrypted); // the create-time passphrase encrypted the new wallet + EXPECT_TRUE(s.locked); // encrypt locks immediately + + EXPECT_TRUE(c->unlockWallet("hunter2")); + const auto s2 = c->encryptionStatus(); + EXPECT_FALSE(s2.locked); +} + void testLiteWalletControllerAsyncLifecycleFailover() { using namespace dragonx::wallet; @@ -4693,7 +5258,6 @@ void testLiteWalletControllerAsyncLifecycleFailover() LiteWalletController controller(liteCaps, conn, LiteClientBridge::fromApi(dragonx::test::makeFakeLiteApi())); LiteWalletCreateRequest req; - req.passphrase = "hunter2"; EXPECT_TRUE(controller.beginCreateWalletAsync(req)); drain(controller); EXPECT_TRUE(controller.walletOpen()); @@ -5466,6 +6030,188 @@ void testAddressChecksumValidation() EXPECT_FALSE(isValidBech32("abc1rzg")); // too short / bad checksum EXPECT_FALSE(isValidBech32("Abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw")); // mixed case EXPECT_FALSE(isValidBech32("nosalt")); // no separator + + // decodeBase58Check exposes the checksum-stripped payload so callers can inspect version/length. + using dragonx::util::decodeBase58Check; + std::vector payload; + EXPECT_TRUE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", payload)); + EXPECT_EQ(payload.size(), (size_t)21); // version(1) + 20-byte hash160, checksum stripped + EXPECT_EQ((int)payload[0], 0); // mainnet P2PKH version byte + EXPECT_FALSE(decodeBase58Check("1A1zP1eP5QGefi2DMPTfTL5SLmv7Divfna", payload)); // bad checksum + + // bech32Hrp returns the (lower-cased) HRP of a valid string, or "" when invalid. + using dragonx::util::bech32Hrp; + EXPECT_EQ(bech32Hrp("abcdef1qpzry9x8gf2tvdw0s3jn54khce6mua7lmqqqxw"), std::string("abcdef")); + EXPECT_EQ(bech32Hrp("A12UEL5L"), std::string("a")); // lower-cased + EXPECT_EQ(bech32Hrp("A12UEL5M"), std::string("")); // invalid → empty + + // Address type recognizers: accept every real DragonX recipient form, reject non-addresses. + using dragonx::util::isTransparentAddress; + using dragonx::util::isShieldedAddress; + using dragonx::util::isValidRecipientAddress; + const std::string p2pkh = "R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti"; // v60 + const std::string p2sh = "bCpbnCkrjoJ6EHXtLx9eASHEbFYyikt35C"; // v85 multisig — the regression + const std::string zaddr = "zs1qqqsyqcyq5rqwzqfpg9scrgwpugpzysnzs23v9ccrydpk8qarc0jqgfzyvjz2f389q5j5ctfvp5"; + EXPECT_TRUE(isTransparentAddress(p2pkh)); + EXPECT_TRUE(isTransparentAddress(p2sh)); // was silently dropped by the old 'R'-only filter + EXPECT_FALSE(isShieldedAddress(p2pkh)); + EXPECT_TRUE(isShieldedAddress(zaddr)); + EXPECT_FALSE(isTransparentAddress(zaddr)); + EXPECT_TRUE(isValidRecipientAddress(p2pkh)); + EXPECT_TRUE(isValidRecipientAddress(p2sh)); + EXPECT_TRUE(isValidRecipientAddress(zaddr)); + // A WIF spending key is NOT a recipient (33/34-byte payload, not 21); nor is a typo'd address. + EXPECT_FALSE(isTransparentAddress("Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C")); + EXPECT_FALSE(isValidRecipientAddress("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWtX")); // flipped checksum char + EXPECT_FALSE(isValidRecipientAddress("")); +} + +// Import-key recognition: the client gate must accept every real DragonX key form and reject +// non-keys, so it never blocks a valid import with "Unrecognized key format" (audit F1/F2/F3). +void testPrivateKeyImportRecognition() +{ + using dragonx::services::WalletSecurityController; + using KeyKind = WalletSecurityController::KeyKind; + + // --- Transparent WIF (DragonX SECRET_KEY version 188; testnet 128) --- + const std::string wifCompressed = "Up3W7uVYkLxCfH91APxjSpkkGBWJyBrm3tt1bCz64V5fpZK9ef3C"; // v188 compressed 'U' + const std::string wifUncompressed = "7JTPumX2kofLQdHKANy8MMLRkmXCmuJcosiv9f4RFqW9oCJXBHD"; // v188 uncompressed '7' + const std::string wifTestnet = "KwFfpDsaF7yxCELuyrH9gP5XL7TAt5b9HPWC1xCQbmrxvhJgMQHb"; // v128 compressed + + EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifCompressed)); + EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifUncompressed)); // F2 regression: '7' was rejected + EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(wifTestnet)); + EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(wifUncompressed)); + EXPECT_EQ(WalletSecurityController::classifyPrivateKey(wifUncompressed), KeyKind::Transparent); + EXPECT_FALSE(WalletSecurityController::isViewingKey(wifCompressed)); + + // A corrupted WIF (flipped last char) fails the checksum → caught locally, not at the daemon. + std::string wifBad = wifCompressed; + wifBad.back() = (wifBad.back() == 'C' ? 'D' : 'C'); + EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(wifBad)); + + // A transparent R-address is Base58Check-valid but NOT a key (21-byte payload, not 33/34). + EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey("R9NXAVJezHiBnT3ijTpg3JUZre7PxhJWti")); + + // --- Sapling incoming viewing key (mainnet HRP "zivks") — the F1 regression --- + const std::string ivk = "zivks1qypqxpq9qcrsszg2pvxq6rs0zqg3yyc5z5tpwxqergd3c8g7rusq45amw7"; + EXPECT_TRUE(WalletSecurityController::isViewingKey(ivk)); + EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(ivk)); + EXPECT_FALSE(WalletSecurityController::isRecognizedPrivateKey(ivk)); // a viewing key can't spend + // The stale Zcash "zxview…" prefix is NOT a DragonX viewing key (and not valid Bech32 here). + EXPECT_FALSE(WalletSecurityController::isViewingKey("zxviews1abcdef")); + + // --- Sapling z spending key (recognized by HRP prefix; daemon vets the long payload) --- + const std::string zspend = "secret-extended-key-main1qxxxxxxxxxxxxxxxxxxxx"; + EXPECT_TRUE(WalletSecurityController::isRecognizedPrivateKey(zspend)); + EXPECT_TRUE(WalletSecurityController::isRecognizedImportKey(zspend)); + EXPECT_EQ(WalletSecurityController::classifyPrivateKey(zspend), KeyKind::Shielded); + + // --- Garbage / empty --- + EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("")); + EXPECT_FALSE(WalletSecurityController::isRecognizedImportKey("hello world")); +} + +// Seed-phrase normalization + word count + the 24-word completeness gate. Guards the lite-restore +// crash fix (only 24-word/32-byte-entropy seeds are safe for the SDXL backend) and the NBSP-paste +// recovery fix. Both restore gates (first-run wizard + Settings) route through these. +void testSeedPhraseHelpers() +{ + using dragonx::util::normalizeSeedPhrase; + using dragonx::util::seedPhraseWordCount; + using dragonx::util::isCompleteRecoveryPhrase; + + // --- completeness gate: 24 words only (12/15/18/21 valid-BIP39 lengths crash the backend) --- + EXPECT_TRUE(isCompleteRecoveryPhrase(24)); + EXPECT_FALSE(isCompleteRecoveryPhrase(12)); + EXPECT_FALSE(isCompleteRecoveryPhrase(15)); + EXPECT_FALSE(isCompleteRecoveryPhrase(21)); + EXPECT_FALSE(isCompleteRecoveryPhrase(23)); + EXPECT_FALSE(isCompleteRecoveryPhrase(25)); + EXPECT_FALSE(isCompleteRecoveryPhrase(0)); + + // --- plain ASCII: trim, collapse runs, count exactly; the common case must be untouched otherwise --- + EXPECT_EQ(normalizeSeedPhrase(" alpha beta\tgamma\ndelta "), std::string("alpha beta gamma delta")); + EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha beta gamma")), 3); + EXPECT_EQ(seedPhraseWordCount(""), 0); + EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(" \t \n ")), 0); // whitespace-only + + // --- NBSP (U+00A0 = 0xC2 0xA0) between words must fold to a real space, not glue the words --- + EXPECT_EQ(normalizeSeedPhrase("alpha\xC2\xA0" "beta"), std::string("alpha beta")); + EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase("alpha\xC2\xA0" "beta")), 2); + // Other Unicode spaces: en space U+2002, ideographic U+3000, narrow NBSP U+202F. + EXPECT_EQ(normalizeSeedPhrase("a\xE2\x80\x82" "b\xE3\x80\x80" "c\xE2\x80\xAF" "d"), std::string("a b c d")); + // Zero-width chars (U+200B, U+FEFF BOM) are stripped, not treated as separators. + EXPECT_EQ(normalizeSeedPhrase("\xEF\xBB\xBF" "alpha\xE2\x80\x8B beta"), std::string("alpha beta")); + + // --- a full 24-word phrase pasted with NBSP separators counts as 24 (regression for the fix) --- + std::string words24; + for (int i = 0; i < 24; ++i) { if (i) words24 += "\xC2\xA0"; words24 += "word"; } + EXPECT_EQ(seedPhraseWordCount(normalizeSeedPhrase(words24)), 24); + EXPECT_TRUE(isCompleteRecoveryPhrase(seedPhraseWordCount(normalizeSeedPhrase(words24)))); + // The normalized form is plain single-space separated (what the backend's split(" ") needs). + EXPECT_EQ(normalizeSeedPhrase(words24).find("\xC2\xA0"), std::string::npos); +} + +// Block-DB abort detection: classify a crashed daemon's console output so the app can offer a +// one-click reindex instead of silently showing a zero balance (a daemon-vs-chaindata format break). +void testBlockDbOutputDiagnosis() +{ + using dragonx::daemon::blockDbOutputLooksBroken; + + // The exact abort sequence we observed on a format-mismatched datadir. + EXPECT_TRUE(blockDbOutputLooksBroken( + "Opened LevelDB successfully\n" + "GetValue: CDataStream error - non-canonical optional discriminant: iostream error\n" + "ERROR: LoadBlockIndex() : failed to read value\n" + ": Error loading block database.\n" + "Aborted block database rebuild. Exiting.\n")); + // Each individual fatal marker also trips it (partial capture / different phrasing). + EXPECT_TRUE(blockDbOutputLooksBroken("... : Error loading block database.")); + EXPECT_TRUE(blockDbOutputLooksBroken("Aborted block database rebuild. Exiting.")); + EXPECT_TRUE(blockDbOutputLooksBroken("ERROR: LoadBlockIndex() : failed to read value")); + + // Normal startup / other failures must NOT be misread as a block-DB problem (no false reindex offer). + EXPECT_FALSE(blockDbOutputLooksBroken( + "Loading block index...\nVerifying blocks...\nLoading wallet...\nRescanning...\nDone loading\n")); + EXPECT_FALSE(blockDbOutputLooksBroken("Error loading wallet")); // wallet corruption → salvage, not reindex + EXPECT_FALSE(blockDbOutputLooksBroken("Error: Could not find any asmap file!")); + EXPECT_FALSE(blockDbOutputLooksBroken("")); + + // Wallet auto-recovery detection: the node salvaged wallet.dat (moved the original to a .bak). + using dragonx::daemon::walletAutoRecovered; + EXPECT_TRUE(walletAutoRecovered( + "Warning: wallet.dat corrupt, data salvaged! Original wallet.dat saved as wallet.1786300000.bak in ...")); + EXPECT_TRUE(walletAutoRecovered("wallet.dat corrupt, salvage failed")); + // The FAILED-salvage sequence a BDB-inconsistent wallet actually produces (must also be detected — + // this is the case that previously slipped through and silently emptied the wallet). + EXPECT_TRUE(walletAutoRecovered( + "Renamed wallet.dat to wallet.1786375505.bak\n" + "CDBEnv::Salvage: Database salvage found errors, all data may not be recoverable.\n" + "Salvage(aggressive) found no records in wallet.1786375505.bak.\n")); + EXPECT_FALSE(walletAutoRecovered("Loading wallet...\nWallet completed loading\n")); // normal load + EXPECT_FALSE(walletAutoRecovered(": Error loading block database.")); // block-DB abort != salvage + EXPECT_FALSE(walletAutoRecovered("")); + + // Newest salvage backup picker (the "wallet..bak" the recovery just made). + using dragonx::daemon::newestWalletSalvageBak; + EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.1786200000.bak", "wallet.1786300000.bak", "peers.dat"}), + std::string("wallet.1786300000.bak")); // highest timestamp wins + EXPECT_EQ(newestWalletSalvageBak({"wallet.dat", "wallet.dat.encrypted.bak", "notes.txt"}), + std::string("")); // no wallet..bak present + EXPECT_EQ(newestWalletSalvageBak({}), std::string("")); + + // Restore must pick the LARGEST (least-salvaged) backup, NOT the newest — a salvage cascade shrinks + // the wallet each round, so the newest .bak can be an emptied 40KB copy while the original is huge. + using dragonx::daemon::largestWalletSalvageBak; + EXPECT_EQ(largestWalletSalvageBak({ + {"wallet.dat", 40000ull}, // current (salvaged, tiny) — not a .bak + {"wallet.1786200000.bak", 194174976ull}, // ORIGINAL — oldest ts, biggest + {"wallet.1786340620.bak", 40960ull}, // latest salvage — newest ts, empty + {"peers.dat", 999ull}}), + std::string("wallet.1786200000.bak")); // largest wins over newest + EXPECT_EQ(largestWalletSalvageBak({{"wallet.dat", 100ull}, {"notes.txt", 5ull}}), std::string("")); + EXPECT_EQ(largestWalletSalvageBak({}), std::string("")); } // Live probe of a real lite server (env-gated). Validates CONNECT_ONLY latency + IP capture. @@ -5642,6 +6388,12 @@ void testDaemonChecksumParsing() "| DragonX-1.0.2-Win64.ZIP | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |"); EXPECT_EQ(mixed.at("dragonx-1.0.2-win64.zip"), std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); + // Regression: a markdown-bolded filename (**archive.zip**) must still parse — otherwise a valid, + // correctly-signed release whose body bolds the name would fail checksum lookup and be refused. + const auto bold = parseDaemonChecksums( + "| **dragonx-1.0.2-win64.zip** | `dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a` |"); + EXPECT_EQ(bold.at("dragonx-1.0.2-win64.zip"), + std::string("dd6a554ac05c834da9910ae796215567e97c426f3aed15b54af1f7b90d48c43a")); } void testDaemonBasenamesAndVersionCore() @@ -5828,6 +6580,94 @@ void testPoolHashrateParsing() EXPECT_FALSE(ok); } +// Schema-aware pool fee parsing (fed to the mining-tab "N% fee" display). +void testPoolFeeParsing() +{ + using namespace dragonx::util; + bool ok = false; + + // pool.dragonx.is custom schema: pools.dragonx.poolFee (a whole-percent number). + const std::string isJson = + R"({"pools":{"dragonx":{"hashrate":27670.14,"poolFee":1,"soloFee":3}}})"; + double fee = parsePoolFee(PoolStatsSchema::DragonXIs, isJson, "", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 1.0, 0.001); + + // Fractional fees survive (display rounds, but the parse must not). + const std::string isFrac = R"({"pools":{"dragonx":{"poolFee":1.5}}})"; + fee = parsePoolFee(PoolStatsSchema::DragonXIs, isFrac, "", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 1.5, 0.001); + + // Miningcore schema: the requested pool id's poolFeePercent. + const std::string ccJson = + R"({"pools":[)" + R"({"id":"dragonx-solo","poolFeePercent":2.0,"poolStats":{"poolHashrate":88780.0}},)" + R"({"id":"dragonx-pplns","poolFeePercent":0.9,"poolStats":{"poolHashrate":1585.9}}]})"; + fee = parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok); + EXPECT_TRUE(ok); + EXPECT_NEAR(fee, 0.9, 0.001); + + // Missing field / malformed / wrong-schema input all fail closed (caller keeps + // the compile-time fallback rather than showing a bogus 0%). + parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"hashrate":1.0}}})", "", ok); + EXPECT_FALSE(ok); // no poolFee key + parsePoolFee(PoolStatsSchema::DragonXIs, "not json", "", ok); + EXPECT_FALSE(ok); + parsePoolFee(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok); + EXPECT_FALSE(ok); + parsePoolFee(PoolStatsSchema::DragonXIs, R"({"pools":{"dragonx":{"poolFee":"1"}}})", "", ok); + EXPECT_FALSE(ok); // string, not number +} + +// The effective pool list = official pools ∪ saved favorites ∪ current custom pool, +// endpoint-deduped, with synthetic user rows flagged official=false. +void testEffectivePools() +{ + using namespace dragonx::util; + const int base = (int)knownPools().size(); + + // Current pool is the official one, nothing saved -> just the official pools. + auto a = effectivePools("pool.dragonx.is:3433", {}); + EXPECT_EQ((int)a.size(), base); + + // A custom current pool (neither official nor saved) appears as an extra row. + auto b = effectivePools("my.pool.example:3333", {}); + EXPECT_EQ((int)b.size(), base + 1); + const KnownPool* custom = findPoolByUrl(b, "my.pool.example:3333"); + EXPECT_TRUE(custom != nullptr); + EXPECT_FALSE(custom->official); + EXPECT_TRUE(custom->feePercent < 0.0); // unknown fee + + // Saved pools are appended; an official one among them and a duplicate collapse. + auto c = effectivePools("pool.dragonx.is:3433", + {"pool.dragonx.is:3433", "alt.pool:1", "alt.pool:1"}); + EXPECT_EQ((int)c.size(), base + 1); + EXPECT_TRUE(findPoolByUrl(c, "alt.pool:1") != nullptr); + + // Current pool equal to a saved one is not listed twice. + auto d = effectivePools("alt.pool:1", {"alt.pool:1"}); + EXPECT_EQ((int)d.size(), base + 1); + + // Blank/whitespace URLs are ignored (no phantom rows). + auto e = effectivePools(" ", {"", " "}); + EXPECT_EQ((int)e.size(), base); +} + +// Fee formatting: whole numbers stay clean, fractional fees keep their decimals. +void testFormatFeePercent() +{ + using dragonx::ui::FormatFeePercent; + EXPECT_TRUE(FormatFeePercent(1.0) == "1"); + EXPECT_TRUE(FormatFeePercent(0.0) == "0"); + EXPECT_TRUE(FormatFeePercent(3.0) == "3"); + EXPECT_TRUE(FormatFeePercent(1.5) == "1.5"); + EXPECT_TRUE(FormatFeePercent(0.9) == "0.9"); + EXPECT_TRUE(FormatFeePercent(1.25) == "1.25"); + EXPECT_TRUE(FormatFeePercent(2.50) == "2.5"); // trailing zero trimmed + EXPECT_TRUE(FormatFeePercent(100.0) == "100"); +} + // Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe. void testPoolWeightedSelection() { @@ -6166,6 +7006,97 @@ void testHushChatDatabase() scrub(dbPath); scrub(dbPath2); } +// Per-conversation local delete: (1) "delete, revive on new message" tombstones the removed messages so +// a chain re-scan can't re-import them — but a NEW txid in the same conversation flows through; the +// tombstone survives a DB reload. (2) "delete & block" removes the rows WITHOUT a tombstone and relies on +// a blocked-cid predicate to drop messages while blocked; unblocking re-imports the conversation. +void testHushChatDeleteConversation() +{ + using namespace dragonx::chat; + namespace fs = std::filesystem; + + const std::string dbPath = (fs::temp_directory_path() / "drgx_chat_del_test.sqlite").string(); + auto scrub = [](const std::string& p) { fs::remove(p); fs::remove(p + "-wal"); fs::remove(p + "-shm"); }; + scrub(dbPath); + + ChatKeyPair alice, bob; + ChatIdentityResult ra = deriveChatIdentityFromSecret("del-alice", alice, true); + ChatIdentityResult rb = deriveChatIdentityFromSecret("del-bob", bob, true); + + // Build one incoming (alice->bob) metadata entry. encryptOutgoing produces a fresh ciphertext each + // call, but dedup/tombstone key off (txid, position) only, so re-using the same txids re-scans them. + auto mkMeta = [&](const std::string& txid, const std::string& cid, const std::string& body) { + std::string e, ct; + EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, body, e, ct) == ChatCryptoStatus::Ok); + HushChatTransactionMetadata m; + m.txid = txid; m.type = HushChatHeaderType::Message; m.conversation_id = cid; + m.reply_zaddr = "zs-alice"; m.sender_public_key_hex = ra.public_key_hex; + m.secretstream_header_hex = e; m.payload_memo = ct; m.payload_position = 1; + return m; + }; + std::vector convX{ mkMeta("dx1", "conv-x", "x-one"), + mkMeta("dx2", "conv-x", "x-two") }; + std::vector convY{ mkMeta("dy1", "conv-y", "y-one") }; + + // (1) Delete with revive-on-new-message. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("del-seed")); + ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); + EXPECT_EQ(svc.ingest(convX, {}, 100), 2); + EXPECT_EQ(svc.ingest(convY, {}, 100), 1); + EXPECT_EQ((int)svc.store().size(), 3); + + EXPECT_TRUE(svc.deleteConversation("conv-x", /*block=*/false)); + EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0); + EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); // untouched + + EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // re-scan: tombstoned, not re-imported + EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 0); + + std::vector convXnew{ mkMeta("dx3", "conv-x", "x-three") }; + EXPECT_EQ(svc.ingest(convXnew, {}, 200), 1); // NEW txid revives the thread + EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); + EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three")); + } + + // (2) Tombstone survives a DB reload into a fresh service. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("del-seed")); + ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); + svc.loadFromDatabase(); + EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); // only the revived dx3 persisted + EXPECT_EQ(svc.store().conversation("conv-x")[0].body, std::string("x-three")); + EXPECT_EQ(svc.ingest(convX, {}, 100), 0); // dx1/dx2 still tombstoned after reload + EXPECT_EQ((int)svc.store().conversation("conv-x").size(), 1); + } + + // (3) Delete & block, then unblock. + { + ChatDatabase db(dbPath); + EXPECT_TRUE(db.unlockWithSecret("del-seed")); + ChatService svc; svc.setIdentity(bob); svc.setPersistence(&db); + svc.loadFromDatabase(); + bool blocked = false; + svc.setBlockedPredicate([&](const std::string& cid) { return blocked && cid == "conv-y"; }); + + EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); + blocked = true; + EXPECT_TRUE(svc.deleteConversation("conv-y", /*block=*/true)); + EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0); + EXPECT_EQ(svc.ingest(convY, {}, 100), 0); // suppressed by the predicate (no tombstone) + EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 0); + + blocked = false; // unblock + EXPECT_EQ(svc.ingest(convY, {}, 100), 1); // re-imported from chain + EXPECT_EQ((int)svc.store().conversation("conv-y").size(), 1); + EXPECT_EQ(svc.store().conversation("conv-y")[0].body, std::string("y-one")); + } + + scrub(dbPath); +} + // Phase 4: outgoing memo construction round-trips through the receive parser + decrypt, and // ChatService compose/recordOutgoing echoes into the store. void testHushChatOutgoing() @@ -6518,6 +7449,22 @@ int main() testWalletSecurityWorkflow(); testWalletSecurityWorkflowExecutor(); testDaemonShutdownPolicy(); + testDatadirLockGate(); +#ifndef _WIN32 + testExecFailureReported(); + testDaemonCrashDetected(); +#endif + testPlatformEnsureDirectory(); + testVerifySaplingParams(); + testConnectHasStalled(); + testIsLocalHost(); + testAllowsPlaintextRemote(); + testConsoleSecretRedaction(); + testNodeStatusBanner(); + testStalenessBadge(); + testNotificationHistory(); + testSeedMigrationResume(); + testLoggerFileSink(); testDaemonLifecycleExecution(); testDaemonLifecycleAdapters(); testConsoleTextLayout(); @@ -6552,6 +7499,7 @@ int main() testLiteWalletControllerM4(); testLiteWalletControllerM5Persistence(); testLiteWalletControllerEncryption(); + testLiteWalletControllerCreateEncryptsWithPassphrase(); testLiteChainNameMigration(); testLiteRefreshModelAppliesToWalletState(); testLiteSendShowsRecipientFromOutgoing(); @@ -6590,17 +7538,24 @@ int main() testLiteOfficialServerDetection(); testPoolRegistryLookup(); testPoolHashrateParsing(); + testPoolFeeParsing(); + testEffectivePools(); + testFormatFeePercent(); testPoolWeightedSelection(); testAtomicFileWrite(); testHushChatCrypto(); testHushChatReceivePath(); testHushChatService(); testHushChatDatabase(); + testHushChatDeleteConversation(); testHushChatOutgoing(); testHushChatTransport(); testHushChatShuffledReceive(); testWalletFileProbe(); testAddressChecksumValidation(); + testPrivateKeyImportRecognition(); + testSeedPhraseHelpers(); + testBlockDbOutputDiagnosis(); testLiteServerProbeLive(); testXmrigLiveInstall(); testGeneratedResourceBehavior(); diff --git a/tools/wallet_rebuild/main.cpp b/tools/wallet_rebuild/main.cpp new file mode 100644 index 0000000..6586f6b --- /dev/null +++ b/tools/wallet_rebuild/main.cpp @@ -0,0 +1,90 @@ +// dragonx-wallet-rebuild — offline recovery helper for a BDB-inconsistent wallet.dat. +// +// Some wallet.dat files are valid Berkeley DB btrees whose EXTENT metadata is stale (the "main" +// subdatabase metapage records a low last_pgno while its live data spans thousands of pages further +// in the file). A tolerant page-walk reads every record, but the daemon's Berkeley DB `verify` +// rejects the file and auto-salvages it — which finds nothing and, on each restart, shrinks the +// wallet to empty (the "salvage cascade" that looks like fund loss). The keys are intact; only the +// DB envelope is broken. +// +// This tool fixes it the way it must be fixed — offline, on the file, before any daemon touches it: +// 1) read all (key,value) records with the tolerant walker (util/wallet_file_probe.h), then +// 2) write them VERBATIM into a fresh, consistent Berkeley DB "main" btree via real libdb put()s, +// so libdb computes correct extent/metapage bookkeeping itself (sidestepping the whole defect). +// Records are copied byte-for-byte: encrypted key material (ckey/csapzkey/mkey) passes through as +// opaque ciphertext — no passphrase, no decryption, no key material ever interpreted. Only large `tx` +// history values (which live in BDB overflow pages) are skipped; a wallet rescan rebuilds those. +// +// Usage: dragonx-wallet-rebuild +// Output: a single JSON line on stdout; exit 0 on success, non-zero on failure. Never touches the +// source (opens it read-only); refuses to overwrite an existing output (DB_EXCL). + +#include "util/wallet_file_probe.h" + +#include + +#include +#include +#include + +int main(int argc, char** argv) +{ + if (argc < 3) { + std::fprintf(stderr, "usage: dragonx-wallet-rebuild \n"); + return 2; + } + const char* src = argv[1]; + const char* dst = argv[2]; + + // --- 1) tolerant read (no libdb; reads records the daemon's BDB can't) --- + const auto rec = dragonx::util::extractWalletBtreeRecords(src); + if (!rec.parsed) { + std::printf("{\"ok\":false,\"error\":\"source is not a readable Berkeley DB btree wallet\"}\n"); + return 3; + } + if (rec.keyRecords == 0) { + // Refuse to produce a keyless wallet — nothing to recover, and installing it would look like loss. + std::printf("{\"ok\":false,\"error\":\"no key records found in source\",\"read\":%zu}\n", + rec.records.size()); + return 4; + } + + // --- 2) write a fresh, consistent BDB "main" btree (what CWalletDB expects) --- + DB* db = nullptr; + int r = db_create(&db, nullptr, 0); + if (r != 0) { + std::printf("{\"ok\":false,\"error\":\"db_create: %s\"}\n", db_strerror(r)); + return 5; + } + // DB_EXCL: never clobber an existing file — the caller passes a fresh path. + r = db->open(db, nullptr, dst, "main", DB_BTREE, DB_CREATE | DB_EXCL, 0600); + if (r != 0) { + std::printf("{\"ok\":false,\"error\":\"open output: %s\"}\n", db_strerror(r)); + db->close(db, 0); + return 6; + } + long wrote = 0; + for (const auto& kv : rec.records) { + DBT k, v; + std::memset(&k, 0, sizeof k); + std::memset(&v, 0, sizeof v); + k.data = const_cast(kv.first.data()); k.size = static_cast(kv.first.size()); + v.data = const_cast(kv.second.data()); v.size = static_cast(kv.second.size()); + r = db->put(db, nullptr, &k, &v, 0); + if (r != 0) { + std::printf("{\"ok\":false,\"error\":\"put failed: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote); + db->close(db, 0); + return 7; + } + ++wrote; + } + r = db->close(db, 0); // close flushes correct metadata + if (r != 0) { + std::printf("{\"ok\":false,\"error\":\"close: %s\",\"wrote\":%ld}\n", db_strerror(r), wrote); + return 8; + } + + std::printf("{\"ok\":true,\"read\":%zu,\"keyRecords\":%d,\"skippedOverflow\":%d,\"wrote\":%ld,\"complete\":%s}\n", + rec.records.size(), rec.keyRecords, rec.skippedOverflow, wrote, rec.complete ? "true" : "false"); + return 0; +}