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:
@@ -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_;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user