fix: Tier-2 UX robustness — action guards, in-progress guards, input trimming

The app-wide "import-key pattern" fixes (missing guards / untrimmed input / no
re-entrancy protection), from the robustness audit:

- Import-key dialog: unify the type indicator and the RPC dispatch on one
  classifier (classifyPrivateKey is now prefix-aware — an "SK..." shielded key no
  longer misroutes to the transparent RPC), trim manual input (not just Paste),
  and gate both the indicator and the Import button on a shared
  isRecognizedPrivateKey() so unrecognized/empty input can't be submitted.
- Shield: guard the submit button on connected + !syncing (like Send), with a
  disabled tooltip, instead of firing into a raw daemon error.
- Mining: disable the pool Mine button when the payout address is empty
  ("enter a payout address first"), and trim the pool URL/worker on persist.
- Console: track in-flight RPC commands (atomic counter + busy() override) so the
  input is disabled while a command runs instead of piling up on the worker.
- Maintenance (rescan/repair/delete-chain/reinstall-daemon): re-entrancy guard —
  bail with "already in progress" instead of launching a duplicate destructive op.
- Bootstrap dialog: re-attach to an in-flight download on reopen instead of
  resetting state and orphaning the running worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:09:23 -05:00
parent df14533ad3
commit 129a8e6449
10 changed files with 122 additions and 28 deletions

View File

@@ -30,8 +30,15 @@ class BootstrapDownloadDialog {
public:
static void show(App* app) {
if (!app || !app->supportsFullNodeLifecycleActions()) return;
s_open = true;
s_app = app;
// If a download is already in flight, RE-ATTACH to it (just reopen showing progress) instead of
// resetting state and orphaning the running worker / restarting an unverified download.
if (app->isBootstrapDownloading()) {
s_open = true;
if (s_state == State::Confirm) s_state = State::Downloading;
return;
}
s_open = true;
s_state = State::Confirm;
s_bootstrap.reset();
s_errorMsg.clear();

View File

@@ -54,6 +54,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
rpc::RPCWorker* worker = app_->consoleWorker();
if (worker) {
in_flight_.fetch_add(1); // gates busy() so the input is disabled until this returns
worker->post([rpc, method, params, this]() -> rpc::RPCWorker::MainCb {
std::string result_str;
bool is_error = false;
@@ -65,6 +66,7 @@ void FullNodeConsoleExecutor::submit(const std::string& cmd)
is_error = true;
}
return [this, result_str, is_error]() {
in_flight_.fetch_sub(1);
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
};

View File

@@ -12,6 +12,7 @@
#include "console_channel.h"
#include "imgui.h"
#include <atomic>
#include <cstdint>
#include <deque>
#include <functional>
@@ -91,9 +92,12 @@ public:
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
ConsoleStatusLine toolbarStatus() const override;
// A command is in flight on the RPC worker — lets the UI disable the input (no queued pile-up).
bool busy() const override { return in_flight_.load() > 0; }
private:
App* app_;
std::atomic<int> in_flight_{0};
size_t last_daemon_output_size_ = 0;
size_t last_xmrig_output_size_ = 0;
int last_daemon_state_ = -1; // daemon::EmbeddedDaemon::State as int

View File

@@ -690,8 +690,12 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// If pool mining is still shutting down after switching to solo,
// keep the button enabled so user can stop it.
bool poolStillRunning = !s_pool_mode && state.pool_mining.xmrig_running;
// Can't start pool mining without a payout address (blank for a new wallet with no z-address);
// only blocks starting — stopping a running miner stays enabled.
bool poolNeedsPayout = s_pool_mode && !state.pool_mining.xmrig_running &&
std::string(s_pool_worker).empty();
bool disabled = s_pool_mode
? (isToggling || poolBlockedBySolo)
? (isToggling || poolBlockedBySolo || poolNeedsPayout)
: (poolStillRunning ? false : (!app->isConnected() || isToggling || isSyncing));
// Glass panel background with state-dependent tint
@@ -821,6 +825,8 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
material::Tooltip(TR("mining_syncing_tooltip"), state.sync.verification_progress * 100.0);
else if (poolBlockedBySolo)
material::Tooltip("%s", TR("mining_stop_solo_for_pool"));
else if (poolNeedsPayout)
material::Tooltip("%s", "Enter a payout address first (generate a Z address)");
else
material::Tooltip("%s", isMiningActive ? TR("stop_mining") : TR("start_mining"));
}

View File

@@ -199,8 +199,19 @@ static void RenderMiningTabContent(App* app)
// Persist pool settings when dirty and no field is active
if (s_pool_settings_dirty && !ImGui::IsAnyItemActive()) {
app->settings()->setPoolUrl(s_pool_url);
app->settings()->setPoolWorker(s_pool_worker);
// Trim whitespace/newlines (a pasted URL or payout address often carries a trailing newline)
// before persisting and feeding it to xmrig; write the trimmed value back so the field agrees.
auto trimmed = [](const char* b) {
std::string s(b);
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back();
return s;
};
std::string poolUrl = trimmed(s_pool_url), poolWorker = trimmed(s_pool_worker);
snprintf(s_pool_url, sizeof(s_pool_url), "%s", poolUrl.c_str());
snprintf(s_pool_worker, sizeof(s_pool_worker), "%s", poolWorker.c_str());
app->settings()->setPoolUrl(poolUrl);
app->settings()->setPoolWorker(poolWorker);
app->settings()->save();
s_pool_settings_dirty = false;

View File

@@ -166,9 +166,12 @@ void ShieldDialog::render(App* app)
ImGui::Spacing();
}
// Buttons
bool can_submit = !s_operation_pending && s_to_address[0] != '\0';
// Buttons — guard on connection/sync like the Send tab (a disconnected or mid-sync submit just
// fails at the daemon with a raw error).
bool sh_connected = app->isConnected();
bool sh_syncing = state.sync.syncing;
bool can_submit = !s_operation_pending && s_to_address[0] != '\0' && sh_connected && !sh_syncing;
if (!can_submit) ImGui::BeginDisabled();
const char* btn_label = (s_mode == Mode::ShieldCoinbase) ? TR("shield_funds") : TR("merge_funds");
@@ -244,7 +247,12 @@ void ShieldDialog::render(App* app)
}
if (!can_submit) ImGui::EndDisabled();
if (!can_submit && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled)) {
if (!sh_connected) material::Tooltip("%s", TR("send_tooltip_not_connected"));
else if (sh_syncing) material::Tooltip("%s", TR("send_tooltip_syncing"));
else if (s_to_address[0]=='\0') material::Tooltip("%s", TR("shield_select_z"));
}
ImGui::SameLine();
if (material::StyledButton(TR("cancel"), ImVec2(cancelBtn.width, 0), S.resolveFont(cancelBtn.font))) {