Compare commits
6 Commits
ac49f44f84
...
ea26c0cbbb
| Author | SHA1 | Date | |
|---|---|---|---|
| ea26c0cbbb | |||
| 13b225d8f5 | |||
| 08aed34bbe | |||
| 5edbe8a276 | |||
| 001e85ac1a | |||
| 393f3d147e |
82
build.sh
82
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,13 +304,8 @@ bundle_linux_daemon() {
|
||||
# asmap.dat
|
||||
find_asmap && cp "$ASMAP_DAT" "$dest/asmap.dat" && info " Bundled asmap.dat"
|
||||
|
||||
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
|
||||
for p in "$SCRIPT_DIR/build/linux/bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/../dragonx-wallet-rebuild"; do
|
||||
if [[ -f "$p" ]]; then
|
||||
cp "$p" "$dest/dragonx-wallet-rebuild"; chmod +x "$dest/dragonx-wallet-rebuild"
|
||||
info " Bundled dragonx-wallet-rebuild"; break
|
||||
fi
|
||||
done
|
||||
# (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
|
||||
}
|
||||
@@ -351,6 +364,10 @@ build_release_linux() {
|
||||
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" \
|
||||
@@ -365,6 +382,9 @@ 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"
|
||||
@@ -402,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"
|
||||
@@ -435,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"
|
||||
@@ -656,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
|
||||
@@ -775,6 +824,10 @@ HDR
|
||||
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" \
|
||||
@@ -791,6 +844,9 @@ HDR
|
||||
[[ -f "bin/${APP_BASENAME}.exe" ]] || { err "Windows build failed"; exit 1; }
|
||||
info "Binary: $(du -h "bin/${APP_BASENAME}.exe" | cut -f1)"
|
||||
|
||||
# A full-node release MUST include the recovery helper — fail loudly, never ship without it.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild.exe" "$win_bdb"
|
||||
|
||||
# ── Package: release/windows/ ────────────────────────────────────────────
|
||||
# Remove only THIS variant's prior artifacts so full-node and lite releases coexist here.
|
||||
mkdir -p "$out"
|
||||
@@ -806,8 +862,9 @@ HDR
|
||||
for f in dragonxd.exe dragonx-cli.exe dragonx-tx.exe; do
|
||||
[[ -f "$DD/$f" ]] && cp "$DD/$f" "$dist_dir/"
|
||||
done
|
||||
# dragonx-wallet-rebuild helper (offline recovery for a BDB-inconsistent wallet.dat)
|
||||
[[ -f "bin/dragonx-wallet-rebuild.exe" ]] && { cp "bin/dragonx-wallet-rebuild.exe" "$dist_dir/"; info " Bundled dragonx-wallet-rebuild.exe"; }
|
||||
# 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
|
||||
@@ -1063,6 +1120,11 @@ TOOLCHAIN
|
||||
|
||||
[[ -f "bin/${APP_BASENAME}" ]] || { err "macOS build failed"; exit 1; }
|
||||
|
||||
# A full-node release MUST include the recovery helper. macOS needs a static libdb-6.2 (Homebrew
|
||||
# berkeley-db for a native build, or a vendored external/dragonx/depends/<triple>) — otherwise CMake
|
||||
# skips the target and this fails loudly rather than shipping a mac release with no recovery option.
|
||||
require_wallet_rebuild_helper "bin/dragonx-wallet-rebuild" "$SCRIPT_DIR/external/dragonx/depends/aarch64-apple-darwin"
|
||||
|
||||
# Strip — use osxcross strip for cross-builds
|
||||
if $IS_CROSS; then
|
||||
local STRIP_CMD="${OSXCROSS}/target/bin/${OSXCROSS_TRIPLE}-strip"
|
||||
@@ -1135,6 +1197,8 @@ TOOLCHAIN
|
||||
else
|
||||
warn "prebuilt-binaries/dragonxd-mac/ not found — place macOS daemon binaries there for bundling"
|
||||
fi
|
||||
# Offline wallet-rebuild recovery helper — required (asserted after build); next to the daemon.
|
||||
cp "bin/dragonx-wallet-rebuild" "$MACOS/" && chmod +x "$MACOS/dragonx-wallet-rebuild" && info " Bundled dragonx-wallet-rebuild"
|
||||
else
|
||||
info "Lite mode: skipping macOS daemon and Sapling/asmap bundling"
|
||||
fi
|
||||
|
||||
624
src/app.cpp
624
src/app.cpp
@@ -1644,8 +1644,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;
|
||||
@@ -2189,9 +2191,18 @@ void App::renderNodeStatusBanner()
|
||||
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.
|
||||
const float bannerH = std::max(minH * ui::Layout::dpiScale(), baseH * ui::Layout::vScale());
|
||||
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();
|
||||
@@ -2222,18 +2233,15 @@ void App::renderNodeStatusBanner()
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
const float winW = ImGui::GetWindowSize().x;
|
||||
|
||||
ImFont* icoFont = m::Type().iconSmall();
|
||||
ImFont* txtFont = m::Type().body2();
|
||||
|
||||
// Icon — centered on its own metrics.
|
||||
ImGui::SetCursorPos(ImVec2(padX, (bannerH - icoFont->LegacySize) * 0.5f));
|
||||
// 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 = (bannerH - txtFont->LegacySize) * 0.5f;
|
||||
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).
|
||||
@@ -2281,7 +2289,7 @@ void App::renderNodeStatusBanner()
|
||||
|
||||
// Action button.
|
||||
if (actionLabel) {
|
||||
ImGui::SetCursorPos(ImVec2(winW - btnW - padX, (bannerH - btnH) * 0.5f));
|
||||
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();
|
||||
@@ -2388,12 +2396,15 @@ 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;
|
||||
@@ -2625,6 +2636,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;
|
||||
@@ -2636,10 +2649,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");
|
||||
@@ -2667,7 +2706,6 @@ void App::renderStatusBar()
|
||||
// including ones whose toast already faded; an unread dot marks alerts that arrived since
|
||||
// the panel was last opened.
|
||||
{
|
||||
const float dp = ui::Layout::dpiScale();
|
||||
auto& notes = ui::Notifications::instance();
|
||||
ImFont* bellFont = ui::material::Type().iconSmall();
|
||||
const bool anyHist = notes.hasHistory();
|
||||
@@ -3994,9 +4032,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();
|
||||
@@ -4167,8 +4212,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);
|
||||
@@ -4292,39 +4341,319 @@ void App::renderWalletRecoveredDialog()
|
||||
|
||||
ui::material::OverlayDialogSpec ov;
|
||||
ov.title = TR("wallet_recovered_title");
|
||||
ov.p_open = &show_wallet_recovered_dialog_;
|
||||
// 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 = 560.0f;
|
||||
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));
|
||||
|
||||
ui::material::DialogWarningHeader(TR("wallet_recovered_warn"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
|
||||
ImGui::TextWrapped("%s", TR("wallet_recovered_body"));
|
||||
ImGui::Dummy(ImVec2(0, ui::Layout::spacingMd()));
|
||||
// Preferred fix when available: REBUILD the wallet database. Plain "Restore original" hands the same
|
||||
// BDB-inconsistent file back and the daemon just re-salvages it (the cascade); the rebuild produces a
|
||||
// fresh, consistent copy of every key that the daemon loads cleanly. Copy/rename-only — never deletes.
|
||||
if (walletRebuildAvailable()) {
|
||||
if (ui::material::TactileButton(TR("wallet_recovered_rebuild"), ImVec2(280.0f * dp, 0))) {
|
||||
rebuildWalletDatabase(); // clears show_wallet_recovered_dialog_
|
||||
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;
|
||||
}
|
||||
// Restore of the untouched original (stops the node, swaps the .bak back over the salvaged copy,
|
||||
// clears the stale BDB env, restarts).
|
||||
if (ui::material::TactileButton(TR("wallet_recovered_restore"), ImVec2(260.0f * dp, 0))) {
|
||||
restoreOriginalWallet(); // clears show_wallet_recovered_dialog_
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("wallet_recovered_open_folder"), ImVec2(200.0f * dp, 0))) {
|
||||
util::Platform::openFolder(util::Platform::getDragonXDataDir()); // manual restore instead
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::material::TactileButton(TR("wallet_recovered_dismiss"), ImVec2(150.0f * dp, 0))) {
|
||||
show_wallet_recovered_dialog_ = false; // acknowledged; keeps the salvaged wallet loaded
|
||||
}
|
||||
|
||||
ui::material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
@@ -4718,17 +5047,17 @@ void App::stopEmbeddedDaemon()
|
||||
// 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
|
||||
@@ -5462,6 +5791,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;
|
||||
@@ -5473,12 +5806,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
|
||||
@@ -5494,8 +5832,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);
|
||||
@@ -5508,7 +5846,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
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -5518,11 +5912,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;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
@@ -5546,7 +5952,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);
|
||||
@@ -5583,7 +5989,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
if (state_.connected && state_.sync.syncing) {
|
||||
float progress = static_cast<float>(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);
|
||||
@@ -5647,7 +6053,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);
|
||||
@@ -5664,7 +6070,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();
|
||||
@@ -5679,50 +6086,25 @@ void App::renderLoadingOverlay(float contentH)
|
||||
IM_COL32(255, 90, 90, 255), errTitle);
|
||||
curY += ts.y + gap * 0.5f;
|
||||
|
||||
// Wallet auto-recovery/salvage takes over the error card: a concise message + prominent one-click
|
||||
// actions, RIGHT HERE in the overlay the user is looking at (the separate dialog can be occluded
|
||||
// by this full-frame overlay while the node is down). Skip the verbose daemon dump so the buttons
|
||||
// stay on-screen. Same handlers as the dialog.
|
||||
if (wallet_auto_recovered_) {
|
||||
const char* msg = TR("wallet_recovered_warn");
|
||||
float wrapW = ws.x * 0.8f; if (wrapW > 640.0f) wrapW = 640.0f;
|
||||
ImVec2 ms = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, msg);
|
||||
dl->AddText(capFont, capFont->LegacySize, ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
||||
IM_COL32(230, 210, 210, 235), msg, nullptr, wrapW);
|
||||
curY += ms.y + gap;
|
||||
const float dpi = ui::Layout::dpiScale();
|
||||
const float bw = 340.0f * dpi;
|
||||
auto placeBtn = [&](const char* label) -> bool {
|
||||
ImGui::SetCursorScreenPos(ImVec2(wp.x + cx - bw * 0.5f, curY));
|
||||
const bool clicked = ui::material::TactileButton(label, ImVec2(bw, 0));
|
||||
curY = ImGui::GetItemRectMax().y + gap * 0.4f;
|
||||
return clicked;
|
||||
};
|
||||
if (walletRebuildAvailable() && placeBtn(TR("wallet_recovered_rebuild"))) rebuildWalletDatabase();
|
||||
if (placeBtn(TR("wallet_recovered_restore"))) restoreOriginalWallet();
|
||||
if (placeBtn(TR("wallet_recovered_open_folder")))
|
||||
util::Platform::openFolder(util::Platform::getDragonXDataDir());
|
||||
} else {
|
||||
// Error details (wrapped) — full diagnostic info.
|
||||
const std::string& errDetail = daemon_controller_->lastError();
|
||||
if (!errDetail.empty()) {
|
||||
float wrapW = ws.x * 0.8f;
|
||||
if (wrapW > 700.0f) wrapW = 700.0f;
|
||||
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";
|
||||
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),
|
||||
IM_COL32(200, 200, 200, 180), hint);
|
||||
curY += hs2.y + gap;
|
||||
}
|
||||
// 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 * 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";
|
||||
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),
|
||||
IM_COL32(200, 200, 200, 180), hint);
|
||||
curY += hs2.y + gap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5757,7 +6139,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
snprintf(stallBody, sizeof(stallBody), TR("loading_stall_body"),
|
||||
(float)(ImGui::GetTime() - connect_stall_since_));
|
||||
float wrapW = ws.x * 0.8f;
|
||||
if (wrapW > 640.0f) wrapW = 640.0f;
|
||||
if (wrapW > 640.0f * dpi) wrapW = 640.0f * dpi;
|
||||
ImVec2 bs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, wrapW, stallBody);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(wp.x + cx - wrapW * 0.5f, curY),
|
||||
@@ -5778,19 +6160,19 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 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);
|
||||
|
||||
14
src/app.h
14
src/app.h
@@ -906,6 +906,20 @@ private:
|
||||
bool wallet_auto_recovered_ = false; // a salvage happened this session
|
||||
bool wallet_auto_recovered_warned_ = false; // guard: only surface it once per session
|
||||
bool show_wallet_recovered_dialog_ = false; // auto-shown warning dialog
|
||||
// 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_;
|
||||
|
||||
@@ -243,7 +243,7 @@ void App::detectWalletAutoRecovery()
|
||||
wallet_auto_recovered_ = true;
|
||||
wallet_auto_recovered_warned_ = true;
|
||||
show_wallet_recovered_dialog_ = true;
|
||||
ui::Notifications::instance().error(TR("wallet_recovered_notify"), 30.0f);
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -408,9 +408,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);
|
||||
}
|
||||
@@ -527,6 +530,15 @@ 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
|
||||
@@ -597,6 +609,11 @@ 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");
|
||||
@@ -995,10 +1012,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<AddressInfo>& 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;
|
||||
}
|
||||
}
|
||||
@@ -1007,11 +1027,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1668,7 +1689,7 @@ void App::refreshCoreData()
|
||||
|
||||
// Auto-shield transparent funds if enabled
|
||||
if (result.balanceOk && settings_ && settings_->getAutoShield() &&
|
||||
state_.transparent_balance > 0.0001 && !state_.sync.syncing &&
|
||||
state_.spendableTransparentBalance > 0.0001 && !state_.sync.syncing &&
|
||||
!auto_shield_pending_.exchange(true)) {
|
||||
std::string targetZAddr;
|
||||
for (const auto& addr : state_.addresses) {
|
||||
@@ -1679,7 +1700,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 =
|
||||
@@ -3029,14 +3050,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
|
||||
}
|
||||
@@ -4575,7 +4596,12 @@ void App::restoreOriginalWallet()
|
||||
ui::Notifications::instance().warning(TR("wallet_restore_busy"));
|
||||
return;
|
||||
}
|
||||
show_wallet_recovered_dialog_ = false;
|
||||
// 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<std::mutex> 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");
|
||||
@@ -4684,6 +4710,19 @@ void App::pumpWalletRestore()
|
||||
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);
|
||||
@@ -4705,7 +4744,10 @@ static std::string findWalletRebuildHelper()
|
||||
const std::string p = d + "/" + exe;
|
||||
if (fs::exists(p, ec)) return p;
|
||||
}
|
||||
return {};
|
||||
// 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(); }
|
||||
@@ -4724,7 +4766,11 @@ void App::rebuildWalletDatabase()
|
||||
const std::string helper = findWalletRebuildHelper();
|
||||
if (helper.empty()) { ui::Notifications::instance().error(TR("wallet_rebuild_no_helper"), 15.0f); return; }
|
||||
|
||||
show_wallet_recovered_dialog_ = false;
|
||||
// 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<std::mutex> lk(wallet_restore_mutex_); wallet_restore_done_ = false; }
|
||||
daemon_restarting_ = true;
|
||||
connection_status_ = TR("sb_restarting_daemon");
|
||||
@@ -5020,7 +5066,10 @@ void App::sendTransaction(const std::string& from, const std::string& to,
|
||||
recipient["address"] = to;
|
||||
recipient["amount"] = util::formatAmountFixed(amount);
|
||||
if (!memo.empty()) {
|
||||
recipient["memo"] = memo;
|
||||
// The daemon rejects a raw memo — it wants hex or a "utf8:" prefix. Prefix the user's plain-text
|
||||
// memo so it's UTF-8-encoded into the note (same as the chat path). The unprefixed `memo` is still
|
||||
// passed to submitZSendMany below for the tx-history display.
|
||||
recipient["memo"] = "utf8:" + memo;
|
||||
}
|
||||
recipients.push_back(recipient);
|
||||
|
||||
@@ -5164,7 +5213,7 @@ void App::resendWithFeeGapWorkaround(const std::string& from, const std::string&
|
||||
nlohmann::json primary;
|
||||
primary["address"] = to;
|
||||
primary["amount"] = util::formatAmountFixed(amount);
|
||||
if (!memo.empty()) primary["memo"] = memo;
|
||||
if (!memo.empty()) primary["memo"] = "utf8:" + memo; // daemon needs hex or a "utf8:" prefix (see submit)
|
||||
recipients.push_back(primary);
|
||||
nlohmann::json selfOut;
|
||||
selfOut["address"] = from;
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -19,12 +19,13 @@ std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
// Rank/filter by the CONFIRMED balance — an address holding only 0-conf change can't be sent from.
|
||||
if (requirePositiveBalance && address.spendableBalance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
return addresses[lhs].spendableBalance > addresses[rhs].spendableBalance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
@@ -34,8 +35,8 @@ int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
if (addresses[i].isSpendable() && addresses[i].spendableBalance > bestBalance) {
|
||||
bestBalance = addresses[i].spendableBalance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -325,6 +335,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;
|
||||
|
||||
@@ -40,6 +40,9 @@ static const EmbeddedResource s_resources[] = {
|
||||
{ g_dragonx_cli_exe_data, g_dragonx_cli_exe_size, RESOURCE_DRAGONX_CLI },
|
||||
{ g_dragonx_tx_exe_data, g_dragonx_tx_exe_size, RESOURCE_DRAGONX_TX },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
{ g_dragonx_wallet_rebuild_exe_data, g_dragonx_wallet_rebuild_exe_size, RESOURCE_DRAGONX_WALLET_REBUILD },
|
||||
#endif
|
||||
#ifdef HAS_EMBEDDED_XMRIG
|
||||
{ g_xmrig_exe_data, g_xmrig_exe_size, RESOURCE_XMRIG },
|
||||
#endif
|
||||
@@ -436,6 +439,24 @@ bool extractEmbeddedResources()
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
// Offline wallet-rebuild recovery helper — extracted next to the daemon so a bare, self-extracting
|
||||
// ObsidianDragon.exe still offers "Repair automatically" (findWalletRebuildHelper() checks this dir).
|
||||
const EmbeddedResource* rebuildRes = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (rebuildRes) {
|
||||
std::string dest = daemonDir + pathSep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
if (!std::filesystem::exists(dest)) {
|
||||
DEBUG_LOGF("[INFO] Extracting dragonx-wallet-rebuild (%zu MB)...\n", rebuildRes->size / (1024*1024));
|
||||
if (!extractResource(rebuildRes, dest)) {
|
||||
success = false;
|
||||
}
|
||||
#ifndef _WIN32
|
||||
else { chmod(dest.c_str(), 0755); }
|
||||
#endif
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Best-effort cleanup of any ".old" binaries left behind by a previous in-use replacement.
|
||||
// Once the old daemon/xmrig process has exited, the file is no longer locked and removes cleanly;
|
||||
// if it's still running, the remove fails harmlessly and we retry on the next startup.
|
||||
@@ -450,6 +471,32 @@ bool extractEmbeddedResources()
|
||||
return success;
|
||||
}
|
||||
|
||||
std::string ensureWalletRebuildHelperExtracted()
|
||||
{
|
||||
#ifdef HAS_EMBEDDED_WALLET_REBUILD
|
||||
const EmbeddedResource* res = getEmbeddedResource(RESOURCE_DRAGONX_WALLET_REBUILD);
|
||||
if (!res || res->size == 0) return {};
|
||||
#ifdef _WIN32
|
||||
const char sep = '\\';
|
||||
#else
|
||||
const char sep = '/';
|
||||
#endif
|
||||
const std::string dir = getDaemonDirectory();
|
||||
const std::string dest = dir + sep + RESOURCE_DRAGONX_WALLET_REBUILD;
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(dest, ec)) return dest; // already extracted
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
if (!extractResource(res, dest)) return {};
|
||||
#ifndef _WIN32
|
||||
chmod(dest.c_str(), 0755);
|
||||
#endif
|
||||
DEBUG_LOGF("[INFO] Extracted wallet-rebuild helper on demand: %s\n", dest.c_str());
|
||||
return dest;
|
||||
#else
|
||||
return {};
|
||||
#endif
|
||||
}
|
||||
|
||||
std::string getDaemonDirectory()
|
||||
{
|
||||
// Daemon binaries live in %APPDATA%/ObsidianDragon/dragonx/ (Windows) or
|
||||
|
||||
@@ -55,6 +55,12 @@ BundledDaemonInfo getBundledDaemonInfo();
|
||||
// caller should stop the daemon first. Returns true if all present resources were written.
|
||||
bool reextractBundledDaemon();
|
||||
|
||||
// Ensure the embedded offline wallet-rebuild recovery helper is extracted to the daemon dir, and
|
||||
// return its path ("" if not embedded in this build or extraction failed). Idempotent — extracts only
|
||||
// when missing. Unlike the first-run extractEmbeddedResources() (gated on needsParamsExtraction()),
|
||||
// this runs on demand so recovery works from a self-contained exe on ANY run, not just the first.
|
||||
std::string ensureWalletRebuildHelperExtracted();
|
||||
|
||||
// Resource names
|
||||
constexpr const char* RESOURCE_SAPLING_SPEND = "sapling-spend.params";
|
||||
constexpr const char* RESOURCE_SAPLING_OUTPUT = "sapling-output.params";
|
||||
@@ -62,6 +68,7 @@ constexpr const char* RESOURCE_ASMAP = "asmap.dat";
|
||||
constexpr const char* RESOURCE_DRAGONXD = "dragonxd.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_CLI = "dragonx-cli.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_TX = "dragonx-tx.exe";
|
||||
constexpr const char* RESOURCE_DRAGONX_WALLET_REBUILD = "dragonx-wallet-rebuild.exe";
|
||||
constexpr const char* RESOURCE_XMRIG = "xmrig.exe";
|
||||
constexpr const char* RESOURCE_DARK_GRADIENT = "dark_gradient.png";
|
||||
constexpr const char* RESOURCE_LOGO = "logo_ObsidianDragon_dark.png";
|
||||
|
||||
@@ -37,16 +37,27 @@ void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& u
|
||||
{
|
||||
if (!unspent.is_array()) return;
|
||||
|
||||
std::map<std::string, double> balances;
|
||||
// Partition each note/utxo by its per-entry "confirmations": `total` (minconf=0 — DISPLAY, includes
|
||||
// the user's own pending 0-conf change) vs `spendable` (confirmations>=1 — what z_sendmany, run at
|
||||
// minconf=1, can actually spend). This lets a single z_listunspent(0)/listunspent(0) feed both.
|
||||
std::map<std::string, double> total;
|
||||
std::map<std::string, double> spendable;
|
||||
for (const auto& output : unspent) {
|
||||
auto address = readOptional<std::string>(output, "address");
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (address && amount) balances[*address] += *amount;
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (!address || !amount) continue;
|
||||
total[*address] += *amount;
|
||||
auto conf = readOptional<int>(output, "confirmations");
|
||||
if (conf && *conf >= 1) spendable[*address] += *amount;
|
||||
}
|
||||
|
||||
// The address lists are rebuilt fresh (default 0) each refresh, so hard-set both — an address with no
|
||||
// notes in this set is 0, and spendableBalance is always a subset sum of balance.
|
||||
for (auto& info : addresses) {
|
||||
auto balance = balances.find(info.address);
|
||||
if (balance != balances.end()) info.balance = balance->second;
|
||||
auto t = total.find(info.address);
|
||||
auto s = spendable.find(info.address);
|
||||
info.balance = (t != total.end()) ? t->second : 0.0;
|
||||
info.spendableBalance = (s != spendable.end()) ? s->second : 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,7 +260,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 +269,11 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||
}
|
||||
if (spendableBalance.is_object()) { // confirmed totals (minconf=1); left unset on old daemons
|
||||
result.spendableShieldedBalance = readBalanceString(spendableBalance, "private");
|
||||
result.spendableTransparentBalance = readBalanceString(spendableBalance, "transparent");
|
||||
result.spendableTotalBalance = readBalanceString(spendableBalance, "total");
|
||||
}
|
||||
|
||||
result.blockchainOk = blockOk && blockInfo.is_object();
|
||||
if (result.blockchainOk) {
|
||||
@@ -274,17 +290,21 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefresh
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc, bool includeBalance)
|
||||
{
|
||||
json totalBalance;
|
||||
json spendableBalance;
|
||||
json blockInfo;
|
||||
bool balanceOk = false;
|
||||
bool blockOk = false;
|
||||
|
||||
if (includeBalance) {
|
||||
try {
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array());
|
||||
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 (...) {}
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -294,7 +314,7 @@ NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefre
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
|
||||
return parseCoreRefreshResult(totalBalance, spendableBalance, balanceOk, blockInfo, blockOk);
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
@@ -611,15 +631,19 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("z_listunspent", json::array());
|
||||
json unspent = rpc.call("z_listunspent", json::array({0, 9999999, false})); // minconf=0 → include 0-conf change
|
||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try {
|
||||
json balance = rpc.call("z_getbalance", json::array({info.address}));
|
||||
if (!balance.is_null()) info.balance = balance.get<double>();
|
||||
try { // display total (minconf=0, includes pending change)
|
||||
json total = rpc.call("z_getbalance", json::array({info.address, 0}));
|
||||
if (!total.is_null()) info.balance = total.get<double>();
|
||||
} catch (...) {}
|
||||
try { // spendable (minconf=1); degrade to the display value on old daemons
|
||||
json conf = rpc.call("z_getbalance", json::array({info.address, 1}));
|
||||
info.spendableBalance = (!conf.is_null()) ? conf.get<double>() : info.balance;
|
||||
} catch (...) { info.spendableBalance = info.balance; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -631,7 +655,7 @@ NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddres
|
||||
}
|
||||
|
||||
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());
|
||||
@@ -1197,6 +1221,12 @@ void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
||||
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
||||
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
||||
// Confirmed/spendable totals; if the minconf=1 call was unavailable (old daemon) degrade to the
|
||||
// display value so nothing is *over*-reported as spendable (z_sendmany stays the final gate).
|
||||
state.spendablePrivateBalance = result.spendableShieldedBalance.value_or(state.privateBalance);
|
||||
state.spendableTransparentBalance = result.spendableTransparentBalance.value_or(state.transparentBalance);
|
||||
state.spendableTotalBalance = result.spendableTotalBalance.value_or(state.totalBalance);
|
||||
state.unconfirmedBalance = std::max(0.0, state.totalBalance - state.spendableTotalBalance);
|
||||
state.last_balance_update = updatedAt;
|
||||
}
|
||||
|
||||
|
||||
@@ -98,9 +98,12 @@ public:
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> shieldedBalance; // display (minconf=0, incl. pending change)
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
std::optional<double> spendableShieldedBalance; // confirmed (minconf=1)
|
||||
std::optional<double> spendableTransparentBalance;
|
||||
std::optional<double> spendableTotalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
@@ -227,6 +230,7 @@ public:
|
||||
RefreshRpcGateway& rpc,
|
||||
const std::optional<ConnectionInfoResult>& prefetchedInfo = std::nullopt);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
const nlohmann::json& spendableBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2081,12 +2081,22 @@ void RenderSettingsPage(App* app) {
|
||||
ImGui::SameLine(0, 0);
|
||||
ImGui::SetCursorPosX(leftX + labelW);
|
||||
ImGui::AlignTextToFramePadding();
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Primary()), "%s", dirPath.c_str());
|
||||
// In the two-column layout this shares one draw list with the Daemon-binary
|
||||
// column (no clip rect between them), so a long OS data-dir path (Windows
|
||||
// AppData / macOS Application Support) would overrun into it. Middle-ellipsize
|
||||
// to the remaining column width (room left for the copy button); the full path
|
||||
// stays available via the tooltip, click-to-open, and the copy button.
|
||||
ImFont* pathFont = ImGui::GetFont();
|
||||
const float pathAvailW = contentW - labelW - Layout::spacingSm()
|
||||
- ImGui::GetFrameHeight() - Layout::spacingXs();
|
||||
const std::string dirShown =
|
||||
material::TruncateToWidth(dirPath, pathFont, pathFont->LegacySize, pathAvailW);
|
||||
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());
|
||||
|
||||
@@ -324,12 +324,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);
|
||||
@@ -820,7 +820,7 @@ 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);
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f) * hs;
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
|
||||
@@ -137,13 +137,15 @@ static double GetAvailableBalance(App* app) {
|
||||
// not a stored list index. The index desyncs from s_from_address after an address-list
|
||||
// refresh, and is left at -1 when the source is chosen from another tab ("Send from this
|
||||
// address") — which previously made the sufficiency check see 0 and block a valid send.
|
||||
// CONFIRMED balance only — this is the spend ceiling (Max button, slider, validation, pre-broadcast
|
||||
// re-check). z_sendmany runs at minconf=1, so offering 0-conf change here would just make the send fail.
|
||||
if (s_from_address[0] != '\0') {
|
||||
for (const auto& a : state.addresses) {
|
||||
if (a.address == s_from_address) return a.balance;
|
||||
if (a.address == s_from_address) return a.spendableBalance;
|
||||
}
|
||||
}
|
||||
if (s_selected_from_idx >= 0 && s_selected_from_idx < static_cast<int>(state.addresses.size())) {
|
||||
return state.addresses[s_selected_from_idx].balance;
|
||||
return state.addresses[s_selected_from_idx].spendableBalance;
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
@@ -252,7 +254,7 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
std::string trunc = util::truncateMiddle(addr.address,
|
||||
static_cast<int>(std::max(S.drawElement("tabs.send", "addr-preview-trunc-min").size, width / S.drawElement("tabs.send", "addr-preview-trunc-divisor").size)));
|
||||
snprintf(buf, sizeof(buf), "%s %s — %.8f %s",
|
||||
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
s_source_preview = buf;
|
||||
} else {
|
||||
s_source_preview = TR("send_select_source");
|
||||
@@ -282,7 +284,7 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
|
||||
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);
|
||||
tag, trunc.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
|
||||
ImGui::PushID(static_cast<int>(i));
|
||||
if (ImGui::Selectable(buf, isCurrent)) {
|
||||
@@ -292,7 +294,7 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
}
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s\nBalance: %.8f %s",
|
||||
addr.address.c_str(), addr.balance, DRAGONX_TICKER);
|
||||
addr.address.c_str(), addr.spendableBalance, DRAGONX_TICKER);
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
@@ -822,7 +824,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.
|
||||
|
||||
@@ -1194,15 +1194,50 @@ void I18n::loadBuiltinEnglish()
|
||||
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).
|
||||
strings_["wallet_recovered_title"] = "Your wallet was auto-recovered";
|
||||
strings_["wallet_recovered_warn"] = "The node moved your wallet aside and loaded a salvaged copy.";
|
||||
strings_["wallet_recovered_body"] = "On startup the node decided your wallet.dat looked damaged and recovered it automatically. Your ORIGINAL wallet was NOT deleted — it was renamed to \"wallet.<numbers>.bak\" in your data folder, and a salvaged copy is loaded now.\n\nThe salvaged copy may be incomplete, so the balance shown here could be wrong — don't treat it as final.\n\nThis is often a false alarm caused by leftover database files (e.g. after moving the wallet between machines). To restore your original: quit the wallet, then in the data folder rename the current wallet.dat aside, rename \"wallet.<numbers>.bak\" back to \"wallet.dat\", delete the \"database\" folder and any \"__db.*\" files, and reopen.";
|
||||
strings_["wallet_recovered_open_folder"] = "Open data folder";
|
||||
strings_["wallet_recovered_dismiss"] = "Keep salvaged copy";
|
||||
strings_["wallet_recovered_restore"] = "Restore original wallet";
|
||||
strings_["wallet_recovered_notify"] = "The node recovered your wallet and moved the original to a .bak — your shown balance may be incomplete. See the prompt to restore it.";
|
||||
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.";
|
||||
// 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.<numbers>.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 wallet and restarting the node…";
|
||||
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.<timestamp>.bak to restore. Nothing was changed.";
|
||||
@@ -1212,8 +1247,9 @@ void I18n::loadBuiltinEnglish()
|
||||
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"] = "Rebuild wallet database (recommended)";
|
||||
strings_["wallet_rebuild_started"] = "Rebuilding your wallet database and restarting the node…";
|
||||
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.";
|
||||
@@ -1345,7 +1381,7 @@ void I18n::loadBuiltinEnglish()
|
||||
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 needs recovery — see the prompt";
|
||||
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";
|
||||
|
||||
@@ -139,6 +139,12 @@ void applyLiteRefreshModelToWalletState(const LiteWalletAppRefreshModel& model,
|
||||
state.transparentBalance = static_cast<double>(model.balance.transparentZatoshis) / kZatoshisPerCoin;
|
||||
state.totalBalance = static_cast<double>(model.balance.totalZatoshis) / kZatoshisPerCoin;
|
||||
state.unconfirmedBalance = static_cast<double>(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) {
|
||||
@@ -178,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) {
|
||||
|
||||
@@ -934,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);
|
||||
|
||||
@@ -1424,10 +1425,10 @@ void testNetworkRefreshRpcCollectors()
|
||||
});
|
||||
auto core = Refresh::collectCoreRefreshResult(coreRpc);
|
||||
EXPECT_TRUE(coreRpc.methodNames() == std::vector<std::string>({
|
||||
"z_gettotalbalance", "getblockchaininfo"
|
||||
"z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo" // display (minconf=0) + spendable (minconf=1)
|
||||
}));
|
||||
EXPECT_EQ(coreRpc.calls[0].params, json::array());
|
||||
EXPECT_EQ(coreRpc.calls[1].params, json::array());
|
||||
EXPECT_EQ(coreRpc.calls[0].params, json::array({0}));
|
||||
EXPECT_EQ(coreRpc.calls[1].params, json::array({1}));
|
||||
EXPECT_TRUE(core.balanceOk);
|
||||
EXPECT_TRUE(core.blockchainOk);
|
||||
EXPECT_NEAR(*core.totalBalance, 4.25, 0.00000001);
|
||||
@@ -1440,7 +1441,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
coreFallbackRpc.addResponse("getblockchaininfo", json{{"blocks", 8}, {"headers", 9}});
|
||||
auto partialCore = Refresh::collectCoreRefreshResult(coreFallbackRpc);
|
||||
EXPECT_TRUE(coreFallbackRpc.methodNames() == std::vector<std::string>({
|
||||
"z_gettotalbalance", "getblockchaininfo"
|
||||
"z_gettotalbalance", "z_gettotalbalance", "getblockchaininfo"
|
||||
}));
|
||||
EXPECT_FALSE(partialCore.balanceOk);
|
||||
EXPECT_TRUE(partialCore.blockchainOk);
|
||||
@@ -1566,7 +1567,7 @@ void testNetworkRefreshRpcCollectors()
|
||||
auto fallbackAddresses = Refresh::collectAddressRefreshResult(fallbackRpc);
|
||||
EXPECT_TRUE(fallbackRpc.methodNames() == std::vector<std::string>({
|
||||
"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);
|
||||
@@ -1963,7 +1964,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}},
|
||||
@@ -1972,6 +1974,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"));
|
||||
|
||||
Reference in New Issue
Block a user