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.version = xs.version;
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)
double memMB = xmrig_manager_->getMemoryUsageMB();
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()) {
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).
@@ -1243,6 +1249,15 @@ void App::render()
// Process deferred encryption from wizard (runs in background)
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
// before the sidebar reads current_page_ (below) so the forced page is reflected.
updateScreenshotSweep();
@@ -2263,6 +2278,7 @@ void App::renderLiteFirstRunPrompt()
static std::vector<std::pair<std::string, bool>> chips; // shuffled (word, consumed)
static int progress = 0; // # words confirmed in order
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
// Restore-from-seed step (step 3). The seed buffer is SECRET — wiped in finish() and on Back.
static char restoreSeed[512] = {};
@@ -2290,6 +2306,7 @@ void App::renderLiteFirstRunPrompt()
chips.clear();
progress = 0;
step = 0;
skipConfirm = false;
creating = false;
lite_firstrun_dismissed_ = true;
ImGui::CloseCurrentPopup();
@@ -2400,14 +2417,28 @@ void App::renderLiteFirstRunPrompt()
std::mt19937 rng{std::random_device{}()};
std::shuffle(chips.begin(), chips.end(), rng);
progress = 0;
skipConfirm = false;
step = 2;
}
ImGui::SameLine();
if (ui::material::TactileButton("Copy", ImVec2(80, 0))) copySecretToClipboard(seed);
ImGui::SameLine();
if (ui::material::TactileButton("Skip", ImVec2(80, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
if (!skipConfirm) {
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) {
// ── Verify: tap the words in order ──────────────────────────────────────
@@ -2456,11 +2487,24 @@ void App::renderLiteFirstRunPrompt()
}
if (!verified) ImGui::EndDisabled();
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();
if (ui::material::TactileButton("Skip", ImVec2(80, 0))) {
ui::Notifications::instance().success(TR("lite_welcome_created"), 6.0f);
finish();
if (ui::material::TactileButton(skipConfirm ? "Skip anyway" : "Skip", ImVec2(120, 0))) {
if (!skipConfirm) {
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) {
// ── Restore from an existing seed phrase ────────────────────────────────
@@ -3344,6 +3388,8 @@ void App::deleteBlockchainData()
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
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;
// 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
ImGuiViewport* vp = ImGui::GetMainViewport();
ImVec2 vp_pos = vp->Pos;
@@ -3499,8 +3556,9 @@ void App::renderShutdownScreen()
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
ImGuiWindowFlags_NoSavedSettings;
// Allow input after 10s so Force Quit button is clickable
if (shutdown_timer_ < 10.0f)
// Allow input only once Force Quit is offered (status stalled / hard ceiling), so the screen stays
// click-through while a normal shutdown is progressing.
if (!allowForceQuit)
shutdownFlags |= ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav;
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();
// 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");
ImVec2 btnSize(ImGui::CalcTextSize(forceLabel).x + 32.0f, 0);
ImGui::SetCursorPosX(cx - btnSize.x * 0.5f);

View File

@@ -601,6 +601,10 @@ private:
// Daemon restart (e.g. after changing debug log categories)
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
float encryption_check_timer_ = 0.0f;
@@ -702,6 +706,10 @@ private:
// Mining toggle guard (prevents concurrent setgenerate calls)
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)
std::atomic<bool> auto_shield_pending_{false};

View File

@@ -1949,6 +1949,10 @@ void App::startPoolMining(int threads)
} else {
ui::Notifications::instance().error("Failed to start pool miner: " + err);
}
} else {
// Miner spawned — it still needs a few seconds to connect to the pool and start hashing.
pool_starting_.store(true, std::memory_order_relaxed);
ui::Notifications::instance().info("Starting pool miner — connecting to the pool…");
}
}
@@ -2406,6 +2410,10 @@ void App::importPrivateKey(const std::string& rawKey, std::function<void(bool, c
else rpc_->call("importprivkey", {key, "", true}); // label "", rescan
} catch (const std::exception& e) {
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]() {
if (!err.empty()) {
@@ -2558,6 +2566,10 @@ void App::submitZSendMany(const std::string& from, const std::string& to, double
ok = true;
} catch (const std::exception& e) {
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]() {
if (send_submissions_in_flight_ > 0) --send_submissions_in_flight_;

View File

@@ -1146,12 +1146,30 @@ void App::renderEncryptWalletDialog() {
// Strength meter bar
{
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";
ImVec4 strengthCol(0.9f, 0.2f, 0.2f, 1.0f);
float strengthPct = 0.25f;
if (len >= 16) { strengthLabel = "Strong"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 1.0f; }
else if (len >= 12) { strengthLabel = "Good"; strengthCol = ImVec4(0.3f,0.9f,0.5f,1); strengthPct = 0.75f; }
else if (len >= 8) { strengthLabel = "Fair"; strengthCol = ImVec4(1,0.7f,0.3f,1); strengthPct = 0.5f; }
int tier = 0; // 0=Weak, 1=Fair, 2=Good, 3=Strong
if (len >= 16) tier = 3;
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 barH = 4.0f;

View File

@@ -287,6 +287,19 @@ static void evaluateLiteLifecycleRequestFromPageState(App* app) {
}
} 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
// entered secret on this return path).
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.debug_categories = settings->getDebugCategories();
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
effects::ImGuiAcrylic::ApplyBlurAmount(s_settingsState.blur_amount);
@@ -2011,8 +2032,11 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(label);
ImGui::SameLine(0, Layout::spacingXs());
ImGui::SetNextItemWidth(inputW);
// Read-only: the RPC credentials are auto-detected from the daemon's DRAGONX.conf,
// so these fields DISPLAY the live connection (editing them here did nothing).
ImGui::InputText(id, buf, bufSz,
password ? ImGuiInputTextFlags_Password : 0);
ImGuiInputTextFlags_ReadOnly |
(password ? ImGuiInputTextFlags_Password : 0));
};
if (fourAcross) {

View File

@@ -221,7 +221,19 @@ public:
ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(saveLabel, ImVec2(saveW, 0), buttonFont)) {
// 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)
s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon));
else

View File

@@ -226,7 +226,9 @@ public:
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();

View File

@@ -115,6 +115,11 @@ void BlockInfoDialog::render(App* app)
ImGui::SetNextItemWidth(heightInput.width);
ImGui::InputInt("##Height", &s_height);
if (s_height < 1) s_height = 1;
// Clamp to the chain tip so navigation/typing can't request a height
// 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();
@@ -287,7 +292,8 @@ void BlockInfoDialog::render(App* app)
if (ImGui::IsItemHovered()) {
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_has_data = false;
}
@@ -308,7 +314,10 @@ void BlockInfoDialog::render(App* app)
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))) {
s_height++;
s_has_data = false;

View File

@@ -215,11 +215,15 @@ private:
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;
ImGui::BeginDisabled(s_cancelling);
if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) {
s_cancelling = true;
s_bootstrap->cancel();
}
ImGui::EndDisabled();
// Check completion
if (s_bootstrap->isDone()) {
@@ -234,6 +238,7 @@ private:
s_state = State::Failed;
}
s_bootstrap.reset();
s_cancelling = false;
}
}
@@ -298,6 +303,7 @@ private:
s_bootstrap->start(dataDir, url);
s_state = State::Downloading;
s_errorMsg.clear();
s_cancelling = false;
}
static inline bool s_open = false;
@@ -305,6 +311,7 @@ private:
static inline State s_state = State::Confirm;
static inline std::unique_ptr<util::Bootstrap> s_bootstrap;
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;
};

