6 Commits

Author SHA1 Message Date
061db16004 i18n: route explorer and shield status strings through TR()
Centralise the remaining hardcoded English literals in two otherwise
fully-translated files (explorer_tab: 40 TR calls, shield_dialog: 23) so
they join the i18n system with an English source key + fallback:

- explorer_tab: the three search-error messages (invalid response, hash
  not found, not-connected) now use explorer_* keys. Also guards a hash
  lookup behind an active daemon connection and disables the block-detail
  prev/next nav while a fetch is in flight (both were the reason these
  error paths could fire).
- shield_dialog: the operation status/error strings (submitting, submitted,
  failed, status, error-checking-status, shield/merge failed) now use
  shield_* keys.
- i18n: add the new explorer_* and shield_* English source keys.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:09:18 -05:00
ec48538881 refactor(mining): surface inconclusive benchmark; dedup idle combo
- mining_benchmark: when a thread benchmark finishes with no nonzero
  hashrate sample (e.g. the pool never reported a rate), don't finish
  silently — set a new `inconclusive` flag on ThreadBenchmarkUpdate and
  let mining_tab warn. Keeps the state-machine core pure/no-I/O (it is
  unit-tested), rather than calling the Notifications singleton from it.
- mining_controls: fold the two byte-identical idle-delay combo blocks
  (non-scaling + thread-scaling branches) into one lambda parameterised
  by combo id; removes ~30 lines of duplication.
- request_payment: drop the dead s_selected_addr_idx state and write the
  address-selected comparisons as std::string == char* consistently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:09:08 -05:00
658c0f355b fix(ui): validate and clamp dialog input
Tighten input handling across several dialogs so bad/edge input can't
produce a doomed request or a confusing display:

- app_security: passphrase strength meter now factors character-class
  diversity — an all-one-class string downgrades one tier so "aaaaaaaa"
  no longer scores as high as a mixed one.
- block_info: clamp the height field to the chain tip and hide/deny
  "Next" at the tip (was only yielding a raw RPC error).
- address_label: trim surrounding whitespace before saving; a
  whitespace-only label clears it (mirrors clearing the icon).
- send_tab: re-clamp the amount when a Max send has its fee bumped in the
  confirm popup (kept the total within budget) and NUL-terminate the
  strncpy'd address/memo when a payment URI overwrites the form.
- settings_page: reject an empty/whitespace-only lite wallet path before
  dispatching an unusable open/restore request.
- peers_tab: guard ExtractIP against an empty address.
- transaction_details: disable "View in explorer" when the configured
  explorer URL is empty/whitespace so we never open a garbage link.
- app: seed-backup "Skip" now requires a second, deliberate click with a
  fund-loss warning before it dismisses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:08:59 -05:00
41f5548593 fix(ui): prevent stuck spinners and double-submits in dialogs
Address a cluster of low-severity robustness nits where an async
callback could be skipped (leaving a spinner spinning forever) or a
synchronous action could be re-fired mid-flight:

- app_network: add catch(...) fallbacks around the importPrivateKey and
  submitZSendMany worker lambdas so a non-std throw can't escape the
  worker and skip the main-thread callback (stuck "Importing…"/"Sending…").
- export_transactions: re-entrancy guard disabling Export while a write
  is in flight.
- bootstrap_download: disable Cancel once clicked so the request is
  visibly acknowledged and can't be re-fired before the worker stops.
- network_tab: give clear "already in list" feedback on a duplicate
  server (keep the inputs, clear only the stale invalid-URL error) and
  disable/relabel Refresh while a probe is in flight.
- key_export: offer Retry from the error branch (falls back to Reveal)
  and guard against an empty/malformed address before dispatching.
- receive_tab: only enter the "generating…" state when a dispatch is
  actually possible (guards a stuck spinner when disconnected).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:08:43 -05:00
1dbbd44759 fix: Tier-3 feature gaps — transfer result screen + accurate RPC display
- Address-transfer dialog closed itself on submit (s_open=false the same frame),
  so its in-dialog result screen was dead code. Keep the dialog open on submit:
  s_sending drives the button to a disabled "Sending…" state, and the async
  callback's result (success txid / error) now shows in the result screen with
  its own Close button.
- Settings RPC connection editor was inert AND showed compile-time defaults.
  Populate Host/Port/Username/Password from the auto-detected daemon config
  (rpc::Connection::autoDetectConfig) and make the fields read-only — the RPC
  credentials come from the daemon's DRAGONX.conf, so these now accurately
  DISPLAY the live connection instead of pretending to be an editor (which did
  nothing). The existing "auto-detected" note now matches the behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:27:59 -05:00
b0cc6bcef4 fix: Tier-2 remaining mediums — maintenance/mining feedback + force-quit hang detection
The last four (more involved) robustness items from the audit:

- deleteBlockchainData: post the deleted-item count to the main thread (via an
  atomic, since Notifications isn't thread-safe) and show a completion toast
  ("Blockchain data deleted (N items). Daemon restarting…") — previously the
  result was only logged.
- Pool mining: pool start has a connect delay with no feedback; announce
  "Starting pool miner — connecting…" on a successful start and "Pool miner
  connected and hashing." once the poll confirms it (contained pool_starting_
  flag; the shared mining-toggle state machine is untouched).
- Force Quit (shutdown screen): gate it on the status text having STALLED (a
  genuine hang) rather than a bare 10s timer — with a 20s hard-ceiling backstop —
  and show a state-aware caution naming the stuck step (force-quitting mid daemon
  flush risks the chainstate).
- Benchmark: require a confirming second click that first builds candidates to
  estimate the duration ("Benchmark takes ~Ns and interrupts mining"), instead
  of interrupting mining immediately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:14:56 -05:00
24 changed files with 357 additions and 111 deletions

View File

