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>
This commit is contained in:
2026-07-05 15:08:43 -05:00
parent 1dbbd44759
commit 41f5548593
6 changed files with 86 additions and 30 deletions

View File

@@ -2410,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()) {
@@ -2562,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

@@ -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

@@ -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,14 +94,16 @@ 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;
std::ofstream file(filepath); std::ofstream file(filepath);
if (!file.is_open()) { if (!file.is_open()) {
s_status = "Failed to create file"; s_status = "Failed to create file";
@@ -138,13 +143,15 @@ void ExportTransactionsDialog::render(App* app)
file.close(); file.close();
s_status = "Exported " + std::to_string(state.transactions.size()) + s_status = "Exported " + std::to_string(state.transactions.size()) +
" 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))) {
s_open = false; 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")); 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

@@ -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

@@ -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