View File

@@ -221,7 +221,7 @@ static void applyBlockDetailResult(const json& result, const std::string& error)
}
if (result.is_null()) {
s_search_error = "Invalid response from daemon";
s_search_error = TR("explorer_invalid_response");
s_show_detail_modal = false;
return;
}
@@ -388,7 +388,7 @@ static void navigateToHash(App* app, const std::string& hash) {
s_expanded_tx_idx = 0;
s_show_detail_modal = false;
} 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');
});
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);
return;
}
@@ -1039,6 +1044,9 @@ static void renderBlockDetailModal(App* app) {
// Nav buttons on same line
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
if (s_detail_height > 1) {
ImGui::PushFont(Type().iconMed());
@@ -1065,6 +1073,8 @@ static void renderBlockDetailModal(App* app) {
ImGui::PopID();
ImGui::PopFont();
}
ImGui::EndDisabled();
}
ImGui::Separator();

View File

@@ -25,6 +25,9 @@ namespace ui {
static bool s_open = false;
static char s_filename[256] = "";
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
static std::string escapeCSV(const std::string& field)
@@ -91,14 +94,16 @@ void ExportTransactionsDialog::render(App* app)
ImGui::Separator();
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 (state.transactions.empty()) {
Notifications::instance().warning(TR("export_tx_none"));
} else {
s_exporting = true;
std::string configDir = util::Platform::getConfigDir();
std::string filepath = configDir + "/" + s_filename;
std::ofstream file(filepath);
if (!file.is_open()) {
s_status = "Failed to create file";
@@ -138,13 +143,15 @@ void ExportTransactionsDialog::render(App* app)
file.close();
s_status = "Exported " + std::to_string(state.transactions.size()) +
s_status = "Exported " + std::to_string(state.transactions.size()) +
" transactions to: " + filepath;
Notifications::instance().success(TR("export_tx_success"), 5.0f);
}
s_exporting = false;
}
}
ImGui::EndDisabled();
ImGui::SameLine();
if (material::StyledButton("Close", ImVec2(closeBtn.width, 0), S.resolveFont(closeBtn.font))) {
s_open = false;

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"));
} else if (!s_error.empty()) {
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()) {
// Show button to fetch key
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.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;

View File

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

View File

@@ -176,8 +176,9 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
idleRightEdge = gBtnX - 4.0f * dp;
}
// Idle delay combo (to the left, when idle is enabled and NOT in thread scaling mode)
if (idleOn && !threadScaling) {
// Idle delay combo renderer (shared by the non-scaling and thread-scaling
// branches below). Draws right-aligned at idleRightEdge and advances it.
auto renderIdleDelayCombo = [&](const char* comboId) {
struct DelayOption { int seconds; const char* label; };
static const DelayOption delays[] = {
{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;
ImGui::SetCursorScreenPos(ImVec2(comboX, comboY));
ImGui::SetNextItemWidth(comboW);
if (ImGui::BeginCombo("##IdleDelay", previewLabel, ImGuiComboFlags_NoArrowButton)) {
if (ImGui::BeginCombo(comboId, previewLabel, ImGuiComboFlags_NoArrowButton)) {
for (const auto& d : delays) {
bool selected = (d.seconds == curDelay);
if (ImGui::Selectable(d.label, selected)) {
@@ -206,6 +207,11 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
if (ImGui::IsItemHovered())
material::Tooltip("%s", TR("tt_idle_delay"));
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
@@ -213,36 +219,7 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
int hwThreads = std::max(1, (int)std::thread::hardware_concurrency());
// Idle delay combo
{
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;
}
renderIdleDelayCombo("##IdleDelayScale");
// 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
std::string worker(s_pool_worker);
if (!worker.empty()) {
s_benchmark.reset();
s_benchmark.was_pool_running = state.pool_mining.xmrig_running;
s_benchmark.prev_threads = s_selected_threads;
s_benchmark.buildCandidates(max_threads);
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
// Stop any active solo mining first
if (mining.generate)
app->stopMining();
static bool s_benchConfirm = false;
if (!s_benchConfirm) {
// First click: build candidates so we can estimate the total duration, then
// require a confirming click (the benchmark interrupts mining and runs a while).
s_benchmark.reset();
s_benchmark.buildCandidates(max_threads);
s_benchConfirm = true;
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) {
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();
}
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)
Type().textColored(TypeStyle::Caption, Primary(), TR("lite_net_random_active"));
ImGui::Spacing();
@@ -191,7 +196,13 @@ void RenderLiteNetworkTab(App* app)
auto servers = st->getLiteServers();
bool exists = false;
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;
p.url = url;
p.label = !addLabel.empty() ? addLabel : url;
@@ -200,8 +211,8 @@ void RenderLiteNetworkTab(App* app)
st->setLiteServers(servers);
st->save();
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())

View File

@@ -37,6 +37,7 @@ static int s_selected_banned_idx = -1;
// Helper: Extract IP without port
static std::string ExtractIP(const std::string& addr)
{
if (addr.empty()) return addr;
std::string ip = addr;
if (ip[0] == '[') {
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());
TactileButton(genLabel, 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;
if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded"));
else {
s_pending_select_address = addr;
Notifications::instance().success(TR("new_shielded_created"));
}
});
// createNewAddress can early-return without ever invoking the callback (e.g. connection
// dropped between this button being enabled and the click landing), which would leave
// s_generating_address stuck. Only enter the "generating…" state when a dispatch is possible.
if (!app->isConnected()) {
s_generating_address = false;
} 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"));
}
});
s_generating_address = true;
if (s_addr_type_filter != 2) {
app->createNewZAddress([](const std::string& addr) {
s_generating_address = false;
if (addr.empty())
Notifications::instance().error(TR("failed_create_shielded"));
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();
@@ -601,6 +608,7 @@ void RenderReceiveTab(App* app)
if (s_request_usd_mode && usd_price > 0) {
ImGui::PushItemWidth(amtInputW);
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;
}
{
@@ -617,6 +625,7 @@ void RenderReceiveTab(App* app)
} else {
ImGui::PushItemWidth(amtInputW);
if (ImGui::InputDouble("##RequestAmount", &s_request_amount, 0, 0, "%.8f")) {
if (s_request_amount < 0) s_request_amount = 0;
if (usd_price > 0)
s_request_usd_amount = s_request_amount * usd_price;
}
@@ -763,9 +772,14 @@ void RenderReceiveTab(App* app)
ImVec2(addrColW, memoInputH));
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);
snprintf(buf, sizeof(buf), "%zu / %d", memo_len, (int)S.drawElement("tabs.receive", "memo-max-display-chars").size);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
size_t memoMax = (size_t)S.drawElement("business", "memo-max-length").size;
bool memoAtCap = memo_len + 1 >= memoMax;
snprintf(buf, sizeof(buf), "%zu / %zu bytes", memo_len, memoMax);
Type().textColored(TypeStyle::Caption, memoAtCap ? Warning() : OnSurfaceDisabled(), buf);
}
// URI preview

View File

@@ -25,7 +25,6 @@ static char s_address[512] = "";
static double s_amount = 0.0;
static char s_memo[512] = "";
static char s_label[128] = "";
static int s_selected_addr_idx = -1;
static std::string s_payment_uri;
static uintptr_t s_qr_texture = 0;
static int s_qr_width = 0;
@@ -45,7 +44,6 @@ void RequestPaymentDialog::show(const std::string& address)
s_amount = 0.0;
s_memo[0] = '\0';
s_label[0] = '\0';
s_selected_addr_idx = -1;
s_uri_dirty = true;
if (!address.empty()) {
@@ -107,13 +105,13 @@ void RequestPaymentDialog::render(App* app)
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);
s_uri_dirty = true;
}
}
}
// T-addresses
if (!state.t_addresses.empty()) {
ImGui::TextDisabled("%s", TR("request_transparent_addrs"));
@@ -124,7 +122,7 @@ void RequestPaymentDialog::render(App* app)
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);
s_uri_dirty = true;
}

View File

@@ -725,7 +725,17 @@ void RenderSendConfirmPopup(App* app) {
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_network_fee"));
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
double feeBefore = s_fee;
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
total = s_amount + s_fee;
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
@@ -1129,9 +1139,16 @@ void RenderSendTab(App* app)
// Handle pending payment from URI
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);
s_to_address[sizeof(s_to_address) - 1] = '\0';
s_amount = app->getPendingAmount();
strncpy(s_memo, app->getPendingMemo().c_str(), sizeof(s_memo) - 1);
s_memo[sizeof(s_memo) - 1] = '\0';
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");
if (material::StyledButton(btn_label, ImVec2(shieldBtn.width, 0), S.resolveFont(shieldBtn.font))) {
s_operation_pending = true;
s_status_message = "Submitting operation...";
s_status_message = TR("shield_submitting");
if (s_mode == Mode::ShieldCoinbase) {
std::string from(s_from_address), to(s_to_address);
@@ -201,14 +201,14 @@ void ShieldDialog::render(App* app)
s_operation_pending = false;
if (error.empty()) {
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"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else {
s_status_message = "Error: " + error;
Notifications::instance().error("Shield failed: " + error);
s_status_message = std::string(TR("shield_error_prefix")) + error;
Notifications::instance().error(std::string(TR("shield_send_failed")) + error);
}
};
});
@@ -235,14 +235,14 @@ void ShieldDialog::render(App* app)
s_operation_pending = false;
if (error.empty()) {
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"));
// Register with the shared poller so an async failure is
// surfaced (and balances refresh) even after this dialog closes.
app->trackOperation(s_operation_id);
} else {
s_status_message = "Error: " + error;
Notifications::instance().error("Merge failed: " + error);
s_status_message = std::string(TR("shield_error_prefix")) + 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");
Notifications::instance().success(TR("shield_merge_done"));
} else if (status == "failed") {
std::string errMsg = op.value("error", nlohmann::json{}).value("message", "Unknown error");
s_status_message = "Operation failed: " + errMsg;
Notifications::instance().error("Operation failed: " + errMsg);
std::string errMsg = op.value("error", nlohmann::json{}).value("message", TR("shield_unknown_error"));
s_status_message = std::string(TR("shield_op_failed")) + errMsg;
Notifications::instance().error(std::string(TR("shield_op_failed")) + errMsg);
} else if (status == "executing") {
s_status_message = TR("shield_in_progress");
} else {
s_status_message = "Status: " + status;
s_status_message = std::string(TR("shield_status_label")) + status;
}
} 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;
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))) {
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
std::string url = explorerBase + tx.txid;
util::Platform::openUrl(url);
}
if (!explorerValid) ImGui::EndDisabled();
ImGui::SameLine();

View File

@@ -1501,6 +1501,15 @@ void I18n::loadBuiltinEnglish()
strings_["shield_to_address"] = "To Address (Shielded):";
strings_["shield_utxo_limit"] = "UTXO Limit:";
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_funds"] = "Merge Funds";
strings_["merge_started"] = "Merge operation started";
@@ -1568,6 +1577,9 @@ void I18n::loadBuiltinEnglish()
strings_["explorer_tx_outputs"] = "Outputs";
strings_["explorer_tx_size"] = "Size";
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";
}