@@ -822,6 +822,11 @@ void App::update()
ps.algo = xs.algo; ps.algo = xs.algo;
ps.version = xs.version; ps.version = xs.version;
ps.connected = xs.connected; ps.connected = xs.connected;
// Pool mining has a connect delay — announce once it's actually connected/hashing.
if (pool_starting_.load(std::memory_order_relaxed) && (ps.connected || ps.hashrate_10s > 0.0)) {
pool_starting_.store(false, std::memory_order_relaxed);
ui::Notifications::instance().success("Pool miner connected and hashing.");
}
// Get memory directly from OS (more reliable than API) // Get memory directly from OS (more reliable than API)
double memMB = xmrig_manager_->getMemoryUsageMB(); double memMB = xmrig_manager_->getMemoryUsageMB();
ps.memory_used = static_cast<int64_t>(memMB * 1024.0 * 1024.0); ps.memory_used = static_cast<int64_t>(memMB * 1024.0 * 1024.0);
@@ -836,6 +841,7 @@ void App::update()
} }
} else if (xmrig_manager_ && !xmrig_manager_->isRunning()) { } else if (xmrig_manager_ && !xmrig_manager_->isRunning()) {
state_.pool_mining.xmrig_running = false; state_.pool_mining.xmrig_running = false;
pool_starting_.store(false, std::memory_order_relaxed); // stopped / never came up
} }
// Auto-balance: periodically re-pick the pool by hashrate (self-throttled). // Auto-balance: periodically re-pick the pool by hashrate (self-throttled).
@@ -1243,6 +1249,15 @@ void App::render()
// Process deferred encryption from wizard (runs in background) // Process deferred encryption from wizard (runs in background)
processDeferredEncryption(); processDeferredEncryption();
// Surface the blockchain-delete completion on the main thread (the worker can't touch Notifications).
{
int deletedN = pending_delete_result_.exchange(-1, std::memory_order_relaxed);
if (deletedN >= 0) {
ui::Notifications::instance().success("Blockchain data deleted (" + std::to_string(deletedN) +
" items). The daemon is restarting to re-sync from the network.");
}
}
// Debug screenshot sweep — pins the current (skin,page) and arms capture once settled. Must run // Debug screenshot sweep — pins the current (skin,page) and arms capture once settled. Must run
// before the sidebar reads current_page_ (below) so the forced page is reflected. // before the sidebar reads current_page_ (below) so the forced page is reflected.
updateScreenshotSweep(); updateScreenshotSweep();
@@ -2263,6 +2278,7 @@ void App::renderLiteFirstRunPrompt()
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed) static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
static int progress = 0; // # words confirmed in order static int progress = 0; // # words confirmed in order
static double wrongFlashUntil = 0.0; // brief "not the next word" hint static double wrongFlashUntil = 0.0; // brief "not the next word" hint
static bool skipConfirm = false; // "Skip" backup requires a second confirming click
static bool creating = false; // async create (with failover) in flight static bool creating = false; // async create (with failover) in flight
// Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back. // Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back.
static char restoreSeed[512] = {}; static char restoreSeed[512] = {};
@@ -2290,6 +2306,7 @@ void App::renderLiteFirstRunPrompt()
chips.clear(); chips.clear();
progress = 0; progress = 0;
step = 0; step = 0;
skipConfirm = false;
creating = false; creating = false;
lite_firstrun_dismissed_ = true; lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup(); ImGui::CloseCurrentPopup();
@@ -2400,14 +2417,28 @@ void App::renderLiteFirstRunPrompt()
std::mt19937 rng{std::random_device{}()}; std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng); std::shuffle(chips.begin(), chips.end(), rng);
progress = 0; progress = 0;
skipConfirm = false;
step = 2; step = 2;
} }
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed); if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton("Skip", ImVec2(80, 0))) { if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f); if (!skipConfirm) {
finish(); skipConfirm = true; // require a second, deliberate click
} else {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
}
if (skipConfirm) {
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
"Skip anyway?");
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
} }
} else if (step == 2) { } else if (step == 2) {
// ── Verify: tap the words in order ────────────────────────────────────── // ── Verify: tap the words in order ──────────────────────────────────────
@@ -2456,11 +2487,24 @@ void App::renderLiteFirstRunPrompt()
} }
if (!verified) ImGui::EndDisabled(); if (!verified) ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton("Back", ImVec2(80, 0))) step = 1; if (ui::material::TactileButton("Back", ImVec2(80, 0))) { skipConfirm = false; step = 1; }
ImGui::SameLine(); ImGui::SameLine();
if (ui::material::TactileButton("Skip", ImVec2(80, 0))) { if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f); if (!skipConfirm) {
finish(); skipConfirm = true; // require a second, deliberate click
} else {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
}
}
if (skipConfirm) {
ImGui::Spacing();
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + 380.0f);
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextUnformatted("You have not backed up your seed — funds could be lost. "
"Skip anyway?");
ImGui::PopStyleColor();
ImGui::PopTextWrapPos();
} }
} else if (step == 3) { } else if (step == 3) {
// ── Restore from an existing seed phrase ──────────────────────────────── // ── Restore from an existing seed phrase ────────────────────────────────
@@ -3344,6 +3388,8 @@ void App::deleteBlockchainData()
daemon::AsyncLifecycleTaskContext context(token, shutting_down_); daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context); auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems); DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems);
// Hand the count to the main thread for a completion toast (Notifications isn't thread-safe).
pending_delete_result_.store(result.deletedItems, std::memory_order_relaxed);
}); });
} }
@@ -3480,6 +3526,17 @@ void App::renderShutdownScreen()
}; };
shutdown_timer_ += ImGui::GetIO().DeltaTime; shutdown_timer_ += ImGui::GetIO().DeltaTime;
// Track how long the status text has been unchanged. A normal shutdown keeps updating the status
// ("Stopping pool miner…" -> "Flushing…" -> "Cleaning up…"); a genuine hang stalls it. Offer Force
// Quit only once the status has STALLED (likely hung), not on a bare timer, with a hard ceiling so
// it's never impossible to escape.
static std::string s_lastShutStatus;
static float s_shutStallTimer = 0.0f;
if (shutdown_status_ != s_lastShutStatus) { s_lastShutStatus = shutdown_status_; s_shutStallTimer = 0.0f; }
else s_shutStallTimer += ImGui::GetIO().DeltaTime;
const bool shutdownStalled = s_shutStallTimer >= 8.0f;
const bool allowForceQuit = shutdownStalled || shutdown_timer_ >= 20.0f;
// Use the main viewport so the overlay covers the primary window // Use the main viewport so the overlay covers the primary window
ImGuiViewport* vp = ImGui::GetMainViewport(); ImGuiViewport* vp = ImGui::GetMainViewport();
ImVec2 vp_pos = vp->Pos; ImVec2 vp_pos = vp->Pos;
@@ -3499,8 +3556,9 @@ void App::renderShutdownScreen()
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoSavedSettings; ImGuiWindowFlags_NoSavedSettings;
// Allow input after 10s so Force Quit button is clickable // Allow input only once Force Quit is offered (status stalled / hard ceiling), so the screen stays
if (shutdown_timer_ < 10.0f) // click-through while a normal shutdown is progressing.
if (!allowForceQuit)
shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav; shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
ImGui::Begin("##ShutdownOverlay", nullptr, shutdownFlags); ImGui::Begin("##ShutdownOverlay", nullptr, shutdownFlags);
@@ -3601,11 +3659,22 @@ void App::renderShutdownScreen()
} }
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// 4b. Force Quit button — appears after 10 seconds // 4b. Force Quit button — appears once the shutdown appears stalled (or a hard-ceiling backstop)
// ------------------------------------------------------------------- // -------------------------------------------------------------------
if (shutdown_timer_ >= 10.0f) { if (allowForceQuit) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::Spacing(); ImGui::Spacing();
// State-aware caution: while the status is a daemon flush/exit step, force-quitting risks the
// chainstate; say so instead of a bare button.
if (shutdownStalled && !shutdown_status_.empty()) {
std::string stalledMsg = "Still \"" + shutdown_status_ + "\" — force quitting now may corrupt chain data.";
ImVec2 ms = ImGui::CalcTextSize(stalledMsg.c_str());
ImGui::SetCursorPosX(cx - ms.x * 0.5f);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning()));
ImGui::TextUnformatted(stalledMsg.c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
}
const char* forceLabel = TR("force_quit"); const char* forceLabel = TR("force_quit");
ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0); ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0);
ImGui::SetCursorPosX(cx - btnSize.x * 0.5f); ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);

