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