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

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

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

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

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