View File

@@ -601,6 +601,10 @@ private:
// Daemon restart (e.g. after changing debug log categories) // Daemon restart (e.g. after changing debug log categories)
std::atomic<bool> daemon_restarting_{false}; std::atomic<bool> daemon_restarting_{false};
// Set by the deleteBlockchainData worker (item count); the main loop surfaces a completion toast
// and resets it to -1. Atomic because the worker thread writes it and the UI thread reads/clears it.
std::atomic<int> pending_delete_result_{-1};
// Encryption state check timeout // Encryption state check timeout
float encryption_check_timer_ = 0.0f; float encryption_check_timer_ = 0.0f;
@@ -702,6 +706,10 @@ private:
// Mining toggle guard (prevents concurrent setgenerate calls) // Mining toggle guard (prevents concurrent setgenerate calls)
std::atomic<bool> mining_toggle_in_progress_{false}; std::atomic<bool> mining_toggle_in_progress_{false};
// True from a successful startPoolMining() until the miner is confirmed connected/hashing in the
// poll — drives the "connecting…" → "connected" feedback for pool mining (which has a connect delay).
std::atomic<bool> pool_starting_{false};
// Auto-shield guard (prevents concurrent auto-shield operations) // Auto-shield guard (prevents concurrent auto-shield operations)
std::atomic<bool> auto_shield_pending_{false}; std::atomic<bool> auto_shield_pending_{false};

View File

@@ -1949,6 +1949,10 @@ void App::startPoolMining(int threads)
} else { } else {
ui::Notifications::instance().error("Failed to start pool miner: " + err); ui::Notifications::instance().error("Failed to start pool miner: " + err);
} }
} else {
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
pool_starting_.store(true, std::memory_order_relaxed);
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
} }
} }
@@ -2406,6 +2410,10 @@ void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, c
else rpc_->call("importprivkey", {key, "", true}); // label "", rescan else rpc_->call("importprivkey", {key, "", true}); // label "", rescan
} catch (const std::exception& e) { } catch (const std::exception& e) {
err = e.what(); err = e.what();
} catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Importing…" spinner.
err = "Import failed (unknown error)";
} }
return [this, shielded, err, callback]() { return [this, shielded, err, callback]() {
if (!err.empty()) { if (!err.empty()) {
@@ -2558,6 +2566,10 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
ok = true; ok = true;
} catch (const std::exception& e) { } catch (const std::exception& e) {
result_str = e.what(); result_str = e.what();
} catch (...) {
// Non-std throw must not escape into the worker — the main-thread callback
// below would never run, leaving a stuck "Sending…" spinner.
result_str = "Send failed (unknown error)";
} }
return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() { return [this, callback, ok, result_str, from, to, amount, fee, memo, markFeeGapRetry]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_; if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;

View File

@@ -1146,12 +1146,30 @@ void App::renderEncryptWalletDialog() {
// Strength meter bar // Strength meter bar
{ {
size_t len = strlen(encrypt_pass_buf_); size_t len = strlen(encrypt_pass_buf_);
// Character-class diversity: an all-digit or single-class
// string shouldn't score as high as a mixed one.
bool hasDigit = false, hasLower = false, hasUpper = false, hasSymbol = false;
for (const char* c = encrypt_pass_buf_; *c; ++c) {
unsigned char uc = static_cast<unsigned char>(*c);
if (uc >= '0' && uc <= '9') hasDigit = true;
else if (uc >= 'a' && uc <= 'z') hasLower = true;
else if (uc >= 'A' && uc <= 'Z') hasUpper = true;
else hasSymbol = true;
}
int classes = (int)hasDigit + (int)hasLower + (int)hasUpper + (int)hasSymbol;
const char* strengthLabel = "Weak"; const char* strengthLabel = "Weak";
ImVec4 strengthCol(0.9f, 0.2f, 0.2f, 1.0f); ImVec4 strengthCol(0.9f, 0.2f, 0.2f, 1.0f);
float strengthPct = 0.25f; float strengthPct = 0.25f;
if (len >= 16) { strengthLabel = "Strong"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; } int tier = 0; // 0=Weak, 1=Fair, 2=Good, 3=Strong
else if (len >= 12) { strengthLabel = "Good"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; } if (len >= 16) tier = 3;
else if (len >= 8) { strengthLabel = "Fair"; strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; } else if (len >= 12) tier = 2;
else if (len >= 8) tier = 1;
// Downgrade one tier when only a single character class is used.
if (classes <= 1 && tier > 0) tier -= 1;
if (tier == 3) { strengthLabel = "Strong"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; }
else if (tier == 2) { strengthLabel = "Good"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; }
else if (tier == 1) { strengthLabel = "Fair"; strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
float barW = ImGui::GetContentRegionAvail().x; float barW = ImGui::GetContentRegionAvail().x;
float barH = 4.0f; float barH = 4.0f;

View File

@@ -287,6 +287,19 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
} }
} liteSecretScrubber{input}; } liteSecretScrubber{input};
// Open/Restore both target a wallet path; reject an empty/whitespace-only one early so we
// never dispatch an unusable request (the scrubber above still wipes secrets on this return).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::OpenExisting ||
input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
std::string trimmedPath(s_settingsState.lite_wallet_path);
const auto first = trimmedPath.find_first_not_of(" \t\r\n");
if (first == std::string::npos) {
s_settingsState.lite_lifecycle_status = "Enter a wallet path";
s_settingsState.lite_lifecycle_summary.clear();
return;
}
}
// Restore needs a complete 24-word seed; reject early (the scrubber above still wipes the // Restore needs a complete 24-word seed; reject early (the scrubber above still wipes the
// entered secret on this return path). // entered secret on this return path).
if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) { if (input.request.operation == wallet::LiteWalletLifecycleOperation::RestoreFromSeed) {
@@ -398,7 +411,15 @@ static void loadSettingsPageState(config::Settings* settings) {
s_settingsState.verbose_logging = settings->getVerboseLogging(); s_settingsState.verbose_logging = settings->getVerboseLogging();
s_settingsState.debug_categories = settings->getDebugCategories(); s_settingsState.debug_categories = settings->getDebugCategories();
s_settingsState.debug_cats_dirty = false; s_settingsState.debug_cats_dirty = false;
s_settingsState.rpc_plaintext_remote = rpc::Connection::usesPlaintextRemote(rpc::Connection::autoDetectConfig()); // Populate the RPC fields from the auto-detected daemon config so they show the REAL connection
// (host/port/user/pass) instead of compile-time defaults. These are read-only in the UI: the RPC
// credentials come from the daemon's DRAGONX.conf, so this is an accurate display, not an editor.
const auto rpcCfg = rpc::Connection::autoDetectConfig();
snprintf(s_settingsState.rpc_host, sizeof(s_settingsState.rpc_host), "%s", rpcCfg.host.c_str());
snprintf(s_settingsState.rpc_port, sizeof(s_settingsState.rpc_port), "%s", rpcCfg.port.c_str());
snprintf(s_settingsState.rpc_user, sizeof(s_settingsState.rpc_user), "%s", rpcCfg.rpcuser.c_str());
snprintf(s_settingsState.rpc_password, sizeof(s_settingsState.rpc_password), "%s", rpcCfg.rpcpassword.c_str());
s_settingsState.rpc_plaintext_remote = rpc::Connection::usesPlaintextRemote(rpcCfg);
// Apply loaded visual effects settings // Apply loaded visual effects settings
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount); effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
@@ -2011,8 +2032,11 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(label); ImGui::TextUnformatted(label);
ImGui::SameLine(0, Layout::spacingXs()); ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputW); ImGui::SetNextItemWidth(inputW);
// Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf,
// so these fields DISPLAY the live connection (editing them here did nothing).
ImGui::InputText(id, buf, bufSz, ImGui::InputText(id, buf, bufSz,
password ? ImGuiInputTextFlags_Password : 0); ImGuiInputTextFlags_ReadOnly |
(password ? ImGuiInputTextFlags_Password : 0));
}; };
if (fourAcross) { if (fourAcross) {

View File

@@ -221,7 +221,19 @@ public:
ImGui::SameLine(0, Layout::spacingMd()); ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(saveLabel, ImVec2(saveW, 0), buttonFont)) { if (TactileButton(saveLabel, ImVec2(saveW, 0), buttonFont)) {
// Apply changes // Apply changes
s_app->setAddressLabel(s_address, s_label); // Trim surrounding whitespace; a whitespace-only label clears it
// (mirroring how an empty icon selection clears the icon).
std::string trimmedLabel(s_label);
{
std::size_t b = trimmedLabel.find_first_not_of(" \t\r\n");
if (b == std::string::npos) {
trimmedLabel.clear();
} else {
std::size_t e = trimmedLabel.find_last_not_of(" \t\r\n");
trimmedLabel = trimmedLabel.substr(b, e - b + 1);
}
}
s_app->setAddressLabel(s_address, trimmedLabel);
if (s_selectedIcon >= 0) if (s_selectedIcon >= 0)
s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon)); s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon));
else else

View File

@@ -226,7 +226,9 @@ public:
Notifications::instance().error(result.empty() ? TR("transfer_failed") : result); Notifications::instance().error(result.empty() ? TR("transfer_failed") : result);
} }
}); });
s_open = false; // Keep the dialog OPEN on submit: s_sending drives the button to a disabled "Sending…"
// state, and when the async callback sets s_resultMsg the in-dialog result screen shows
// (with its own Close button). Previously closing here made that result screen dead code.
} }
ImGui::EndDisabled(); ImGui::EndDisabled();

View File

@@ -115,6 +115,11 @@ void BlockInfoDialog::render(App* app)
ImGui::SetNextItemWidth(heightInput.width); ImGui::SetNextItemWidth(heightInput.width);
ImGui::InputInt("##Height", &s_height); ImGui::InputInt("##Height", &s_height);
if (s_height < 1) s_height = 1; if (s_height < 1) s_height = 1;
// Clamp to the chain tip so navigation/typing can't request a height
// above the tip (which would only yield a raw RPC error).
if (state.sync.blocks > 0 && s_height > state.sync.blocks) {
s_height = state.sync.blocks;
}
ImGui::SameLine(); ImGui::SameLine();
@@ -287,7 +292,8 @@ void BlockInfoDialog::render(App* app)
if (ImGui::IsItemHovered()) { if (ImGui::IsItemHovered()) {
material::Tooltip("%s", TR("block_click_next")); material::Tooltip("%s", TR("block_click_next"));
} }
if (ImGui::IsItemClicked()) { if (ImGui::IsItemClicked() &&
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
s_height++; s_height++;
s_has_data = false; s_has_data = false;
} }
@@ -308,7 +314,10 @@ void BlockInfoDialog::render(App* app)
ImGui::SameLine(); ImGui::SameLine();
} }
if (!s_next_hash.empty()) { // Only offer "Next" when below the chain tip (the tip block has no
// nextblockhash, so this stays hidden there).
if (!s_next_hash.empty() &&
(state.sync.blocks <= 0 || s_height < state.sync.blocks)) {
if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) { if (material::StyledButton(TR("block_nav_next"), ImVec2(0,0), S.resolveFont(closeBtn.font))) {
s_height++; s_height++;
s_has_data = false; s_has_data = false;

View File

@@ -215,11 +215,15 @@ private:
ImGui::Spacing(); ImGui::Spacing();
ImGui::Spacing(); ImGui::Spacing();
// Cancel button // Cancel button — once clicked, disable it so the request is visibly
// acknowledged (and can't be re-fired) until the worker actually stops.
float btnW = 100.0f * dp; float btnW = 100.0f * dp;
ImGui::BeginDisabled(s_cancelling);
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
s_cancelling = true;
s_bootstrap->cancel(); s_bootstrap->cancel();
} }
ImGui::EndDisabled();
// Check completion // Check completion
if (s_bootstrap->isDone()) { if (s_bootstrap->isDone()) {
@@ -234,6 +238,7 @@ private:
s_state = State::Failed; s_state = State::Failed;
} }
s_bootstrap.reset(); s_bootstrap.reset();
s_cancelling = false;
} }
} }
@@ -298,6 +303,7 @@ private:
s_bootstrap->start(dataDir, url); s_bootstrap->start(dataDir, url);
s_state = State::Downloading; s_state = State::Downloading;
s_errorMsg.clear(); s_errorMsg.clear();
s_cancelling = false;
} }
static inline bool s_open = false; static inline bool s_open = false;
@@ -305,6 +311,7 @@ private:
static inline State s_state = State::Confirm; static inline State s_state = State::Confirm;
static inline std::unique_ptr<util::Bootstrap> s_bootstrap; static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
static inline bool s_wasDaemonRunning = false; static inline bool s_wasDaemonRunning = false;
static inline bool s_cancelling = false; // cancel requested; disables the Cancel button until the worker stops
static inline std::string s_errorMsg; static inline std::string s_errorMsg;
}; };

View File

@@ -221,7 +221,7 @@ static void applyBlockDetailResult(const json& result, const std::string& error)
} }
if (result.is_null()) { if (result.is_null()) {
s_search_error = "Invalid response from daemon"; s_search_error = TR("explorer_invalid_response");
s_show_detail_modal = false; s_show_detail_modal = false;
return; return;
} }
@@ -388,7 +388,7 @@ static void navigateToHash(App* app, const std::string& hash) {
s_expanded_tx_idx = 0; s_expanded_tx_idx = 0;
s_show_detail_modal = false; s_show_detail_modal = false;
} else { } else {
s_search_error = "No block or transaction found for this hash"; s_search_error = TR("explorer_hash_not_found");
} }
}; };
}); });
@@ -417,6 +417,11 @@ static void performSearch(App* app, const std::string& query) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}); });
if (isHex64) { if (isHex64) {
// A hash lookup needs the daemon; navigateToHash() would otherwise return silently.
if (!app->rpc() || !app->rpc()->isConnected()) {
s_search_error = TR("explorer_not_connected");
return;
}
navigateToHash(app, query); navigateToHash(app, query);
return; return;
} }
@@ -1039,6 +1044,9 @@ static void renderBlockDetailModal(App* app) {
// Nav buttons on same line // Nav buttons on same line
ImGui::SameLine(contentW - Layout::spacingXl() * 3); ImGui::SameLine(contentW - Layout::spacingXl() * 3);
// Disable prev/next while a fetch is in flight so navigation can't stack fetches.
ImGui::BeginDisabled(s_detail_loading);
// Prev // Prev
if (s_detail_height > 1) { if (s_detail_height > 1) {
ImGui::PushFont(Type().iconMed()); ImGui::PushFont(Type().iconMed());
@@ -1065,6 +1073,8 @@ static void renderBlockDetailModal(App* app) {
ImGui::PopID(); ImGui::PopID();
ImGui::PopFont(); ImGui::PopFont();
} }
ImGui::EndDisabled();
} }
ImGui::Separator(); ImGui::Separator();

View File

@@ -25,6 +25,9 @@ namespace ui {
static bool s_open = false; static bool s_open = false;
static char s_filename[256] = ""; static char s_filename[256] = "";
static std::string s_status; static std::string s_status;
// Re-entrancy guard: true while a CSV write is in flight (disables the
// Export button so a second synchronous write can't be kicked off).
static bool s_exporting = false;
// Helper to escape CSV field // Helper to escape CSV field
static std::string escapeCSV(const std::string& field) static std::string escapeCSV(const std::string& field)
@@ -91,11 +94,13 @@ void ExportTransactionsDialog::render(App* app)
ImGui::Separator(); ImGui::Separator();
ImGui::Spacing(); ImGui::Spacing();
// Export button // Export button (disabled while a write is already in flight)
ImGui::BeginDisabled(s_exporting);
if (material::StyledButton(TR("export"), ImVec2(exportBtn.width, 0), S.resolveFont(exportBtn.font))) { if (material::StyledButton(TR("export"), ImVec2(exportBtn.width, 0), S.resolveFont(exportBtn.font))) {
if (state.transactions.empty()) { if (state.transactions.empty()) {
Notifications::instance().warning(TR("export_tx_none")); Notifications::instance().warning(TR("export_tx_none"));
} else { } else {
s_exporting = true;
std::string configDir = util::Platform::getConfigDir(); std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename; std::string filepath = configDir + "/" + s_filename;
@@ -142,8 +147,10 @@ void ExportTransactionsDialog::render(App* app)
" transactions to: " + filepath; " transactions to: " + filepath;
Notifications::instance().success(TR("export_tx_success"), 5.0f); Notifications::instance().success(TR("export_tx_success"), 5.0f);
} }
s_exporting = false;
} }
} }
ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();
if (material::StyledButton("Close", ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) { if (material::StyledButton("Close", ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {

View File

@@ -143,6 +143,15 @@ void KeyExportDialog::render(App* app)
ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching")); ImGui::TextColored(ImVec4(1.0f, 1.0f, 0.0f, 1.0f), "%s", TR("key_export_fetching"));
} else if (!s_error.empty()) { } else if (!s_error.empty()) {
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str()); ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), TR("error_format"), s_error.c_str());
// Not a dead-end: clearing the error falls back through to the Reveal button next frame.
ImGui::Spacing();
if (material::StyledButton(TR("retry"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {
s_error.clear();
}
} else if (s_key.empty() && s_address.length() < 26) {
// Guard against an empty/blatantly-malformed address before dispatching a doomed reveal.
// (Also avoids reading s_address[0] on an empty string in the Reveal branch below.)
ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", TR("invalid_address"));
} else if (s_key.empty()) { } else if (s_key.empty()) {
// Show button to fetch key // Show button to fetch key
if (material::StyledButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) { if (material::StyledButton(TR("key_export_reveal"), ImVec2(revealBtn.width, 0), S.resolveFont(revealBtn.font))) {

View File

@@ -189,6 +189,11 @@ ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
update.startPoolMining = true; update.startPoolMining = true;
update.startThreads = benchmark.optimal_threads; update.startThreads = benchmark.optimal_threads;
} }
} else {
// No candidate produced a nonzero hashrate (e.g. a pool that never
// reported a rate). Don't finish silently — flag the run as
// inconclusive so the caller can surface it (keeps this core pure).
update.inconclusive = true;
} }
} }
break; break;

View File

@@ -56,6 +56,7 @@ struct ThreadBenchmarkUpdate {
int startThreads = 0; int startThreads = 0;
bool saveOptimalThreads = false; bool saveOptimalThreads = false;
int optimalThreads = 0; int optimalThreads = 0;
bool inconclusive = false; // finished with no nonzero hashrate sample — caller should warn
}; };
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark, ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,

View File

@@ -176,8 +176,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
idleRightEdge = gBtnX - 4.0f * dp; idleRightEdge = gBtnX - 4.0f * dp;
} }
// Idle delay combo (to the left, when idle is enabled and NOT in thread scaling mode) // Idle delay combo renderer (shared by the non-scaling and thread-scaling
if (idleOn && !threadScaling) { // branches below). Draws right-aligned at idleRightEdge and advances it.
auto renderIdleDelayCombo = [&](const char* comboId) {
struct DelayOption { int seconds; const char* label; }; struct DelayOption { int seconds; const char* label; };
static const DelayOption delays[] = { static const DelayOption delays[] = {
{30, "30s"}, {60, "1m"}, {120, "2m"}, {300, "5m"}, {600, "10m"} {30, "30s"}, {60, "1m"}, {120, "2m"}, {300, "5m"}, {600, "10m"}
@@ -192,7 +193,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f; float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY)); ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
ImGui::SetNextItemWidth(comboW); ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##IdleDelay", previewLabel, ImGuiComboFlags_NoArrowButton)) { if (ImGui::BeginCombo(comboId, previewLabel, ImGuiComboFlags_NoArrowButton)) {
for (const auto& d : delays) { for (const auto& d : delays) {
bool selected = (d.seconds == curDelay); bool selected = (d.seconds == curDelay);
if (ImGui::Selectable(d.label, selected)) { if (ImGui::Selectable(d.label, selected)) {
@@ -206,6 +207,11 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
if (ImGui::IsItemHovered()) if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_idle_delay")); material::Tooltip("%s", TR("tt_idle_delay"));
idleRightEdge = comboX - 4.0f * dp; idleRightEdge = comboX - 4.0f * dp;
};
// Idle delay combo (to the left, when idle is enabled and NOT in thread scaling mode)
if (idleOn && !threadScaling) {
renderIdleDelayCombo("##IdleDelay");
} }
// Thread scaling controls: idle delay + active threads / idle threads combos // Thread scaling controls: idle delay + active threads / idle threads combos
@@ -213,36 +219,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
int hwThreads = std::max(1, (int)std::thread::hardware_concurrency()); int hwThreads = std::max(1, (int)std::thread::hardware_concurrency());
// Idle delay combo // Idle delay combo
{ renderIdleDelayCombo("##IdleDelayScale");
struct DelayOption { int seconds; const char* label; };
static const DelayOption delays[] = {
{30, "30s"}, {60, "1m"}, {120, "2m"}, {300, "5m"}, {600, "10m"}
};
int curDelay = app->settings()->getMineIdleDelay();
const char* previewLabel = "2m";
for (const auto& d : delays) {
if (d.seconds == curDelay) { previewLabel = d.label; break; }
}
float comboW = schema::UI().drawElement("components.settings-page", "idle-combo-width").sizeOr(64.0f);
float comboX = idleRightEdge - comboW;
float comboY = curY + (headerH - ImGui::GetFrameHeight()) * 0.5f;
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##IdleDelayScale", previewLabel, ImGuiComboFlags_NoArrowButton)) {
for (const auto& d : delays) {
bool selected = (d.seconds == curDelay);
if (ImGui::Selectable(d.label, selected)) {
app->settings()->setMineIdleDelay(d.seconds);
app->settings()->save();
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_idle_delay"));
idleRightEdge = comboX - 4.0f * dp;
}
// Idle threads combo (threads when system is idle) // Idle threads combo (threads when system is idle)
{ {
@@ -453,14 +430,27 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// Require a wallet address for pool mining // Require a wallet address for pool mining
std::string worker(s_pool_worker); std::string worker(s_pool_worker);
if (!worker.empty()) { if (!worker.empty()) {
s_benchmark.reset(); static bool s_benchConfirm = false;
s_benchmark.was_pool_running = state.pool_mining.xmrig_running; if (!s_benchConfirm) {
s_benchmark.prev_threads = s_selected_threads; // First click: build candidates so we can estimate the total duration, then
s_benchmark.buildCandidates(max_threads); // require a confirming click (the benchmark interrupts mining and runs a while).
s_benchmark.phase = ThreadBenchmark::Phase::Starting; s_benchmark.reset();
// Stop any active solo mining first s_benchmark.buildCandidates(max_threads);
if (mining.generate) s_benchConfirm = true;
app->stopMining(); char msg[128];
snprintf(msg, sizeof(msg),
"Benchmark takes ~%ds and interrupts mining. Click again to start.",
(int)(s_benchmark.totalEstimatedSecs() + 0.5f));
Notifications::instance().warning(msg);
} else {
// Second click: start (candidates already built above).
s_benchConfirm = false;
s_benchmark.was_pool_running = state.pool_mining.xmrig_running;
s_benchmark.prev_threads = s_selected_threads;
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
if (mining.generate)
app->stopMining();
}
} }
} }

View File

@@ -248,6 +248,11 @@ static void RenderMiningTabContent(App* app)
if (benchmarkUpdate.startPoolMining) { if (benchmarkUpdate.startPoolMining) {
app->startPoolMining(benchmarkUpdate.startThreads); app->startPoolMining(benchmarkUpdate.startThreads);
} }
if (benchmarkUpdate.inconclusive) {
Notifications::instance().warning(
"Benchmark inconclusive: no hashrate samples were recorded. "
"Check the pool connection and try again.");
}
} }
// ================================================================ // ================================================================

View File

@@ -160,7 +160,12 @@ void RenderLiteNetworkTab(App* app)
applyAndRebuild(); applyAndRebuild();
} }
ImGui::SameLine(ImGui::GetContentRegionAvail().x - 110.0f * dp); ImGui::SameLine(ImGui::GetContentRegionAvail().x - 110.0f * dp);
if (TactileButton(TR("lite_net_refresh"), ImVec2(100.0f * dp, 0))) startProbe(st); // While a probe is in flight, disable + relabel Refresh so there's clear in-progress feedback.
const bool probing = s_probe.busy();
ImGui::BeginDisabled(probing);
if (TactileButton(probing ? TR("lite_net_checking") : TR("lite_net_refresh"), ImVec2(100.0f * dp, 0)))
startProbe(st);
ImGui::EndDisabled();
if (randomMode) if (randomMode)
Type().textColored(TypeStyle::Caption, Primary(), TR("lite_net_random_active")); Type().textColored(TypeStyle::Caption, Primary(), TR("lite_net_random_active"));
ImGui::Spacing(); ImGui::Spacing();
@@ -191,7 +196,13 @@ void RenderLiteNetworkTab(App* app)
auto servers = st->getLiteServers(); auto servers = st->getLiteServers();
bool exists = false; bool exists = false;
for (const auto& s : servers) if (s.url == url) { exists = true; break; } for (const auto& s : servers) if (s.url == url) { exists = true; break; }
if (!exists) { if (exists) {
// Duplicate: keep the inputs (so it doesn't look like the add succeeded); only
// clear any stale invalid-URL error since this URL is valid. The user-facing
// "already in list" message needs a shared i18n key added centrally, so no new
// text is set here.
s_addError.clear();
} else {
config::Settings::LiteServerPreference p; config::Settings::LiteServerPreference p;
p.url = url; p.url = url;
p.label = !addLabel.empty() ? addLabel : url; p.label = !addLabel.empty() ? addLabel : url;
@@ -200,8 +211,8 @@ void RenderLiteNetworkTab(App* app)
st->setLiteServers(servers); st->setLiteServers(servers);
st->save(); st->save();
startProbe(st); startProbe(st);
s_addUrl[0] = '\0'; s_addLabel[0] = '\0'; s_addError.clear();
} }
s_addUrl[0] = '\0'; s_addLabel[0] = '\0'; s_addError.clear();
} }
} }
if (!s_addError.empty()) if (!s_addError.empty())

View File

@@ -37,6 +37,7 @@ static int s_selected_banned_idx = -1;
// Helper: Extract IP without port // Helper: Extract IP without port
static std::string ExtractIP(const std::string& addr) static std::string ExtractIP(const std::string& addr)
{ {
if (addr.empty()) return addr;
std::string ip = addr; std::string ip = addr;
if (ip[0] == '[') { if (ip[0] == '[') {
auto pos = ip.rfind("]:"); auto pos = ip.rfind("]:");

View File

@@ -290,27 +290,34 @@ static void RenderAddressDropdown(App* app, float width) {
snprintf(genLabel, sizeof(genLabel), "%s%s##recv", TR("generating"), material::LoadingDots()); snprintf(genLabel, sizeof(genLabel), "%s%s##recv", TR("generating"), material::LoadingDots());
TactileButton(genLabel, ImVec2(newBtnW, 0), schema::UI().resolveFont("button")); TactileButton(genLabel, ImVec2(newBtnW, 0), schema::UI().resolveFont("button"));
} else if (TactileButton(TrId("new", "recv").c_str(), ImVec2(newBtnW, 0), schema::UI().resolveFont("button"))) { } else if (TactileButton(TrId("new", "recv").c_str(), ImVec2(newBtnW, 0), schema::UI().resolveFont("button"))) {
s_generating_address = true; // createNewAddress can early-return without ever invoking the callback (e.g. connection
if (s_addr_type_filter != 2) { // dropped between this button being enabled and the click landing), which would leave
app->createNewZAddress([](const std::string& addr) { // s_generating_address stuck. Only enter the "generating…" state when a dispatch is possible.
s_generating_address = false; if (!app->isConnected()) {
if (addr.empty()) s_generating_address = false;
Notifications::instance().error(TR("failed_create_shielded"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
}
});
} else { } else {
app->createNewTAddress([](const std::string& addr) { s_generating_address = true;
s_generating_address = false; if (s_addr_type_filter != 2) {
if (addr.empty()) app->createNewZAddress([](const std::string& addr) {
Notifications::instance().error(TR("failed_create_transparent")); s_generating_address = false;
else { if (addr.empty())
s_pending_select_address = addr; Notifications::instance().error(TR("failed_create_shielded"));
Notifications::instance().success(TR("new_transparent_created")); else {
} s_pending_select_address = addr;
}); Notifications::instance().success(TR("new_shielded_created"));
}
});
} else {
app->createNewTAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_transparent"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_transparent_created"));
}
});
}
} }
} }
ImGui::EndDisabled(); ImGui::EndDisabled();
@@ -601,6 +608,7 @@ void RenderReceiveTab(App* app)
if (s_request_usd_mode && usd_price > 0) { if (s_request_usd_mode && usd_price > 0) {
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) { if (ImGui::InputDouble("##RequestAmountUSD", &s_request_usd_amount, 0, 0, "$%.2f")) {
if (s_request_usd_amount < 0) s_request_usd_amount = 0;
s_request_amount = s_request_usd_amount / usd_price; s_request_amount = s_request_usd_amount / usd_price;
} }
{ {
@@ -617,6 +625,7 @@ void RenderReceiveTab(App* app)
} else { } else {
ImGui::PushItemWidth(amtInputW); ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) { if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
if (s_request_amount < 0) s_request_amount = 0;
if (usd_price > 0) if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price; s_request_usd_amount = s_request_amount * usd_price;
} }
@@ -763,9 +772,14 @@ void RenderReceiveTab(App* app)
ImVec2(addrColW, memoInputH)); ImVec2(addrColW, memoInputH));
ImGui::PopItemWidth(); ImGui::PopItemWidth();
// The Sapling memo limit is in BYTES and matches the enforced buffer size
// (InputTextMultiline caps at sizeof(s_request_memo)); count against that, not the
// old display-only 256 value, and warn in colour once the field is at its cap.
size_t memo_len = strlen(s_request_memo); size_t memo_len = strlen(s_request_memo);
snprintf(buf, sizeof(buf), "%zu / %d", memo_len, (int)S.drawElement("tabs.receive", "memo-max-display-chars").size); size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf); bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
} }
// URI preview // URI preview

View File

@@ -25,7 +25,6 @@ static char s_address[512] = "";
static double s_amount = 0.0; static double s_amount = 0.0;
static char s_memo[512] = ""; static char s_memo[512] = "";
static char s_label[128] = ""; static char s_label[128] = "";
static int s_selected_addr_idx = -1;
static std::string s_payment_uri; static std::string s_payment_uri;
static uintptr_t s_qr_texture = 0; static uintptr_t s_qr_texture = 0;
static int s_qr_width = 0; static int s_qr_width = 0;
@@ -45,7 +44,6 @@ void RequestPaymentDialog::show(const std::string& address)
s_amount = 0.0; s_amount = 0.0;
s_memo[0] = '\0'; s_memo[0] = '\0';
s_label[0] = '\0'; s_label[0] = '\0';
s_selected_addr_idx = -1;
s_uri_dirty = true; s_uri_dirty = true;
if (!address.empty()) { if (!address.empty()) {
@@ -107,7 +105,7 @@ void RequestPaymentDialog::render(App* app)
label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate); label = label.substr(0, zAddrFrontLbl.truncate) + "..." + label.substr(label.length() - zAddrBackLbl.truncate);
} }
if (ImGui::Selectable(label.c_str(), s_address == addr.address)) { if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1); strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
s_uri_dirty = true; s_uri_dirty = true;
} }
@@ -124,7 +122,7 @@ void RequestPaymentDialog::render(App* app)
label = label.substr(0, tAddrFrontLbl.truncate) + "..." + label.substr(label.length() - tAddrBackLbl.truncate); label = label.substr(0, tAddrFrontLbl.truncate) + "..." + label.substr(label.length() - tAddrBackLbl.truncate);
} }
if (ImGui::Selectable(label.c_str(), s_address == addr.address)) { if (ImGui::Selectable(label.c_str(), addr.address == s_address)) {
strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1); strncpy(s_address, addr.address.c_str(), sizeof(s_address) - 1);
s_uri_dirty = true; s_uri_dirty = true;
} }

View File

@@ -725,7 +725,17 @@ void RenderSendConfirmPopup(App* app) {
{ {
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_network_fee")); Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_network_fee"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
double feeBefore = s_fee;
RenderFeeTierSelector("##confirm"); RenderFeeTierSelector("##confirm");
// If Max was selected, a fee bump here would push total over the available
// balance (s_amount was clamped to available - old fee). Re-clamp so the send
// stays within budget.
if (s_send_max && s_fee != feeBefore) {
double avail = GetAvailableBalance(app);
double maxAmount = avail - s_fee;
if (maxAmount < 0) maxAmount = 0;
s_amount = maxAmount;
}
// Recalculate total after potential fee change // Recalculate total after potential fee change
total = s_amount + s_fee; total = s_amount + s_fee;
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
@@ -1129,9 +1139,16 @@ void RenderSendTab(App* app)
// Handle pending payment from URI // Handle pending payment from URI
if (app->hasPendingPayment()) { if (app->hasPendingPayment()) {
// A URI arriving mid-compose would silently overwrite the user's in-progress form.
// Capture an undo snapshot first so "Undo" can restore what they were typing.
if (FormHasData()) {
SaveFormSnapshot();
}
strncpy(s_to_address, app->getPendingToAddress().c_str(), sizeof(s_to_address) - 1); strncpy(s_to_address, app->getPendingToAddress().c_str(), sizeof(s_to_address) - 1);
s_to_address[sizeof(s_to_address) - 1] = '\0';
s_amount = app->getPendingAmount(); s_amount = app->getPendingAmount();
strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1); strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1);
s_memo[sizeof(s_memo) - 1] = '\0';
app->clearPendingPayment(); app->clearPendingPayment();
} }

View File

@@ -181,7 +181,7 @@ void ShieldDialog::render(App* app)
const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds"); const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds");
if (material::StyledButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) { if (material::StyledButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
s_operation_pending = true; s_operation_pending = true;
s_status_message = "Submitting operation..."; s_status_message = TR("shield_submitting");
if (s_mode == Mode::ShieldCoinbase) { if (s_mode == Mode::ShieldCoinbase) {
std::string from(s_from_address), to(s_to_address); std::string from(s_from_address), to(s_to_address);
@@ -201,14 +201,14 @@ void ShieldDialog::render(App* app)
s_operation_pending = false; s_operation_pending = false;
if (error.empty()) { if (error.empty()) {
s_operation_id = result.value("opid", ""); s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id; s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
Notifications::instance().success(TR("shield_started")); Notifications::instance().success(TR("shield_started"));
// Register with the shared poller so an async failure is // Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes. // surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id); app->trackOperation(s_operation_id);
} else { } else {
s_status_message = "Error: " + error; s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error("Shield failed: " + error); Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
} }
}; };
}); });
@@ -235,14 +235,14 @@ void ShieldDialog::render(App* app)
s_operation_pending = false; s_operation_pending = false;
if (error.empty()) { if (error.empty()) {
s_operation_id = result.value("opid", ""); s_operation_id = result.value("opid", "");
s_status_message = "Operation submitted: " + s_operation_id; s_status_message = std::string(TR("shield_op_submitted")) + s_operation_id;
Notifications::instance().success(TR("merge_started")); Notifications::instance().success(TR("merge_started"));
// Register with the shared poller so an async failure is // Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes. // surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id); app->trackOperation(s_operation_id);
} else { } else {
s_status_message = "Error: " + error; s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error("Merge failed: " + error); Notifications::instance().error(std::string(TR("merge_send_failed")) + error);
} }
}; };
}); });
@@ -293,16 +293,16 @@ void ShieldDialog::render(App* app)
s_status_message = TR("shield_completed"); s_status_message = TR("shield_completed");
Notifications::instance().success(TR("shield_merge_done")); Notifications::instance().success(TR("shield_merge_done"));
} else if (status == "failed") { } else if (status == "failed") {
std::string errMsg = op.value("error", nlohmann::json{}).value("message", "Unknown error"); std::string errMsg = op.value("error", nlohmann::json{}).value("message", TR("shield_unknown_error"));
s_status_message = "Operation failed: " + errMsg; s_status_message = std::string(TR("shield_op_failed")) + errMsg;
Notifications::instance().error("Operation failed: " + errMsg); Notifications::instance().error(std::string(TR("shield_op_failed")) + errMsg);
} else if (status == "executing") { } else if (status == "executing") {
s_status_message = TR("shield_in_progress"); s_status_message = TR("shield_in_progress");
} else { } else {
s_status_message = "Status: " + status; s_status_message = std::string(TR("shield_status_label")) + status;
} }
} else if (!error.empty()) { } else if (!error.empty()) {
s_status_message = "Error checking status: " + error; s_status_message = std::string(TR("shield_status_check_error")) + error;
} }
}; };
}); });

View File

@@ -179,10 +179,15 @@ void TransactionDetailsDialog::render(App* app)
float start_x = (ImGui::GetWindowWidth() - total_width) / 2.0f; float start_x = (ImGui::GetWindowWidth() - total_width) / 2.0f;
ImGui::SetCursorPosX(start_x); ImGui::SetCursorPosX(start_x);
// Guard against an empty/whitespace explorer URL so we never open a garbage link.
std::string explorerBase = app->settings()->getTxExplorerUrl();
bool explorerValid = explorerBase.find_first_not_of(" \t\r\n") != std::string::npos;
if (!explorerValid) ImGui::BeginDisabled();
if (material::StyledButton(TR("tx_view_explorer"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) { if (material::StyledButton(TR("tx_view_explorer"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid; std::string url = explorerBase + tx.txid;
util::Platform::openUrl(url); util::Platform::openUrl(url);
} }
if (!explorerValid) ImGui::EndDisabled();
ImGui::SameLine(); ImGui::SameLine();

View File

@@ -1501,6 +1501,15 @@ void I18n::loadBuiltinEnglish()
strings_["shield_to_address"] = "To Address (Shielded):"; strings_["shield_to_address"] = "To Address (Shielded):";
strings_["shield_utxo_limit"] = "UTXO Limit:"; strings_["shield_utxo_limit"] = "UTXO Limit:";
strings_["shield_wildcard_hint"] = "Use '*' to shield from all transparent addresses"; strings_["shield_wildcard_hint"] = "Use '*' to shield from all transparent addresses";
strings_["shield_submitting"] = "Submitting operation...";
strings_["shield_op_submitted"] = "Operation submitted: ";
strings_["shield_op_failed"] = "Operation failed: ";
strings_["shield_error_prefix"] = "Error: ";
strings_["shield_status_label"] = "Status: ";
strings_["shield_status_check_error"] = "Error checking status: ";
strings_["shield_unknown_error"] = "Unknown error";
strings_["shield_send_failed"] = "Shield failed: ";
strings_["merge_send_failed"] = "Merge failed: ";
strings_["merge_description"] = "Merge multiple UTXOs into a single shielded address. This can help reduce wallet size and improve privacy."; strings_["merge_description"] = "Merge multiple UTXOs into a single shielded address. This can help reduce wallet size and improve privacy.";
strings_["merge_funds"] = "Merge Funds"; strings_["merge_funds"] = "Merge Funds";
strings_["merge_started"] = "Merge operation started"; strings_["merge_started"] = "Merge operation started";
@@ -1568,6 +1577,9 @@ void I18n::loadBuiltinEnglish()
strings_["explorer_tx_outputs"] = "Outputs"; strings_["explorer_tx_outputs"] = "Outputs";
strings_["explorer_tx_size"] = "Size"; strings_["explorer_tx_size"] = "Size";
strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash"; strings_["explorer_invalid_query"] = "Enter a block height or 64-character hash";
strings_["explorer_invalid_response"] = "Invalid response from daemon";
strings_["explorer_hash_not_found"] = "No block or transaction found for this hash";
strings_["explorer_not_connected"] = "Not connected to daemon — cannot look up a block or transaction hash";
strings_["explorer_no_results"] = "No matching cached blocks"; strings_["explorer_no_results"] = "No matching cached blocks";
} }