Refactor app services and stabilize refresh/UI flows
- Add refresh scheduler and network refresh service boundaries for typed refresh results, ordered RPC collectors, applicators, and price parsing. - Add daemon lifecycle and wallet security workflow helpers while preserving App-owned command RPC, decrypt, cancellation, and UI handoff behavior. - Split balance, console, mining, amount formatting, and async task logic into focused modules with expanded Phase 4 test coverage. - Fix market price loading by triggering price refresh immediately, avoiding queue-pressure drops, tracking loading/error state, and adding translations. - Polish send, explorer, peers, settings, theme/schema, and related tab UI. - Replace checked-in generated language headers with build-generated resources. - Document the cleanup audit, UI static-state guidance, and architecture updates.
This commit is contained in:
347
src/app.cpp
347
src/app.cpp
@@ -11,7 +11,9 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "daemon/xmrig_manager.h"
|
||||
#include "ui/windows/main_window.h"
|
||||
#include "ui/windows/balance_tab.h"
|
||||
@@ -82,6 +84,7 @@
|
||||
#include <filesystem>
|
||||
#include <thread>
|
||||
#include <chrono>
|
||||
#include <exception>
|
||||
|
||||
#ifdef _WIN32
|
||||
#include <windows.h>
|
||||
@@ -96,6 +99,47 @@ using json = nlohmann::json;
|
||||
App::App() = default;
|
||||
App::~App() = default;
|
||||
|
||||
bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
|
||||
{
|
||||
const char* label = context ? context : "App";
|
||||
try {
|
||||
client.call("stop");
|
||||
DEBUG_LOGF("[%s] Stop command sent\n", label);
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[%s] Stop RPC failed: %s\n", label, e.what());
|
||||
} catch (...) {
|
||||
DEBUG_LOGF("[%s] Stop RPC failed with unknown exception\n", label);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
class AppDaemonLifecycleRuntime final : public daemon::DaemonController::LifecycleRuntime {
|
||||
public:
|
||||
explicit AppDaemonLifecycleRuntime(App& app) : app_(app) {}
|
||||
|
||||
void stopDaemonWithPolicy() override { app_.stopEmbeddedDaemon(); }
|
||||
bool startDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
void resetOutputOffset() override { app_.daemon_output_offset_ = 0; }
|
||||
|
||||
void requestRpcStopAndDisconnect(const char* context, const char* reason) override
|
||||
{
|
||||
if (app_.rpc_ && app_.rpc_->isConnected()) {
|
||||
app_.sendStopCommandSafely(*app_.rpc_, context);
|
||||
app_.rpc_->disconnect();
|
||||
}
|
||||
app_.onDisconnected(reason ? reason : "Daemon lifecycle");
|
||||
}
|
||||
|
||||
int deleteBlockchainData() override
|
||||
{
|
||||
return daemon::BlockchainDataCleaner::removeBlockchainData(util::Platform::getDragonXDataDir());
|
||||
}
|
||||
|
||||
private:
|
||||
App& app_;
|
||||
};
|
||||
|
||||
bool App::init()
|
||||
{
|
||||
DEBUG_LOGF("Initializing ObsidianDragon...\n");
|
||||
@@ -219,6 +263,7 @@ bool App::init()
|
||||
// Initialize background RPC worker thread
|
||||
worker_ = std::make_unique<rpc::RPCWorker>();
|
||||
worker_->start();
|
||||
network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);
|
||||
|
||||
// Forward error/warning notifications to the console tab
|
||||
// Use ConsoleTab colors so the Errors filter works correctly
|
||||
@@ -331,6 +376,7 @@ void App::update()
|
||||
if (fast_worker_) {
|
||||
fast_worker_->drainResults();
|
||||
}
|
||||
async_tasks_.reapCompleted();
|
||||
|
||||
// Auto-lock check (only when connected + encrypted + unlocked)
|
||||
if (state_.connected && state_.isUnlocked()) {
|
||||
@@ -346,21 +392,13 @@ void App::update()
|
||||
state_.rebuildAddressList();
|
||||
}
|
||||
|
||||
// Update timers
|
||||
core_timer_ += io.DeltaTime;
|
||||
address_timer_ += io.DeltaTime;
|
||||
transaction_timer_ += io.DeltaTime;
|
||||
peer_timer_ += io.DeltaTime;
|
||||
price_timer_ += io.DeltaTime;
|
||||
fast_refresh_timer_ += io.DeltaTime;
|
||||
tx_age_timer_ += io.DeltaTime;
|
||||
opid_poll_timer_ += io.DeltaTime;
|
||||
using RefreshTimer = services::NetworkRefreshService::Timer;
|
||||
network_refresh_.tick(io.DeltaTime);
|
||||
|
||||
// Fast refresh (mining stats + daemon memory) every second
|
||||
// Skip when wallet is locked — no need to poll, and queued tasks
|
||||
// would delay the PIN unlock worker task.
|
||||
if (fast_refresh_timer_ >= FAST_REFRESH_INTERVAL) {
|
||||
fast_refresh_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Fast)) {
|
||||
if (state_.connected && !state_.isLocked()) {
|
||||
refreshMiningInfo();
|
||||
|
||||
@@ -435,13 +473,13 @@ void App::update()
|
||||
}
|
||||
|
||||
// Populate solo mining log lines from daemon output
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
state_.mining.log_lines = embedded_daemon_->getRecentLines(50);
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
state_.mining.log_lines = daemon_controller_->recentLines(50);
|
||||
}
|
||||
|
||||
// Check daemon output for rescan progress (offloaded to worker)
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
std::string newOutput = embedded_daemon_->getOutputSince(daemon_output_offset_);
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
std::string newOutput = daemon_controller_->outputSince(daemon_output_offset_);
|
||||
if (!newOutput.empty() && fast_worker_) {
|
||||
fast_worker_->post([this, output = std::move(newOutput)]() -> rpc::RPCWorker::MainCb {
|
||||
// Parse on worker thread — pure string work, no shared state access
|
||||
@@ -524,7 +562,7 @@ void App::update()
|
||||
};
|
||||
});
|
||||
}
|
||||
} else if (!embedded_daemon_ || !embedded_daemon_->isRunning()) {
|
||||
} else if (!daemon_controller_ || !daemon_controller_->isRunning()) {
|
||||
// Clear rescan state if daemon is not running (but preserve during restart)
|
||||
if (state_.sync.rescanning && state_.sync.rescan_progress >= 0.99f) {
|
||||
state_.sync.rescanning = false;
|
||||
@@ -534,9 +572,9 @@ void App::update()
|
||||
}
|
||||
|
||||
// Poll pending z_sendmany operations for completion
|
||||
if (opid_poll_timer_ >= OPID_POLL_INTERVAL && !pending_opids_.empty()
|
||||
if (network_refresh_.isDue(RefreshTimer::Opid) && !pending_opids_.empty()
|
||||
&& state_.connected && fast_worker_) {
|
||||
opid_poll_timer_ = 0.0f;
|
||||
network_refresh_.reset(RefreshTimer::Opid);
|
||||
auto opids = pending_opids_; // copy for worker thread
|
||||
fast_worker_->post([this, opids]() -> rpc::RPCWorker::MainCb {
|
||||
auto* rpc = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
@@ -598,9 +636,7 @@ void App::update()
|
||||
transactions_dirty_ = true;
|
||||
addresses_dirty_ = true;
|
||||
last_tx_block_height_ = -1;
|
||||
core_timer_ = active_core_interval_;
|
||||
transaction_timer_ = active_tx_interval_;
|
||||
address_timer_ = active_addr_interval_;
|
||||
network_refresh_.markWalletMutationRefresh();
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -609,27 +645,22 @@ void App::update()
|
||||
// Per-category refresh with tab-aware intervals
|
||||
// Skip when wallet is locked — same reason as above.
|
||||
if (state_.connected && !state_.isLocked()) {
|
||||
if (core_timer_ >= active_core_interval_) {
|
||||
core_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
||||
refreshCoreData();
|
||||
}
|
||||
// Skip balance/tx/address refresh during warmup — RPC calls fail with -28
|
||||
if (!state_.warming_up) {
|
||||
if (transaction_timer_ >= active_tx_interval_) {
|
||||
transaction_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Transactions)) {
|
||||
refreshTransactionData();
|
||||
}
|
||||
if (address_timer_ >= active_addr_interval_) {
|
||||
address_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Addresses)) {
|
||||
refreshAddressData();
|
||||
}
|
||||
}
|
||||
if (peer_timer_ >= active_peer_interval_) {
|
||||
peer_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Peers)) {
|
||||
refreshPeerInfo();
|
||||
}
|
||||
} else if (core_timer_ >= active_core_interval_) {
|
||||
core_timer_ = 0.0f;
|
||||
} else if (network_refresh_.consumeDue(RefreshTimer::Core)) {
|
||||
if (!connection_in_progress_ &&
|
||||
wizard_phase_ == WizardPhase::None &&
|
||||
!bootstrap_downloading_) {
|
||||
@@ -638,8 +669,7 @@ void App::update()
|
||||
}
|
||||
|
||||
// Price refresh every 60 seconds
|
||||
if (price_timer_ >= PRICE_INTERVAL) {
|
||||
price_timer_ = 0.0f;
|
||||
if (network_refresh_.consumeDue(RefreshTimer::Price)) {
|
||||
if (settings_->getFetchPrices()) {
|
||||
refreshPrice();
|
||||
}
|
||||
@@ -1159,7 +1189,7 @@ void App::render()
|
||||
// Use fast-lane worker for console commands to avoid head-of-line
|
||||
// blocking behind the consolidated refreshData() batch.
|
||||
// Fall back to main rpc/worker if fast-lane hasn't connected yet.
|
||||
console_tab_.render(embedded_daemon_.get(),
|
||||
console_tab_.render(daemon_controller_ ? daemon_controller_->daemon() : nullptr,
|
||||
(fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(),
|
||||
(fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get(),
|
||||
xmrig_manager_.get());
|
||||
@@ -1572,7 +1602,7 @@ void App::renderStatusBar()
|
||||
}
|
||||
|
||||
// Decrypt-import background task indicator
|
||||
if (decrypt_import_active_) {
|
||||
if (wallet_security_workflow_.importActive()) {
|
||||
ImGui::SameLine(0, sbSectionGap);
|
||||
ImGui::TextDisabled("|");
|
||||
ImGui::SameLine(0, sbSeparatorGap);
|
||||
@@ -1992,10 +2022,7 @@ void App::renderAntivirusHelpDialog()
|
||||
void App::refreshNow()
|
||||
{
|
||||
// Trigger immediate refresh on all categories
|
||||
core_timer_ = active_core_interval_;
|
||||
transaction_timer_ = active_tx_interval_;
|
||||
address_timer_ = active_addr_interval_;
|
||||
peer_timer_ = active_peer_interval_;
|
||||
network_refresh_.markImmediateRefresh();
|
||||
transactions_dirty_ = true; // Force transaction list update
|
||||
addresses_dirty_ = true; // Force address/balance update
|
||||
last_tx_block_height_ = -1; // Reset tx cache
|
||||
@@ -2155,12 +2182,12 @@ bool App::startEmbeddedDaemon()
|
||||
}
|
||||
}
|
||||
|
||||
// Create daemon manager if needed
|
||||
if (!embedded_daemon_) {
|
||||
embedded_daemon_ = std::make_unique<daemon::EmbeddedDaemon>();
|
||||
// Create daemon controller if needed
|
||||
if (!daemon_controller_) {
|
||||
daemon_controller_ = std::make_unique<daemon::DaemonController>();
|
||||
|
||||
// Set up state callback
|
||||
embedded_daemon_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||
daemon_controller_->setStateCallback([this](daemon::EmbeddedDaemon::State state, const std::string& msg) {
|
||||
switch (state) {
|
||||
case daemon::EmbeddedDaemon::State::Starting:
|
||||
daemon_status_ = TR("sb_starting_daemon");
|
||||
@@ -2181,27 +2208,17 @@ bool App::startEmbeddedDaemon()
|
||||
});
|
||||
}
|
||||
|
||||
// Sync debug logging categories from user settings
|
||||
if (settings_) {
|
||||
embedded_daemon_->setDebugCategories(settings_->getDebugCategories());
|
||||
embedded_daemon_->setMaxConnections(settings_->getMaxConnections());
|
||||
}
|
||||
|
||||
return embedded_daemon_->start();
|
||||
return daemon_controller_->start(settings_.get());
|
||||
}
|
||||
|
||||
void App::stopEmbeddedDaemon()
|
||||
{
|
||||
if (!embedded_daemon_) return;
|
||||
if (!daemon_controller_) return;
|
||||
|
||||
// Never stop an external daemon unless the user explicitly opted in
|
||||
// via the "Stop external daemon" checkbox in Settings. This is a
|
||||
// defence-in-depth guard — callers should also check, but this
|
||||
// ensures no code path accidentally shuts down a daemon we don't own.
|
||||
if (embedded_daemon_->externalDaemonDetected() &&
|
||||
!(settings_ && settings_->getStopExternalDaemon())) {
|
||||
DEBUG_LOGF("stopEmbeddedDaemon: external daemon detected — "
|
||||
"skipping (stop_external_daemon setting is off)\n");
|
||||
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
||||
false, settings_ && settings_->getStopExternalDaemon());
|
||||
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
||||
DEBUG_LOGF("stopEmbeddedDaemon: %s — skipping\n", shutdownDecision.logReason);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2231,15 +2248,12 @@ void App::stopEmbeddedDaemon()
|
||||
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
||||
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
||||
// Use a short timeout — if daemon isn't listening yet, don't block
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword)) {
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword,
|
||||
config.use_tls)) {
|
||||
DEBUG_LOGF("Temporary RPC connected, sending stop...\n");
|
||||
try {
|
||||
tmp_rpc->call("stop");
|
||||
if (sendStopCommandSafely(*tmp_rpc, "Temporary daemon stop")) {
|
||||
stop_sent = true;
|
||||
DEBUG_LOGF("Stop command sent via temporary connection\n");
|
||||
} catch (...) {
|
||||
DEBUG_LOGF("Stop RPC failed via temporary connection\n");
|
||||
}
|
||||
tmp_rpc->disconnect();
|
||||
} else {
|
||||
@@ -2263,19 +2277,21 @@ void App::stopEmbeddedDaemon()
|
||||
shutdown_status_ = "Waiting for dragonxd process to exit...";
|
||||
// 20s grace period for the RPC "stop" to complete (LevelDB flush).
|
||||
// Only after that does stop() escalate to SIGTERM, then SIGKILL.
|
||||
embedded_daemon_->stop(20000);
|
||||
daemon_controller_->stop(20000);
|
||||
}
|
||||
|
||||
bool App::isEmbeddedDaemonRunning() const
|
||||
{
|
||||
return embedded_daemon_ && embedded_daemon_->isRunning();
|
||||
return daemon_controller_ && daemon_controller_->isRunning();
|
||||
}
|
||||
|
||||
void App::rescanBlockchain()
|
||||
{
|
||||
if (!isUsingEmbeddedDaemon() || !embedded_daemon_) {
|
||||
ui::Notifications::instance().warning(
|
||||
"Rescan requires embedded daemon. Restart your daemon with -rescan manually.");
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::Rescan,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (!decision.allowed) {
|
||||
ui::Notifications::instance().warning(decision.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2285,103 +2301,65 @@ void App::rescanBlockchain()
|
||||
// Initialize rescan state for status bar display
|
||||
state_.sync.rescanning = true;
|
||||
state_.sync.rescan_progress = 0.0f;
|
||||
state_.sync.rescan_status = "Starting rescan...";
|
||||
state_.sync.rescan_status = decision.status;
|
||||
|
||||
// Set rescan flag BEFORE stopping so it's ready when we restart
|
||||
embedded_daemon_->setRescanOnNextStart(true);
|
||||
DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", embedded_daemon_->rescanOnNextStart() ? 1 : 0);
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
DEBUG_LOGF("[App] Rescan flag set, rescanOnNextStart=%d\n", daemon_controller_->rescanOnNextStart() ? 1 : 0);
|
||||
|
||||
// Stop daemon, then restart
|
||||
std::thread([this]() {
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
DEBUG_LOGF("[App] Stopping daemon for rescan...\n");
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
DEBUG_LOGF("[App] Waiting for daemon to fully stop...\n");
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
// Reset output offset so we parse fresh output for rescan progress
|
||||
daemon_output_offset_ = 0;
|
||||
|
||||
DEBUG_LOGF("[App] Starting daemon with rescan flag=%d\n", embedded_daemon_->rescanOnNextStart() ? 1 : 0);
|
||||
startEmbeddedDaemon();
|
||||
}).detach();
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
});
|
||||
}
|
||||
|
||||
void App::deleteBlockchainData()
|
||||
{
|
||||
if (!isUsingEmbeddedDaemon() || !embedded_daemon_) {
|
||||
ui::Notifications::instance().warning(
|
||||
"Delete blockchain requires embedded daemon. Stop your daemon manually and delete the data directory.");
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::DeleteBlockchainData,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (!decision.allowed) {
|
||||
ui::Notifications::instance().warning(decision.warning);
|
||||
return;
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Deleting blockchain data - stopping daemon first\n");
|
||||
ui::Notifications::instance().info("Stopping daemon and deleting blockchain data...");
|
||||
|
||||
std::thread([this]() {
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
DEBUG_LOGF("[App] Stopping daemon for blockchain deletion...\n");
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
|
||||
// Directories to remove
|
||||
const char* dirs[] = { "blocks", "chainstate", "database", "notarizations" };
|
||||
// Files to remove
|
||||
const char* files[] = { "peers.dat", "fee_estimates.dat", "banlist.dat",
|
||||
"db.log", ".lock" };
|
||||
|
||||
int removed = 0;
|
||||
std::error_code ec;
|
||||
for (auto d : dirs) {
|
||||
fs::path p = fs::path(dataDir) / d;
|
||||
if (fs::exists(p, ec)) {
|
||||
auto n = fs::remove_all(p, ec);
|
||||
if (!ec) { removed += (int)n; DEBUG_LOGF("[App] Removed %s (%d entries)\n", d, (int)n); }
|
||||
else { DEBUG_LOGF("[App] Failed to remove %s: %s\n", d, ec.message().c_str()); }
|
||||
}
|
||||
}
|
||||
for (auto f : files) {
|
||||
fs::path p = fs::path(dataDir) / f;
|
||||
if (fs::remove(p, ec)) { removed++; DEBUG_LOGF("[App] Removed %s\n", f); }
|
||||
}
|
||||
|
||||
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", removed);
|
||||
|
||||
daemon_output_offset_ = 0;
|
||||
startEmbeddedDaemon();
|
||||
}).detach();
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
auto result = daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
DEBUG_LOGF("[App] Blockchain data deleted (%d items removed), restarting daemon...\n", result.deletedItems);
|
||||
});
|
||||
}
|
||||
|
||||
bool App::stopDaemonForBootstrap()
|
||||
{
|
||||
bool wasRunning = isEmbeddedDaemonRunning();
|
||||
if (wasRunning) {
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::BootstrapStop,
|
||||
isUsingEmbeddedDaemon(), daemon_controller_ != nullptr, isEmbeddedDaemonRunning());
|
||||
if (decision.wasRunning) {
|
||||
DEBUG_LOGF("[App] Stopping embedded daemon for bootstrap download...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::ImmediateLifecycleTaskContext context;
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
}
|
||||
return wasRunning;
|
||||
return decision.wasRunning;
|
||||
}
|
||||
|
||||
double App::getDaemonMemoryUsageMB() const
|
||||
{
|
||||
// If we have an embedded daemon with a tracked process handle, use it
|
||||
// directly — more reliable than a process scan since we own the handle.
|
||||
if (embedded_daemon_ && embedded_daemon_->isRunning()) {
|
||||
double mb = embedded_daemon_->getMemoryUsageMB();
|
||||
if (daemon_controller_ && daemon_controller_->isRunning()) {
|
||||
double mb = daemon_controller_->memoryUsageMB();
|
||||
daemon_mem_diag_ = "embedded";
|
||||
if (mb > 0.0) return mb;
|
||||
} else {
|
||||
@@ -2408,6 +2386,7 @@ void App::beginShutdown()
|
||||
quit_requested_ = true;
|
||||
shutdown_timer_ = 0.0f;
|
||||
shutdown_start_time_ = std::chrono::steady_clock::now();
|
||||
async_tasks_.cancelAll();
|
||||
|
||||
// Signal the RPC worker to stop accepting new tasks (non-blocking).
|
||||
// The actual thread join + rpc disconnect happen in shutdown() after
|
||||
@@ -2425,13 +2404,13 @@ void App::beginShutdown()
|
||||
xmrig_manager_->stop(3000);
|
||||
}
|
||||
|
||||
DEBUG_LOGF("beginShutdown: starting (embedded_daemon_=%s)\n",
|
||||
embedded_daemon_ ? "yes" : "no");
|
||||
DEBUG_LOGF("beginShutdown: starting (daemon_controller_=%s)\n",
|
||||
daemon_controller_ ? "yes" : "no");
|
||||
|
||||
// If no embedded daemon, just mark done — don't stop
|
||||
// an externally-managed daemon that the user started themselves.
|
||||
// Worker join + RPC disconnect happen in shutdown().
|
||||
if (!embedded_daemon_) {
|
||||
if (!daemon_controller_) {
|
||||
DEBUG_LOGF("beginShutdown: no embedded daemon, disconnecting only\n");
|
||||
shutdown_status_ = "Disconnecting...";
|
||||
if (settings_) {
|
||||
@@ -2446,21 +2425,17 @@ void App::beginShutdown()
|
||||
settings_->save();
|
||||
}
|
||||
|
||||
// If user opted to keep daemon running, just mark done.
|
||||
// Also never stop an external daemon the user started themselves,
|
||||
// unless they've explicitly enabled the "stop external daemon" setting.
|
||||
bool externalDaemon = embedded_daemon_ && embedded_daemon_->externalDaemonDetected();
|
||||
if ((settings_ && settings_->getKeepDaemonRunning()) ||
|
||||
(externalDaemon && !(settings_ && settings_->getStopExternalDaemon()))) {
|
||||
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n",
|
||||
externalDaemon ? "external daemon (not ours to stop)"
|
||||
: "keep_daemon_running enabled");
|
||||
shutdown_status_ = "Disconnecting (daemon stays running)...";
|
||||
auto shutdownDecision = daemon_controller_->shutdownDecision(
|
||||
settings_ && settings_->getKeepDaemonRunning(),
|
||||
settings_ && settings_->getStopExternalDaemon());
|
||||
if (shutdownDecision.action == daemon::DaemonController::ShutdownAction::DisconnectOnly) {
|
||||
DEBUG_LOGF("beginShutdown: %s, skipping daemon stop\n", shutdownDecision.logReason);
|
||||
shutdown_status_ = shutdownDecision.status;
|
||||
shutdown_complete_ = true;
|
||||
return;
|
||||
}
|
||||
|
||||
shutdown_status_ = "Sending stop command to daemon...";
|
||||
shutdown_status_ = shutdownDecision.status;
|
||||
DEBUG_LOGF("beginShutdown: spawning shutdown thread for daemon stop\n");
|
||||
|
||||
// Run the daemon shutdown on a background thread so the UI
|
||||
@@ -2684,8 +2659,8 @@ void App::renderShutdownScreen()
|
||||
// -------------------------------------------------------------------
|
||||
// 6. Daemon output panel — terminal-style box
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_) {
|
||||
auto lines = embedded_daemon_->getRecentLines(8);
|
||||
if (daemon_controller_) {
|
||||
auto lines = daemon_controller_->recentLines(8);
|
||||
if (!lines.empty()) {
|
||||
float panelW = vp_size.x * shutElem("panel-width-fraction", 0.70f);
|
||||
float panelX = cx - panelW * 0.5f;
|
||||
@@ -2894,7 +2869,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 3b. Deferred encryption status
|
||||
// -------------------------------------------------------------------
|
||||
if (deferred_encrypt_pending_) {
|
||||
if (wallet_security_.hasDeferredEncryption()) {
|
||||
curY += gap;
|
||||
ImFont* capFont = Type().caption();
|
||||
if (!capFont) capFont = ImGui::GetFont();
|
||||
@@ -2926,8 +2901,8 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 3c. Daemon crash error message
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_ &&
|
||||
embedded_daemon_->getState() == daemon::EmbeddedDaemon::State::Error) {
|
||||
if (daemon_controller_ &&
|
||||
daemon_controller_->state() == daemon::EmbeddedDaemon::State::Error) {
|
||||
curY += gap;
|
||||
ImFont* capFont = Type().caption();
|
||||
if (!capFont) capFont = ImGui::GetFont();
|
||||
@@ -2943,7 +2918,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
curY += ts.y + gap * 0.5f;
|
||||
|
||||
// Error details (wrapped) — show full diagnostic info
|
||||
const std::string& errDetail = embedded_daemon_->getLastError();
|
||||
const std::string& errDetail = daemon_controller_->lastError();
|
||||
if (!errDetail.empty()) {
|
||||
float wrapW = ws.x * 0.8f;
|
||||
if (wrapW > 700.0f) wrapW = 700.0f;
|
||||
@@ -2955,7 +2930,7 @@ void App::renderLoadingOverlay(float contentH)
|
||||
}
|
||||
|
||||
// Crash count hint
|
||||
if (embedded_daemon_->getCrashCount() >= 3) {
|
||||
if (daemon_controller_->crashCount() >= 3) {
|
||||
const char* hint = "Use Settings > Restart Daemon to try again";
|
||||
ImVec2 hs2 = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, hint);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
@@ -2968,8 +2943,8 @@ void App::renderLoadingOverlay(float contentH)
|
||||
// -------------------------------------------------------------------
|
||||
// 4. Daemon output snippet (last few lines, if embedded)
|
||||
// -------------------------------------------------------------------
|
||||
if (embedded_daemon_) {
|
||||
auto lines = embedded_daemon_->getRecentLines(8);
|
||||
if (daemon_controller_) {
|
||||
auto lines = daemon_controller_->recentLines(8);
|
||||
if (!lines.empty()) {
|
||||
curY += gap;
|
||||
|
||||
@@ -3026,6 +3001,8 @@ void App::shutdown()
|
||||
// do synchronous shutdown as fallback.
|
||||
if (!shutting_down_) {
|
||||
DEBUG_LOGF("Synchronous shutdown fallback...\n");
|
||||
async_tasks_.cancelAll();
|
||||
async_tasks_.joinAll();
|
||||
if (worker_) {
|
||||
worker_->stop();
|
||||
}
|
||||
@@ -3035,7 +3012,7 @@ void App::shutdown()
|
||||
if (settings_) {
|
||||
settings_->save();
|
||||
}
|
||||
if (embedded_daemon_) {
|
||||
if (daemon_controller_) {
|
||||
stopEmbeddedDaemon();
|
||||
}
|
||||
if (rpc_) {
|
||||
@@ -3051,14 +3028,7 @@ void App::shutdown()
|
||||
if (shutdown_thread_.joinable()) {
|
||||
shutdown_thread_.join();
|
||||
}
|
||||
// Wait for wizard's external daemon stop thread
|
||||
if (wizard_stop_thread_.joinable()) {
|
||||
wizard_stop_thread_.join();
|
||||
}
|
||||
// Wait for daemon restart thread
|
||||
if (daemon_restart_thread_.joinable()) {
|
||||
daemon_restart_thread_.join();
|
||||
}
|
||||
async_tasks_.joinAll();
|
||||
// Join the RPC worker thread (was signaled in beginShutdown via requestStop)
|
||||
if (worker_) {
|
||||
worker_->stop();
|
||||
@@ -3096,43 +3066,30 @@ bool App::hasPendingRPCResults() const {
|
||||
}
|
||||
void App::restartDaemon()
|
||||
{
|
||||
if (!use_embedded_daemon_ || daemon_restarting_.load()) return;
|
||||
auto decision = daemon::DaemonController::evaluateLifecycleOperation(
|
||||
daemon::DaemonController::LifecycleOperation::ManualRestart,
|
||||
use_embedded_daemon_, daemon_controller_ != nullptr, isEmbeddedDaemonRunning(), daemon_restarting_.load());
|
||||
if (!decision.allowed) return;
|
||||
daemon_restarting_ = true;
|
||||
|
||||
// Reset crash counter on manual restart
|
||||
if (embedded_daemon_) {
|
||||
embedded_daemon_->resetCrashCount();
|
||||
}
|
||||
daemon_controller_->prepareLifecycleOperation(decision, settings_.get());
|
||||
|
||||
DEBUG_LOGF("[App] Restarting embedded daemon...\n");
|
||||
connection_status_ = TR("sb_restarting_daemon");
|
||||
|
||||
// Disconnect RPC so the loading overlay appears
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
if (decision.disconnectRpc && rpc_ && rpc_->isConnected()) {
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Daemon restart");
|
||||
|
||||
// Sync debug categories from settings to daemon
|
||||
if (embedded_daemon_ && settings_) {
|
||||
embedded_daemon_->setDebugCategories(settings_->getDebugCategories());
|
||||
}
|
||||
|
||||
// Run stop + start on a background thread to avoid blocking the UI.
|
||||
// The 5-second auto-retry in render() will reconnect once the daemon
|
||||
// is back up.
|
||||
if (daemon_restart_thread_.joinable()) {
|
||||
daemon_restart_thread_.join();
|
||||
}
|
||||
daemon_restart_thread_ = std::thread([this]() {
|
||||
if (embedded_daemon_ && isEmbeddedDaemonRunning()) {
|
||||
stopEmbeddedDaemon();
|
||||
}
|
||||
if (shutting_down_) { daemon_restarting_ = false; return; }
|
||||
// Brief pause to let the port free up
|
||||
for (int i = 0; i < 5 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
startEmbeddedDaemon();
|
||||
async_tasks_.submit(decision.taskName, [this, decision](const util::AsyncTaskManager::Token& token) {
|
||||
AppDaemonLifecycleRuntime runtime(*this);
|
||||
daemon::AsyncLifecycleTaskContext context(token, shutting_down_);
|
||||
daemon_controller_->executeLifecycleOperation(decision, runtime, context);
|
||||
daemon_restarting_ = false;
|
||||
DEBUG_LOGF("[App] Daemon restart complete — waiting for RPC...\n");
|
||||
});
|
||||
|
||||
98
src/app.h
98
src/app.h
@@ -14,6 +14,10 @@
|
||||
#include <unordered_set>
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
#include "services/wallet_security_controller.h"
|
||||
#include "services/wallet_security_workflow.h"
|
||||
#include "util/async_task_manager.h"
|
||||
#include "ui/sidebar.h"
|
||||
#include "ui/windows/console_tab.h"
|
||||
#include "imgui.h"
|
||||
@@ -25,7 +29,7 @@ namespace dragonx {
|
||||
class RPCWorker;
|
||||
}
|
||||
namespace config { class Settings; }
|
||||
namespace daemon { class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace daemon { class DaemonController; class EmbeddedDaemon; class XmrigManager; }
|
||||
namespace util { class Bootstrap; class SecureVault; }
|
||||
}
|
||||
|
||||
@@ -183,7 +187,9 @@ public:
|
||||
// Peers
|
||||
const std::vector<PeerInfo>& getPeers() const { return state_.peers; }
|
||||
const std::vector<BannedPeer>& getBannedPeers() const { return state_.bannedPeers; }
|
||||
bool isPeerRefreshInProgress() const { return peer_refresh_in_progress_.load(std::memory_order_relaxed); }
|
||||
bool isPeerRefreshInProgress() const {
|
||||
return network_refresh_.jobInProgress(services::NetworkRefreshService::Job::Peers);
|
||||
}
|
||||
void banPeer(const std::string& ip, int duration_seconds = 86400);
|
||||
void unbanPeer(const std::string& ip);
|
||||
void clearBans();
|
||||
@@ -232,12 +238,7 @@ public:
|
||||
void refreshMarketData();
|
||||
|
||||
/// @brief Per-category refresh intervals, adjusted by active tab
|
||||
struct RefreshIntervals {
|
||||
float core; // balance + sync status
|
||||
float transactions; // tx list + enrichment
|
||||
float addresses; // address lists + balances
|
||||
float peers; // peer info (0 = disabled)
|
||||
};
|
||||
using RefreshIntervals = services::NetworkRefreshService::Intervals;
|
||||
|
||||
/// @brief Get recommended refresh intervals for a given page
|
||||
static RefreshIntervals getIntervalsForPage(ui::NavPage page);
|
||||
@@ -340,10 +341,7 @@ public:
|
||||
void showChangePassphraseDialog() { show_change_passphrase_ = true; }
|
||||
void showDecryptDialog() {
|
||||
show_decrypt_dialog_ = true;
|
||||
decrypt_phase_ = 0; // passphrase entry
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.reset();
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
}
|
||||
|
||||
@@ -357,6 +355,11 @@ public:
|
||||
bool hasPendingRPCResults() const;
|
||||
|
||||
private:
|
||||
friend class AppDaemonLifecycleRuntime;
|
||||
friend class AppDaemonLifecycleTaskContext;
|
||||
|
||||
bool sendStopCommandSafely(rpc::RPCClient& client, const char* context);
|
||||
|
||||
// Subsystems
|
||||
std::unique_ptr<rpc::RPCClient> rpc_;
|
||||
std::unique_ptr<rpc::RPCWorker> worker_;
|
||||
@@ -371,8 +374,9 @@ private:
|
||||
rpc::ConnectionConfig saved_config_;
|
||||
|
||||
std::unique_ptr<config::Settings> settings_;
|
||||
std::unique_ptr<daemon::EmbeddedDaemon> embedded_daemon_;
|
||||
std::unique_ptr<daemon::DaemonController> daemon_controller_;
|
||||
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
|
||||
util::AsyncTaskManager async_tasks_;
|
||||
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
|
||||
|
||||
// Wallet state
|
||||
@@ -390,7 +394,6 @@ private:
|
||||
|
||||
// Daemon restart (e.g. after changing debug log categories)
|
||||
std::atomic<bool> daemon_restarting_{false};
|
||||
std::thread daemon_restart_thread_;
|
||||
|
||||
// Encryption state check timeout
|
||||
float encryption_check_timer_ = 0.0f;
|
||||
@@ -423,6 +426,7 @@ private:
|
||||
// Connection
|
||||
std::string connection_status_ = "Disconnected";
|
||||
bool connection_in_progress_ = false;
|
||||
bool remote_rpc_plaintext_warning_shown_ = false;
|
||||
float loading_timer_ = 0.0f; // spinner animation for loading overlay
|
||||
|
||||
// Current page (sidebar navigation)
|
||||
@@ -460,64 +464,24 @@ private:
|
||||
std::string pending_memo_;
|
||||
std::string pending_label_;
|
||||
|
||||
// Per-category timers (in seconds since last refresh)
|
||||
float core_timer_ = 0.0f; // balance + sync status
|
||||
float address_timer_ = 0.0f; // address lists
|
||||
float transaction_timer_ = 0.0f; // transaction list
|
||||
float peer_timer_ = 0.0f; // peer info
|
||||
float price_timer_ = 0.0f;
|
||||
float fast_refresh_timer_ = 0.0f; // For mining stats
|
||||
|
||||
// Default refresh intervals (seconds)
|
||||
static constexpr float CORE_INTERVAL_DEFAULT = 5.0f;
|
||||
static constexpr float ADDRESS_INTERVAL_DEFAULT = 15.0f;
|
||||
static constexpr float TX_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PEER_INTERVAL_DEFAULT = 10.0f;
|
||||
static constexpr float PRICE_INTERVAL = 60.0f;
|
||||
static constexpr float FAST_REFRESH_INTERVAL = 1.0f;
|
||||
|
||||
// Active intervals — adjusted by tab priority via applyRefreshPolicy()
|
||||
float active_core_interval_ = CORE_INTERVAL_DEFAULT;
|
||||
float active_tx_interval_ = TX_INTERVAL_DEFAULT;
|
||||
float active_addr_interval_ = ADDRESS_INTERVAL_DEFAULT;
|
||||
float active_peer_interval_ = PEER_INTERVAL_DEFAULT;
|
||||
|
||||
// Per-category refresh guards (prevent worker queue pileup)
|
||||
std::atomic<bool> core_refresh_in_progress_{false};
|
||||
std::atomic<bool> address_refresh_in_progress_{false};
|
||||
std::atomic<bool> tx_refresh_in_progress_{false};
|
||||
|
||||
// Mining refresh guard (prevents worker queue pileup)
|
||||
std::atomic<bool> mining_refresh_in_progress_{false};
|
||||
// Per-category refresh timers, policy, and worker queue guards.
|
||||
services::NetworkRefreshService network_refresh_;
|
||||
int mining_slow_counter_ = 0; // counts fast ticks; fires slow refresh every N
|
||||
|
||||
// Mining toggle guard (prevents concurrent setgenerate calls)
|
||||
std::atomic<bool> mining_toggle_in_progress_{false};
|
||||
|
||||
// Peer refresh guard (visual feedback for refresh button)
|
||||
std::atomic<bool> peer_refresh_in_progress_{false};
|
||||
|
||||
// Auto-shield guard (prevents concurrent auto-shield operations)
|
||||
std::atomic<bool> auto_shield_pending_{false};
|
||||
|
||||
// P4: Incremental transaction cache
|
||||
int last_tx_block_height_ = -1; // block height at last full tx fetch
|
||||
float tx_age_timer_ = 0.0f; // seconds since last tx fetch
|
||||
static constexpr float TX_MAX_AGE = 15.0f; // force tx refresh every N seconds even without new blocks
|
||||
static constexpr int MAX_VIEWTX_PER_CYCLE = 25; // cap z_viewtransaction calls per refresh
|
||||
|
||||
// P4b: z_viewtransaction result cache — avoids re-calling the RPC for
|
||||
// txids we've already enriched. Keyed by txid.
|
||||
struct ViewTxCacheEntry {
|
||||
std::string from_address; // first spend address
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
std::unordered_map<std::string, ViewTxCacheEntry> viewtx_cache_;
|
||||
using ViewTxCacheEntry = services::NetworkRefreshService::TransactionViewCacheEntry;
|
||||
services::NetworkRefreshService::TransactionViewCache viewtx_cache_;
|
||||
|
||||
// P4c: Confirmed transaction cache — deeply-confirmed txns (>= 10 confs)
|
||||
// are accumulated here and reused across refresh cycles. Only
|
||||
@@ -533,9 +497,6 @@ private:
|
||||
|
||||
// Pending z_sendmany operation tracking
|
||||
std::vector<std::string> pending_opids_; // opids to poll for completion
|
||||
float opid_poll_timer_ = 0.0f;
|
||||
static constexpr float OPID_POLL_INTERVAL = 2.0f;
|
||||
|
||||
// Txids from completed z_sendmany operations.
|
||||
// Ensures shielded sends are discoverable by z_viewtransaction
|
||||
// even when they don't appear in listtransactions or
|
||||
@@ -549,15 +510,13 @@ private:
|
||||
std::string wizard_pending_passphrase_; // held until daemon connects
|
||||
std::string wizard_saved_passphrase_; // held until PinSetup completes/skipped
|
||||
|
||||
// Deferred encryption (wizard background task)
|
||||
std::string deferred_encrypt_passphrase_;
|
||||
std::string deferred_encrypt_pin_;
|
||||
bool deferred_encrypt_pending_ = false;
|
||||
// Wallet security flow state shared by wizard/settings encryption paths.
|
||||
services::WalletSecurityController wallet_security_;
|
||||
services::WalletSecurityWorkflow wallet_security_workflow_;
|
||||
|
||||
// Wizard: stopping an external daemon before bootstrap
|
||||
bool wizard_stopping_external_ = false;
|
||||
std::string wizard_stop_status_;
|
||||
std::thread wizard_stop_thread_;
|
||||
|
||||
// PIN vault
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
@@ -602,14 +561,7 @@ private:
|
||||
|
||||
// Decrypt wallet dialog state
|
||||
bool show_decrypt_dialog_ = false;
|
||||
int decrypt_phase_ = 0; // 0=passphrase, 1=working, 2=done, 3=error
|
||||
int decrypt_step_ = 0; // 0=unlock, 1=export, 2=stop, 3=rename, 4=restart, 5=import
|
||||
char decrypt_pass_buf_[256] = {};
|
||||
std::string decrypt_status_;
|
||||
bool decrypt_in_progress_ = false;
|
||||
std::chrono::steady_clock::time_point decrypt_step_start_time_{};
|
||||
std::chrono::steady_clock::time_point decrypt_overall_start_time_{};
|
||||
std::atomic<bool> decrypt_import_active_{false}; // background z_importwallet running
|
||||
|
||||
// Wizard PIN setup state
|
||||
char wizard_pin_buf_[16] = {};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@
|
||||
#include "rpc/rpc_client.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/wallet_security_workflow_executor.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
@@ -27,13 +28,196 @@
|
||||
|
||||
#include "imgui.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <cstdint>
|
||||
#include <filesystem>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
class WalletSecurityRpcAdapter : public services::WalletSecurityController::RpcGateway {
|
||||
public:
|
||||
explicit WalletSecurityRpcAdapter(rpc::RPCClient* rpc) : rpc_(rpc) {}
|
||||
|
||||
bool encryptWallet(const std::string& passphrase, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("encryptwallet", {passphrase}); }, error);
|
||||
}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_importwallet", {filePath}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
};
|
||||
|
||||
class WalletSecurityVaultAdapter : public services::WalletSecurityController::VaultGateway {
|
||||
public:
|
||||
explicit WalletSecurityVaultAdapter(util::SecureVault* vault) : vault_(vault) {}
|
||||
|
||||
bool storePin(const std::string& pin, const std::string& passphrase) override {
|
||||
return vault_ && vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
private:
|
||||
util::SecureVault* vault_ = nullptr;
|
||||
};
|
||||
|
||||
class WalletSecurityDecryptRpcAdapter : public services::WalletSecurityWorkflowExecutor::RpcGateway {
|
||||
public:
|
||||
using StopFn = std::function<bool(rpc::RPCClient&, const char*)>;
|
||||
|
||||
WalletSecurityDecryptRpcAdapter(rpc::RPCClient* rpc, StopFn stopFn)
|
||||
: rpc_(rpc), stopFn_(std::move(stopFn)) {}
|
||||
|
||||
bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("walletpassphrase", {passphrase, timeoutSeconds}); }, error);
|
||||
}
|
||||
|
||||
bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("z_exportwallet", {fileName}, timeoutSeconds); }, error);
|
||||
}
|
||||
|
||||
bool requestDaemonStop(std::string& error) override {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
bool ok = stopFn_ ? stopFn_(*rpc_, "Decrypt export daemon stop") : false;
|
||||
if (!ok) error = "Stop RPC failed";
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool probeDaemon(std::string& error) override {
|
||||
return callWithError([&] { rpc_->call("getinfo"); }, error);
|
||||
}
|
||||
|
||||
private:
|
||||
template <typename Fn>
|
||||
bool callWithError(Fn&& fn, std::string& error) {
|
||||
if (!rpc_) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fn();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
rpc::RPCClient* rpc_ = nullptr;
|
||||
StopFn stopFn_;
|
||||
};
|
||||
|
||||
class WalletSecurityImportRpcAdapter : public services::WalletSecurityWorkflowExecutor::ImportGateway {
|
||||
public:
|
||||
WalletSecurityImportRpcAdapter(rpc::RPCClient* fallbackRpc, rpc::ConnectionConfig config)
|
||||
: fallbackRpc_(fallbackRpc), config_(std::move(config)) {}
|
||||
|
||||
bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) override {
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(config_.host, config_.port,
|
||||
config_.rpcuser, config_.rpcpassword,
|
||||
config_.use_tls);
|
||||
if (!importRpcOk) importRpc.reset();
|
||||
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : fallbackRpc_;
|
||||
if (!rpcForImport) {
|
||||
error = "RPC client unavailable";
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
rpcForImport->call("z_importwallet", {exportPath}, timeoutSeconds);
|
||||
if (importRpc) importRpc->disconnect();
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
if (importRpc) importRpc->disconnect();
|
||||
error = e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
rpc::RPCClient* fallbackRpc_ = nullptr;
|
||||
rpc::ConnectionConfig config_;
|
||||
};
|
||||
|
||||
class WalletSecurityFileAdapter : public services::WalletSecurityWorkflowExecutor::FileGateway {
|
||||
public:
|
||||
std::string dataDir() override { return util::Platform::getDragonXDataDir(); }
|
||||
|
||||
bool backupEncryptedWallet(const services::WalletSecurityWorkflowExecutor::WalletFilePlan& filePlan,
|
||||
std::string& error) override {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(filePlan.walletPath, ec)) return true;
|
||||
|
||||
std::filesystem::remove(filePlan.backupPath, ec);
|
||||
ec.clear();
|
||||
std::filesystem::rename(filePlan.walletPath, filePlan.backupPath, ec);
|
||||
if (ec) {
|
||||
error = ec.message();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
class WalletSecurityDaemonAdapter : public services::WalletSecurityWorkflowExecutor::DaemonGateway {
|
||||
public:
|
||||
WalletSecurityDaemonAdapter(App& app, const util::AsyncTaskManager::Token& token)
|
||||
: app_(app), token_(token) {}
|
||||
|
||||
bool isUsingEmbeddedDaemon() const override { return app_.isUsingEmbeddedDaemon(); }
|
||||
void stopEmbeddedDaemon() override { app_.stopEmbeddedDaemon(); }
|
||||
bool startEmbeddedDaemon() override { return app_.startEmbeddedDaemon(); }
|
||||
bool cancelled() const override { return token_.cancelled(); }
|
||||
bool shuttingDown() const override { return app_.isShuttingDown(); }
|
||||
void sleepForMs(int milliseconds) override {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
private:
|
||||
App& app_;
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
// ===========================================================================
|
||||
// Wallet encryption helpers
|
||||
@@ -45,9 +229,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
auto result = rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, passphrase]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
{std::move(passphrase), {}}, rpcAdapter, nullptr);
|
||||
if (result.encrypted) {
|
||||
return [this]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Wallet encrypted. Restarting daemon...";
|
||||
@@ -78,22 +264,22 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
connection_status_ = TR("restarting_after_encryption");
|
||||
// Give daemon a moment to shut down, then restart
|
||||
// (do this off the main thread to avoid stalling the UI)
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
@@ -118,15 +304,11 @@ void App::encryptWalletWithPassphrase(const std::string& passphrase) {
|
||||
// Called every frame from render() until the task completes.
|
||||
// ---------------------------------------------------------------------------
|
||||
void App::processDeferredEncryption() {
|
||||
if (!deferred_encrypt_pending_) return;
|
||||
if (!wallet_security_.hasDeferredEncryption()) return;
|
||||
|
||||
// Phase 1: wait for daemon connection
|
||||
if (!state_.connected || !rpc_ || !rpc_->isConnected()) {
|
||||
// Throttle connection attempts to every 3 seconds
|
||||
static double s_lastAttempt = -10.0;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_lastAttempt >= 3.0) {
|
||||
s_lastAttempt = now;
|
||||
if (wallet_security_.shouldAttemptDeferredConnect(ImGui::GetTime())) {
|
||||
if (!connection_in_progress_) {
|
||||
// Just try to connect — tryConnect is now async
|
||||
tryConnect();
|
||||
@@ -140,31 +322,29 @@ void App::processDeferredEncryption() {
|
||||
|
||||
// Phase 2: connected — launch encryption
|
||||
if (!encrypt_in_progress_) {
|
||||
std::string passphrase = deferred_encrypt_passphrase_;
|
||||
std::string pin = deferred_encrypt_pin_;
|
||||
auto deferredEncryption = wallet_security_.deferredEncryption();
|
||||
std::string passphrase = std::move(deferredEncryption.passphrase);
|
||||
std::string pin = std::move(deferredEncryption.pin);
|
||||
|
||||
encrypt_in_progress_ = true;
|
||||
encrypt_status_ = "Encrypting wallet...";
|
||||
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase, pin]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("encryptwallet", {passphrase});
|
||||
worker_->post([this, request = services::WalletSecurityController::DeferredEncryptionSnapshot{std::move(passphrase), std::move(pin)}]() mutable -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityRpcAdapter rpcAdapter(rpc_.get());
|
||||
WalletSecurityVaultAdapter vaultAdapter(vault_.get());
|
||||
auto result = wallet_security_.runDeferredEncryption(
|
||||
std::move(request), rpcAdapter, vault_ ? &vaultAdapter : nullptr);
|
||||
|
||||
// Store PIN vault on the worker thread (Argon2id is expensive)
|
||||
bool pinStored = false;
|
||||
if (!pin.empty() && vault_) {
|
||||
pinStored = vault_->store(pin, passphrase);
|
||||
}
|
||||
|
||||
return [this, pinStored, pin]() {
|
||||
if (result.encrypted) {
|
||||
return [this, result]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_.clear();
|
||||
DEBUG_LOGF("[App] Wallet encrypted (deferred)\n");
|
||||
|
||||
// Finalize PIN settings on main thread
|
||||
if (!pin.empty()) {
|
||||
if (pinStored) {
|
||||
if (result.pinProvided) {
|
||||
if (result.pinStored) {
|
||||
settings_->setPinEnabled(true);
|
||||
settings_->save();
|
||||
ui::Notifications::instance().info("Wallet encrypted & PIN set", 5.0f);
|
||||
@@ -176,59 +356,32 @@ void App::processDeferredEncryption() {
|
||||
ui::Notifications::instance().info("Wallet encrypted successfully", 5.0f);
|
||||
}
|
||||
|
||||
// Securely clear deferred state
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
deferred_encrypt_pending_ = false;
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
|
||||
// Restart daemon (it shuts itself down after encryptwallet)
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
std::thread([this]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
async_tasks_.submit("deferred-encrypt-daemon-restart", [this](const util::AsyncTaskManager::Token& token) {
|
||||
for (int i = 0; i < 20 && !token.cancelled() && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
if (token.cancelled() || shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
// tryConnect will be called by the update loop
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
ui::Notifications::instance().warning(
|
||||
"Please restart your daemon for encryption to take effect.");
|
||||
}
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
} else {
|
||||
std::string err = result.error;
|
||||
return [this, err]() {
|
||||
encrypt_in_progress_ = false;
|
||||
encrypt_status_ = "Encryption failed: " + err;
|
||||
deferred_encrypt_pending_ = false;
|
||||
DEBUG_LOGF("[App] Deferred encryptwallet failed: %s\n", err.c_str());
|
||||
ui::Notifications::instance().error("Encryption failed: " + err);
|
||||
|
||||
// Clean up sensitive data on failure
|
||||
if (!deferred_encrypt_passphrase_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_passphrase_[0],
|
||||
deferred_encrypt_passphrase_.size());
|
||||
deferred_encrypt_passphrase_.clear();
|
||||
}
|
||||
if (!deferred_encrypt_pin_.empty()) {
|
||||
util::SecureVault::secureZero(
|
||||
&deferred_encrypt_pin_[0],
|
||||
deferred_encrypt_pin_.size());
|
||||
deferred_encrypt_pin_.clear();
|
||||
}
|
||||
wallet_security_.clearDeferredEncryption();
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -244,14 +397,9 @@ void App::unlockWallet(const std::string& passphrase, int timeout) {
|
||||
auto* w = (fast_worker_ && fast_worker_->isRunning()) ? fast_worker_.get() : worker_.get();
|
||||
auto* r = (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
|
||||
w->post([this, r, passphrase, timeout]() -> rpc::RPCWorker::MainCb {
|
||||
bool ok = false;
|
||||
std::string err_msg;
|
||||
try {
|
||||
r->call("walletpassphrase", {passphrase, timeout});
|
||||
ok = true;
|
||||
} catch (const std::exception& e) {
|
||||
err_msg = e.what();
|
||||
}
|
||||
WalletSecurityRpcAdapter rpcAdapter(r);
|
||||
bool ok = rpcAdapter.unlockWallet(passphrase, timeout, err_msg);
|
||||
|
||||
return [this, ok, err_msg, timeout]() {
|
||||
lock_unlock_in_progress_ = false;
|
||||
@@ -1146,14 +1294,18 @@ void App::renderEncryptWalletDialog() {
|
||||
void App::renderDecryptWalletDialog() {
|
||||
if (!show_decrypt_dialog_) return;
|
||||
using namespace ui::material;
|
||||
using DecryptPhase = services::WalletSecurityWorkflow::DecryptPhase;
|
||||
using DecryptStep = services::WalletSecurityWorkflow::DecryptStep;
|
||||
|
||||
bool canClose = (decrypt_phase_ != 1); // don't close while working
|
||||
auto decryptState = wallet_security_workflow_.snapshot();
|
||||
|
||||
bool canClose = wallet_security_workflow_.canClose();
|
||||
bool* pOpen = canClose ? &show_decrypt_dialog_ : nullptr;
|
||||
|
||||
if (BeginOverlayDialog("Remove Wallet Encryption", pOpen, 480.0f, 0.94f)) {
|
||||
|
||||
// ---- Phase 0: Passphrase entry ----
|
||||
if (decrypt_phase_ == 0) {
|
||||
if (decryptState.phase == DecryptPhase::PassphraseEntry) {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 0.7f, 0.3f, 1));
|
||||
ImGui::TextWrapped(ICON_MD_WARNING
|
||||
" This will remove encryption from your wallet. "
|
||||
@@ -1176,143 +1328,110 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGuiInputTextFlags_EnterReturnsTrue);
|
||||
ImGui::PopItemWidth();
|
||||
|
||||
if (!decrypt_status_.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decrypt_status_.c_str());
|
||||
if (!decryptState.status.empty()) {
|
||||
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", decryptState.status.c_str());
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
bool valid = strlen(decrypt_pass_buf_) >= 1;
|
||||
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
ImGui::BeginDisabled(!valid || decrypt_in_progress_);
|
||||
ImGui::BeginDisabled(!valid || decryptState.inProgress);
|
||||
if (ImGui::Button("Remove Encryption", ImVec2(btnW, 40)) || (enterPressed && valid)) {
|
||||
std::string passphrase(decrypt_pass_buf_);
|
||||
memset(decrypt_pass_buf_, 0, sizeof(decrypt_pass_buf_));
|
||||
decrypt_phase_ = 1;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_in_progress_ = true;
|
||||
decrypt_status_ = "Unlocking wallet...";
|
||||
decrypt_overall_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_step_start_time_ = decrypt_overall_start_time_;
|
||||
wallet_security_workflow_.start(std::chrono::steady_clock::now());
|
||||
|
||||
// Run entire decrypt flow on worker thread
|
||||
if (worker_) {
|
||||
worker_->post([this, passphrase]() -> rpc::RPCWorker::MainCb {
|
||||
// Step 1: Unlock wallet
|
||||
try {
|
||||
rpc_->call("walletpassphrase", {passphrase, 600});
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Incorrect passphrase";
|
||||
decrypt_phase_ = 0; // back to entry
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto unlock = services::WalletSecurityWorkflowExecutor::unlockWallet(passphrase, decryptRpc);
|
||||
if (!unlock.ok) {
|
||||
return [this]() {
|
||||
wallet_security_workflow_.failEntry("Incorrect passphrase");
|
||||
};
|
||||
}
|
||||
|
||||
// Update step on main thread
|
||||
return [this]() {
|
||||
decrypt_step_ = 1;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Exporting wallet keys...";
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::ExportKeys,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::ExportKeys),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 2
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string exportFile = "obsidiandecryptexport" +
|
||||
std::to_string(std::time(nullptr));
|
||||
std::string exportPath = dataDir + exportFile;
|
||||
|
||||
try {
|
||||
rpc_->call("z_exportwallet", {exportFile}, 300L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
WalletSecurityFileAdapter files;
|
||||
auto exportOutcome = services::WalletSecurityWorkflowExecutor::exportWallet(
|
||||
decryptRpc, files, static_cast<std::uint64_t>(std::time(nullptr)));
|
||||
if (!exportOutcome.ok) {
|
||||
std::string err = exportOutcome.error;
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Export failed: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 2;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Stopping daemon...";
|
||||
auto filePlan = exportOutcome.filePlan;
|
||||
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::StopDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::StopDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 3
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
try {
|
||||
rpc_->call("stop");
|
||||
} catch (...) {
|
||||
// stop often throws because connection drops
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
services::WalletSecurityWorkflowExecutor::stopDaemon(decryptRpc);
|
||||
|
||||
// Wait for daemon to fully stop
|
||||
for (int i = 0; i < 30 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 3;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Backing up encrypted wallet...";
|
||||
return [this, filePlan]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::BackupWallet,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::BackupWallet),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
// Continue with step 4 (rename)
|
||||
worker_->post([this, exportPath]() -> rpc::RPCWorker::MainCb {
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string walletPath = dataDir + "wallet.dat";
|
||||
std::string backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
std::error_code ec;
|
||||
if (std::filesystem::exists(walletPath, ec)) {
|
||||
std::filesystem::remove(backupPath, ec);
|
||||
std::filesystem::rename(walletPath, backupPath, ec);
|
||||
if (ec) {
|
||||
std::string err = ec.message();
|
||||
return [this, err]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Failed to rename wallet.dat: " + err;
|
||||
decrypt_phase_ = 3;
|
||||
};
|
||||
}
|
||||
worker_->post([this, filePlan]() -> rpc::RPCWorker::MainCb {
|
||||
WalletSecurityFileAdapter files;
|
||||
auto backup = services::WalletSecurityWorkflowExecutor::backupEncryptedWallet(files, filePlan);
|
||||
if (!backup.ok) {
|
||||
std::string err = backup.error;
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
}
|
||||
|
||||
return [this, exportPath]() {
|
||||
decrypt_step_ = 4;
|
||||
decrypt_step_start_time_ = std::chrono::steady_clock::now();
|
||||
decrypt_status_ = "Restarting daemon...";
|
||||
return [this, exportPath = filePlan.exportPath]() {
|
||||
wallet_security_workflow_.advanceTo(DecryptStep::RestartDaemon,
|
||||
services::WalletSecurityWorkflow::stepStatus(DecryptStep::RestartDaemon),
|
||||
std::chrono::steady_clock::now());
|
||||
|
||||
auto restartAndImport = [this, exportPath]() {
|
||||
for (int i = 0; i < 20 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
|
||||
if (isUsingEmbeddedDaemon()) {
|
||||
stopEmbeddedDaemon();
|
||||
if (shutting_down_) return;
|
||||
for (int i = 0; i < 10 && !shutting_down_; ++i)
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||
if (shutting_down_) return;
|
||||
startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
// Wait for daemon to become available
|
||||
int maxWait = 60;
|
||||
bool daemonUp = false;
|
||||
for (int i = 0; i < maxWait && !shutting_down_; i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
try {
|
||||
rpc_->call("getinfo");
|
||||
daemonUp = true;
|
||||
break;
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
if (!daemonUp) {
|
||||
auto restartAndImport = [this, exportPath](const util::AsyncTaskManager::Token& token) {
|
||||
WalletSecurityDaemonAdapter daemonAdapter(*this, token);
|
||||
WalletSecurityDecryptRpcAdapter decryptRpc(rpc_.get(),
|
||||
[this](rpc::RPCClient& client, const char* context) {
|
||||
return sendStopCommandSafely(client, context);
|
||||
});
|
||||
auto restart = services::WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
daemonAdapter, decryptRpc);
|
||||
if (!restart.ok) {
|
||||
if (restart.error.empty()) return;
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_in_progress_ = false;
|
||||
decrypt_status_ = "Daemon failed to restart";
|
||||
decrypt_phase_ = 3;
|
||||
worker_->post([this, err = restart.error]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
wallet_security_workflow_.fail(err);
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1324,9 +1443,8 @@ void App::renderDecryptWalletDialog() {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
// Close the decrypt dialog — user can use the wallet now
|
||||
decrypt_in_progress_ = false;
|
||||
wallet_security_workflow_.closeDialogForImport();
|
||||
show_decrypt_dialog_ = false;
|
||||
decrypt_import_active_ = true;
|
||||
|
||||
// Mark rescanning so status bar picks it up immediately
|
||||
state_.sync.rescanning = true;
|
||||
@@ -1334,13 +1452,15 @@ void App::renderDecryptWalletDialog() {
|
||||
|
||||
// Clear encryption state early — vault/PIN removed now,
|
||||
// wallet file is already unencrypted
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
services::WalletSecurityWorkflowExecutor::cleanupVaultAndPin([this]() {
|
||||
if (vault_ && vault_->hasVault()) {
|
||||
vault_->removeVault();
|
||||
}
|
||||
if (settings_ && settings_->getPinEnabled()) {
|
||||
settings_->setPinEnabled(false);
|
||||
settings_->save();
|
||||
}
|
||||
});
|
||||
|
||||
ui::Notifications::instance().info(
|
||||
"Importing keys & rescanning blockchain — wallet is usable while this runs",
|
||||
@@ -1349,32 +1469,17 @@ void App::renderDecryptWalletDialog() {
|
||||
});
|
||||
}
|
||||
|
||||
// Step 6: Import wallet in background (use full path)
|
||||
// Use a SEPARATE RPC client so the main rpc_'s
|
||||
// curl_mutex isn't held for the entire import duration.
|
||||
// Blocking rpc_ prevents refreshData/refreshPeerInfo
|
||||
// from running, which leaves the UI with no peers.
|
||||
auto importRpc = std::make_unique<rpc::RPCClient>();
|
||||
bool importRpcOk = importRpc->connect(
|
||||
saved_config_.host, saved_config_.port,
|
||||
saved_config_.rpcuser, saved_config_.rpcpassword);
|
||||
if (!importRpcOk) {
|
||||
// Fall back to main client if temp connect fails
|
||||
importRpc.reset();
|
||||
}
|
||||
auto* rpcForImport = importRpc ? importRpc.get() : rpc_.get();
|
||||
|
||||
// Use 20-minute timeout — import + rescan can be very slow
|
||||
try {
|
||||
rpcForImport->call("z_importwallet", {exportPath}, 1200L);
|
||||
} catch (const std::exception& e) {
|
||||
std::string err = e.what();
|
||||
WalletSecurityImportRpcAdapter importAdapter(rpc_.get(), saved_config_);
|
||||
auto importResult = services::WalletSecurityWorkflowExecutor::importWallet(
|
||||
importAdapter, exportPath);
|
||||
if (!importResult.ok) {
|
||||
std::string err = importResult.error;
|
||||
if (worker_) {
|
||||
worker_->post([this, err]() -> rpc::RPCWorker::MainCb {
|
||||
return [this, err]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
ui::Notifications::instance().error(
|
||||
"Key import failed: " + err +
|
||||
err +
|
||||
"\nEncrypted backup: wallet.dat.encrypted.bak",
|
||||
12.0f);
|
||||
};
|
||||
@@ -1383,18 +1488,12 @@ void App::renderDecryptWalletDialog() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disconnect the temporary RPC client
|
||||
if (importRpc) {
|
||||
importRpc->disconnect();
|
||||
importRpc.reset();
|
||||
}
|
||||
|
||||
// Success — force full state refresh so peers,
|
||||
// balances, and addresses are fetched immediately.
|
||||
if (worker_) {
|
||||
worker_->post([this]() -> rpc::RPCWorker::MainCb {
|
||||
return [this]() {
|
||||
decrypt_import_active_ = false;
|
||||
wallet_security_workflow_.finishImport();
|
||||
|
||||
// Force address + peer refresh
|
||||
addresses_dirty_ = true;
|
||||
@@ -1414,7 +1513,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
};
|
||||
|
||||
std::thread(restartAndImport).detach();
|
||||
async_tasks_.submit("decrypt-restart-import", restartAndImport);
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1434,7 +1533,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 1: Working ----
|
||||
} else if (decrypt_phase_ == 1) {
|
||||
} else if (decryptState.phase == DecryptPhase::Working) {
|
||||
// Step checklist
|
||||
const char* stepLabels[] = {
|
||||
"Unlocking wallet",
|
||||
@@ -1448,17 +1547,18 @@ void App::renderDecryptWalletDialog() {
|
||||
// Compute elapsed times
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto stepElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_step_start_time_).count();
|
||||
now - decryptState.stepStarted).count();
|
||||
auto totalElapsed = std::chrono::duration_cast<std::chrono::seconds>(
|
||||
now - decrypt_overall_start_time_).count();
|
||||
now - decryptState.overallStarted).count();
|
||||
|
||||
ImGui::Spacing();
|
||||
for (int i = 0; i < numSteps; i++) {
|
||||
ImGui::PushFont(Type().iconMed());
|
||||
if (i < decrypt_step_) {
|
||||
if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
// Completed
|
||||
ImGui::TextColored(ImVec4(0.3f, 0.9f, 0.4f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
} else if (i == decrypt_step_) {
|
||||
} else if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// In progress - animate
|
||||
float alpha = 0.5f + 0.5f * sinf((float)ImGui::GetTime() * 4.0f);
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, alpha), ICON_MD_PENDING);
|
||||
@@ -1469,7 +1569,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::PopFont();
|
||||
ImGui::SameLine();
|
||||
|
||||
if (i == decrypt_step_) {
|
||||
if (i == services::WalletSecurityWorkflow::stepIndex(decryptState.step)) {
|
||||
// Show step label with elapsed time
|
||||
int mins = (int)(stepElapsed / 60);
|
||||
int secs = (int)(stepElapsed % 60);
|
||||
@@ -1480,7 +1580,8 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.6f, 1.0f),
|
||||
"%s... (%ds)", stepLabels[i], secs);
|
||||
}
|
||||
} else if (i < decrypt_step_) {
|
||||
} else if (services::WalletSecurityWorkflow::stepIsComplete(decryptState.step,
|
||||
services::WalletSecurityWorkflow::stepFromIndex(i))) {
|
||||
ImGui::TextColored(ImVec4(0.6f, 0.8f, 0.6f, 1.0f), "%s", stepLabels[i]);
|
||||
} else {
|
||||
ImGui::TextDisabled("%s", stepLabels[i]);
|
||||
@@ -1515,7 +1616,7 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::Spacing();
|
||||
|
||||
// Step-specific hints
|
||||
if (decrypt_step_ == 4) {
|
||||
if (decryptState.step == DecryptStep::RestartDaemon) {
|
||||
ImGui::TextWrapped("Waiting for the daemon to finish starting up...");
|
||||
} else {
|
||||
ImGui::TextWrapped("Please wait. The daemon is exporting keys, restarting, "
|
||||
@@ -1531,7 +1632,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 2: Success ----
|
||||
} else if (decrypt_phase_ == 2) {
|
||||
} else if (decryptState.phase == DecryptPhase::Success) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.5f, 1.0f), ICON_MD_CHECK_CIRCLE);
|
||||
ImGui::PopFont();
|
||||
@@ -1549,7 +1650,7 @@ void App::renderDecryptWalletDialog() {
|
||||
}
|
||||
|
||||
// ---- Phase 3: Error ----
|
||||
} else if (decrypt_phase_ == 3) {
|
||||
} else if (decryptState.phase == DecryptPhase::Error) {
|
||||
ImGui::PushFont(Type().iconLarge());
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), ICON_MD_ERROR);
|
||||
ImGui::PopFont();
|
||||
@@ -1557,14 +1658,12 @@ void App::renderDecryptWalletDialog() {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "Decryption failed");
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::TextWrapped("%s", decrypt_status_.c_str());
|
||||
ImGui::TextWrapped("%s", decryptState.status.c_str());
|
||||
|
||||
ImGui::Spacing();
|
||||
float btnW = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f;
|
||||
if (ImGui::Button("Try Again", ImVec2(btnW, 40))) {
|
||||
decrypt_phase_ = 0;
|
||||
decrypt_step_ = 0;
|
||||
decrypt_status_.clear();
|
||||
wallet_security_workflow_.reset();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button("Close", ImVec2(btnW, 40))) {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "rpc/rpc_worker.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "config/settings.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "daemon/embedded_daemon.h"
|
||||
#include "ui/notifications.h"
|
||||
#include "ui/material/color_theme.h"
|
||||
@@ -39,13 +40,43 @@ namespace dragonx {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace {
|
||||
|
||||
struct WizardLowSpecSnapshot {
|
||||
bool valid = false;
|
||||
float blur = 0.0f;
|
||||
float uiOp = 0.0f;
|
||||
bool fx = false;
|
||||
bool scanline = false;
|
||||
};
|
||||
|
||||
struct WizardUiState {
|
||||
float blur_amount = 1.5f;
|
||||
bool theme_effects = true;
|
||||
float ui_opacity = 1.0f;
|
||||
bool low_spec = false;
|
||||
bool scanline = true;
|
||||
std::string balance_layout = "classic";
|
||||
int language_index = 0;
|
||||
bool appearance_init = false;
|
||||
WizardLowSpecSnapshot low_spec_snapshot;
|
||||
float card0_max_h = 0.0f;
|
||||
float card1_max_h = 0.0f;
|
||||
double external_last_check = -10.0;
|
||||
bool daemon_prestarted = false;
|
||||
};
|
||||
|
||||
WizardUiState s_wizardUi;
|
||||
|
||||
} // namespace
|
||||
|
||||
void App::restartWizard()
|
||||
{
|
||||
DEBUG_LOGF("[App] Restarting setup wizard — stopping daemon...\n");
|
||||
|
||||
// Reset crash counter for fresh wizard attempt
|
||||
if (embedded_daemon_) {
|
||||
embedded_daemon_->resetCrashCount();
|
||||
if (daemon_controller_) {
|
||||
daemon_controller_->resetCrashCount();
|
||||
}
|
||||
|
||||
// Disconnect RPC
|
||||
@@ -56,10 +87,11 @@ void App::restartWizard()
|
||||
|
||||
// Stop the embedded daemon in a background thread to avoid
|
||||
// blocking the UI for up to 32 seconds (RPC stop + process wait).
|
||||
if (embedded_daemon_ && isEmbeddedDaemonRunning()) {
|
||||
std::thread([this]() {
|
||||
if (daemon_controller_ && isEmbeddedDaemonRunning()) {
|
||||
async_tasks_.submit("wizard-restart-stop-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) return;
|
||||
stopEmbeddedDaemon();
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
|
||||
// Enter wizard — the wizard completion handler already calls
|
||||
@@ -73,6 +105,7 @@ void App::restartWizard()
|
||||
// ===========================================================================
|
||||
|
||||
void App::renderFirstRunWizard() {
|
||||
auto& wizardUi = s_wizardUi;
|
||||
ImGuiViewport* viewport = ImGui::GetMainViewport();
|
||||
ImGui::SetNextWindowPos(viewport->WorkPos);
|
||||
ImGui::SetNextWindowSize(viewport->WorkSize);
|
||||
@@ -243,15 +276,14 @@ void App::renderFirstRunWizard() {
|
||||
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
|
||||
cy += 14.0f * dp;
|
||||
|
||||
// Statics for appearance settings
|
||||
static float wiz_blur_amount = 1.5f;
|
||||
static bool wiz_theme_effects = true;
|
||||
static float wiz_ui_opacity = 1.0f;
|
||||
static bool wiz_low_spec = false;
|
||||
static bool wiz_scanline = true;
|
||||
static std::string wiz_balance_layout = "classic";
|
||||
static int wiz_language_index = 0;
|
||||
static bool wiz_appearance_init = false;
|
||||
float& wiz_blur_amount = wizardUi.blur_amount;
|
||||
bool& wiz_theme_effects = wizardUi.theme_effects;
|
||||
float& wiz_ui_opacity = wizardUi.ui_opacity;
|
||||
bool& wiz_low_spec = wizardUi.low_spec;
|
||||
bool& wiz_scanline = wizardUi.scanline;
|
||||
std::string& wiz_balance_layout = wizardUi.balance_layout;
|
||||
int& wiz_language_index = wizardUi.language_index;
|
||||
bool& wiz_appearance_init = wizardUi.appearance_init;
|
||||
if (!wiz_appearance_init) {
|
||||
wiz_blur_amount = settings_->getBlurMultiplier();
|
||||
wiz_theme_effects = settings_->getThemeEffectsEnabled();
|
||||
@@ -398,7 +430,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
// --- Low-spec mode checkbox ---
|
||||
// Snapshot for restoring settings when low-spec is turned off
|
||||
static struct { bool valid; float blur; float uiOp; bool fx; bool scanline; } wiz_lsSnap = {};
|
||||
WizardLowSpecSnapshot& wiz_lsSnap = wizardUi.low_spec_snapshot;
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cx, cy));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
|
||||
@@ -596,7 +628,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen
|
||||
static float card0MaxH = 0.0f;
|
||||
float& card0MaxH = wizardUi.card0_max_h;
|
||||
card0MaxH = std::max(card0MaxH, cy - card0Top);
|
||||
card0Bot = card0Top + card0MaxH;
|
||||
|
||||
@@ -774,14 +806,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Retry##bs", ImVec2(btnW2, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap retry...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap retry");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
@@ -808,17 +833,23 @@ void App::renderFirstRunWizard() {
|
||||
if (isFocused) {
|
||||
static std::atomic<bool> s_extCached{false};
|
||||
static std::atomic<bool> s_checkInFlight{false};
|
||||
static double s_extLastCheck = -10.0;
|
||||
double& s_extLastCheck = wizardUi.external_last_check;
|
||||
double now = ImGui::GetTime();
|
||||
if (now - s_extLastCheck >= 2.0 && !s_checkInFlight.load()) {
|
||||
s_extLastCheck = now;
|
||||
bool embeddedRunning = isEmbeddedDaemonRunning();
|
||||
s_checkInFlight.store(true);
|
||||
std::thread([embeddedRunning]() {
|
||||
async_tasks_.submit("wizard-external-daemon-check", [embeddedRunning](const util::AsyncTaskManager::Token& token) {
|
||||
if (token.cancelled()) {
|
||||
s_checkInFlight.store(false);
|
||||
return;
|
||||
}
|
||||
bool inUse = daemon::EmbeddedDaemon::isRpcPortInUse();
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
if (!token.cancelled()) {
|
||||
s_extCached.store(inUse && !embeddedRunning);
|
||||
}
|
||||
s_checkInFlight.store(false);
|
||||
}).detach();
|
||||
});
|
||||
}
|
||||
externalRunning = s_extCached.load();
|
||||
}
|
||||
@@ -859,19 +890,19 @@ void App::renderFirstRunWizard() {
|
||||
if (ImGui::Button("Stop Daemon##wiz", ImVec2(stopW, btnH2))) {
|
||||
wizard_stopping_external_ = true;
|
||||
wizard_stop_status_ = "Sending stop command...";
|
||||
if (wizard_stop_thread_.joinable()) wizard_stop_thread_.join();
|
||||
wizard_stop_thread_ = std::thread([this]() {
|
||||
async_tasks_.submit("wizard-stop-external-daemon", [this](const util::AsyncTaskManager::Token& token) {
|
||||
auto config = rpc::Connection::autoDetectConfig();
|
||||
if (!config.rpcuser.empty() && !config.rpcpassword.empty()) {
|
||||
auto tmp_rpc = std::make_unique<rpc::RPCClient>();
|
||||
if (tmp_rpc->connect(config.host, config.port,
|
||||
config.rpcuser, config.rpcpassword)) {
|
||||
try { tmp_rpc->call("stop"); } catch (...) {}
|
||||
config.rpcuser, config.rpcpassword,
|
||||
config.use_tls)) {
|
||||
sendStopCommandSafely(*tmp_rpc, "Wizard external daemon stop");
|
||||
tmp_rpc->disconnect();
|
||||
}
|
||||
}
|
||||
wizard_stop_status_ = "Waiting for daemon to shut down...";
|
||||
for (int i = 0; i < 60; i++) {
|
||||
for (int i = 0; i < 60 && !token.cancelled(); i++) {
|
||||
std::this_thread::sleep_for(std::chrono::seconds(1));
|
||||
if (!daemon::EmbeddedDaemon::isRpcPortInUse()) {
|
||||
wizard_stop_status_ = "Daemon stopped.";
|
||||
@@ -879,6 +910,7 @@ void App::renderFirstRunWizard() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (token.cancelled()) return;
|
||||
wizard_stop_status_ = "Daemon did not stop — try manually.";
|
||||
wizard_stopping_external_ = false;
|
||||
});
|
||||
@@ -955,14 +987,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Download##bs", ImVec2(dlBtnW, btnH2))) {
|
||||
// Stop embedded daemon before bootstrap to avoid chain data corruption
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
bootstrap_->start(dataDir);
|
||||
@@ -978,14 +1003,7 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(ui::material::OnSurface()));
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 8.0f * dp);
|
||||
if (ImGui::Button("Mirror##bs_mirror", ImVec2(mirrorW, btnH2))) {
|
||||
if (isEmbeddedDaemonRunning()) {
|
||||
DEBUG_LOGF("[Wizard] Stopping embedded daemon before bootstrap (mirror)...\n");
|
||||
if (rpc_ && rpc_->isConnected()) {
|
||||
try { rpc_->call("stop"); } catch (...) {}
|
||||
rpc_->disconnect();
|
||||
}
|
||||
onDisconnected("Bootstrap");
|
||||
}
|
||||
stopDaemonForBootstrap();
|
||||
bootstrap_ = std::make_unique<util::Bootstrap>();
|
||||
std::string dataDir = util::Platform::getDragonXDataDir();
|
||||
std::string mirrorUrl = std::string(util::Bootstrap::kMirrorUrl) + "/" + util::Bootstrap::kZipName;
|
||||
@@ -1012,7 +1030,7 @@ void App::renderFirstRunWizard() {
|
||||
|
||||
cy += cardPad;
|
||||
// Lock card height to the tallest content ever seen (but not when collapsed)
|
||||
static float card1MaxH = 0.0f;
|
||||
float& card1MaxH = wizardUi.card1_max_h;
|
||||
if (isCollapsed) {
|
||||
card1Bot = card1Top + (cy - card1Top);
|
||||
} else {
|
||||
@@ -1037,7 +1055,7 @@ void App::renderFirstRunWizard() {
|
||||
// Pre-start daemon when encrypt card becomes focused so it's ready
|
||||
// by the time the user finishes typing their passphrase
|
||||
if (isFocused) {
|
||||
static bool wiz_daemon_prestarted = false;
|
||||
bool& wiz_daemon_prestarted = wizardUi.daemon_prestarted;
|
||||
if (!wiz_daemon_prestarted) {
|
||||
wiz_daemon_prestarted = true;
|
||||
if (!state_.connected && isUsingEmbeddedDaemon() && !isEmbeddedDaemonRunning()) {
|
||||
@@ -1281,10 +1299,9 @@ void App::renderFirstRunWizard() {
|
||||
ImGui::BeginDisabled(!canEncrypt);
|
||||
if (ImGui::Button("Encrypt & Continue##wiz", ImVec2(encBtnW, btnH2))) {
|
||||
// Save passphrase + optional PIN for background processing
|
||||
deferred_encrypt_passphrase_ = std::string(encrypt_pass_buf_);
|
||||
if (pinEntered && pinOk)
|
||||
deferred_encrypt_pin_ = pinStr;
|
||||
deferred_encrypt_pending_ = true;
|
||||
wallet_security_.beginDeferredEncryption(
|
||||
std::string(encrypt_pass_buf_),
|
||||
(pinEntered && pinOk) ? pinStr : std::string());
|
||||
|
||||
// Clear sensitive buffers
|
||||
memset(encrypt_pass_buf_, 0, sizeof(encrypt_pass_buf_));
|
||||
|
||||
117
src/daemon/daemon_controller.cpp
Normal file
117
src/daemon/daemon_controller.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include "daemon_controller.h"
|
||||
#include "../config/settings.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
DaemonController::DaemonController()
|
||||
: daemon_(std::make_unique<EmbeddedDaemon>())
|
||||
{
|
||||
}
|
||||
|
||||
DaemonController::~DaemonController() = default;
|
||||
|
||||
void DaemonController::setStateCallback(StateCallback callback)
|
||||
{
|
||||
daemon_->setStateCallback(std::move(callback));
|
||||
}
|
||||
|
||||
void DaemonController::syncSettings(const config::Settings* settings)
|
||||
{
|
||||
if (!settings) return;
|
||||
daemon_->setDebugCategories(settings->getDebugCategories());
|
||||
daemon_->setMaxConnections(settings->getMaxConnections());
|
||||
}
|
||||
|
||||
bool DaemonController::start(const config::Settings* settings)
|
||||
{
|
||||
syncSettings(settings);
|
||||
return daemon_->start();
|
||||
}
|
||||
|
||||
void DaemonController::stop(int waitMs)
|
||||
{
|
||||
daemon_->stop(waitMs);
|
||||
}
|
||||
|
||||
bool DaemonController::isRunning() const
|
||||
{
|
||||
return daemon_->isRunning();
|
||||
}
|
||||
|
||||
bool DaemonController::externalDaemonDetected() const
|
||||
{
|
||||
return daemon_->externalDaemonDetected();
|
||||
}
|
||||
|
||||
DaemonController::State DaemonController::state() const
|
||||
{
|
||||
return daemon_->getState();
|
||||
}
|
||||
|
||||
const std::string& DaemonController::lastError() const
|
||||
{
|
||||
return daemon_->getLastError();
|
||||
}
|
||||
|
||||
int DaemonController::crashCount() const
|
||||
{
|
||||
return daemon_->getCrashCount();
|
||||
}
|
||||
|
||||
int DaemonController::lastBlockHeight() const
|
||||
{
|
||||
return daemon_ ? daemon_->getLastBlockHeight() : 0;
|
||||
}
|
||||
|
||||
double DaemonController::memoryUsageMB() const
|
||||
{
|
||||
return daemon_ ? daemon_->getMemoryUsageMB() : 0.0;
|
||||
}
|
||||
|
||||
std::vector<std::string> DaemonController::recentLines(std::size_t count) const
|
||||
{
|
||||
return daemon_ ? daemon_->getRecentLines(count) : std::vector<std::string>{};
|
||||
}
|
||||
|
||||
std::string DaemonController::outputSince(std::size_t& offset) const
|
||||
{
|
||||
return daemon_ ? daemon_->getOutputSince(offset) : std::string{};
|
||||
}
|
||||
|
||||
void DaemonController::resetCrashCount()
|
||||
{
|
||||
daemon_->resetCrashCount();
|
||||
}
|
||||
|
||||
void DaemonController::setRescanOnNextStart(bool enabled)
|
||||
{
|
||||
daemon_->setRescanOnNextStart(enabled);
|
||||
}
|
||||
|
||||
bool DaemonController::rescanOnNextStart() const
|
||||
{
|
||||
return daemon_->rescanOnNextStart();
|
||||
}
|
||||
|
||||
void DaemonController::prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings)
|
||||
{
|
||||
if (settings) syncSettings(settings);
|
||||
if (decision.resetCrashCount) resetCrashCount();
|
||||
if (decision.setRescanOnNextStart) setRescanOnNextStart(true);
|
||||
}
|
||||
|
||||
DaemonController::ShutdownDecision DaemonController::shutdownDecision(
|
||||
bool keepDaemonRunning, bool stopExternalDaemon) const
|
||||
{
|
||||
return evaluateShutdownPolicy(static_cast<bool>(daemon_),
|
||||
daemon_ && daemon_->externalDaemonDetected(),
|
||||
keepDaemonRunning,
|
||||
stopExternalDaemon);
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
225
src/daemon/daemon_controller.h
Normal file
225
src/daemon/daemon_controller.h
Normal file
@@ -0,0 +1,225 @@
|
||||
#pragma once
|
||||
|
||||
#include "embedded_daemon.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace config { class Settings; }
|
||||
namespace daemon {
|
||||
|
||||
class DaemonController {
|
||||
public:
|
||||
using State = EmbeddedDaemon::State;
|
||||
using StateCallback = EmbeddedDaemon::StateCallback;
|
||||
|
||||
enum class ShutdownAction {
|
||||
DisconnectOnly,
|
||||
StopDaemon
|
||||
};
|
||||
|
||||
struct ShutdownDecision {
|
||||
ShutdownAction action = ShutdownAction::DisconnectOnly;
|
||||
const char* logReason = "no embedded daemon";
|
||||
const char* status = "Disconnecting...";
|
||||
};
|
||||
|
||||
enum class LifecycleOperation {
|
||||
ManualRestart,
|
||||
Rescan,
|
||||
DeleteBlockchainData,
|
||||
BootstrapStop
|
||||
};
|
||||
|
||||
struct LifecycleDecision {
|
||||
LifecycleOperation operation = LifecycleOperation::ManualRestart;
|
||||
bool allowed = false;
|
||||
bool wasRunning = false;
|
||||
const char* taskName = "";
|
||||
const char* status = "";
|
||||
const char* warning = "";
|
||||
bool resetCrashCount = false;
|
||||
bool setRescanOnNextStart = false;
|
||||
bool disconnectRpc = false;
|
||||
int restartDelayMs = 0;
|
||||
};
|
||||
|
||||
class LifecycleTaskContext {
|
||||
public:
|
||||
virtual ~LifecycleTaskContext() = default;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
class LifecycleRuntime {
|
||||
public:
|
||||
virtual ~LifecycleRuntime() = default;
|
||||
virtual void stopDaemonWithPolicy() = 0;
|
||||
virtual bool startDaemon() = 0;
|
||||
virtual int deleteBlockchainData() = 0;
|
||||
virtual void resetOutputOffset() = 0;
|
||||
virtual void requestRpcStopAndDisconnect(const char* context, const char* reason) = 0;
|
||||
};
|
||||
|
||||
struct LifecycleExecutionResult {
|
||||
bool completed = false;
|
||||
bool cancelled = false;
|
||||
bool stopped = false;
|
||||
bool started = false;
|
||||
int deletedItems = 0;
|
||||
};
|
||||
|
||||
DaemonController();
|
||||
~DaemonController();
|
||||
|
||||
DaemonController(const DaemonController&) = delete;
|
||||
DaemonController& operator=(const DaemonController&) = delete;
|
||||
|
||||
EmbeddedDaemon* daemon() { return daemon_.get(); }
|
||||
const EmbeddedDaemon* daemon() const { return daemon_.get(); }
|
||||
|
||||
void setStateCallback(StateCallback callback);
|
||||
void syncSettings(const config::Settings* settings);
|
||||
|
||||
bool start(const config::Settings* settings);
|
||||
void stop(int waitMs);
|
||||
|
||||
bool isRunning() const;
|
||||
bool externalDaemonDetected() const;
|
||||
State state() const;
|
||||
const std::string& lastError() const;
|
||||
int crashCount() const;
|
||||
int lastBlockHeight() const;
|
||||
double memoryUsageMB() const;
|
||||
std::vector<std::string> recentLines(std::size_t count) const;
|
||||
std::string outputSince(std::size_t& offset) const;
|
||||
|
||||
void resetCrashCount();
|
||||
void setRescanOnNextStart(bool enabled);
|
||||
bool rescanOnNextStart() const;
|
||||
|
||||
static ShutdownDecision evaluateShutdownPolicy(bool hasDaemon,
|
||||
bool externalDaemonDetected,
|
||||
bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) {
|
||||
if (!hasDaemon) {
|
||||
return {};
|
||||
}
|
||||
if (keepDaemonRunning) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"keep_daemon_running enabled",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
if (externalDaemonDetected && !stopExternalDaemon) {
|
||||
return {ShutdownAction::DisconnectOnly,
|
||||
"external daemon (not ours to stop)",
|
||||
"Disconnecting (daemon stays running)..."};
|
||||
}
|
||||
return {ShutdownAction::StopDaemon,
|
||||
"stopping managed daemon",
|
||||
"Sending stop command to daemon..."};
|
||||
}
|
||||
static LifecycleDecision evaluateLifecycleOperation(LifecycleOperation operation,
|
||||
bool usingEmbeddedDaemon,
|
||||
bool hasDaemon,
|
||||
bool daemonRunning,
|
||||
bool restartInProgress = false) {
|
||||
switch (operation) {
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (!usingEmbeddedDaemon || restartInProgress) return {};
|
||||
return {operation, true, daemonRunning, "daemon-restart", "Restarting daemon...", "",
|
||||
true, false, true, 500};
|
||||
case LifecycleOperation::Rescan:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Rescan requires embedded daemon. Restart your daemon with -rescan manually."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "rescan-blockchain", "Starting rescan...", "",
|
||||
false, true, false, 3000};
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
if (!usingEmbeddedDaemon || !hasDaemon) {
|
||||
return {operation, false, daemonRunning, "", "",
|
||||
"Delete blockchain requires embedded daemon. Stop your daemon manually and delete the data directory."};
|
||||
}
|
||||
return {operation, true, daemonRunning, "delete-blockchain-data", "Deleting blockchain data...", "",
|
||||
false, false, false, 3000};
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
return {operation, true, daemonRunning, "bootstrap-stop-daemon", "Stopping daemon for bootstrap...", "",
|
||||
false, false, true, 0};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
void prepareLifecycleOperation(const LifecycleDecision& decision,
|
||||
const config::Settings* settings = nullptr);
|
||||
static inline LifecycleExecutionResult executeLifecycleOperation(const LifecycleDecision& decision,
|
||||
LifecycleRuntime& runtime,
|
||||
LifecycleTaskContext& task)
|
||||
{
|
||||
LifecycleExecutionResult result;
|
||||
if (!decision.allowed) return result;
|
||||
|
||||
auto waitForDelay = [&]() {
|
||||
int waitTicks = std::max(0, decision.restartDelayMs / 100);
|
||||
for (int i = 0; i < waitTicks && !task.cancelled() && !task.shuttingDown(); ++i) {
|
||||
task.sleepForMs(100);
|
||||
}
|
||||
};
|
||||
|
||||
auto cancelled = [&]() {
|
||||
result.cancelled = task.cancelled() || task.shuttingDown();
|
||||
return result.cancelled;
|
||||
};
|
||||
|
||||
switch (decision.operation) {
|
||||
case LifecycleOperation::BootstrapStop:
|
||||
if (decision.wasRunning) {
|
||||
runtime.requestRpcStopAndDisconnect("Bootstrap daemon stop", "Bootstrap");
|
||||
result.stopped = true;
|
||||
}
|
||||
result.completed = true;
|
||||
return result;
|
||||
case LifecycleOperation::ManualRestart:
|
||||
if (decision.wasRunning) {
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
}
|
||||
break;
|
||||
case LifecycleOperation::Rescan:
|
||||
case LifecycleOperation::DeleteBlockchainData:
|
||||
runtime.stopDaemonWithPolicy();
|
||||
result.stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (cancelled()) return result;
|
||||
waitForDelay();
|
||||
if (cancelled()) return result;
|
||||
|
||||
if (decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
result.deletedItems = runtime.deleteBlockchainData();
|
||||
if (cancelled()) return result;
|
||||
}
|
||||
|
||||
if (decision.operation == LifecycleOperation::Rescan ||
|
||||
decision.operation == LifecycleOperation::DeleteBlockchainData) {
|
||||
runtime.resetOutputOffset();
|
||||
}
|
||||
|
||||
result.started = runtime.startDaemon();
|
||||
result.completed = !cancelled();
|
||||
return result;
|
||||
}
|
||||
ShutdownDecision shutdownDecision(bool keepDaemonRunning,
|
||||
bool stopExternalDaemon) const;
|
||||
|
||||
private:
|
||||
std::unique_ptr<EmbeddedDaemon> daemon_;
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
93
src/daemon/lifecycle_adapters.cpp
Normal file
93
src/daemon/lifecycle_adapters.cpp
Normal file
@@ -0,0 +1,93 @@
|
||||
#include "lifecycle_adapters.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
AsyncLifecycleTaskContext::AsyncLifecycleTaskContext(
|
||||
const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown)
|
||||
: token_(token), shuttingDown_(shuttingDown)
|
||||
{
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return token_.cancelled();
|
||||
}
|
||||
|
||||
bool AsyncLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return shuttingDown_.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void AsyncLifecycleTaskContext::sleepForMs(int milliseconds)
|
||||
{
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(std::max(0, milliseconds)));
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::cancelled() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ImmediateLifecycleTaskContext::shuttingDown() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void ImmediateLifecycleTaskContext::sleepForMs(int)
|
||||
{
|
||||
}
|
||||
|
||||
int BlockchainDataCleaner::removeBlockchainData(const std::filesystem::path& dataDir)
|
||||
{
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static constexpr std::array<const char*, 4> directories = {
|
||||
"blocks", "chainstate", "database", "notarizations"
|
||||
};
|
||||
static constexpr std::array<const char*, 5> files = {
|
||||
"peers.dat", "fee_estimates.dat", "banlist.dat", "db.log", ".lock"
|
||||
};
|
||||
|
||||
int removed = 0;
|
||||
for (const char* directoryName : directories) {
|
||||
fs::path path = dataDir / directoryName;
|
||||
std::error_code existsError;
|
||||
if (!fs::exists(path, existsError)) continue;
|
||||
|
||||
std::error_code removeError;
|
||||
auto count = fs::remove_all(path, removeError);
|
||||
if (!removeError) {
|
||||
removed += static_cast<int>(count);
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s (%d entries)\n",
|
||||
directoryName, static_cast<int>(count));
|
||||
} else {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
directoryName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
for (const char* fileName : files) {
|
||||
fs::path path = dataDir / fileName;
|
||||
std::error_code removeError;
|
||||
if (fs::remove(path, removeError)) {
|
||||
++removed;
|
||||
DEBUG_LOGF("[DaemonLifecycle] Removed %s\n", fileName);
|
||||
} else if (removeError) {
|
||||
DEBUG_LOGF("[DaemonLifecycle] Failed to remove %s: %s\n",
|
||||
fileName, removeError.message().c_str());
|
||||
}
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
39
src/daemon/lifecycle_adapters.h
Normal file
39
src/daemon/lifecycle_adapters.h
Normal file
@@ -0,0 +1,39 @@
|
||||
#pragma once
|
||||
|
||||
#include "daemon_controller.h"
|
||||
#include "../util/async_task_manager.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <filesystem>
|
||||
|
||||
namespace dragonx {
|
||||
namespace daemon {
|
||||
|
||||
class AsyncLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
AsyncLifecycleTaskContext(const util::AsyncTaskManager::Token& token,
|
||||
const std::atomic<bool>& shuttingDown);
|
||||
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
|
||||
private:
|
||||
const util::AsyncTaskManager::Token& token_;
|
||||
const std::atomic<bool>& shuttingDown_;
|
||||
};
|
||||
|
||||
class ImmediateLifecycleTaskContext final : public DaemonController::LifecycleTaskContext {
|
||||
public:
|
||||
bool cancelled() const override;
|
||||
bool shuttingDown() const override;
|
||||
void sleepForMs(int milliseconds) override;
|
||||
};
|
||||
|
||||
class BlockchainDataCleaner final {
|
||||
public:
|
||||
static int removeBlockchainData(const std::filesystem::path& dataDir);
|
||||
};
|
||||
|
||||
} // namespace daemon
|
||||
} // namespace dragonx
|
||||
@@ -3,12 +3,44 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "wallet_state.h"
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance)
|
||||
{
|
||||
std::vector<size_t> indices;
|
||||
indices.reserve(addresses.size());
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
const auto& address = addresses[i];
|
||||
if (!address.isSpendable()) continue;
|
||||
if (requirePositiveBalance && address.balance <= 0.0) continue;
|
||||
indices.push_back(i);
|
||||
}
|
||||
|
||||
std::sort(indices.begin(), indices.end(), [&](size_t lhs, size_t rhs) {
|
||||
return addresses[lhs].balance > addresses[rhs].balance;
|
||||
});
|
||||
return indices;
|
||||
}
|
||||
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses)
|
||||
{
|
||||
int bestIndex = -1;
|
||||
double bestBalance = 0.0;
|
||||
for (size_t i = 0; i < addresses.size(); ++i) {
|
||||
if (addresses[i].isSpendable() && addresses[i].balance > bestBalance) {
|
||||
bestBalance = addresses[i].balance;
|
||||
bestIndex = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
return bestIndex;
|
||||
}
|
||||
|
||||
std::string TransactionInfo::getTimeString() const
|
||||
{
|
||||
if (timestamp == 0) return "Unknown";
|
||||
|
||||
@@ -29,6 +29,10 @@ struct AddressInfo {
|
||||
bool isSpendable() const { return has_spending_key; }
|
||||
};
|
||||
|
||||
std::vector<size_t> sortedSpendableAddressIndices(const std::vector<AddressInfo>& addresses,
|
||||
bool requirePositiveBalance = true);
|
||||
int bestSpendableAddressIndex(const std::vector<AddressInfo>& addresses);
|
||||
|
||||
/**
|
||||
* @brief Represents a wallet transaction
|
||||
*/
|
||||
@@ -137,6 +141,8 @@ struct MarketInfo {
|
||||
double market_cap = 0.0;
|
||||
std::string last_updated;
|
||||
std::chrono::steady_clock::time_point last_fetch_time{};
|
||||
bool price_loading = false;
|
||||
std::string price_error;
|
||||
|
||||
// Price history for chart
|
||||
std::vector<double> price_history;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,14 @@
|
||||
#include "../config/version.h"
|
||||
#include "../resources/embedded_resources.h"
|
||||
|
||||
#include <sodium.h>
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
#include "../util/logger.h"
|
||||
|
||||
@@ -26,6 +29,56 @@ namespace fs = std::filesystem;
|
||||
namespace dragonx {
|
||||
namespace rpc {
|
||||
|
||||
namespace {
|
||||
|
||||
std::string generateSecureRandomString(size_t length)
|
||||
{
|
||||
static constexpr char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
static constexpr uint32_t charsetSize = static_cast<uint32_t>(sizeof(charset) - 1);
|
||||
|
||||
if (sodium_init() < 0) {
|
||||
DEBUG_LOGF("Failed to initialize libsodium for RPC credential generation\n");
|
||||
return {};
|
||||
}
|
||||
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
for (size_t i = 0; i < length; ++i) {
|
||||
result.push_back(charset[randombytes_uniform(charsetSize)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::string lowercase(std::string value)
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) {
|
||||
return static_cast<char>(std::tolower(c));
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
bool parseBoolValue(const std::string& value)
|
||||
{
|
||||
std::string lowered = lowercase(value);
|
||||
return lowered == "1" || lowered == "true" || lowered == "yes" || lowered == "on";
|
||||
}
|
||||
|
||||
bool applyCookieAuth(ConnectionConfig& config, const std::string& dataDir)
|
||||
{
|
||||
std::string cookieUser, cookiePass;
|
||||
if (!Connection::readAuthCookie(dataDir, cookieUser, cookiePass)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
config.auth_source = AuthSource::Cookie;
|
||||
if (config.hush_dir.empty()) config.hush_dir = dataDir;
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Connection::Connection() = default;
|
||||
Connection::~Connection() = default;
|
||||
|
||||
@@ -140,8 +193,14 @@ ConnectionConfig Connection::parseConfFile(const std::string& path)
|
||||
config.host = value;
|
||||
} else if (key == "proxy") {
|
||||
config.proxy = value;
|
||||
} else if (key == "rpctls" || key == "rpcssl" || key == "use_tls" || key == "rpcuse_tls") {
|
||||
config.use_tls = parseBoolValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (!config.rpcuser.empty() || !config.rpcpassword.empty()) {
|
||||
config.auth_source = AuthSource::ConfigFile;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -177,10 +236,7 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
|
||||
// If rpcpassword is empty, the daemon may be using .cookie auth
|
||||
if (config.rpcpassword.empty()) {
|
||||
std::string cookieUser, cookiePass;
|
||||
if (readAuthCookie(data_dir, cookieUser, cookiePass)) {
|
||||
config.rpcuser = cookieUser;
|
||||
config.rpcpassword = cookiePass;
|
||||
if (applyCookieAuth(config, data_dir)) {
|
||||
DEBUG_LOGF("Using .cookie authentication (no rpcpassword in config)\n");
|
||||
}
|
||||
}
|
||||
@@ -196,23 +252,57 @@ ConnectionConfig Connection::autoDetectConfig()
|
||||
return config;
|
||||
}
|
||||
|
||||
bool Connection::buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig)
|
||||
{
|
||||
if (base.auth_source == AuthSource::Cookie) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string dataDir = base.hush_dir.empty() ? getDefaultDataDir() : base.hush_dir;
|
||||
ConnectionConfig fallback = base;
|
||||
if (!applyCookieAuth(fallback, dataDir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cookieConfig = std::move(fallback);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool Connection::isLocalHost(const std::string& host)
|
||||
{
|
||||
std::string lowered = lowercase(host);
|
||||
if (!lowered.empty() && lowered.front() == '[' && lowered.back() == ']') {
|
||||
lowered = lowered.substr(1, lowered.size() - 2);
|
||||
}
|
||||
|
||||
return lowered == "localhost" || lowered == "localhost." ||
|
||||
lowered == "::1" || lowered == "0:0:0:0:0:0:0:1" ||
|
||||
lowered == "127.0.0.1" || lowered.rfind("127.", 0) == 0;
|
||||
}
|
||||
|
||||
bool Connection::usesPlaintextRemote(const ConnectionConfig& config)
|
||||
{
|
||||
return !config.use_tls && !isLocalHost(config.host);
|
||||
}
|
||||
|
||||
const char* Connection::authSourceName(AuthSource source)
|
||||
{
|
||||
switch (source) {
|
||||
case AuthSource::ConfigFile: return "config";
|
||||
case AuthSource::Cookie: return "cookie";
|
||||
case AuthSource::Missing: return "missing";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
bool Connection::createDefaultConfig(const std::string& path)
|
||||
{
|
||||
// Generate random rpcuser/rpcpassword
|
||||
auto generateRandomString = [](int length) -> std::string {
|
||||
const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
||||
std::string result;
|
||||
result.reserve(length);
|
||||
|
||||
std::srand(static_cast<unsigned>(std::time(nullptr)));
|
||||
for (int i = 0; i < length; i++) {
|
||||
result += charset[std::rand() % (sizeof(charset) - 1)];
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
std::string rpcuser = generateRandomString(16);
|
||||
std::string rpcpassword = generateRandomString(32);
|
||||
std::string rpcuser = generateSecureRandomString(16);
|
||||
std::string rpcpassword = generateSecureRandomString(32);
|
||||
if (rpcuser.empty() || rpcpassword.empty()) {
|
||||
DEBUG_LOGF("Failed to generate secure RPC credentials for config file: %s\n", path.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
std::ofstream file(path);
|
||||
if (!file.is_open()) {
|
||||
|
||||
@@ -12,6 +12,12 @@ namespace rpc {
|
||||
/**
|
||||
* @brief Connection configuration
|
||||
*/
|
||||
enum class AuthSource {
|
||||
Missing,
|
||||
ConfigFile,
|
||||
Cookie
|
||||
};
|
||||
|
||||
struct ConnectionConfig {
|
||||
std::string host = "127.0.0.1";
|
||||
std::string port = "21769";
|
||||
@@ -20,6 +26,8 @@ struct ConnectionConfig {
|
||||
std::string hush_dir;
|
||||
std::string proxy; // SOCKS5 proxy for Tor
|
||||
bool use_embedded = true;
|
||||
bool use_tls = false;
|
||||
AuthSource auth_source = AuthSource::Missing;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -96,6 +104,23 @@ public:
|
||||
*/
|
||||
static bool readAuthCookie(const std::string& dataDir, std::string& user, std::string& password);
|
||||
|
||||
/**
|
||||
* @brief Build a cookie-auth retry config from a failed config-auth attempt
|
||||
*/
|
||||
static bool buildCookieAuthConfig(const ConnectionConfig& base, ConnectionConfig& cookieConfig);
|
||||
|
||||
/**
|
||||
* @brief Whether a host is local enough for plaintext HTTP RPC
|
||||
*/
|
||||
static bool isLocalHost(const std::string& host);
|
||||
|
||||
/**
|
||||
* @brief Whether this config would send RPC credentials over plaintext to a remote host
|
||||
*/
|
||||
static bool usesPlaintextRemote(const ConnectionConfig& config);
|
||||
|
||||
static const char* authSourceName(AuthSource source);
|
||||
|
||||
private:
|
||||
};
|
||||
|
||||
|
||||
@@ -60,6 +60,13 @@ RPCClient::~RPCClient() = default;
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password)
|
||||
{
|
||||
return connect(host, port, user, password, false);
|
||||
}
|
||||
|
||||
bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls)
|
||||
{
|
||||
std::lock_guard<std::recursive_mutex> lk(curl_mutex_);
|
||||
host_ = host;
|
||||
@@ -69,8 +76,7 @@ bool RPCClient::connect(const std::string& host, const std::string& port,
|
||||
std::string credentials = user + ":" + password;
|
||||
auth_ = util::base64_encode(credentials);
|
||||
|
||||
// Build URL - use HTTP for localhost RPC (TLS not always enabled)
|
||||
impl_->url = "http://" + host + ":" + port + "/";
|
||||
impl_->url = std::string(useTls ? "https://" : "http://") + host + ":" + port + "/";
|
||||
VERBOSE_LOGF("Connecting to dragonxd at %s\n", impl_->url.c_str());
|
||||
|
||||
// Clean up previous curl handle/headers to avoid leaks on retries
|
||||
|
||||
@@ -43,6 +43,10 @@ public:
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password);
|
||||
|
||||
bool connect(const std::string& host, const std::string& port,
|
||||
const std::string& user, const std::string& password,
|
||||
bool useTls);
|
||||
|
||||
/**
|
||||
* @brief Disconnect from dragonxd
|
||||
*/
|
||||
|
||||
@@ -97,6 +97,18 @@ bool RPCWorker::hasPendingResults() const
|
||||
return !results_.empty();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingTaskCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(taskMtx_);
|
||||
return tasks_.size();
|
||||
}
|
||||
|
||||
std::size_t RPCWorker::pendingResultCount() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lk(resultMtx_);
|
||||
return results_.size();
|
||||
}
|
||||
|
||||
void RPCWorker::run()
|
||||
{
|
||||
while (true) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
@@ -69,6 +70,8 @@ public:
|
||||
|
||||
/// True when there are completed results waiting for the main thread.
|
||||
bool hasPendingResults() const;
|
||||
std::size_t pendingTaskCount() const;
|
||||
std::size_t pendingResultCount() const;
|
||||
|
||||
/// True when the worker thread is running.
|
||||
bool isRunning() const { return running_.load(std::memory_order_relaxed); }
|
||||
@@ -80,7 +83,7 @@ private:
|
||||
std::atomic<bool> running_{false};
|
||||
|
||||
// ---- Task queue (produced by main thread, consumed by worker) ----
|
||||
std::mutex taskMtx_;
|
||||
mutable std::mutex taskMtx_;
|
||||
std::condition_variable taskCv_;
|
||||
std::deque<WorkFn> tasks_;
|
||||
|
||||
|
||||
976
src/services/network_refresh_service.cpp
Normal file
976
src/services/network_refresh_service.cpp
Normal file
@@ -0,0 +1,976 @@
|
||||
#include "network_refresh_service.h"
|
||||
#include "../util/logger.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T>
|
||||
std::optional<T> readOptional(const json& source, const char* key)
|
||||
{
|
||||
try {
|
||||
if (source.contains(key)) return source[key].get<T>();
|
||||
} catch (...) {}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<double> readBalanceString(const json& source, const char* key)
|
||||
{
|
||||
try {
|
||||
if (source.contains(key)) return std::stod(source[key].get<std::string>());
|
||||
} catch (...) {}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void applyBalancesFromUnspent(std::vector<AddressInfo>& addresses, const json& unspent)
|
||||
{
|
||||
if (!unspent.is_array()) return;
|
||||
|
||||
std::map<std::string, double> balances;
|
||||
for (const auto& output : unspent) {
|
||||
auto address = readOptional<std::string>(output, "address");
|
||||
auto amount = readOptional<double>(output, "amount");
|
||||
if (address && amount) balances[*address] += *amount;
|
||||
}
|
||||
|
||||
for (auto& info : addresses) {
|
||||
auto balance = balances.find(info.address);
|
||||
if (balance != balances.end()) info.balance = balance->second;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
NetworkRefreshService::ConnectionInfoResult NetworkRefreshService::parseConnectionInfoResult(const json& info)
|
||||
{
|
||||
ConnectionInfoResult result;
|
||||
if (!info.is_object()) return result;
|
||||
|
||||
result.ok = true;
|
||||
result.daemonVersion = readOptional<int>(info, "version");
|
||||
result.protocolVersion = readOptional<int>(info, "protocolversion");
|
||||
result.p2pPort = readOptional<int>(info, "p2pport");
|
||||
result.longestChain = readOptional<int>(info, "longestchain");
|
||||
result.notarized = readOptional<int>(info, "notarized");
|
||||
result.blocks = readOptional<int>(info, "blocks");
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::WalletEncryptionResult NetworkRefreshService::parseWalletEncryptionResult(const json& walletInfo)
|
||||
{
|
||||
WalletEncryptionResult result;
|
||||
if (!walletInfo.is_object()) return result;
|
||||
|
||||
result.ok = true;
|
||||
result.encrypted = walletInfo.contains("unlocked_until");
|
||||
if (result.encrypted) {
|
||||
try {
|
||||
result.unlockedUntil = walletInfo["unlocked_until"].get<std::int64_t>();
|
||||
} catch (...) {
|
||||
result.ok = false;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::WarmupPollResult NetworkRefreshService::collectWarmupPollResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
WarmupPollResult result;
|
||||
try {
|
||||
json info = rpc.call("getinfo", json::array());
|
||||
result.ready = true;
|
||||
result.info = parseConnectionInfoResult(info);
|
||||
} catch (const std::exception& e) {
|
||||
result.errorMessage = e.what();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::ConnectionInitResult NetworkRefreshService::collectConnectionInitResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
ConnectionInitResult result;
|
||||
|
||||
try {
|
||||
json info = rpc.call("getinfo", json::array());
|
||||
result.info = parseConnectionInfoResult(info);
|
||||
} catch (...) {}
|
||||
|
||||
try {
|
||||
json walletInfo = rpc.call("getwalletinfo", json::array());
|
||||
result.encryption = parseWalletEncryptionResult(walletInfo);
|
||||
} catch (...) {}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::parseCoreRefreshResult(
|
||||
const json& totalBalance, bool balanceOk, const json& blockInfo, bool blockOk)
|
||||
{
|
||||
CoreRefreshResult result;
|
||||
result.balanceOk = balanceOk && totalBalance.is_object();
|
||||
if (result.balanceOk) {
|
||||
result.shieldedBalance = readBalanceString(totalBalance, "private");
|
||||
result.transparentBalance = readBalanceString(totalBalance, "transparent");
|
||||
result.totalBalance = readBalanceString(totalBalance, "total");
|
||||
}
|
||||
|
||||
result.blockchainOk = blockOk && blockInfo.is_object();
|
||||
if (result.blockchainOk) {
|
||||
result.blocks = readOptional<int>(blockInfo, "blocks");
|
||||
result.headers = readOptional<int>(blockInfo, "headers");
|
||||
result.verificationProgress = readOptional<double>(blockInfo, "verificationprogress");
|
||||
result.longestChain = readOptional<int>(blockInfo, "longestchain");
|
||||
result.notarized = readOptional<int>(blockInfo, "notarized");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::CoreRefreshResult NetworkRefreshService::collectCoreRefreshResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
json totalBalance;
|
||||
json blockInfo;
|
||||
bool balanceOk = false;
|
||||
bool blockOk = false;
|
||||
|
||||
try {
|
||||
totalBalance = rpc.call("z_gettotalbalance", json::array());
|
||||
balanceOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("Balance error: %s\n", e.what());
|
||||
}
|
||||
|
||||
try {
|
||||
blockInfo = rpc.call("getblockchaininfo", json::array());
|
||||
blockOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("BlockchainInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return parseCoreRefreshResult(totalBalance, balanceOk, blockInfo, blockOk);
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::parseMiningRefreshResult(
|
||||
const json& miningInfo, bool miningOk, const json& localHashrate, bool hashrateOk, double daemonMemoryMb)
|
||||
{
|
||||
MiningRefreshResult result;
|
||||
result.daemonMemoryMb = daemonMemoryMb;
|
||||
|
||||
if (hashrateOk) {
|
||||
try {
|
||||
result.localHashrate = localHashrate.get<double>();
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
result.miningOk = miningOk && miningInfo.is_object();
|
||||
if (result.miningOk) {
|
||||
result.generate = readOptional<bool>(miningInfo, "generate");
|
||||
result.genproclimit = readOptional<int>(miningInfo, "genproclimit");
|
||||
result.blocks = readOptional<int>(miningInfo, "blocks");
|
||||
result.difficulty = readOptional<double>(miningInfo, "difficulty");
|
||||
result.networkHashrate = readOptional<double>(miningInfo, "networkhashps");
|
||||
result.chain = readOptional<std::string>(miningInfo, "chain");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::MiningRefreshResult NetworkRefreshService::collectMiningRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh)
|
||||
{
|
||||
json miningInfo;
|
||||
json localHashrate;
|
||||
bool miningOk = false;
|
||||
bool hashrateOk = false;
|
||||
|
||||
try {
|
||||
localHashrate = rpc.call("getlocalsolps", json::array());
|
||||
hashrateOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getLocalHashrate error: %s\n", e.what());
|
||||
}
|
||||
|
||||
if (includeSlowRefresh) {
|
||||
try {
|
||||
miningInfo = rpc.call("getmininginfo", json::array());
|
||||
miningOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getMiningInfo error: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
return parseMiningRefreshResult(miningInfo, miningOk, localHashrate, hashrateOk, daemonMemoryMb);
|
||||
}
|
||||
|
||||
NetworkRefreshService::PeerRefreshResult NetworkRefreshService::parsePeerRefreshResult(
|
||||
const json& peers, const json& bannedPeers)
|
||||
{
|
||||
PeerRefreshResult result;
|
||||
|
||||
if (peers.is_array()) {
|
||||
for (const auto& peer : peers) {
|
||||
PeerInfo info;
|
||||
if (auto value = readOptional<int>(peer, "id")) info.id = *value;
|
||||
if (auto value = readOptional<std::string>(peer, "addr")) info.addr = *value;
|
||||
if (auto value = readOptional<std::string>(peer, "subver")) info.subver = *value;
|
||||
if (auto value = readOptional<std::string>(peer, "services")) info.services = *value;
|
||||
if (auto value = readOptional<int>(peer, "version")) info.version = *value;
|
||||
if (auto value = readOptional<std::int64_t>(peer, "conntime")) info.conntime = *value;
|
||||
if (auto value = readOptional<int>(peer, "banscore")) info.banscore = *value;
|
||||
if (auto value = readOptional<double>(peer, "pingtime")) info.pingtime = *value;
|
||||
if (auto value = readOptional<std::int64_t>(peer, "bytessent")) info.bytessent = *value;
|
||||
if (auto value = readOptional<std::int64_t>(peer, "bytesrecv")) info.bytesrecv = *value;
|
||||
if (auto value = readOptional<int>(peer, "startingheight")) info.startingheight = *value;
|
||||
if (auto value = readOptional<int>(peer, "synced_headers")) info.synced_headers = *value;
|
||||
if (auto value = readOptional<int>(peer, "synced_blocks")) info.synced_blocks = *value;
|
||||
if (auto value = readOptional<bool>(peer, "inbound")) info.inbound = *value;
|
||||
if (auto value = readOptional<std::string>(peer, "tls_cipher")) info.tls_cipher = *value;
|
||||
if (auto value = readOptional<bool>(peer, "tls_verified")) info.tls_verified = *value;
|
||||
result.peers.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
if (bannedPeers.is_array()) {
|
||||
for (const auto& ban : bannedPeers) {
|
||||
BannedPeer info;
|
||||
if (auto value = readOptional<std::string>(ban, "address")) info.address = *value;
|
||||
if (auto value = readOptional<std::int64_t>(ban, "banned_until")) info.banned_until = *value;
|
||||
result.bannedPeers.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::PeerRefreshResult NetworkRefreshService::collectPeerRefreshResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
json peers = json::array();
|
||||
json bannedPeers = json::array();
|
||||
bool peersOk = false;
|
||||
bool bannedOk = false;
|
||||
|
||||
try {
|
||||
peers = rpc.call("getpeerinfo", json::array());
|
||||
peersOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getPeerInfo error: %s\n", e.what());
|
||||
}
|
||||
|
||||
try {
|
||||
bannedPeers = rpc.call("listbanned", json::array());
|
||||
bannedOk = true;
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listBanned error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return parsePeerRefreshResult(peersOk ? peers : json::array(),
|
||||
bannedOk ? bannedPeers : json::array());
|
||||
}
|
||||
|
||||
std::optional<NetworkRefreshService::PriceRefreshResult> NetworkRefreshService::parseCoinGeckoPriceResponse(
|
||||
const std::string& response, std::time_t fetchedAt)
|
||||
{
|
||||
try {
|
||||
auto parsed = json::parse(response);
|
||||
if (!parsed.contains("dragonx-2")) return std::nullopt;
|
||||
|
||||
const auto& data = parsed["dragonx-2"];
|
||||
PriceRefreshResult result;
|
||||
result.market.price_usd = data.value("usd", 0.0);
|
||||
result.market.price_btc = data.value("btc", 0.0);
|
||||
result.market.change_24h = data.value("usd_24h_change", 0.0);
|
||||
result.market.volume_24h = data.value("usd_24h_vol", 0.0);
|
||||
result.market.market_cap = data.value("usd_market_cap", 0.0);
|
||||
|
||||
char buf[64];
|
||||
std::tm* tm = std::localtime(&fetchedAt);
|
||||
if (tm && std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", tm) > 0) {
|
||||
result.market.last_updated = buf;
|
||||
}
|
||||
return result;
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
NetworkRefreshService::PriceHttpResult NetworkRefreshService::parsePriceHttpResponse(
|
||||
const PriceHttpResponse& response,
|
||||
std::time_t fetchedAt)
|
||||
{
|
||||
PriceHttpResult result;
|
||||
|
||||
if (!response.transportOk || response.httpStatus != 200) {
|
||||
const std::string reason = response.transportOk ? "OK" : response.transportError;
|
||||
result.errorMessage = "Price fetch failed: " + reason +
|
||||
" (HTTP " + std::to_string(response.httpStatus) + ")";
|
||||
return result;
|
||||
}
|
||||
|
||||
auto parsed = parseCoinGeckoPriceResponse(response.body, fetchedAt);
|
||||
if (!parsed) {
|
||||
result.errorMessage = "Price fetch returned an unrecognized response";
|
||||
return result;
|
||||
}
|
||||
|
||||
result.price = std::move(*parsed);
|
||||
return result;
|
||||
}
|
||||
|
||||
AddressInfo NetworkRefreshService::buildShieldedAddressInfo(const std::string& address,
|
||||
const json& validation,
|
||||
bool validationSucceeded)
|
||||
{
|
||||
AddressInfo info;
|
||||
info.address = address;
|
||||
info.type = "shielded";
|
||||
|
||||
if (!validationSucceeded) {
|
||||
info.has_spending_key = true;
|
||||
return info;
|
||||
}
|
||||
|
||||
info.has_spending_key = false;
|
||||
if (auto isMine = readOptional<bool>(validation, "ismine")) {
|
||||
info.has_spending_key = *isMine;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
AddressInfo NetworkRefreshService::buildTransparentAddressInfo(const std::string& address)
|
||||
{
|
||||
AddressInfo info;
|
||||
info.address = address;
|
||||
info.type = "transparent";
|
||||
return info;
|
||||
}
|
||||
|
||||
std::vector<AddressInfo> NetworkRefreshService::parseTransparentAddressList(const json& addressList)
|
||||
{
|
||||
std::vector<AddressInfo> addresses;
|
||||
if (!addressList.is_array()) return addresses;
|
||||
|
||||
for (const auto& addressJson : addressList) {
|
||||
try {
|
||||
addresses.push_back(buildTransparentAddressInfo(addressJson.get<std::string>()));
|
||||
} catch (...) {}
|
||||
}
|
||||
return addresses;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const json& unspent)
|
||||
{
|
||||
applyBalancesFromUnspent(addresses, unspent);
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const json& unspent)
|
||||
{
|
||||
applyBalancesFromUnspent(addresses, unspent);
|
||||
}
|
||||
|
||||
NetworkRefreshService::AddressRefreshResult NetworkRefreshService::collectAddressRefreshResult(RefreshRpcGateway& rpc)
|
||||
{
|
||||
AddressRefreshResult result;
|
||||
|
||||
try {
|
||||
json zList = rpc.call("z_listaddresses", json::array());
|
||||
if (zList.is_array()) {
|
||||
for (const auto& addressJson : zList) {
|
||||
std::string address;
|
||||
try {
|
||||
address = addressJson.get<std::string>();
|
||||
} catch (...) {
|
||||
continue;
|
||||
}
|
||||
|
||||
json validationResult;
|
||||
bool validationSucceeded = false;
|
||||
try {
|
||||
validationResult = rpc.call("z_validateaddress", json::array({address}));
|
||||
validationSucceeded = true;
|
||||
} catch (...) {
|
||||
// Older daemons can fail validation for wallet-owned addresses.
|
||||
}
|
||||
result.shieldedAddresses.push_back(
|
||||
buildShieldedAddressInfo(address, validationResult, validationSucceeded));
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listaddresses error: %s\n", e.what());
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("z_listunspent", json::array());
|
||||
applyShieldedBalancesFromUnspent(result.shieldedAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listunspent unavailable (%s), falling back to z_getbalance\n", e.what());
|
||||
for (auto& info : result.shieldedAddresses) {
|
||||
try {
|
||||
json balance = rpc.call("z_getbalance", json::array({info.address}));
|
||||
if (!balance.is_null()) info.balance = balance.get<double>();
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
json tList = rpc.call("getaddressesbyaccount", json::array({""}));
|
||||
result.transparentAddresses = parseTransparentAddressList(tList);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("getaddressesbyaccount error: %s\n", e.what());
|
||||
}
|
||||
|
||||
try {
|
||||
json unspent = rpc.call("listunspent", json::array());
|
||||
applyTransparentBalancesFromUnspent(result.transparentAddresses, unspent);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listunspent error: %s\n", e.what());
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
NetworkRefreshService::TransactionRefreshSnapshot NetworkRefreshService::buildTransactionRefreshSnapshot(
|
||||
const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids)
|
||||
{
|
||||
TransactionRefreshSnapshot snapshot;
|
||||
|
||||
for (const auto& address : state.z_addresses) {
|
||||
if (!address.address.empty()) snapshot.shieldedAddresses.push_back(address.address);
|
||||
}
|
||||
|
||||
for (const auto& cachedView : viewTxCache) {
|
||||
snapshot.fullyEnrichedTxids.insert(cachedView.first);
|
||||
}
|
||||
for (const auto& transaction : state.transactions) {
|
||||
if (transaction.confirmations > 6 && transaction.timestamp != 0 && !transaction.txid.empty()) {
|
||||
snapshot.fullyEnrichedTxids.insert(transaction.txid);
|
||||
}
|
||||
}
|
||||
|
||||
snapshot.viewTxCache = viewTxCache;
|
||||
snapshot.sendTxids = sendTxids;
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const json& result)
|
||||
{
|
||||
if (!result.is_array()) return;
|
||||
|
||||
for (const auto& transactionJson : result) {
|
||||
TransactionInfo info;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "txid")) info.txid = *value;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "category")) info.type = *value;
|
||||
if (auto value = readOptional<double>(transactionJson, "amount")) info.amount = *value;
|
||||
if (auto value = readOptional<std::int64_t>(transactionJson, "time")) {
|
||||
info.timestamp = *value;
|
||||
} else if (auto received = readOptional<std::int64_t>(transactionJson, "timereceived")) {
|
||||
info.timestamp = *received;
|
||||
}
|
||||
if (auto value = readOptional<int>(transactionJson, "confirmations")) info.confirmations = *value;
|
||||
if (auto value = readOptional<std::string>(transactionJson, "address")) info.address = *value;
|
||||
if (!info.txid.empty()) knownTxids.insert(info.txid);
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const json& received)
|
||||
{
|
||||
if (received.is_null() || !received.is_array()) return;
|
||||
|
||||
for (const auto& note : received) {
|
||||
auto txid = readOptional<std::string>(note, "txid");
|
||||
if (!txid || txid->empty()) continue;
|
||||
|
||||
auto change = readOptional<bool>(note, "change");
|
||||
if (change && *change) continue;
|
||||
|
||||
bool dominated = false;
|
||||
for (const auto& existing : transactions) {
|
||||
if (existing.txid == *txid && existing.type == "receive") {
|
||||
dominated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (dominated) continue;
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = *txid;
|
||||
info.type = "receive";
|
||||
info.address = address;
|
||||
if (auto value = readOptional<double>(note, "amount")) info.amount = *value;
|
||||
if (auto value = readOptional<int>(note, "confirmations")) info.confirmations = *value;
|
||||
if (auto value = readOptional<std::int64_t>(note, "time")) info.timestamp = *value;
|
||||
if (auto value = readOptional<std::string>(note, "memoStr")) info.memo = *value;
|
||||
knownTxids.insert(*txid);
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
NetworkRefreshService::TransactionViewCacheEntry NetworkRefreshService::parseViewTransactionCacheEntry(
|
||||
const json& viewTransaction)
|
||||
{
|
||||
TransactionViewCacheEntry entry;
|
||||
if (!viewTransaction.is_object()) return entry;
|
||||
|
||||
if (viewTransaction.contains("spends") && viewTransaction["spends"].is_array()) {
|
||||
for (const auto& spend : viewTransaction["spends"]) {
|
||||
if (auto address = readOptional<std::string>(spend, "address")) {
|
||||
entry.from_address = *address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (viewTransaction.contains("outputs") && viewTransaction["outputs"].is_array()) {
|
||||
for (const auto& output : viewTransaction["outputs"]) {
|
||||
bool outgoing = false;
|
||||
if (auto value = readOptional<bool>(output, "outgoing")) outgoing = *value;
|
||||
if (!outgoing) continue;
|
||||
|
||||
TransactionViewCacheEntry::Output out;
|
||||
if (auto value = readOptional<std::string>(output, "address")) out.address = *value;
|
||||
if (auto value = readOptional<double>(output, "value")) out.value = *value;
|
||||
if (auto value = readOptional<std::string>(output, "memoStr")) out.memo = *value;
|
||||
entry.outgoing_outputs.push_back(std::move(out));
|
||||
}
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry)
|
||||
{
|
||||
for (const auto& out : entry.outgoing_outputs) {
|
||||
bool alreadyTracked = false;
|
||||
for (const auto& existing : transactions) {
|
||||
if (existing.txid == txid && existing.type == "send" &&
|
||||
std::abs(existing.amount + out.value) < 0.00000001) {
|
||||
alreadyTracked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (alreadyTracked) continue;
|
||||
|
||||
TransactionInfo info;
|
||||
info.txid = txid;
|
||||
info.type = "send";
|
||||
info.address = out.address;
|
||||
info.amount = -out.value;
|
||||
info.memo = out.memo;
|
||||
info.from_address = entry.from_address;
|
||||
for (const auto& existing : transactions) {
|
||||
if (existing.txid == txid) {
|
||||
info.confirmations = existing.confirmations;
|
||||
info.timestamp = existing.timestamp;
|
||||
break;
|
||||
}
|
||||
}
|
||||
transactions.push_back(std::move(info));
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions)
|
||||
{
|
||||
std::sort(transactions.begin(), transactions.end(),
|
||||
[](const TransactionInfo& left, const TransactionInfo& right) {
|
||||
return left.timestamp > right.timestamp;
|
||||
});
|
||||
}
|
||||
|
||||
NetworkRefreshService::TransactionRefreshResult NetworkRefreshService::collectTransactionRefreshResult(
|
||||
RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int maxViewTransactionsPerCycle)
|
||||
{
|
||||
TransactionRefreshResult result;
|
||||
result.blockHeight = currentBlockHeight;
|
||||
|
||||
std::set<std::string> knownTxids;
|
||||
|
||||
try {
|
||||
json transactions = rpc.call("listtransactions", json::array({"", 9999}));
|
||||
appendTransparentTransactions(result.transactions, knownTxids, transactions);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("listtransactions error: %s\n", e.what());
|
||||
}
|
||||
|
||||
for (const auto& address : snapshot.shieldedAddresses) {
|
||||
try {
|
||||
json received = rpc.call("z_listreceivedbyaddress", json::array({address, 0}));
|
||||
appendShieldedReceivedTransactions(result.transactions, knownTxids, address, received);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("z_listreceivedbyaddress error for %s: %s\n",
|
||||
address.substr(0, 12).c_str(), e.what());
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto& txid : snapshot.sendTxids) {
|
||||
knownTxids.insert(txid);
|
||||
}
|
||||
|
||||
int viewTxCount = 0;
|
||||
for (const auto& txid : knownTxids) {
|
||||
if (snapshot.fullyEnrichedTxids.count(txid)) continue;
|
||||
|
||||
auto cached = snapshot.viewTxCache.find(txid);
|
||||
if (cached != snapshot.viewTxCache.end()) {
|
||||
appendViewTransactionOutputs(result.transactions, txid, cached->second);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (viewTxCount >= maxViewTransactionsPerCycle) break;
|
||||
++viewTxCount;
|
||||
|
||||
try {
|
||||
json viewTransaction = rpc.call("z_viewtransaction", json::array({txid}));
|
||||
if (viewTransaction.is_null() || !viewTransaction.is_object()) continue;
|
||||
|
||||
auto entry = parseViewTransactionCacheEntry(viewTransaction);
|
||||
appendViewTransactionOutputs(result.transactions, txid, entry);
|
||||
|
||||
for (auto& info : result.transactions) {
|
||||
if (info.txid != txid || info.timestamp != 0) continue;
|
||||
|
||||
try {
|
||||
json rawTransaction = rpc.call("gettransaction", json::array({txid}));
|
||||
if (!rawTransaction.is_null()) {
|
||||
if (auto value = readOptional<std::int64_t>(rawTransaction, "time")) info.timestamp = *value;
|
||||
if (auto value = readOptional<int>(rawTransaction, "confirmations")) info.confirmations = *value;
|
||||
}
|
||||
} catch (...) {}
|
||||
break;
|
||||
}
|
||||
|
||||
result.newViewTxEntries[txid] = std::move(entry);
|
||||
} catch (const std::exception& e) {
|
||||
(void)e;
|
||||
}
|
||||
}
|
||||
|
||||
sortTransactionsNewestFirst(result.transactions);
|
||||
return result;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result)
|
||||
{
|
||||
if (!result.ok) return;
|
||||
|
||||
if (result.daemonVersion) state.daemon_version = *result.daemonVersion;
|
||||
if (result.protocolVersion) state.protocol_version = *result.protocolVersion;
|
||||
if (result.p2pPort) state.p2p_port = *result.p2pPort;
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (result.notarized) state.notarized = *result.notarized;
|
||||
if (result.blocks) state.sync.blocks = *result.blocks;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result)
|
||||
{
|
||||
if (!result.ok) return;
|
||||
|
||||
if (result.encrypted) {
|
||||
state.encrypted = true;
|
||||
state.unlocked_until = result.unlockedUntil;
|
||||
state.locked = (result.unlockedUntil == 0);
|
||||
} else {
|
||||
state.encrypted = false;
|
||||
state.locked = false;
|
||||
state.unlocked_until = 0;
|
||||
}
|
||||
state.encryption_state_known = true;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result)
|
||||
{
|
||||
applyConnectionInfoResult(state, result.info);
|
||||
applyWalletEncryptionResult(state, result.encryption);
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyCoreRefreshResult(WalletState& state,
|
||||
const CoreRefreshResult& result,
|
||||
std::time_t updatedAt)
|
||||
{
|
||||
if (result.balanceOk) {
|
||||
if (result.shieldedBalance) state.shielded_balance = *result.shieldedBalance;
|
||||
if (result.transparentBalance) state.transparent_balance = *result.transparentBalance;
|
||||
if (result.totalBalance) state.total_balance = *result.totalBalance;
|
||||
state.last_balance_update = updatedAt;
|
||||
}
|
||||
|
||||
if (!result.blockchainOk) return;
|
||||
|
||||
if (result.blocks) state.sync.blocks = *result.blocks;
|
||||
if (result.headers) state.sync.headers = *result.headers;
|
||||
if (result.verificationProgress) state.sync.verification_progress = *result.verificationProgress;
|
||||
if (result.longestChain && *result.longestChain > 0) state.longestchain = *result.longestChain;
|
||||
if (state.longestchain > 0 && state.sync.blocks > state.longestchain) state.longestchain = state.sync.blocks;
|
||||
if (state.longestchain > 0)
|
||||
state.sync.syncing = (state.sync.blocks < state.longestchain - 2);
|
||||
else
|
||||
state.sync.syncing = (state.sync.blocks < state.sync.headers - 2);
|
||||
if (result.notarized) state.notarized = *result.notarized;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyMiningRefreshResult(WalletState& state,
|
||||
const MiningRefreshResult& result,
|
||||
std::time_t updatedAt)
|
||||
{
|
||||
if (result.localHashrate) {
|
||||
state.mining.localHashrate = *result.localHashrate;
|
||||
state.mining.hashrate_history.push_back(state.mining.localHashrate);
|
||||
if (state.mining.hashrate_history.size() > MiningInfo::MAX_HISTORY) {
|
||||
state.mining.hashrate_history.erase(state.mining.hashrate_history.begin());
|
||||
}
|
||||
}
|
||||
|
||||
if (result.miningOk) {
|
||||
if (result.generate) state.mining.generate = *result.generate;
|
||||
if (result.genproclimit) state.mining.genproclimit = *result.genproclimit;
|
||||
if (result.blocks) state.mining.blocks = *result.blocks;
|
||||
if (result.difficulty) state.mining.difficulty = *result.difficulty;
|
||||
if (result.networkHashrate) state.mining.networkHashrate = *result.networkHashrate;
|
||||
if (result.chain) state.mining.chain = *result.chain;
|
||||
state.last_mining_update = updatedAt;
|
||||
}
|
||||
|
||||
state.mining.daemon_memory_mb = result.daemonMemoryMb;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyPeerRefreshResult(WalletState& state,
|
||||
PeerRefreshResult&& result,
|
||||
std::time_t updatedAt)
|
||||
{
|
||||
state.peers = std::move(result.peers);
|
||||
state.bannedPeers = std::move(result.bannedPeers);
|
||||
state.last_peer_update = updatedAt;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::markPriceRefreshStarted(WalletState& state)
|
||||
{
|
||||
state.market.price_loading = true;
|
||||
state.market.price_error.clear();
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyPriceRefreshResult(WalletState& state,
|
||||
const PriceRefreshResult& result,
|
||||
std::chrono::steady_clock::time_point fetchedAt)
|
||||
{
|
||||
state.market.price_loading = false;
|
||||
state.market.price_error.clear();
|
||||
state.market.price_usd = result.market.price_usd;
|
||||
state.market.price_btc = result.market.price_btc;
|
||||
state.market.change_24h = result.market.change_24h;
|
||||
state.market.volume_24h = result.market.volume_24h;
|
||||
state.market.market_cap = result.market.market_cap;
|
||||
state.market.last_updated = result.market.last_updated;
|
||||
state.market.last_fetch_time = fetchedAt;
|
||||
|
||||
state.market.price_history.push_back(result.market.price_usd);
|
||||
if (state.market.price_history.size() > MarketInfo::MAX_HISTORY) {
|
||||
state.market.price_history.erase(state.market.price_history.begin());
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyPriceRefreshFailure(WalletState& state,
|
||||
const std::string& errorMessage)
|
||||
{
|
||||
state.market.price_loading = false;
|
||||
state.market.price_error = errorMessage.empty()
|
||||
? std::string("Price fetch failed")
|
||||
: errorMessage;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result)
|
||||
{
|
||||
state.z_addresses = std::move(result.shieldedAddresses);
|
||||
state.t_addresses = std::move(result.transparentAddresses);
|
||||
}
|
||||
|
||||
void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,
|
||||
TransactionCacheUpdate cacheUpdate,
|
||||
TransactionRefreshResult&& result,
|
||||
std::time_t updatedAt)
|
||||
{
|
||||
state.transactions = std::move(result.transactions);
|
||||
state.last_tx_update = updatedAt;
|
||||
cacheUpdate.lastTxBlockHeight = result.blockHeight;
|
||||
|
||||
for (auto& [txid, entry] : result.newViewTxEntries) {
|
||||
cacheUpdate.viewTxCache[txid] = std::move(entry);
|
||||
cacheUpdate.sendTxids.erase(txid);
|
||||
}
|
||||
|
||||
cacheUpdate.confirmedTxCache.clear();
|
||||
cacheUpdate.confirmedTxIds.clear();
|
||||
for (const auto& tx : state.transactions) {
|
||||
if (tx.confirmations >= 10 && tx.timestamp != 0) {
|
||||
cacheUpdate.confirmedTxIds.insert(tx.txid);
|
||||
cacheUpdate.confirmedTxCache.push_back(tx);
|
||||
}
|
||||
}
|
||||
cacheUpdate.confirmedCacheBlock = result.blockHeight;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const
|
||||
{
|
||||
return scheduler_.shouldRefreshTransactions(lastTxBlockHeight,
|
||||
currentBlockHeight,
|
||||
transactionsEmpty,
|
||||
transactionsDirty);
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::beginJob(Job job)
|
||||
{
|
||||
return beginDispatch(job).accepted;
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
|
||||
{
|
||||
return beginDispatch(job, queuedWork, maxQueuedWork).accepted;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::finishJob(Job job)
|
||||
{
|
||||
jobFlag(job).store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::jobInProgress(Job job) const
|
||||
{
|
||||
return jobFlag(job).load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void NetworkRefreshService::resetJobs()
|
||||
{
|
||||
coreInProgress_.store(false, std::memory_order_release);
|
||||
addressesInProgress_.store(false, std::memory_order_release);
|
||||
transactionsInProgress_.store(false, std::memory_order_release);
|
||||
miningInProgress_.store(false, std::memory_order_release);
|
||||
peersInProgress_.store(false, std::memory_order_release);
|
||||
priceInProgress_.store(false, std::memory_order_release);
|
||||
encryptionInProgress_.store(false, std::memory_order_release);
|
||||
connectionInitInProgress_.store(false, std::memory_order_release);
|
||||
}
|
||||
|
||||
NetworkRefreshService::DispatchTicket NetworkRefreshService::beginDispatch(
|
||||
Job job, std::size_t queuedWork, std::size_t maxQueuedWork)
|
||||
{
|
||||
std::size_t index = jobIndex(job);
|
||||
lastQueueDepth_[index].store(queuedWork, std::memory_order_release);
|
||||
if (maxQueuedWork > 0 && queuedWork >= maxQueuedWork) {
|
||||
skippedQueuePressure_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
return {job, generations_[index].load(std::memory_order_acquire), false};
|
||||
}
|
||||
|
||||
bool expected = false;
|
||||
if (!jobFlag(job).compare_exchange_strong(expected, true, std::memory_order_acq_rel)) {
|
||||
skippedInFlight_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
return {job, generations_[index].load(std::memory_order_acquire), false};
|
||||
}
|
||||
|
||||
std::uint64_t generation = generations_[index].fetch_add(1, std::memory_order_acq_rel) + 1;
|
||||
started_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
return {job, generation, true};
|
||||
}
|
||||
|
||||
bool NetworkRefreshService::completeDispatch(const DispatchTicket& ticket)
|
||||
{
|
||||
if (!ticket.accepted) return false;
|
||||
std::size_t index = jobIndex(ticket.job);
|
||||
if (generations_[index].load(std::memory_order_acquire) != ticket.generation) {
|
||||
staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
return false;
|
||||
}
|
||||
jobFlag(ticket.job).store(false, std::memory_order_release);
|
||||
finished_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
return true;
|
||||
}
|
||||
|
||||
void NetworkRefreshService::cancelDispatch(const DispatchTicket& ticket)
|
||||
{
|
||||
if (!ticket.accepted) return;
|
||||
std::size_t index = jobIndex(ticket.job);
|
||||
if (generations_[index].load(std::memory_order_acquire) == ticket.generation) {
|
||||
jobFlag(ticket.job).store(false, std::memory_order_release);
|
||||
} else {
|
||||
staleCallbacks_[index].fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
NetworkRefreshService::JobStats NetworkRefreshService::stats(Job job) const
|
||||
{
|
||||
std::size_t index = jobIndex(job);
|
||||
return {
|
||||
started_[index].load(std::memory_order_acquire),
|
||||
finished_[index].load(std::memory_order_acquire),
|
||||
skippedInFlight_[index].load(std::memory_order_acquire),
|
||||
skippedQueuePressure_[index].load(std::memory_order_acquire),
|
||||
staleCallbacks_[index].load(std::memory_order_acquire),
|
||||
lastQueueDepth_[index].load(std::memory_order_acquire)
|
||||
};
|
||||
}
|
||||
|
||||
std::atomic<bool>& NetworkRefreshService::jobFlag(Job job)
|
||||
{
|
||||
switch (job) {
|
||||
case Job::Core: return coreInProgress_;
|
||||
case Job::Addresses: return addressesInProgress_;
|
||||
case Job::Transactions: return transactionsInProgress_;
|
||||
case Job::Mining: return miningInProgress_;
|
||||
case Job::Peers: return peersInProgress_;
|
||||
case Job::Price: return priceInProgress_;
|
||||
case Job::Encryption: return encryptionInProgress_;
|
||||
case Job::ConnectionInit: return connectionInitInProgress_;
|
||||
case Job::Count: return coreInProgress_;
|
||||
}
|
||||
return coreInProgress_;
|
||||
}
|
||||
|
||||
const std::atomic<bool>& NetworkRefreshService::jobFlag(Job job) const
|
||||
{
|
||||
switch (job) {
|
||||
case Job::Core: return coreInProgress_;
|
||||
case Job::Addresses: return addressesInProgress_;
|
||||
case Job::Transactions: return transactionsInProgress_;
|
||||
case Job::Mining: return miningInProgress_;
|
||||
case Job::Peers: return peersInProgress_;
|
||||
case Job::Price: return priceInProgress_;
|
||||
case Job::Encryption: return encryptionInProgress_;
|
||||
case Job::ConnectionInit: return connectionInitInProgress_;
|
||||
case Job::Count: return coreInProgress_;
|
||||
}
|
||||
return coreInProgress_;
|
||||
}
|
||||
|
||||
std::size_t NetworkRefreshService::jobIndex(Job job)
|
||||
{
|
||||
std::size_t index = static_cast<std::size_t>(job);
|
||||
constexpr std::size_t count = static_cast<std::size_t>(Job::Count);
|
||||
return index < count ? index : 0;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
336
src/services/network_refresh_service.h
Normal file
336
src/services/network_refresh_service.h
Normal file
@@ -0,0 +1,336 @@
|
||||
#pragma once
|
||||
|
||||
#include "data/wallet_state.h"
|
||||
#include "refresh_scheduler.h"
|
||||
#include "rpc/rpc_worker.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
#include <set>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class NetworkRefreshService {
|
||||
public:
|
||||
using Timer = RefreshScheduler::Timer;
|
||||
using Intervals = RefreshScheduler::Intervals;
|
||||
|
||||
class RefreshRpcGateway {
|
||||
public:
|
||||
virtual ~RefreshRpcGateway() = default;
|
||||
virtual nlohmann::json call(const std::string& method,
|
||||
const nlohmann::json& params) = 0;
|
||||
};
|
||||
|
||||
enum class Job {
|
||||
Core,
|
||||
Addresses,
|
||||
Transactions,
|
||||
Mining,
|
||||
Peers,
|
||||
Price,
|
||||
Encryption,
|
||||
ConnectionInit,
|
||||
Count
|
||||
};
|
||||
|
||||
struct DispatchTicket {
|
||||
Job job = Job::Core;
|
||||
std::uint64_t generation = 0;
|
||||
bool accepted = false;
|
||||
};
|
||||
|
||||
struct JobStats {
|
||||
std::uint64_t started = 0;
|
||||
std::uint64_t finished = 0;
|
||||
std::uint64_t skippedInFlight = 0;
|
||||
std::uint64_t skippedQueuePressure = 0;
|
||||
std::uint64_t staleCallbacks = 0;
|
||||
std::size_t lastQueueDepth = 0;
|
||||
};
|
||||
|
||||
struct EnqueueResult {
|
||||
DispatchTicket ticket;
|
||||
bool enqueued = false;
|
||||
std::size_t queueDepth = 0;
|
||||
};
|
||||
|
||||
struct ConnectionInfoResult {
|
||||
bool ok = false;
|
||||
std::optional<int> daemonVersion;
|
||||
std::optional<int> protocolVersion;
|
||||
std::optional<int> p2pPort;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
std::optional<int> blocks;
|
||||
};
|
||||
|
||||
struct WalletEncryptionResult {
|
||||
bool ok = false;
|
||||
bool encrypted = false;
|
||||
std::int64_t unlockedUntil = 0;
|
||||
};
|
||||
|
||||
struct WarmupPollResult {
|
||||
bool ready = false;
|
||||
ConnectionInfoResult info;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct ConnectionInitResult {
|
||||
ConnectionInfoResult info;
|
||||
WalletEncryptionResult encryption;
|
||||
};
|
||||
|
||||
struct CoreRefreshResult {
|
||||
bool balanceOk = false;
|
||||
std::optional<double> shieldedBalance;
|
||||
std::optional<double> transparentBalance;
|
||||
std::optional<double> totalBalance;
|
||||
bool blockchainOk = false;
|
||||
std::optional<int> blocks;
|
||||
std::optional<int> headers;
|
||||
std::optional<double> verificationProgress;
|
||||
std::optional<int> longestChain;
|
||||
std::optional<int> notarized;
|
||||
};
|
||||
|
||||
struct MiningRefreshResult {
|
||||
std::optional<double> localHashrate;
|
||||
bool miningOk = false;
|
||||
std::optional<bool> generate;
|
||||
std::optional<int> genproclimit;
|
||||
std::optional<int> blocks;
|
||||
std::optional<double> difficulty;
|
||||
std::optional<double> networkHashrate;
|
||||
std::optional<std::string> chain;
|
||||
double daemonMemoryMb = 0.0;
|
||||
};
|
||||
|
||||
struct PeerRefreshResult {
|
||||
std::vector<PeerInfo> peers;
|
||||
std::vector<BannedPeer> bannedPeers;
|
||||
};
|
||||
|
||||
struct PriceRefreshResult {
|
||||
MarketInfo market;
|
||||
};
|
||||
|
||||
struct PriceHttpResponse {
|
||||
bool transportOk = false;
|
||||
long httpStatus = 0;
|
||||
std::string body;
|
||||
std::string transportError;
|
||||
};
|
||||
|
||||
struct PriceHttpResult {
|
||||
std::optional<PriceRefreshResult> price;
|
||||
std::string errorMessage;
|
||||
};
|
||||
|
||||
struct AddressRefreshResult {
|
||||
std::vector<AddressInfo> shieldedAddresses;
|
||||
std::vector<AddressInfo> transparentAddresses;
|
||||
};
|
||||
|
||||
struct TransactionViewCacheEntry {
|
||||
std::string from_address;
|
||||
struct Output {
|
||||
std::string address;
|
||||
double value = 0.0;
|
||||
std::string memo;
|
||||
};
|
||||
std::vector<Output> outgoing_outputs;
|
||||
};
|
||||
|
||||
using TransactionViewCache = std::unordered_map<std::string, TransactionViewCacheEntry>;
|
||||
|
||||
struct TransactionRefreshSnapshot {
|
||||
std::vector<std::string> shieldedAddresses;
|
||||
std::unordered_set<std::string> fullyEnrichedTxids;
|
||||
TransactionViewCache viewTxCache;
|
||||
std::unordered_set<std::string> sendTxids;
|
||||
};
|
||||
|
||||
struct TransactionRefreshResult {
|
||||
std::vector<TransactionInfo> transactions;
|
||||
int blockHeight = -1;
|
||||
TransactionViewCache newViewTxEntries;
|
||||
};
|
||||
|
||||
struct TransactionCacheUpdate {
|
||||
TransactionViewCache& viewTxCache;
|
||||
std::unordered_set<std::string>& sendTxids;
|
||||
std::vector<TransactionInfo>& confirmedTxCache;
|
||||
std::unordered_set<std::string>& confirmedTxIds;
|
||||
int& confirmedCacheBlock;
|
||||
int& lastTxBlockHeight;
|
||||
};
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page) { return RefreshScheduler::intervalsForPage(page); }
|
||||
|
||||
static ConnectionInfoResult parseConnectionInfoResult(const nlohmann::json& info);
|
||||
static WalletEncryptionResult parseWalletEncryptionResult(const nlohmann::json& walletInfo);
|
||||
static WarmupPollResult collectWarmupPollResult(RefreshRpcGateway& rpc);
|
||||
static ConnectionInitResult collectConnectionInitResult(RefreshRpcGateway& rpc);
|
||||
static CoreRefreshResult parseCoreRefreshResult(const nlohmann::json& totalBalance,
|
||||
bool balanceOk,
|
||||
const nlohmann::json& blockInfo,
|
||||
bool blockOk);
|
||||
static CoreRefreshResult collectCoreRefreshResult(RefreshRpcGateway& rpc);
|
||||
static MiningRefreshResult parseMiningRefreshResult(const nlohmann::json& miningInfo,
|
||||
bool miningOk,
|
||||
const nlohmann::json& localHashrate,
|
||||
bool hashrateOk,
|
||||
double daemonMemoryMb);
|
||||
static MiningRefreshResult collectMiningRefreshResult(RefreshRpcGateway& rpc,
|
||||
double daemonMemoryMb,
|
||||
bool includeSlowRefresh);
|
||||
static PeerRefreshResult parsePeerRefreshResult(const nlohmann::json& peers,
|
||||
const nlohmann::json& bannedPeers);
|
||||
static PeerRefreshResult collectPeerRefreshResult(RefreshRpcGateway& rpc);
|
||||
static std::optional<PriceRefreshResult> parseCoinGeckoPriceResponse(const std::string& response,
|
||||
std::time_t fetchedAt);
|
||||
static PriceHttpResult parsePriceHttpResponse(const PriceHttpResponse& response,
|
||||
std::time_t fetchedAt);
|
||||
static AddressInfo buildShieldedAddressInfo(const std::string& address,
|
||||
const nlohmann::json& validation,
|
||||
bool validationSucceeded);
|
||||
static AddressInfo buildTransparentAddressInfo(const std::string& address);
|
||||
static std::vector<AddressInfo> parseTransparentAddressList(const nlohmann::json& addressList);
|
||||
static void applyShieldedBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static void applyTransparentBalancesFromUnspent(std::vector<AddressInfo>& addresses,
|
||||
const nlohmann::json& unspent);
|
||||
static AddressRefreshResult collectAddressRefreshResult(RefreshRpcGateway& rpc);
|
||||
static TransactionRefreshSnapshot buildTransactionRefreshSnapshot(const WalletState& state,
|
||||
const TransactionViewCache& viewTxCache,
|
||||
const std::unordered_set<std::string>& sendTxids);
|
||||
static void appendTransparentTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const nlohmann::json& result);
|
||||
static void appendShieldedReceivedTransactions(std::vector<TransactionInfo>& transactions,
|
||||
std::set<std::string>& knownTxids,
|
||||
const std::string& address,
|
||||
const nlohmann::json& received);
|
||||
static TransactionViewCacheEntry parseViewTransactionCacheEntry(const nlohmann::json& viewTransaction);
|
||||
static void appendViewTransactionOutputs(std::vector<TransactionInfo>& transactions,
|
||||
const std::string& txid,
|
||||
const TransactionViewCacheEntry& entry);
|
||||
static void sortTransactionsNewestFirst(std::vector<TransactionInfo>& transactions);
|
||||
static TransactionRefreshResult collectTransactionRefreshResult(RefreshRpcGateway& rpc,
|
||||
const TransactionRefreshSnapshot& snapshot,
|
||||
int currentBlockHeight,
|
||||
int maxViewTransactionsPerCycle);
|
||||
|
||||
static void applyConnectionInfoResult(WalletState& state, const ConnectionInfoResult& result);
|
||||
static void applyWalletEncryptionResult(WalletState& state, const WalletEncryptionResult& result);
|
||||
static void applyConnectionInitResult(WalletState& state, const ConnectionInitResult& result);
|
||||
static void applyCoreRefreshResult(WalletState& state,
|
||||
const CoreRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyMiningRefreshResult(WalletState& state,
|
||||
const MiningRefreshResult& result,
|
||||
std::time_t updatedAt);
|
||||
static void applyPeerRefreshResult(WalletState& state,
|
||||
PeerRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
static void markPriceRefreshStarted(WalletState& state);
|
||||
static void applyPriceRefreshResult(WalletState& state,
|
||||
const PriceRefreshResult& result,
|
||||
std::chrono::steady_clock::time_point fetchedAt);
|
||||
static void applyPriceRefreshFailure(WalletState& state,
|
||||
const std::string& errorMessage);
|
||||
static void applyAddressRefreshResult(WalletState& state,
|
||||
AddressRefreshResult&& result);
|
||||
static void applyTransactionRefreshResult(WalletState& state,
|
||||
TransactionCacheUpdate cacheUpdate,
|
||||
TransactionRefreshResult&& result,
|
||||
std::time_t updatedAt);
|
||||
|
||||
void applyPage(ui::NavPage page) { scheduler_.applyPage(page); }
|
||||
void setIntervals(Intervals intervals) { scheduler_.setIntervals(intervals); }
|
||||
const Intervals& intervals() const { return scheduler_.intervals(); }
|
||||
|
||||
void tick(float deltaSeconds) { scheduler_.tick(deltaSeconds); }
|
||||
bool isDue(Timer timer) const { return scheduler_.isDue(timer); }
|
||||
bool consumeDue(Timer timer) { return scheduler_.consumeDue(timer); }
|
||||
void reset(Timer timer) { scheduler_.reset(timer); }
|
||||
void markDue(Timer timer) { scheduler_.markDue(timer); }
|
||||
void setTimer(Timer timer, float seconds) { scheduler_.setTimer(timer, seconds); }
|
||||
float timer(Timer timer) const { return scheduler_.timer(timer); }
|
||||
float interval(Timer timer) const { return scheduler_.interval(timer); }
|
||||
void markImmediateRefresh() { scheduler_.markImmediateRefresh(); }
|
||||
void markWalletMutationRefresh() { scheduler_.markWalletMutationRefresh(); }
|
||||
void resetTxAge() { scheduler_.resetTxAge(); }
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
bool beginJob(Job job);
|
||||
bool beginJob(Job job, std::size_t queuedWork, std::size_t maxQueuedWork);
|
||||
void finishJob(Job job);
|
||||
bool jobInProgress(Job job) const;
|
||||
void resetJobs();
|
||||
DispatchTicket beginDispatch(Job job, std::size_t queuedWork = 0, std::size_t maxQueuedWork = 0);
|
||||
bool completeDispatch(const DispatchTicket& ticket);
|
||||
void cancelDispatch(const DispatchTicket& ticket);
|
||||
JobStats stats(Job job) const;
|
||||
|
||||
template <typename Worker, typename WorkFn>
|
||||
EnqueueResult enqueue(Job job, Worker& worker, WorkFn&& work, std::size_t maxQueuedWork = 0)
|
||||
{
|
||||
std::size_t queueDepth = worker.pendingTaskCount();
|
||||
auto ticket = beginDispatch(job, queueDepth, maxQueuedWork);
|
||||
if (!ticket.accepted) return {ticket, false, queueDepth};
|
||||
|
||||
worker.post([this, ticket, work = std::forward<WorkFn>(work)]() mutable -> rpc::RPCWorker::MainCb {
|
||||
auto mainCallback = work();
|
||||
return [this, ticket, mainCallback = std::move(mainCallback)]() mutable {
|
||||
if (!completeDispatch(ticket)) return;
|
||||
if (mainCallback) mainCallback();
|
||||
};
|
||||
});
|
||||
|
||||
return {ticket, true, queueDepth};
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool>& jobFlag(Job job);
|
||||
const std::atomic<bool>& jobFlag(Job job) const;
|
||||
static std::size_t jobIndex(Job job);
|
||||
|
||||
RefreshScheduler scheduler_;
|
||||
std::atomic<bool> coreInProgress_{false};
|
||||
std::atomic<bool> addressesInProgress_{false};
|
||||
std::atomic<bool> transactionsInProgress_{false};
|
||||
std::atomic<bool> miningInProgress_{false};
|
||||
std::atomic<bool> peersInProgress_{false};
|
||||
std::atomic<bool> priceInProgress_{false};
|
||||
std::atomic<bool> encryptionInProgress_{false};
|
||||
std::atomic<bool> connectionInitInProgress_{false};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> generations_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> started_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> finished_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedInFlight_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> skippedQueuePressure_{};
|
||||
std::array<std::atomic<std::uint64_t>, static_cast<std::size_t>(Job::Count)> staleCallbacks_{};
|
||||
std::array<std::atomic<std::size_t>, static_cast<std::size_t>(Job::Count)> lastQueueDepth_{};
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
158
src/services/refresh_scheduler.cpp
Normal file
158
src/services/refresh_scheduler.cpp
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "refresh_scheduler.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
RefreshScheduler::Intervals RefreshScheduler::intervalsForPage(ui::NavPage page)
|
||||
{
|
||||
using NP = ui::NavPage;
|
||||
switch (page) {
|
||||
case NP::Overview: return {2.0f, 10.0f, 15.0f, 0.0f};
|
||||
case NP::Send: return {3.0f, 10.0f, 5.0f, 0.0f};
|
||||
case NP::Receive: return {5.0f, 15.0f, 5.0f, 0.0f};
|
||||
case NP::History: return {5.0f, 3.0f, 15.0f, 0.0f};
|
||||
case NP::Mining: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
case NP::Peers: return {5.0f, 15.0f, 15.0f, 5.0f};
|
||||
case NP::Market: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
default: return {5.0f, 15.0f, 15.0f, 0.0f};
|
||||
}
|
||||
}
|
||||
|
||||
void RefreshScheduler::applyPage(ui::NavPage page)
|
||||
{
|
||||
setIntervals(intervalsForPage(page));
|
||||
}
|
||||
|
||||
void RefreshScheduler::setIntervals(Intervals intervals)
|
||||
{
|
||||
intervals_ = intervals;
|
||||
}
|
||||
|
||||
void RefreshScheduler::tick(float deltaSeconds)
|
||||
{
|
||||
float delta = std::max(0.0f, deltaSeconds);
|
||||
timers_.core += delta;
|
||||
timers_.transactions += delta;
|
||||
timers_.addresses += delta;
|
||||
timers_.peers += delta;
|
||||
timers_.price += delta;
|
||||
timers_.fast += delta;
|
||||
timers_.txAge += delta;
|
||||
timers_.opid += delta;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::isDue(Timer timer) const
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
return timerInterval > 0.0f && timerRef(timer) >= timerInterval;
|
||||
}
|
||||
|
||||
bool RefreshScheduler::consumeDue(Timer timer)
|
||||
{
|
||||
if (!isDue(timer)) return false;
|
||||
reset(timer);
|
||||
return true;
|
||||
}
|
||||
|
||||
void RefreshScheduler::reset(Timer timer)
|
||||
{
|
||||
timerRef(timer) = 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markDue(Timer timer)
|
||||
{
|
||||
float timerInterval = interval(timer);
|
||||
timerRef(timer) = timerInterval > 0.0f ? timerInterval : 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::setTimer(Timer timer, float seconds)
|
||||
{
|
||||
timerRef(timer) = std::max(0.0f, seconds);
|
||||
}
|
||||
|
||||
float RefreshScheduler::timer(Timer timer) const
|
||||
{
|
||||
return timerRef(timer);
|
||||
}
|
||||
|
||||
float RefreshScheduler::interval(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return intervals_.core;
|
||||
case Timer::Transactions: return intervals_.transactions;
|
||||
case Timer::Addresses: return intervals_.addresses;
|
||||
case Timer::Peers: return intervals_.peers;
|
||||
case Timer::Price: return kPrice;
|
||||
case Timer::Fast: return kFast;
|
||||
case Timer::TxAge: return kTxMaxAge;
|
||||
case Timer::Opid: return kOpidPoll;
|
||||
}
|
||||
return 0.0f;
|
||||
}
|
||||
|
||||
void RefreshScheduler::markImmediateRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
markDue(Timer::Peers);
|
||||
}
|
||||
|
||||
void RefreshScheduler::markWalletMutationRefresh()
|
||||
{
|
||||
markDue(Timer::Core);
|
||||
markDue(Timer::Transactions);
|
||||
markDue(Timer::Addresses);
|
||||
}
|
||||
|
||||
void RefreshScheduler::resetTxAge()
|
||||
{
|
||||
reset(Timer::TxAge);
|
||||
}
|
||||
|
||||
bool RefreshScheduler::shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const
|
||||
{
|
||||
return lastTxBlockHeight < 0
|
||||
|| currentBlockHeight != lastTxBlockHeight
|
||||
|| transactionsEmpty
|
||||
|| transactionsDirty
|
||||
|| isDue(Timer::TxAge);
|
||||
}
|
||||
|
||||
float& RefreshScheduler::timerRef(Timer timer)
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
const float& RefreshScheduler::timerRef(Timer timer) const
|
||||
{
|
||||
switch (timer) {
|
||||
case Timer::Core: return timers_.core;
|
||||
case Timer::Transactions: return timers_.transactions;
|
||||
case Timer::Addresses: return timers_.addresses;
|
||||
case Timer::Peers: return timers_.peers;
|
||||
case Timer::Price: return timers_.price;
|
||||
case Timer::Fast: return timers_.fast;
|
||||
case Timer::TxAge: return timers_.txAge;
|
||||
case Timer::Opid: return timers_.opid;
|
||||
}
|
||||
return timers_.core;
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
81
src/services/refresh_scheduler.h
Normal file
81
src/services/refresh_scheduler.h
Normal file
@@ -0,0 +1,81 @@
|
||||
#pragma once
|
||||
|
||||
#include "ui/sidebar.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class RefreshScheduler {
|
||||
public:
|
||||
enum class Timer {
|
||||
Core,
|
||||
Transactions,
|
||||
Addresses,
|
||||
Peers,
|
||||
Price,
|
||||
Fast,
|
||||
TxAge,
|
||||
Opid
|
||||
};
|
||||
|
||||
struct Intervals {
|
||||
float core;
|
||||
float transactions;
|
||||
float addresses;
|
||||
float peers;
|
||||
};
|
||||
|
||||
static constexpr float kCoreDefault = 5.0f;
|
||||
static constexpr float kAddressDefault = 15.0f;
|
||||
static constexpr float kTransactionDefault = 10.0f;
|
||||
static constexpr float kPeerDefault = 10.0f;
|
||||
static constexpr float kPrice = 60.0f;
|
||||
static constexpr float kFast = 1.0f;
|
||||
static constexpr float kTxMaxAge = 15.0f;
|
||||
static constexpr float kOpidPoll = 2.0f;
|
||||
|
||||
static Intervals intervalsForPage(ui::NavPage page);
|
||||
|
||||
void applyPage(ui::NavPage page);
|
||||
void setIntervals(Intervals intervals);
|
||||
const Intervals& intervals() const { return intervals_; }
|
||||
|
||||
void tick(float deltaSeconds);
|
||||
|
||||
bool isDue(Timer timer) const;
|
||||
bool consumeDue(Timer timer);
|
||||
void reset(Timer timer);
|
||||
void markDue(Timer timer);
|
||||
void setTimer(Timer timer, float seconds);
|
||||
float timer(Timer timer) const;
|
||||
float interval(Timer timer) const;
|
||||
|
||||
void markImmediateRefresh();
|
||||
void markWalletMutationRefresh();
|
||||
void resetTxAge();
|
||||
bool shouldRefreshTransactions(int lastTxBlockHeight,
|
||||
int currentBlockHeight,
|
||||
bool transactionsEmpty,
|
||||
bool transactionsDirty) const;
|
||||
|
||||
private:
|
||||
struct Timers {
|
||||
float core = 0.0f;
|
||||
float transactions = 0.0f;
|
||||
float addresses = 0.0f;
|
||||
float peers = 0.0f;
|
||||
float price = 0.0f;
|
||||
float fast = 0.0f;
|
||||
float txAge = 0.0f;
|
||||
float opid = 0.0f;
|
||||
};
|
||||
|
||||
float& timerRef(Timer timer);
|
||||
const float& timerRef(Timer timer) const;
|
||||
|
||||
Intervals intervals_{kCoreDefault, kTransactionDefault, kAddressDefault, kPeerDefault};
|
||||
Timers timers_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
127
src/services/wallet_security_controller.cpp
Normal file
127
src/services/wallet_security_controller.cpp
Normal file
@@ -0,0 +1,127 @@
|
||||
#include "wallet_security_controller.h"
|
||||
#include "../util/secure_vault.h"
|
||||
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityController::~WalletSecurityController()
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
}
|
||||
|
||||
void WalletSecurityController::beginDeferredEncryption(std::string passphrase, std::string pin)
|
||||
{
|
||||
clearDeferredEncryption();
|
||||
deferred_.passphrase = std::move(passphrase);
|
||||
deferred_.pin = std::move(pin);
|
||||
deferred_.pending = true;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionSnapshot WalletSecurityController::deferredEncryption() const
|
||||
{
|
||||
return {deferred_.passphrase, deferred_.pin};
|
||||
}
|
||||
|
||||
bool WalletSecurityController::shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds)
|
||||
{
|
||||
if (!deferred_.pending) return false;
|
||||
if (nowSeconds - deferred_.lastConnectAttempt < minIntervalSeconds) return false;
|
||||
deferred_.lastConnectAttempt = nowSeconds;
|
||||
return true;
|
||||
}
|
||||
|
||||
void WalletSecurityController::clearDeferredEncryption()
|
||||
{
|
||||
secureClear(deferred_.passphrase);
|
||||
secureClear(deferred_.pin);
|
||||
deferred_.pending = false;
|
||||
deferred_.lastConnectAttempt = -10.0;
|
||||
}
|
||||
|
||||
WalletSecurityController::DeferredEncryptionResult WalletSecurityController::runDeferredEncryption(
|
||||
DeferredEncryptionSnapshot request, RpcGateway& rpc, VaultGateway* vault)
|
||||
{
|
||||
DeferredEncryptionResult result;
|
||||
result.pinProvided = !request.pin.empty();
|
||||
|
||||
std::string error;
|
||||
if (!rpc.encryptWallet(request.passphrase, error)) {
|
||||
result.error = error.empty() ? "encryptwallet failed" : error;
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
result.encrypted = true;
|
||||
result.restartRequired = true;
|
||||
if (result.pinProvided && vault) {
|
||||
result.pinStored = vault->storePin(request.pin, request.passphrase);
|
||||
}
|
||||
|
||||
secureClear(request.passphrase);
|
||||
secureClear(request.pin);
|
||||
return result;
|
||||
}
|
||||
|
||||
WalletSecurityController::PinValidationResult WalletSecurityController::validatePinSetup(
|
||||
const std::string& pin, const std::string& confirmation, bool allowEmpty, std::size_t minLength)
|
||||
{
|
||||
if (pin.empty() && confirmation.empty()) {
|
||||
return allowEmpty
|
||||
? PinValidationResult{true, PinValidationError::None, ""}
|
||||
: PinValidationResult{false, PinValidationError::Empty, "PIN is required"};
|
||||
}
|
||||
if (pin != confirmation) {
|
||||
return {false, PinValidationError::Mismatch, "PINs do not match"};
|
||||
}
|
||||
if (pin.size() < minLength) {
|
||||
return {false, PinValidationError::TooShort, "PIN is too short"};
|
||||
}
|
||||
for (unsigned char c : pin) {
|
||||
if (!std::isdigit(c)) {
|
||||
return {false, PinValidationError::NonDigit, "PIN must contain only digits"};
|
||||
}
|
||||
}
|
||||
return {true, PinValidationError::None, ""};
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyAddress(const std::string& address)
|
||||
{
|
||||
return !address.empty() && address[0] == 'z' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
WalletSecurityController::KeyKind WalletSecurityController::classifyPrivateKey(const std::string& key)
|
||||
{
|
||||
return !key.empty() && key[0] == 's' ? KeyKind::Shielded : KeyKind::Transparent;
|
||||
}
|
||||
|
||||
const char* WalletSecurityController::importSuccessMessage(KeyKind kind)
|
||||
{
|
||||
return kind == KeyKind::Shielded
|
||||
? "Z-address key imported successfully. Wallet is rescanning."
|
||||
: "T-address key imported successfully. Wallet is rescanning.";
|
||||
}
|
||||
|
||||
std::string WalletSecurityController::decryptExportFileName(std::uint64_t timestampSeconds)
|
||||
{
|
||||
char buffer[64];
|
||||
snprintf(buffer, sizeof(buffer), "obsidiandecryptexport%llu",
|
||||
static_cast<unsigned long long>(timestampSeconds));
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
void WalletSecurityController::secureClear(std::string& value)
|
||||
{
|
||||
if (!value.empty()) {
|
||||
util::SecureVault::secureZero(&value[0], value.size());
|
||||
value.clear();
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
93
src/services/wallet_security_controller.h
Normal file
93
src/services/wallet_security_controller.h
Normal file
@@ -0,0 +1,93 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityController {
|
||||
public:
|
||||
enum class PinValidationError {
|
||||
None,
|
||||
Empty,
|
||||
Mismatch,
|
||||
TooShort,
|
||||
NonDigit
|
||||
};
|
||||
|
||||
struct PinValidationResult {
|
||||
bool ok = false;
|
||||
PinValidationError error = PinValidationError::None;
|
||||
const char* message = "";
|
||||
};
|
||||
|
||||
struct DeferredEncryptionSnapshot {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool encryptWallet(const std::string& passphrase, std::string& error) = 0;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool importWallet(const std::string& filePath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class VaultGateway {
|
||||
public:
|
||||
virtual ~VaultGateway() = default;
|
||||
virtual bool storePin(const std::string& pin, const std::string& passphrase) = 0;
|
||||
};
|
||||
|
||||
enum class KeyKind {
|
||||
Transparent,
|
||||
Shielded
|
||||
};
|
||||
|
||||
struct DeferredEncryptionResult {
|
||||
bool encrypted = false;
|
||||
bool pinProvided = false;
|
||||
bool pinStored = false;
|
||||
bool restartRequired = false;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
~WalletSecurityController();
|
||||
|
||||
void beginDeferredEncryption(std::string passphrase, std::string pin = {});
|
||||
bool hasDeferredEncryption() const { return deferred_.pending; }
|
||||
DeferredEncryptionSnapshot deferredEncryption() const;
|
||||
bool shouldAttemptDeferredConnect(double nowSeconds, double minIntervalSeconds = 3.0);
|
||||
void clearDeferredEncryption();
|
||||
|
||||
DeferredEncryptionResult runDeferredEncryption(DeferredEncryptionSnapshot request,
|
||||
RpcGateway& rpc,
|
||||
VaultGateway* vault);
|
||||
|
||||
static PinValidationResult validatePinSetup(const std::string& pin,
|
||||
const std::string& confirmation,
|
||||
bool allowEmpty = false,
|
||||
std::size_t minLength = 4);
|
||||
static KeyKind classifyAddress(const std::string& address);
|
||||
static KeyKind classifyPrivateKey(const std::string& key);
|
||||
static const char* importSuccessMessage(KeyKind kind);
|
||||
static std::string decryptExportFileName(std::uint64_t timestampSeconds);
|
||||
static void secureClear(std::string& value);
|
||||
|
||||
private:
|
||||
struct DeferredEncryptionState {
|
||||
std::string passphrase;
|
||||
std::string pin;
|
||||
bool pending = false;
|
||||
double lastConnectAttempt = -10.0;
|
||||
};
|
||||
|
||||
DeferredEncryptionState deferred_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
114
src/services/wallet_security_workflow.cpp
Normal file
114
src/services/wallet_security_workflow.cpp
Normal file
@@ -0,0 +1,114 @@
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
void WalletSecurityWorkflow::reset()
|
||||
{
|
||||
state_ = {};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::start(std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = stepStatus(DecryptStep::Unlock);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
state_.overallStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now)
|
||||
{
|
||||
state_.phase = DecryptPhase::Working;
|
||||
state_.step = step;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = true;
|
||||
state_.stepStarted = now;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::failEntry(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::PassphraseEntry;
|
||||
state_.step = DecryptStep::Unlock;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::fail(std::string status)
|
||||
{
|
||||
state_.phase = DecryptPhase::Error;
|
||||
state_.status = std::move(status);
|
||||
state_.inProgress = false;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::closeDialogForImport()
|
||||
{
|
||||
state_.inProgress = false;
|
||||
state_.importActive = true;
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflow::finishImport()
|
||||
{
|
||||
state_.importActive = false;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::WalletFilePlan WalletSecurityWorkflow::planWalletFiles(
|
||||
const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds)
|
||||
{
|
||||
WalletFilePlan plan;
|
||||
plan.dataDir = dataDir;
|
||||
plan.exportFile = WalletSecurityController::decryptExportFileName(timestampSeconds);
|
||||
plan.exportPath = dataDir + plan.exportFile;
|
||||
plan.walletPath = dataDir + "wallet.dat";
|
||||
plan.backupPath = dataDir + "wallet.dat.encrypted.bak";
|
||||
return plan;
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepStatus(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet...";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys...";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon...";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet...";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon...";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys...";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const char* WalletSecurityWorkflow::stepLabel(DecryptStep step)
|
||||
{
|
||||
switch (step) {
|
||||
case DecryptStep::Unlock: return "Unlocking wallet";
|
||||
case DecryptStep::ExportKeys: return "Exporting wallet keys";
|
||||
case DecryptStep::StopDaemon: return "Stopping daemon";
|
||||
case DecryptStep::BackupWallet: return "Backing up encrypted wallet";
|
||||
case DecryptStep::RestartDaemon: return "Restarting daemon";
|
||||
case DecryptStep::ImportKeys: return "Importing wallet keys";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
WalletSecurityWorkflow::DecryptStep WalletSecurityWorkflow::stepFromIndex(int step)
|
||||
{
|
||||
if (step <= 0) return DecryptStep::Unlock;
|
||||
if (step == 1) return DecryptStep::ExportKeys;
|
||||
if (step == 2) return DecryptStep::StopDaemon;
|
||||
if (step == 3) return DecryptStep::BackupWallet;
|
||||
if (step == 4) return DecryptStep::RestartDaemon;
|
||||
return DecryptStep::ImportKeys;
|
||||
}
|
||||
|
||||
bool WalletSecurityWorkflow::stepIsComplete(DecryptStep current, DecryptStep candidate)
|
||||
{
|
||||
return stepIndex(candidate) < stepIndex(current);
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
78
src/services/wallet_security_workflow.h
Normal file
78
src/services/wallet_security_workflow.h
Normal file
@@ -0,0 +1,78 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_controller.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflow {
|
||||
public:
|
||||
enum class DecryptPhase {
|
||||
PassphraseEntry = 0,
|
||||
Working = 1,
|
||||
Success = 2,
|
||||
Error = 3
|
||||
};
|
||||
|
||||
enum class DecryptStep {
|
||||
Unlock = 0,
|
||||
ExportKeys = 1,
|
||||
StopDaemon = 2,
|
||||
BackupWallet = 3,
|
||||
RestartDaemon = 4,
|
||||
ImportKeys = 5
|
||||
};
|
||||
|
||||
struct DecryptSnapshot {
|
||||
DecryptPhase phase = DecryptPhase::PassphraseEntry;
|
||||
DecryptStep step = DecryptStep::Unlock;
|
||||
std::string status;
|
||||
bool inProgress = false;
|
||||
bool importActive = false;
|
||||
std::chrono::steady_clock::time_point stepStarted{};
|
||||
std::chrono::steady_clock::time_point overallStarted{};
|
||||
};
|
||||
|
||||
struct WalletFilePlan {
|
||||
std::string dataDir;
|
||||
std::string exportFile;
|
||||
std::string exportPath;
|
||||
std::string walletPath;
|
||||
std::string backupPath;
|
||||
};
|
||||
|
||||
void reset();
|
||||
void start(std::chrono::steady_clock::time_point now);
|
||||
void advanceTo(DecryptStep step, std::string status,
|
||||
std::chrono::steady_clock::time_point now);
|
||||
void failEntry(std::string status);
|
||||
void fail(std::string status);
|
||||
void closeDialogForImport();
|
||||
void finishImport();
|
||||
|
||||
DecryptSnapshot snapshot() const { return state_; }
|
||||
DecryptPhase phase() const { return state_.phase; }
|
||||
DecryptStep step() const { return state_.step; }
|
||||
const std::string& status() const { return state_.status; }
|
||||
bool inProgress() const { return state_.inProgress; }
|
||||
bool importActive() const { return state_.importActive; }
|
||||
bool canClose() const { return state_.phase != DecryptPhase::Working; }
|
||||
|
||||
static WalletFilePlan planWalletFiles(const std::string& dataDir,
|
||||
std::uint64_t timestampSeconds);
|
||||
static const char* stepStatus(DecryptStep step);
|
||||
static const char* stepLabel(DecryptStep step);
|
||||
static int stepIndex(DecryptStep step) { return static_cast<int>(step); }
|
||||
static DecryptStep stepFromIndex(int step);
|
||||
static bool stepIsComplete(DecryptStep current, DecryptStep candidate);
|
||||
|
||||
private:
|
||||
DecryptSnapshot state_;
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
104
src/services/wallet_security_workflow_executor.cpp
Normal file
104
src/services/wallet_security_workflow_executor.cpp
Normal file
@@ -0,0 +1,104 @@
|
||||
#include "wallet_security_workflow_executor.h"
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::unlockWallet(
|
||||
const std::string& passphrase, RpcGateway& rpc, int timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!rpc.unlockWallet(passphrase, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Incorrect passphrase" : error, true};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::ExportOutcome WalletSecurityWorkflowExecutor::exportWallet(
|
||||
RpcGateway& rpc, FileGateway& files, std::uint64_t timestampSeconds, long timeoutSeconds)
|
||||
{
|
||||
ExportOutcome outcome;
|
||||
outcome.filePlan = WalletSecurityWorkflow::planWalletFiles(files.dataDir(), timestampSeconds);
|
||||
|
||||
std::string error;
|
||||
if (!rpc.exportWallet(outcome.filePlan.exportFile, timeoutSeconds, error)) {
|
||||
outcome.ok = false;
|
||||
outcome.error = error.empty() ? "Export failed" : "Export failed: " + error;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
outcome.ok = true;
|
||||
return outcome;
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::stopDaemon(RpcGateway& rpc)
|
||||
{
|
||||
std::string error;
|
||||
(void)rpc.requestDaemonStop(error);
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::backupEncryptedWallet(
|
||||
FileGateway& files, const WalletFilePlan& filePlan)
|
||||
{
|
||||
std::string error;
|
||||
if (!files.backupEncryptedWallet(filePlan, error)) {
|
||||
return {false, error.empty() ? "Failed to rename wallet.dat" : "Failed to rename wallet.dat: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::restartDaemonAndWait(
|
||||
DaemonGateway& daemon, RpcGateway& rpc, int preRestartDelayMs,
|
||||
int embeddedRestartSettleMs, int maxProbeSeconds)
|
||||
{
|
||||
auto waitForMs = [&](int milliseconds) -> bool {
|
||||
int remaining = milliseconds;
|
||||
while (remaining > 0 && !daemon.cancelled() && !daemon.shuttingDown()) {
|
||||
int slice = remaining >= 100 ? 100 : remaining;
|
||||
daemon.sleepForMs(slice);
|
||||
remaining -= slice;
|
||||
}
|
||||
return !daemon.cancelled() && !daemon.shuttingDown();
|
||||
};
|
||||
|
||||
if (!waitForMs(preRestartDelayMs)) return {false, "", false};
|
||||
|
||||
if (daemon.isUsingEmbeddedDaemon()) {
|
||||
daemon.stopEmbeddedDaemon();
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!waitForMs(embeddedRestartSettleMs)) return {false, "", false};
|
||||
daemon.startEmbeddedDaemon();
|
||||
}
|
||||
|
||||
bool daemonUp = false;
|
||||
std::string lastError;
|
||||
for (int i = 0; i < maxProbeSeconds && !daemon.cancelled() && !daemon.shuttingDown(); ++i) {
|
||||
daemon.sleepForMs(1000);
|
||||
if (rpc.probeDaemon(lastError)) {
|
||||
daemonUp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (daemon.cancelled() || daemon.shuttingDown()) return {false, "", false};
|
||||
if (!daemonUp) return {false, "Daemon failed to restart", false};
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
WalletSecurityWorkflowExecutor::Outcome WalletSecurityWorkflowExecutor::importWallet(
|
||||
ImportGateway& importer, const std::string& exportPath, long timeoutSeconds)
|
||||
{
|
||||
std::string error;
|
||||
if (!importer.importWallet(exportPath, timeoutSeconds, error)) {
|
||||
return {false, error.empty() ? "Key import failed" : "Key import failed: " + error, false};
|
||||
}
|
||||
return {true, {}, false};
|
||||
}
|
||||
|
||||
void WalletSecurityWorkflowExecutor::cleanupVaultAndPin(const VaultCleanupGateway& cleanup)
|
||||
{
|
||||
if (cleanup) cleanup();
|
||||
}
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
83
src/services/wallet_security_workflow_executor.h
Normal file
83
src/services/wallet_security_workflow_executor.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#pragma once
|
||||
|
||||
#include "wallet_security_workflow.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace services {
|
||||
|
||||
class WalletSecurityWorkflowExecutor {
|
||||
public:
|
||||
using WalletFilePlan = WalletSecurityWorkflow::WalletFilePlan;
|
||||
|
||||
struct Outcome {
|
||||
bool ok = false;
|
||||
std::string error;
|
||||
bool passphraseRejected = false;
|
||||
};
|
||||
|
||||
struct ExportOutcome : Outcome {
|
||||
WalletFilePlan filePlan;
|
||||
};
|
||||
|
||||
class RpcGateway {
|
||||
public:
|
||||
virtual ~RpcGateway() = default;
|
||||
virtual bool unlockWallet(const std::string& passphrase, int timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool exportWallet(const std::string& fileName, long timeoutSeconds, std::string& error) = 0;
|
||||
virtual bool requestDaemonStop(std::string& error) = 0;
|
||||
virtual bool probeDaemon(std::string& error) = 0;
|
||||
};
|
||||
|
||||
class ImportGateway {
|
||||
public:
|
||||
virtual ~ImportGateway() = default;
|
||||
virtual bool importWallet(const std::string& exportPath, long timeoutSeconds, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class FileGateway {
|
||||
public:
|
||||
virtual ~FileGateway() = default;
|
||||
virtual std::string dataDir() = 0;
|
||||
virtual bool backupEncryptedWallet(const WalletFilePlan& filePlan, std::string& error) = 0;
|
||||
};
|
||||
|
||||
class DaemonGateway {
|
||||
public:
|
||||
virtual ~DaemonGateway() = default;
|
||||
virtual bool isUsingEmbeddedDaemon() const = 0;
|
||||
virtual void stopEmbeddedDaemon() = 0;
|
||||
virtual bool startEmbeddedDaemon() = 0;
|
||||
virtual bool cancelled() const = 0;
|
||||
virtual bool shuttingDown() const = 0;
|
||||
virtual void sleepForMs(int milliseconds) = 0;
|
||||
};
|
||||
|
||||
using VaultCleanupGateway = std::function<void()>;
|
||||
|
||||
static Outcome unlockWallet(const std::string& passphrase,
|
||||
RpcGateway& rpc,
|
||||
int timeoutSeconds = 600);
|
||||
static ExportOutcome exportWallet(RpcGateway& rpc,
|
||||
FileGateway& files,
|
||||
std::uint64_t timestampSeconds,
|
||||
long timeoutSeconds = 300L);
|
||||
static Outcome stopDaemon(RpcGateway& rpc);
|
||||
static Outcome backupEncryptedWallet(FileGateway& files,
|
||||
const WalletFilePlan& filePlan);
|
||||
static Outcome restartDaemonAndWait(DaemonGateway& daemon,
|
||||
RpcGateway& rpc,
|
||||
int preRestartDelayMs = 2000,
|
||||
int embeddedRestartSettleMs = 1000,
|
||||
int maxProbeSeconds = 60);
|
||||
static Outcome importWallet(ImportGateway& importer,
|
||||
const std::string& exportPath,
|
||||
long timeoutSeconds = 1200L);
|
||||
static void cleanupVaultAndPin(const VaultCleanupGateway& cleanup);
|
||||
};
|
||||
|
||||
} // namespace services
|
||||
} // namespace dragonx
|
||||
@@ -432,6 +432,20 @@ inline float columnOffset(float ratio, float availW) {
|
||||
return availW * ratio;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Dialogs
|
||||
// ============================================================================
|
||||
|
||||
inline float kDialogDefaultWidth() { return schema::UI().drawElement("dialog", "width-default").sizeOr(480.0f) * dpiScale(); }
|
||||
inline float kDialogLargeWidth() { return schema::UI().drawElement("dialog", "width-lg").sizeOr(600.0f) * dpiScale(); }
|
||||
inline float kDialogExtraLargeWidth() { return schema::UI().drawElement("dialog", "width-xl").sizeOr(660.0f) * dpiScale(); }
|
||||
inline float kDialogMinWidth() { return schema::UI().drawElement("dialog", "min-width").sizeOr(280.0f) * dpiScale(); }
|
||||
inline float kDialogFormWidth() { return schema::UI().drawElement("dialog", "form-width").sizeOr(400.0f) * dpiScale(); }
|
||||
inline float kDialogActionWidth() { return schema::UI().drawElement("dialog", "action-width").sizeOr(100.0f) * dpiScale(); }
|
||||
inline float kDialogActionGap() { return schema::UI().drawElement("dialog", "action-gap").sizeOr(8.0f) * dpiScale(); }
|
||||
inline float kDialogMaxHeightRatio() { return schema::UI().drawElement("dialog", "max-height-ratio").sizeOr(0.94f); }
|
||||
inline float kDialogCompactBottomRatio() { return schema::UI().drawElement("dialog", "compact-bottom-ratio").sizeOr(0.64f); }
|
||||
|
||||
// ============================================================================
|
||||
// Buttons
|
||||
// ============================================================================
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
@@ -916,7 +917,7 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col
|
||||
// Similar to the shutdown screen pattern but for interactive dialogs.
|
||||
|
||||
inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f,
|
||||
float cardBottomViewportRatio = 0.85f)
|
||||
float cardBottomViewportRatio = 0.85f, const char* idSuffix = nullptr)
|
||||
{
|
||||
MarkOverlayDialogActive();
|
||||
|
||||
@@ -933,7 +934,16 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
|
||||
|
||||
bool opened = ImGui::Begin("##OverlayScrim", nullptr,
|
||||
std::string scrimId = "##OverlayScrim";
|
||||
std::string childId = "##OverlayDialogContent";
|
||||
if (idSuffix && idSuffix[0] != '\0') {
|
||||
scrimId += "_";
|
||||
scrimId += idSuffix;
|
||||
childId += "_";
|
||||
childId += idSuffix;
|
||||
}
|
||||
|
||||
bool opened = ImGui::Begin(scrimId.c_str(), nullptr,
|
||||
ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize |
|
||||
ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar |
|
||||
ImGuiWindowFlags_NoScrollWithMouse | ImGuiWindowFlags_NoNav |
|
||||
@@ -983,7 +993,7 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth
|
||||
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28, 24));
|
||||
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); // Transparent - glass already drawn
|
||||
|
||||
bool childVisible = ImGui::BeginChild("##OverlayDialogContent",
|
||||
bool childVisible = ImGui::BeginChild(childId.c_str(),
|
||||
ImVec2(cardWidth, 0), // 0 height = auto-size
|
||||
ImGuiChildFlags_AutoResizeY | ImGuiChildFlags_AlwaysUseWindowPadding,
|
||||
ImGuiWindowFlags_NoScrollbar);
|
||||
@@ -1007,6 +1017,23 @@ inline void EndOverlayDialog()
|
||||
ImGui::PopStyleColor(); // WindowBg scrim
|
||||
}
|
||||
|
||||
inline void PlaceOverlayDialogActions(float totalWidth)
|
||||
{
|
||||
float rowStartX = ImGui::GetCursorPosX();
|
||||
float contentW = ImGui::GetContentRegionAvail().x;
|
||||
ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalWidth) * 0.5f));
|
||||
}
|
||||
|
||||
inline void BeginOverlayDialogFooter(float totalActionWidth, bool drawSeparator = true)
|
||||
{
|
||||
ImGui::Spacing();
|
||||
if (drawSeparator) {
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
}
|
||||
PlaceOverlayDialogActions(totalActionWidth);
|
||||
}
|
||||
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
|
||||
115
src/ui/material/project_icons.h
Normal file
115
src/ui/material/project_icons.h
Normal file
@@ -0,0 +1,115 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "typography.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace material {
|
||||
namespace project_icons {
|
||||
|
||||
struct ProjectIcon {
|
||||
const char* name;
|
||||
const char* glyph;
|
||||
};
|
||||
|
||||
inline constexpr const char* kPickaxeName = "pickaxe";
|
||||
inline constexpr const char* kPickaxeGlyph = "\xEE\x80\x81";
|
||||
|
||||
inline constexpr ProjectIcon kWalletIcons[] = {
|
||||
{"savings", ICON_MD_SAVINGS}, {"account_balance", ICON_MD_ACCOUNT_BALANCE}, {"account_balance_wallet", ICON_MD_ACCOUNT_BALANCE_WALLET}, {"wallet", ICON_MD_WALLET},
|
||||
{"payments", ICON_MD_PAYMENTS}, {"credit_card", ICON_MD_CREDIT_CARD}, {"local_atm", ICON_MD_LOCAL_ATM}, {"diamond", ICON_MD_DIAMOND},
|
||||
{"attach_money", ICON_MD_ATTACH_MONEY}, {"currency_bitcoin", ICON_MD_CURRENCY_BITCOIN}, {"currency_exchange", ICON_MD_CURRENCY_EXCHANGE}, {"balance", ICON_MD_BALANCE},
|
||||
{"calculate", ICON_MD_CALCULATE}, {"trending_up", ICON_MD_TRENDING_UP}, {"euro", ICON_MD_EURO}, {"leaderboard", ICON_MD_LEADERBOARD},
|
||||
{"paid", ICON_MD_PAID}, {"sell", ICON_MD_SELL}, {"receipt", ICON_MD_RECEIPT}, {"percent", ICON_MD_PERCENT},
|
||||
{"price_change", ICON_MD_PRICE_CHANGE}, {"price_check", ICON_MD_PRICE_CHECK}, {"toll", ICON_MD_TOLL}, {"money", ICON_MD_MONEY},
|
||||
{"show_chart", ICON_MD_SHOW_CHART}, {"candlestick_chart", ICON_MD_CANDLESTICK_CHART}, {"bar_chart", ICON_MD_BAR_CHART}, {"pie_chart", ICON_MD_PIE_CHART},
|
||||
{"area_chart", ICON_MD_AREA_CHART}, {"stacked_bar_chart", ICON_MD_STACKED_BAR_CHART}, {"waterfall_chart", ICON_MD_WATERFALL_CHART}, {"scatter_plot", ICON_MD_SCATTER_PLOT},
|
||||
{"query_stats", ICON_MD_QUERY_STATS}, {"speed", ICON_MD_SPEED}, {"donut_large", ICON_MD_DONUT_LARGE},
|
||||
{kPickaxeName, nullptr},
|
||||
{"hardware", ICON_MD_HARDWARE}, {"construction", ICON_MD_CONSTRUCTION}, {"handyman", ICON_MD_HANDYMAN}, {"build", ICON_MD_BUILD},
|
||||
{"carpenter", ICON_MD_CARPENTER}, {"plumbing", ICON_MD_PLUMBING}, {"home_repair_service", ICON_MD_HOME_REPAIR_SERVICE}, {"precision_manufacturing", ICON_MD_PRECISION_MANUFACTURING},
|
||||
{"factory", ICON_MD_FACTORY}, {"warehouse", ICON_MD_WAREHOUSE}, {"inventory", ICON_MD_INVENTORY}, {"recycling", ICON_MD_RECYCLING},
|
||||
{"oil_barrel", ICON_MD_OIL_BARREL}, {"offline_bolt", ICON_MD_OFFLINE_BOLT}, {"thunderstorm", ICON_MD_THUNDERSTORM}, {"terminal", ICON_MD_TERMINAL},
|
||||
{"storage", ICON_MD_STORAGE}, {"memory", ICON_MD_MEMORY}, {"developer_board", ICON_MD_DEVELOPER_BOARD},
|
||||
{"shield", ICON_MD_SHIELD}, {"security", ICON_MD_SECURITY}, {"lock", ICON_MD_LOCK}, {"swap_horiz", ICON_MD_SWAP_HORIZ},
|
||||
{"verified", ICON_MD_VERIFIED}, {"verified_user", ICON_MD_VERIFIED_USER}, {"key", ICON_MD_KEY}, {"badge", ICON_MD_BADGE},
|
||||
{"store", ICON_MD_STORE}, {"storefront", ICON_MD_STOREFRONT}, {"shopping_bag", ICON_MD_SHOPPING_BAG}, {"business", ICON_MD_BUSINESS},
|
||||
{"work", ICON_MD_WORK}, {"real_estate_agent", ICON_MD_REAL_ESTATE_AGENT}, {"gavel", ICON_MD_GAVEL}, {"local_shipping", ICON_MD_LOCAL_SHIPPING},
|
||||
{"home", ICON_MD_HOME}, {"apartment", ICON_MD_APARTMENT}, {"cottage", ICON_MD_COTTAGE}, {"landscape", ICON_MD_LANDSCAPE},
|
||||
{"account_circle", ICON_MD_ACCOUNT_CIRCLE}, {"face", ICON_MD_FACE}, {"manage_accounts", ICON_MD_MANAGE_ACCOUNTS}, {"groups", ICON_MD_GROUPS}, {"mood", ICON_MD_MOOD},
|
||||
{"rocket_launch", ICON_MD_ROCKET_LAUNCH}, {"flight", ICON_MD_FLIGHT}, {"directions_car", ICON_MD_DIRECTIONS_CAR}, {"travel_explore", ICON_MD_TRAVEL_EXPLORE},
|
||||
{"explore", ICON_MD_EXPLORE}, {"location_on", ICON_MD_LOCATION_ON}, {"map", ICON_MD_MAP}, {"luggage", ICON_MD_LUGGAGE}, {"anchor", ICON_MD_ANCHOR},
|
||||
{"public", ICON_MD_PUBLIC}, {"language", ICON_MD_LANGUAGE}, {"forest", ICON_MD_FOREST}, {"park", ICON_MD_PARK},
|
||||
{"water_drop", ICON_MD_WATER_DROP}, {"beach_access", ICON_MD_BEACH_ACCESS}, {"energy_savings_leaf", ICON_MD_ENERGY_SAVINGS_LEAF}, {"solar_power", ICON_MD_SOLAR_POWER},
|
||||
{"favorite", ICON_MD_FAVORITE}, {"star", ICON_MD_STAR}, {"celebration", ICON_MD_CELEBRATION}, {"casino", ICON_MD_CASINO},
|
||||
{"auto_awesome", ICON_MD_AUTO_AWESOME}, {"emoji_events", ICON_MD_EMOJI_EVENTS}, {"military_tech", ICON_MD_MILITARY_TECH}, {"flag", ICON_MD_FLAG},
|
||||
{"bolt", ICON_MD_BOLT}, {"tungsten", ICON_MD_TUNGSTEN}, {"lightbulb", ICON_MD_LIGHTBULB}, {"insights", ICON_MD_INSIGHTS},
|
||||
{"hub", ICON_MD_HUB}, {"token", ICON_MD_TOKEN}, {"electric_bolt", ICON_MD_ELECTRIC_BOLT}, {"science", ICON_MD_SCIENCE}, {"biotech", ICON_MD_BIOTECH},
|
||||
{"category", ICON_MD_CATEGORY}, {"label", ICON_MD_LABEL}, {"school", ICON_MD_SCHOOL}, {"local_hospital", ICON_MD_LOCAL_HOSPITAL}, {"local_florist", ICON_MD_LOCAL_FLORIST},
|
||||
{"coffee", ICON_MD_LOCAL_CAFE}, {"restaurant", ICON_MD_RESTAURANT}, {"wine_bar", ICON_MD_WINE_BAR}, {"liquor", ICON_MD_LIQUOR},
|
||||
{"outdoor_grill", ICON_MD_OUTDOOR_GRILL}, {"nightlife", ICON_MD_NIGHTLIFE}, {"sports_bar", ICON_MD_SPORTS_BAR},
|
||||
{"pets", ICON_MD_PETS}, {"fitness_center", ICON_MD_FITNESS_CENTER}, {"spa", ICON_MD_SPA}, {"self_improvement", ICON_MD_SELF_IMPROVEMENT},
|
||||
{"psychology", ICON_MD_PSYCHOLOGY}, {"sports_soccer", ICON_MD_SPORTS_SOCCER}, {"sports_esports", ICON_MD_SPORTS_ESPORTS},
|
||||
{"hiking", ICON_MD_HIKING}, {"palette", ICON_MD_PALETTE}, {"museum", ICON_MD_MUSEUM}, {"church", ICON_MD_CHURCH}, {"surfing", ICON_MD_SURFING},
|
||||
{"redeem", ICON_MD_REDEEM}, {"handshake", ICON_MD_HANDSHAKE}, {"healing", ICON_MD_HEALING}, {"volunteer", ICON_MD_VOLUNTEER_ACTIVISM},
|
||||
{"stadium", ICON_MD_STADIUM}, {"temple_buddhist", ICON_MD_TEMPLE_BUDDHIST}, {"theater_comedy", ICON_MD_THEATER_COMEDY}, {"watch", ICON_MD_WATCH},
|
||||
};
|
||||
|
||||
inline int walletIconCount()
|
||||
{
|
||||
return static_cast<int>(sizeof(kWalletIcons) / sizeof(kWalletIcons[0]));
|
||||
}
|
||||
|
||||
inline const char* walletIconName(int index)
|
||||
{
|
||||
return (index >= 0 && index < walletIconCount()) ? kWalletIcons[index].name : "";
|
||||
}
|
||||
|
||||
inline const char* glyphForName(const std::string& name)
|
||||
{
|
||||
for (const auto& icon : kWalletIcons) {
|
||||
if (name == icon.name) return icon.glyph;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
inline bool drawByName(ImDrawList* drawList,
|
||||
const std::string& name,
|
||||
ImVec2 center,
|
||||
ImU32 color,
|
||||
ImFont* iconFont,
|
||||
float iconFontSize)
|
||||
{
|
||||
if (!drawList) return false;
|
||||
|
||||
if (name == kPickaxeName) {
|
||||
ImFont* pickaxeFont = Typography::instance().pickaxeFontForSize(iconFontSize);
|
||||
if (!pickaxeFont) return false;
|
||||
|
||||
ImVec2 iconSize = pickaxeFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, kPickaxeGlyph);
|
||||
drawList->AddText(pickaxeFont, iconFontSize,
|
||||
ImVec2(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f), color, kPickaxeGlyph);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* glyph = glyphForName(name);
|
||||
if (!glyph || !iconFont) return false;
|
||||
|
||||
ImVec2 iconSize = iconFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, glyph);
|
||||
drawList->AddText(iconFont, iconFontSize,
|
||||
ImVec2(center.x - iconSize.x * 0.5f, center.y - iconSize.y * 0.5f), color, glyph);
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace project_icons
|
||||
} // namespace material
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ bool UISchema::loadFromFile(const std::string& path) {
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
@@ -172,7 +172,7 @@ bool UISchema::loadFromString(const std::string& tomlStr, const std::string& lab
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
@@ -251,7 +251,7 @@ bool UISchema::mergeOverlayFromFile(const std::string& path) {
|
||||
for (const auto& flatSection : {"business", "animations", "console",
|
||||
"backdrop", "shutdown", "notifications", "status-bar",
|
||||
"qr-code", "content-area", "style", "responsive",
|
||||
"spacing", "spacing-tokens", "button", "input", "fonts",
|
||||
"spacing", "spacing-tokens", "button", "dialog", "input", "fonts",
|
||||
"inline-dialogs", "sidebar", "panels", "typography",
|
||||
"effects", "security"}) {
|
||||
if (auto* sec = root[flatSection].as_table()) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -121,33 +122,15 @@ void RenderAboutDialog(App* app, bool* p_open)
|
||||
|
||||
// Links
|
||||
if (material::StyledButton(TR("about_website"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://dragonx.is");
|
||||
#elif __APPLE__
|
||||
system("open https://dragonx.is");
|
||||
#else
|
||||
system("xdg-open https://dragonx.is &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://dragonx.is");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_github"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
#elif __APPLE__
|
||||
system("open https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
#else
|
||||
system("xdg-open https://git.dragonx.is/dragonx/ObsidianDragon &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon");
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("about_block_explorer"), ImVec2(linkBtn.width, 0), S.resolveFont(linkBtn.font))) {
|
||||
#ifdef _WIN32
|
||||
system("start https://explorer.dragonx.is");
|
||||
#elif __APPLE__
|
||||
system("open https://explorer.dragonx.is");
|
||||
#else
|
||||
system("xdg-open https://explorer.dragonx.is &");
|
||||
#endif
|
||||
util::Platform::openUrl("https://explorer.dragonx.is");
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "imgui.h"
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -35,6 +36,12 @@ static data::AddressBook& getAddressBook() {
|
||||
return *s_address_book;
|
||||
}
|
||||
|
||||
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||
if (destSize == 0) return;
|
||||
std::strncpy(dest, source.c_str(), destSize - 1);
|
||||
dest[destSize - 1] = '\0';
|
||||
}
|
||||
|
||||
void AddressBookDialog::show()
|
||||
{
|
||||
s_open = true;
|
||||
@@ -66,15 +73,101 @@ void AddressBookDialog::render(App* app)
|
||||
auto notesInput = S.input("dialogs.address-book", "notes-input");
|
||||
auto actionBtn = S.button("dialogs.address-book", "action-button");
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
|
||||
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
|
||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||
};
|
||||
|
||||
auto renderEntryDialog = [&]() {
|
||||
bool isEdit = s_show_edit_dialog;
|
||||
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
|
||||
if (!*open) return;
|
||||
|
||||
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
|
||||
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
|
||||
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
|
||||
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
|
||||
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
|
||||
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
|
||||
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
|
||||
|
||||
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
|
||||
Layout::kDialogCompactBottomRatio(), id)) {
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputText(isEdit ? "##EditLabel" : "##AddLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputText(isEdit ? "##EditAddress" : "##AddAddress", s_edit_address, sizeof(s_edit_address));
|
||||
if (!isEdit) {
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(formW);
|
||||
ImGui::InputTextMultiline(isEdit ? "##EditNotes" : "##AddNotes",
|
||||
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
|
||||
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
float totalActionsW = actionW * 2.0f + actionGap;
|
||||
material::BeginOverlayDialogFooter(totalActionsW);
|
||||
|
||||
if (!canSubmit) ImGui::BeginDisabled();
|
||||
|
||||
const char* primaryLabel = isEdit ? TR("save") : TR("add");
|
||||
if (material::StyledButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (isEdit) {
|
||||
if (getAddressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
} else {
|
||||
if (getAddressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!canSubmit) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine(0, actionGap);
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) {
|
||||
*open = false;
|
||||
}
|
||||
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
};
|
||||
|
||||
if (material::BeginOverlayDialog(TR("address_book_title"), &s_open, win.width, 0.94f)) {
|
||||
auto& book = getAddressBook();
|
||||
|
||||
// Toolbar
|
||||
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = true;
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
clearEditFields();
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
@@ -86,9 +179,7 @@ void AddressBookDialog::render(App* app)
|
||||
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
if (has_selection) {
|
||||
const auto& entry = book.entries()[s_selected_index];
|
||||
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
|
||||
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
|
||||
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
}
|
||||
@@ -153,9 +244,7 @@ void AddressBookDialog::render(App* app)
|
||||
// Double-click to edit
|
||||
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
|
||||
s_selected_index = static_cast<int>(i);
|
||||
strncpy(s_edit_label, entry.label.c_str(), sizeof(s_edit_label) - 1);
|
||||
strncpy(s_edit_address, entry.address.c_str(), sizeof(s_edit_address) - 1);
|
||||
strncpy(s_edit_notes, entry.notes.c_str(), sizeof(s_edit_notes) - 1);
|
||||
loadEditFields(entry);
|
||||
s_show_edit_dialog = true;
|
||||
}
|
||||
|
||||
@@ -186,123 +275,8 @@ void AddressBookDialog::render(App* app)
|
||||
ImGui::TextDisabled(TR("address_book_count"), book.size());
|
||||
material::EndOverlayDialog();
|
||||
}
|
||||
|
||||
// Add dialog
|
||||
if (s_show_add_dialog) {
|
||||
ImGui::OpenPopup("Add Address");
|
||||
}
|
||||
|
||||
// Re-use center for sub-dialogs
|
||||
ImVec2 center = ImGui::GetMainViewport()->GetCenter();
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
|
||||
if (ImGui::BeginPopupModal("Add Address", &s_show_add_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
material::Type().text(material::TypeStyle::H6, TR("address_book_add"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##AddLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##AddAddress", s_edit_address, sizeof(s_edit_address));
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
|
||||
const char* clipboard = ImGui::GetClipboardText();
|
||||
if (clipboard) {
|
||||
strncpy(s_edit_address, clipboard, sizeof(s_edit_address) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputTextMultiline("##AddNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
bool can_add = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
|
||||
if (!can_add) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("add"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (getAddressBook().addEntry(entry)) {
|
||||
Notifications::instance().success(TR("address_book_added"));
|
||||
s_show_add_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_exists"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!can_add) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_add_dialog = false;
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
|
||||
// Edit dialog
|
||||
if (s_show_edit_dialog) {
|
||||
ImGui::OpenPopup("Edit Address");
|
||||
}
|
||||
|
||||
ImGui::SetNextWindowPos(center, ImGuiCond_Appearing, ImVec2(0.5f, 0.5f));
|
||||
|
||||
if (ImGui::BeginPopupModal("Edit Address", &s_show_edit_dialog, ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||
material::Type().text(material::TypeStyle::H6, TR("address_book_edit"));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
ImGui::Text("%s", TR("label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##EditLabel", s_edit_label, sizeof(s_edit_label));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("address_label"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputText("##EditAddress", s_edit_address, sizeof(s_edit_address));
|
||||
|
||||
ImGui::Spacing();
|
||||
|
||||
ImGui::Text("%s", TR("notes_optional"));
|
||||
ImGui::SetNextItemWidth(addrInput.width);
|
||||
ImGui::InputTextMultiline("##EditNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(addrInput.width, notesInput.height > 0 ? notesInput.height : 60));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Separator();
|
||||
ImGui::Spacing();
|
||||
|
||||
bool can_save = strlen(s_edit_label) > 0 && strlen(s_edit_address) > 0;
|
||||
if (!can_save) ImGui::BeginDisabled();
|
||||
|
||||
if (material::StyledButton(TR("save"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
data::AddressBookEntry entry(s_edit_label, s_edit_address, s_edit_notes);
|
||||
if (getAddressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
s_show_edit_dialog = false;
|
||||
} else {
|
||||
Notifications::instance().error(TR("address_book_update_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!can_save) ImGui::EndDisabled();
|
||||
|
||||
ImGui::SameLine();
|
||||
if (material::StyledButton(TR("cancel"), ImVec2(actionBtn.width, 0), S.resolveFont(actionBtn.font))) {
|
||||
s_show_edit_dialog = false;
|
||||
}
|
||||
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
renderEntryDialog();
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
|
||||
@@ -12,9 +12,8 @@
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/typography.h"
|
||||
#include "../material/project_icons.h"
|
||||
#include "../theme.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "imgui.h"
|
||||
|
||||
namespace dragonx {
|
||||
@@ -22,8 +21,6 @@ namespace ui {
|
||||
|
||||
class AddressLabelDialog {
|
||||
public:
|
||||
static constexpr const char* kPickaxeGlyph = "\xEE\x80\x81";
|
||||
|
||||
static bool drawIconByName(ImDrawList* dl,
|
||||
const std::string& name,
|
||||
ImVec2 center,
|
||||
@@ -31,23 +28,7 @@ public:
|
||||
ImU32 color,
|
||||
ImFont* iconFont,
|
||||
float iconFontSize) {
|
||||
if (name == "pickaxe") {
|
||||
ImFont* pickaxeFont = material::Typography::instance().pickaxeFontForSize(iconFontSize);
|
||||
if (!pickaxeFont) return false;
|
||||
|
||||
ImVec2 iSz = pickaxeFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, kPickaxeGlyph);
|
||||
dl->AddText(pickaxeFont, iconFontSize,
|
||||
ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, kPickaxeGlyph);
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* glyph = iconGlyphForName(name);
|
||||
if (!glyph || !iconFont) return false;
|
||||
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, glyph);
|
||||
dl->AddText(iconFont, iconFontSize,
|
||||
ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, glyph);
|
||||
return true;
|
||||
return material::project_icons::drawByName(dl, name, center, color, iconFont, iconFontSize);
|
||||
}
|
||||
|
||||
static void show(App* app, const std::string& address, bool isZ) {
|
||||
@@ -64,8 +45,8 @@ public:
|
||||
s_label[sizeof(s_label) - 1] = '\0';
|
||||
|
||||
std::string existingIcon = app->getAddressIcon(address);
|
||||
for (int i = 0; i < kIconCount; ++i) {
|
||||
if (kIconNames[i] == existingIcon) {
|
||||
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
|
||||
if (material::project_icons::walletIconName(i) == existingIcon) {
|
||||
s_selectedIcon = i;
|
||||
break;
|
||||
}
|
||||
@@ -134,13 +115,14 @@ public:
|
||||
|
||||
// Build filtered index list
|
||||
std::vector<int> visible;
|
||||
visible.reserve(kIconCount);
|
||||
visible.reserve(material::project_icons::walletIconCount());
|
||||
{
|
||||
// Simple case-insensitive substring match on icon name
|
||||
std::string needle(s_iconSearch);
|
||||
for (char& c : needle) c = (char)std::tolower((unsigned char)c);
|
||||
for (int i = 0; i < kIconCount; ++i) {
|
||||
if (needle.empty() || std::strstr(kIconNames[i], needle.c_str()) != nullptr)
|
||||
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
|
||||
const char* iconName = material::project_icons::walletIconName(i);
|
||||
if (needle.empty() || std::strstr(iconName, needle.c_str()) != nullptr)
|
||||
visible.push_back(i);
|
||||
}
|
||||
}
|
||||
@@ -162,6 +144,7 @@ public:
|
||||
int col = 0;
|
||||
for (int vi = 0; vi < (int)visible.size(); ++vi) {
|
||||
int i = visible[vi];
|
||||
const char* iconName = material::project_icons::walletIconName(i);
|
||||
if (col != 0) ImGui::SameLine(0, 4.0f * dp);
|
||||
ImVec2 pos = ImGui::GetCursorScreenPos();
|
||||
ImVec2 mn = pos;
|
||||
@@ -179,7 +162,7 @@ public:
|
||||
|
||||
// Icon centered in cell
|
||||
drawIconByName(dl,
|
||||
kIconNames[i],
|
||||
iconName,
|
||||
ImVec2(mn.x + cellSz * 0.5f, mn.y + cellSz * 0.5f),
|
||||
iconFsz,
|
||||
sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()),
|
||||
@@ -189,7 +172,7 @@ public:
|
||||
ImGui::PushID(i);
|
||||
ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz));
|
||||
if (ImGui::IsItemClicked()) s_selectedIcon = i;
|
||||
if (hov) ImGui::SetTooltip("%s", kIconNames[i]);
|
||||
if (hov) ImGui::SetTooltip("%s", iconName);
|
||||
ImGui::PopID();
|
||||
|
||||
col = (col + 1) % cols;
|
||||
@@ -239,7 +222,7 @@ public:
|
||||
// Apply changes
|
||||
s_app->setAddressLabel(s_address, s_label);
|
||||
if (s_selectedIcon >= 0)
|
||||
s_app->setAddressIcon(s_address, kIconNames[s_selectedIcon]);
|
||||
s_app->setAddressIcon(s_address, material::project_icons::walletIconName(s_selectedIcon));
|
||||
else
|
||||
s_app->setAddressIcon(s_address, "");
|
||||
s_open = false;
|
||||
@@ -260,123 +243,10 @@ private:
|
||||
static inline int s_selectedIcon = -1;
|
||||
static inline char s_iconSearch[64] = {};
|
||||
|
||||
// Icon palette — wallet-relevant Material Design icons
|
||||
static inline const char* kIconNames[] = {
|
||||
// Finance / Crypto
|
||||
"savings", "account_balance", "account_balance_wallet", "wallet",
|
||||
"payments", "credit_card", "local_atm", "diamond",
|
||||
"attach_money", "currency_bitcoin", "currency_exchange", "balance",
|
||||
"calculate", "trending_up", "euro", "leaderboard",
|
||||
"paid", "sell", "receipt", "percent",
|
||||
"price_change", "price_check", "toll", "money",
|
||||
// Charts / Analytics
|
||||
"show_chart", "candlestick_chart", "bar_chart", "pie_chart",
|
||||
"area_chart", "stacked_bar_chart", "waterfall_chart", "scatter_plot",
|
||||
"query_stats", "speed", "donut_large",
|
||||
// Mining / Tools
|
||||
"pickaxe",
|
||||
"hardware", "construction", "handyman", "build",
|
||||
"carpenter", "plumbing", "home_repair_service", "precision_manufacturing",
|
||||
"factory", "warehouse", "inventory", "recycling",
|
||||
"oil_barrel", "offline_bolt", "thunderstorm", "terminal",
|
||||
"storage", "memory", "developer_board",
|
||||
// Security / Auth
|
||||
"shield", "security", "lock", "swap_horiz",
|
||||
"verified", "verified_user", "key", "badge",
|
||||
// Commerce / Business
|
||||
"store", "storefront", "shopping_bag", "business",
|
||||
"work", "real_estate_agent", "gavel", "local_shipping",
|
||||
// Home / Property
|
||||
"home", "apartment", "cottage", "landscape",
|
||||
// People / Identity
|
||||
"account_circle", "face", "manage_accounts", "groups", "mood",
|
||||
// Travel / Transport
|
||||
"rocket_launch", "flight", "directions_car", "travel_explore",
|
||||
"explore", "location_on", "map", "luggage", "anchor",
|
||||
// Nature / Outdoors
|
||||
"public", "language", "forest", "park",
|
||||
"water_drop", "beach_access", "energy_savings_leaf", "solar_power",
|
||||
// Social / Lifestyle
|
||||
"favorite", "star", "celebration", "casino",
|
||||
"auto_awesome", "emoji_events", "military_tech", "flag",
|
||||
// Tech / Science
|
||||
"bolt", "tungsten", "lightbulb", "insights",
|
||||
"hub", "token", "electric_bolt", "science", "biotech",
|
||||
// Organisation
|
||||
"category", "label", "school", "local_hospital", "local_florist",
|
||||
// Food / Drink
|
||||
"coffee", "restaurant", "wine_bar", "liquor",
|
||||
"outdoor_grill", "nightlife", "sports_bar",
|
||||
// Recreation / Health
|
||||
"pets", "fitness_center", "spa", "self_improvement",
|
||||
"psychology", "sports_soccer", "sports_esports",
|
||||
"hiking", "palette", "museum", "church", "surfing",
|
||||
// Community
|
||||
"redeem", "handshake", "healing", "volunteer",
|
||||
"stadium", "temple_buddhist", "theater_comedy", "watch",
|
||||
};
|
||||
static inline const char* kIconGlyphs[] = {
|
||||
// Finance / Crypto
|
||||
ICON_MD_SAVINGS, ICON_MD_ACCOUNT_BALANCE, ICON_MD_ACCOUNT_BALANCE_WALLET, ICON_MD_WALLET,
|
||||
ICON_MD_PAYMENTS, ICON_MD_CREDIT_CARD, ICON_MD_LOCAL_ATM, ICON_MD_DIAMOND,
|
||||
ICON_MD_ATTACH_MONEY, ICON_MD_CURRENCY_BITCOIN, ICON_MD_CURRENCY_EXCHANGE, ICON_MD_BALANCE,
|
||||
ICON_MD_CALCULATE, ICON_MD_TRENDING_UP, ICON_MD_EURO, ICON_MD_LEADERBOARD,
|
||||
ICON_MD_PAID, ICON_MD_SELL, ICON_MD_RECEIPT, ICON_MD_PERCENT,
|
||||
ICON_MD_PRICE_CHANGE, ICON_MD_PRICE_CHECK, ICON_MD_TOLL, ICON_MD_MONEY,
|
||||
// Charts / Analytics
|
||||
ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART, ICON_MD_BAR_CHART, ICON_MD_PIE_CHART,
|
||||
ICON_MD_AREA_CHART, ICON_MD_STACKED_BAR_CHART, ICON_MD_WATERFALL_CHART, ICON_MD_SCATTER_PLOT,
|
||||
ICON_MD_QUERY_STATS, ICON_MD_SPEED, ICON_MD_DONUT_LARGE,
|
||||
// Mining / Tools
|
||||
nullptr,
|
||||
ICON_MD_HARDWARE, ICON_MD_CONSTRUCTION, ICON_MD_HANDYMAN, ICON_MD_BUILD,
|
||||
ICON_MD_CARPENTER, ICON_MD_PLUMBING, ICON_MD_HOME_REPAIR_SERVICE, ICON_MD_PRECISION_MANUFACTURING,
|
||||
ICON_MD_FACTORY, ICON_MD_WAREHOUSE, ICON_MD_INVENTORY, ICON_MD_RECYCLING,
|
||||
ICON_MD_OIL_BARREL, ICON_MD_OFFLINE_BOLT, ICON_MD_THUNDERSTORM, ICON_MD_TERMINAL,
|
||||
ICON_MD_STORAGE, ICON_MD_MEMORY, ICON_MD_DEVELOPER_BOARD,
|
||||
// Security / Auth
|
||||
ICON_MD_SHIELD, ICON_MD_SECURITY, ICON_MD_LOCK, ICON_MD_SWAP_HORIZ,
|
||||
ICON_MD_VERIFIED, ICON_MD_VERIFIED_USER, ICON_MD_KEY, ICON_MD_BADGE,
|
||||
// Commerce / Business
|
||||
ICON_MD_STORE, ICON_MD_STOREFRONT, ICON_MD_SHOPPING_BAG, ICON_MD_BUSINESS,
|
||||
ICON_MD_WORK, ICON_MD_REAL_ESTATE_AGENT, ICON_MD_GAVEL, ICON_MD_LOCAL_SHIPPING,
|
||||
// Home / Property
|
||||
ICON_MD_HOME, ICON_MD_APARTMENT, ICON_MD_COTTAGE, ICON_MD_LANDSCAPE,
|
||||
// People / Identity
|
||||
ICON_MD_ACCOUNT_CIRCLE, ICON_MD_FACE, ICON_MD_MANAGE_ACCOUNTS, ICON_MD_GROUPS, ICON_MD_MOOD,
|
||||
// Travel / Transport
|
||||
ICON_MD_ROCKET_LAUNCH, ICON_MD_FLIGHT, ICON_MD_DIRECTIONS_CAR, ICON_MD_TRAVEL_EXPLORE,
|
||||
ICON_MD_EXPLORE, ICON_MD_LOCATION_ON, ICON_MD_MAP, ICON_MD_LUGGAGE, ICON_MD_ANCHOR,
|
||||
// Nature / Outdoors
|
||||
ICON_MD_PUBLIC, ICON_MD_LANGUAGE, ICON_MD_FOREST, ICON_MD_PARK,
|
||||
ICON_MD_WATER_DROP, ICON_MD_BEACH_ACCESS, ICON_MD_ENERGY_SAVINGS_LEAF, ICON_MD_SOLAR_POWER,
|
||||
// Social / Lifestyle
|
||||
ICON_MD_FAVORITE, ICON_MD_STAR, ICON_MD_CELEBRATION, ICON_MD_CASINO,
|
||||
ICON_MD_AUTO_AWESOME, ICON_MD_EMOJI_EVENTS, ICON_MD_MILITARY_TECH, ICON_MD_FLAG,
|
||||
// Tech / Science
|
||||
ICON_MD_BOLT, ICON_MD_TUNGSTEN, ICON_MD_LIGHTBULB, ICON_MD_INSIGHTS,
|
||||
ICON_MD_HUB, ICON_MD_TOKEN, ICON_MD_ELECTRIC_BOLT, ICON_MD_SCIENCE, ICON_MD_BIOTECH,
|
||||
// Organisation
|
||||
ICON_MD_CATEGORY, ICON_MD_LABEL, ICON_MD_SCHOOL, ICON_MD_LOCAL_HOSPITAL, ICON_MD_LOCAL_FLORIST,
|
||||
// Food / Drink
|
||||
ICON_MD_LOCAL_CAFE, ICON_MD_RESTAURANT, ICON_MD_WINE_BAR, ICON_MD_LIQUOR,
|
||||
ICON_MD_OUTDOOR_GRILL, ICON_MD_NIGHTLIFE, ICON_MD_SPORTS_BAR,
|
||||
// Recreation / Health
|
||||
ICON_MD_PETS, ICON_MD_FITNESS_CENTER, ICON_MD_SPA, ICON_MD_SELF_IMPROVEMENT,
|
||||
ICON_MD_PSYCHOLOGY, ICON_MD_SPORTS_SOCCER, ICON_MD_SPORTS_ESPORTS,
|
||||
ICON_MD_HIKING, ICON_MD_PALETTE, ICON_MD_MUSEUM, ICON_MD_CHURCH, ICON_MD_SURFING,
|
||||
// Community
|
||||
ICON_MD_REDEEM, ICON_MD_HANDSHAKE, ICON_MD_HEALING, ICON_MD_VOLUNTEER_ACTIVISM,
|
||||
ICON_MD_STADIUM, ICON_MD_TEMPLE_BUDDHIST, ICON_MD_THEATER_COMEDY, ICON_MD_WATCH,
|
||||
};
|
||||
static constexpr int kIconCount = static_cast<int>(std::size(kIconNames));
|
||||
|
||||
public:
|
||||
// Expose for the address list to look up icon glyphs by name
|
||||
static const char* iconGlyphForName(const std::string& name) {
|
||||
for (int i = 0; i < kIconCount; ++i)
|
||||
if (kIconNames[i] == name) return kIconGlyphs[i];
|
||||
return nullptr;
|
||||
return material::project_icons::glyphForName(name);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
106
src/ui/windows/balance_address_list.cpp
Normal file
106
src/ui/windows/balance_address_list.cpp
Normal file
@@ -0,0 +1,106 @@
|
||||
#include "balance_address_list.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstdio>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
namespace {
|
||||
|
||||
bool containsIgnoreCase(const std::string& haystack, const std::string& needle)
|
||||
{
|
||||
if (needle.empty()) return true;
|
||||
auto it = std::search(haystack.begin(), haystack.end(),
|
||||
needle.begin(), needle.end(),
|
||||
[](char a, char b) {
|
||||
return std::tolower(static_cast<unsigned char>(a)) ==
|
||||
std::tolower(static_cast<unsigned char>(b));
|
||||
});
|
||||
return it != haystack.end();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter)
|
||||
{
|
||||
if (!input.info) return false;
|
||||
return containsIgnoreCase(input.info->address, filter) ||
|
||||
containsIgnoreCase(input.info->label, filter) ||
|
||||
containsIgnoreCase(input.label, filter);
|
||||
}
|
||||
|
||||
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
|
||||
const std::string& filter,
|
||||
bool hideZeroBalances,
|
||||
bool showHidden)
|
||||
{
|
||||
std::vector<AddressListRow> rows;
|
||||
rows.reserve(inputs.size());
|
||||
for (const auto& input : inputs) {
|
||||
if (!input.info) continue;
|
||||
if (!AddressListMatchesFilter(input, filter)) continue;
|
||||
if (input.hidden && !showHidden) continue;
|
||||
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
|
||||
rows.push_back({input.info, input.isZ, input.hidden, input.favorite,
|
||||
input.label, input.icon, input.sortOrder});
|
||||
}
|
||||
|
||||
std::sort(rows.begin(), rows.end(),
|
||||
[](const AddressListRow& a, const AddressListRow& b) {
|
||||
bool aHasOrder = a.sortOrder >= 0;
|
||||
bool bHasOrder = b.sortOrder >= 0;
|
||||
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
|
||||
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
|
||||
if (a.favorite != b.favorite) return a.favorite > b.favorite;
|
||||
if (a.isZ != b.isZ) return a.isZ > b.isZ;
|
||||
return a.info->balance > b.info->balance;
|
||||
});
|
||||
return rows;
|
||||
}
|
||||
|
||||
AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
float rowY,
|
||||
float rowWidth,
|
||||
float rowHeight,
|
||||
float rowPadLeft,
|
||||
float rowIconSize,
|
||||
float spacingMd,
|
||||
float spacingSm,
|
||||
float spacingXs)
|
||||
{
|
||||
AddressRowLayout layout;
|
||||
layout.contentStartX = rowX + rowPadLeft;
|
||||
layout.contentStartY = rowY + spacingMd;
|
||||
layout.buttonSize = rowHeight - spacingMd * 2.0f;
|
||||
|
||||
const float buttonY = rowY + (rowHeight - layout.buttonSize) * 0.5f;
|
||||
const float rightEdge = rowX + rowWidth;
|
||||
const float favoriteX = rightEdge - layout.buttonSize - spacingXs;
|
||||
const float visibilityX = favoriteX - spacingSm - layout.buttonSize;
|
||||
|
||||
layout.favoriteButton = {favoriteX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||
layout.visibilityButton = {visibilityX, buttonY, layout.buttonSize, layout.buttonSize};
|
||||
layout.contentRight = visibilityX - spacingSm;
|
||||
layout.labelX = layout.contentStartX + rowIconSize * 2.0f + spacingMd;
|
||||
return layout;
|
||||
}
|
||||
|
||||
std::string FormatAddressUsdValue(double balance, double priceUsd)
|
||||
{
|
||||
if (priceUsd <= 0.0 || balance <= 0.0) return {};
|
||||
|
||||
char buffer[32];
|
||||
const double usdValue = balance * priceUsd;
|
||||
if (usdValue >= 1.0) {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.2f", usdValue);
|
||||
} else if (usdValue >= 0.01) {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.4f", usdValue);
|
||||
} else {
|
||||
std::snprintf(buffer, sizeof(buffer), "$%.6f", usdValue);
|
||||
}
|
||||
return buffer;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
65
src/ui/windows/balance_address_list.h
Normal file
65
src/ui/windows/balance_address_list.h
Normal file
@@ -0,0 +1,65 @@
|
||||
#pragma once
|
||||
|
||||
#include "data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct AddressListInput {
|
||||
const AddressInfo* info = nullptr;
|
||||
bool isZ = false;
|
||||
bool hidden = false;
|
||||
bool favorite = false;
|
||||
std::string label;
|
||||
std::string icon;
|
||||
int sortOrder = -1;
|
||||
};
|
||||
|
||||
struct AddressListRow {
|
||||
const AddressInfo* info = nullptr;
|
||||
bool isZ = false;
|
||||
bool hidden = false;
|
||||
bool favorite = false;
|
||||
std::string label;
|
||||
std::string icon;
|
||||
int sortOrder = -1;
|
||||
};
|
||||
|
||||
struct AddressRowRect {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float width = 0.0f;
|
||||
float height = 0.0f;
|
||||
};
|
||||
|
||||
struct AddressRowLayout {
|
||||
float contentStartX = 0.0f;
|
||||
float contentStartY = 0.0f;
|
||||
float labelX = 0.0f;
|
||||
float contentRight = 0.0f;
|
||||
float buttonSize = 0.0f;
|
||||
AddressRowRect favoriteButton;
|
||||
AddressRowRect visibilityButton;
|
||||
};
|
||||
|
||||
bool AddressListMatchesFilter(const AddressListInput& input, const std::string& filter);
|
||||
std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListInput>& inputs,
|
||||
const std::string& filter,
|
||||
bool hideZeroBalances,
|
||||
bool showHidden);
|
||||
AddressRowLayout ComputeAddressRowLayout(float rowX,
|
||||
float rowY,
|
||||
float rowWidth,
|
||||
float rowHeight,
|
||||
float rowPadLeft,
|
||||
float rowIconSize,
|
||||
float spacingMd,
|
||||
float spacingSm,
|
||||
float spacingXs);
|
||||
std::string FormatAddressUsdValue(double balance, double priceUsd);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
52
src/ui/windows/balance_recent_tx.cpp
Normal file
52
src/ui/windows/balance_recent_tx.cpp
Normal file
@@ -0,0 +1,52 @@
|
||||
#include "balance_recent_tx.h"
|
||||
#include "../../config/version.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
std::string truncateRecentAddress(const std::string& address, int maxLen)
|
||||
{
|
||||
if (address.length() <= static_cast<size_t>(maxLen)) return address;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return address.substr(0, half) + "..." + address.substr(address.length() - half);
|
||||
}
|
||||
|
||||
std::string formatRecentAmount(const std::string& type, double amount)
|
||||
{
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
|
||||
type == "send" ? "-" : "+",
|
||||
std::abs(amount), DRAGONX_TICKER);
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
std::string recentTimeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
}
|
||||
|
||||
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen)
|
||||
{
|
||||
return {
|
||||
tx.getTypeDisplay(),
|
||||
truncateRecentAddress(tx.address, addressMaxLen),
|
||||
formatRecentAmount(tx.type, tx.amount),
|
||||
recentTimeAgo(tx.timestamp)
|
||||
};
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
20
src/ui/windows/balance_recent_tx.h
Normal file
20
src/ui/windows/balance_recent_tx.h
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct RecentTxDisplay {
|
||||
std::string typeText;
|
||||
std::string addressText;
|
||||
std::string amountText;
|
||||
std::string timeText;
|
||||
};
|
||||
|
||||
RecentTxDisplay buildRecentTxDisplay(const TransactionInfo& tx, int addressMaxLen);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -3,6 +3,9 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "balance_tab.h"
|
||||
#include "balance_address_list.h"
|
||||
#include "balance_tab_helpers.h"
|
||||
#include "balance_recent_tx.h"
|
||||
#include "key_export_dialog.h"
|
||||
#include "qr_popup_dialog.h"
|
||||
#include "address_label_dialog.h"
|
||||
@@ -32,92 +35,12 @@
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
// Helper: build "TranslatedLabel##id" for ImGui widgets that use label as ID
|
||||
static std::string TrId(const char* tr_key, const char* id) {
|
||||
std::string s = TR(tr_key);
|
||||
s += "##";
|
||||
s += id;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Case-insensitive substring search
|
||||
static bool containsIgnoreCase(const std::string& str, const std::string& search) {
|
||||
if (search.empty()) return true;
|
||||
std::string s = str, q = search;
|
||||
std::transform(s.begin(), s.end(), s.begin(), ::tolower);
|
||||
std::transform(q.begin(), q.end(), q.begin(), ::tolower);
|
||||
return s.find(q) != std::string::npos;
|
||||
}
|
||||
|
||||
// Relative time string ("2m ago", "3h ago", etc.)
|
||||
static std::string timeAgo(int64_t timestamp) {
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = (int64_t)std::time(nullptr);
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
|
||||
// Draw a small transaction-type icon (send=up, receive=down, mined=construction)
|
||||
static void DrawTxIcon(ImDrawList* dl, const std::string& type,
|
||||
float cx, float cy, float /*s*/, ImU32 col)
|
||||
{
|
||||
using namespace material;
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* icon;
|
||||
if (type == "send") {
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
} else {
|
||||
icon = ICON_MD_CONSTRUCTION;
|
||||
}
|
||||
ImVec2 sz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(cx - sz.x * 0.5f, cy - sz.y * 0.5f), col, icon);
|
||||
}
|
||||
|
||||
// Animated balance state — lerps smoothly toward target
|
||||
static double s_dispTotal = 0.0;
|
||||
static double s_dispShielded = 0.0;
|
||||
static double s_dispTransparent = 0.0;
|
||||
static double s_dispUnconfirmed = 0.0;
|
||||
|
||||
// Helper to truncate address for display
|
||||
static std::string truncateAddress(const std::string& addr, int maxLen = 32) {
|
||||
if (addr.length() <= static_cast<size_t>(maxLen)) return addr;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return addr.substr(0, half) + "..." + addr.substr(addr.length() - half);
|
||||
}
|
||||
|
||||
// Helper to draw a sparkline polyline within a bounding box
|
||||
static void DrawSparkline(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax,
|
||||
const std::vector<double>& data, ImU32 color,
|
||||
float thickness = 1.5f)
|
||||
{
|
||||
if (data.size() < 2) return;
|
||||
double lo = *std::min_element(data.begin(), data.end());
|
||||
double hi = *std::max_element(data.begin(), data.end());
|
||||
double range = hi - lo;
|
||||
if (range < 1e-12) range = 1.0;
|
||||
|
||||
float w = pMax.x - pMin.x;
|
||||
float h = pMax.y - pMin.y;
|
||||
int n = (int)data.size();
|
||||
|
||||
std::vector<ImVec2> pts;
|
||||
pts.reserve(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
float x = pMin.x + (float)i / (float)(n - 1) * w;
|
||||
float y = pMax.y - (float)((data[i] - lo) / range) * h;
|
||||
pts.push_back(ImVec2(x, y));
|
||||
}
|
||||
dl->AddPolyline(pts.data(), n, color, ImDrawFlags_None, thickness);
|
||||
}
|
||||
|
||||
// Forward declarations for all layout functions
|
||||
static void RenderBalanceClassic(App* app);
|
||||
static void RenderBalanceDonut(App* app);
|
||||
@@ -1207,51 +1130,37 @@ static void RenderBalanceClassic(App* app)
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
|
||||
// Icon
|
||||
ImU32 iconCol;
|
||||
if (tx.type == "send")
|
||||
iconCol = Error();
|
||||
else if (tx.type == "receive")
|
||||
iconCol = Success();
|
||||
else
|
||||
iconCol = Warning();
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
|
||||
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
|
||||
|
||||
// Type label
|
||||
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp),
|
||||
OnSurfaceMedium(), tx.getTypeDisplay().c_str());
|
||||
OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
// Address (truncated)
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), trAddr.c_str());
|
||||
OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
// Amount (right-aligned area)
|
||||
char amtBuf[32];
|
||||
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
|
||||
tx.type == "send" ? "-" : "+",
|
||||
std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(
|
||||
capFont->LegacySize, 10000, 0, amtBuf);
|
||||
capFont->LegacySize, 10000, 0, display.amountText.c_str());
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(S.drawElement("tabs.balance", "amount-right-min-margin").size, S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2 * dp),
|
||||
tx.type == "send" ? Error()
|
||||
: Success(),
|
||||
amtBuf);
|
||||
recentTxAmountColor(tx.type),
|
||||
display.amountText.c_str());
|
||||
|
||||
// Time ago
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(
|
||||
capFont->LegacySize, 10000, 0, ago.c_str());
|
||||
capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
// Clickable row — hover highlight + navigate to History
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
@@ -1380,45 +1289,22 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
static float s_copiedTime = 0.0f;
|
||||
|
||||
// ---- Build and filter address rows ----
|
||||
struct AddrRow {
|
||||
const AddressInfo* info;
|
||||
bool isZ, hidden, favorite;
|
||||
std::string label, icon;
|
||||
int sortOrder;
|
||||
};
|
||||
std::vector<AddrRow> rows;
|
||||
rows.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
std::vector<AddressListInput> rowInputs;
|
||||
rowInputs.reserve(state.z_addresses.size() + state.t_addresses.size());
|
||||
|
||||
auto addRows = [&](const auto& addrs, bool isZ) {
|
||||
for (const auto& a : addrs) {
|
||||
std::string filter(addr_search);
|
||||
std::string addrLabel = app->getAddressLabel(a.address);
|
||||
if (!containsIgnoreCase(a.address, filter) &&
|
||||
!containsIgnoreCase(a.label, filter) &&
|
||||
!containsIgnoreCase(addrLabel, filter)) continue;
|
||||
bool isHidden = app->isAddressHidden(a.address);
|
||||
if (isHidden && !s_showHidden) continue;
|
||||
bool isFav = app->isAddressFavorite(a.address);
|
||||
if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue;
|
||||
rows.push_back({&a, isZ, isHidden, isFav,
|
||||
addrLabel, app->getAddressIcon(a.address),
|
||||
app->getAddressSortOrder(a.address)});
|
||||
rowInputs.push_back({&a, isZ, isHidden, isFav,
|
||||
addrLabel, app->getAddressIcon(a.address),
|
||||
app->getAddressSortOrder(a.address)});
|
||||
}
|
||||
};
|
||||
addRows(state.z_addresses, true);
|
||||
addRows(state.t_addresses, false);
|
||||
|
||||
// Sort: custom order (if any) → favorites → Z first → balance desc
|
||||
std::sort(rows.begin(), rows.end(),
|
||||
[](const AddrRow& a, const AddrRow& b) -> bool {
|
||||
bool aHasOrder = a.sortOrder >= 0;
|
||||
bool bHasOrder = b.sortOrder >= 0;
|
||||
if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder;
|
||||
if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder;
|
||||
if (a.favorite != b.favorite) return a.favorite > b.favorite;
|
||||
if (a.isZ != b.isZ) return a.isZ > b.isZ;
|
||||
return a.info->balance > b.info->balance;
|
||||
});
|
||||
auto rows = BuildAddressListRows(rowInputs, addr_search, s_hideZeroBalances, s_showHidden);
|
||||
|
||||
// ---- Toolbar: search + checkboxes + create buttons ----
|
||||
float avail = ImGui::GetContentRegionAvail().x;
|
||||
@@ -1677,24 +1563,21 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
}
|
||||
|
||||
float cx = rowPos.x + rowPadLeft;
|
||||
float cy = rowPos.y + Layout::spacingMd();
|
||||
auto rowLayout = ComputeAddressRowLayout(
|
||||
rowPos.x, rowPos.y, innerW, rowH, rowPadLeft, rowIconSz,
|
||||
Layout::spacingMd(), Layout::spacingSm(), Layout::spacingXs());
|
||||
float cx = rowLayout.contentStartX;
|
||||
float cy = rowLayout.contentStartY;
|
||||
|
||||
// ---- Button zone (right edge): [eye] [star] ----
|
||||
float btnH = rowH - Layout::spacingMd() * 2.0f;
|
||||
float btnW = btnH;
|
||||
float btnGap = Layout::spacingSm();
|
||||
float btnY = rowPos.y + (rowH - btnH) * 0.5f;
|
||||
float rightEdge = rowPos.x + innerW;
|
||||
float starX = rightEdge - btnW - Layout::spacingXs();
|
||||
float eyeX = starX - btnGap - btnW;
|
||||
float btnRound = 6.0f * dp;
|
||||
bool btnClicked = false;
|
||||
|
||||
if (!isDragged) {
|
||||
// Star button
|
||||
{
|
||||
ImVec2 bMin(starX, btnY), bMax(starX + btnW, btnY + btnH);
|
||||
const auto& starRect = rowLayout.favoriteButton;
|
||||
ImVec2 bMin(starRect.x, starRect.y), bMax(starRect.x + starRect.width, starRect.y + starRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound);
|
||||
dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp);
|
||||
@@ -1703,7 +1586,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon);
|
||||
ImU32 starCol = row.favorite ? favGoldIcon : (bHov ? OnSurface() : OnSurfaceDisabled());
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(starX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), starCol, starIcon);
|
||||
ImVec2(starRect.x + (starRect.width - iSz.x) * 0.5f,
|
||||
starRect.y + (starRect.height - iSz.y) * 0.5f), starCol, starIcon);
|
||||
if (bHov && mouseClicked) {
|
||||
if (row.favorite) app->unfavoriteAddress(addr.address);
|
||||
else app->favoriteAddress(addr.address);
|
||||
@@ -1715,10 +1599,11 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
// Eye button (zero balance or hidden)
|
||||
bool showEye = true;
|
||||
// Always reserve space for both buttons so content doesn't shift
|
||||
float contentRight = eyeX - Layout::spacingSm();
|
||||
float contentRight = rowLayout.contentRight;
|
||||
|
||||
if (showEye) {
|
||||
ImVec2 bMin(eyeX, btnY), bMax(eyeX + btnW, btnY + btnH);
|
||||
const auto& eyeRect = rowLayout.visibilityButton;
|
||||
ImVec2 bMin(eyeRect.x, eyeRect.y), bMax(eyeRect.x + eyeRect.width, eyeRect.y + eyeRect.height);
|
||||
bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax);
|
||||
dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound);
|
||||
dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp);
|
||||
@@ -1727,7 +1612,8 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon);
|
||||
ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled();
|
||||
dl->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(eyeX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), iconCol, hideIcon);
|
||||
ImVec2(eyeRect.x + (eyeRect.width - iSz.x) * 0.5f,
|
||||
eyeRect.y + (eyeRect.height - iSz.y) * 0.5f), iconCol, hideIcon);
|
||||
if (bHov && mouseClicked) {
|
||||
if (row.hidden) app->unhideAddress(addr.address);
|
||||
else app->hideAddress(addr.address);
|
||||
@@ -1756,7 +1642,7 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
}
|
||||
|
||||
// ---- Type label (first line) ----
|
||||
float labelX = cx + rowIconSz * 2.0f + Layout::spacingMd();
|
||||
float labelX = rowLayout.labelX;
|
||||
{
|
||||
const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent");
|
||||
const char* hiddenTag = row.hidden ? TR("hidden_tag") : "";
|
||||
@@ -1805,18 +1691,13 @@ static void RenderSharedAddressList(App* app, float listH, float availW,
|
||||
|
||||
// ---- USD value (second line, right-aligned) ----
|
||||
{
|
||||
double priceUsd = state.market.price_usd;
|
||||
if (priceUsd > 0.0 && addr.balance > 0.0) {
|
||||
char usdBuf[32];
|
||||
double usdVal = addr.balance * priceUsd;
|
||||
if (usdVal >= 1.0) snprintf(usdBuf, sizeof(usdBuf), "$%.2f", usdVal);
|
||||
else if (usdVal >= 0.01) snprintf(usdBuf, sizeof(usdBuf), "$%.4f", usdVal);
|
||||
else snprintf(usdBuf, sizeof(usdBuf), "$%.6f", usdVal);
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdBuf);
|
||||
std::string usdText = FormatAddressUsdValue(addr.balance, state.market.price_usd);
|
||||
if (!usdText.empty()) {
|
||||
ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(contentRight - usdSz.x,
|
||||
cy + body2->LegacySize + Layout::spacingXs()),
|
||||
OnSurfaceDisabled(), usdBuf);
|
||||
OnSurfaceDisabled(), usdText.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2006,39 +1887,30 @@ static void RenderSharedRecentTx(App* app, float recentH, float availW, float hs
|
||||
const auto& tx = txs[i];
|
||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||
float rowY = rowPos.y + rowH * 0.5f;
|
||||
ImU32 iconCol;
|
||||
if (tx.type == "send") iconCol = Error();
|
||||
else if (tx.type == "receive") iconCol = Success();
|
||||
else iconCol = Warning();
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, iconCol);
|
||||
auto display = buildRecentTxDisplay(tx, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
DrawTxIcon(dl, tx.type, rowPos.x + Layout::spacingMd(), rowY, iconSz, recentTxIconColor(tx.type));
|
||||
|
||||
float tx_x = rowPos.x + Layout::spacingMd() + iconSz * 2.0f + Layout::spacingSm();
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), tx.getTypeDisplay().c_str());
|
||||
ImVec2(tx_x, rowPos.y + 2 * dp), OnSurfaceMedium(), display.typeText.c_str());
|
||||
|
||||
float addrX = tx_x + S.drawElement("tabs.balance", "recent-tx-addr-offset").sizeOr(65.0f);
|
||||
std::string trAddr = truncateAddress(tx.address, (int)S.drawElement("tabs.balance", "recent-tx-addr-trunc").sizeOr(20.0f));
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), trAddr.c_str());
|
||||
ImVec2(addrX, rowPos.y + 2 * dp), OnSurfaceDisabled(), display.addressText.c_str());
|
||||
|
||||
char amtBuf[32];
|
||||
snprintf(amtBuf, sizeof(amtBuf), "%s%.4f %s",
|
||||
tx.type == "send" ? "-" : "+",
|
||||
std::abs(tx.amount), DRAGONX_TICKER);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, amtBuf);
|
||||
ImVec2 amtSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.amountText.c_str());
|
||||
float rightEdge = rowPos.x + ImGui::GetContentRegionAvail().x;
|
||||
float amtX = rightEdge - amtSz.x - std::max(
|
||||
S.drawElement("tabs.balance", "amount-right-min-margin").size,
|
||||
S.drawElement("tabs.balance", "amount-right-margin").size * hs);
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(amtX, rowPos.y + 2 * dp),
|
||||
tx.type == "send" ? Error() : Success(), amtBuf);
|
||||
recentTxAmountColor(tx.type), display.amountText.c_str());
|
||||
|
||||
std::string ago = timeAgo(tx.timestamp);
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, ago.c_str());
|
||||
ImVec2 agoSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, display.timeText.c_str());
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(rightEdge - agoSz.x - S.drawElement("tabs.balance", "recent-tx-time-margin").sizeOr(4.0f), rowPos.y + 2 * dp),
|
||||
OnSurfaceDisabled(), ago.c_str());
|
||||
OnSurfaceDisabled(), display.timeText.c_str());
|
||||
|
||||
float rowW = ImGui::GetContentRegionAvail().x;
|
||||
ImVec2 rowEnd(rowPos.x + rowW, rowPos.y + rowH);
|
||||
|
||||
117
src/ui/windows/balance_tab_helpers.cpp
Normal file
117
src/ui/windows/balance_tab_helpers.cpp
Normal file
@@ -0,0 +1,117 @@
|
||||
#include "balance_tab_helpers.h"
|
||||
|
||||
#include "../../config/version.h"
|
||||
#include "../../embedded/IconsMaterialDesign.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
std::string TrId(const char* trKey, const char* id)
|
||||
{
|
||||
std::string value = TR(trKey);
|
||||
value += "##";
|
||||
value += id;
|
||||
return value;
|
||||
}
|
||||
|
||||
bool containsIgnoreCase(const std::string& value, const std::string& search)
|
||||
{
|
||||
if (search.empty()) return true;
|
||||
std::string haystack = value;
|
||||
std::string needle = search;
|
||||
std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::tolower);
|
||||
std::transform(needle.begin(), needle.end(), needle.begin(), ::tolower);
|
||||
return haystack.find(needle) != std::string::npos;
|
||||
}
|
||||
|
||||
std::string timeAgo(int64_t timestamp)
|
||||
{
|
||||
if (timestamp <= 0) return "";
|
||||
int64_t now = static_cast<int64_t>(std::time(nullptr));
|
||||
int64_t diff = now - timestamp;
|
||||
if (diff < 0) diff = 0;
|
||||
if (diff < 60) return std::to_string(diff) + "s ago";
|
||||
if (diff < 3600) return std::to_string(diff / 60) + "m ago";
|
||||
if (diff < 86400) return std::to_string(diff / 3600) + "h ago";
|
||||
return std::to_string(diff / 86400) + "d ago";
|
||||
}
|
||||
|
||||
std::string truncateAddress(const std::string& address, int maxLen)
|
||||
{
|
||||
if (address.length() <= static_cast<size_t>(maxLen)) return address;
|
||||
int half = (maxLen - 3) / 2;
|
||||
return address.substr(0, half) + "..." + address.substr(address.length() - half);
|
||||
}
|
||||
|
||||
ImU32 recentTxIconColor(const std::string& type)
|
||||
{
|
||||
using namespace material;
|
||||
if (type == "send") return Error();
|
||||
if (type == "receive") return Success();
|
||||
return Warning();
|
||||
}
|
||||
|
||||
ImU32 recentTxAmountColor(const std::string& type)
|
||||
{
|
||||
using namespace material;
|
||||
return type == "send" ? Error() : Success();
|
||||
}
|
||||
|
||||
std::string formatRecentTxAmount(const std::string& type, double amount)
|
||||
{
|
||||
char buffer[32];
|
||||
snprintf(buffer, sizeof(buffer), "%s%.4f %s",
|
||||
type == "send" ? "-" : "+",
|
||||
std::abs(amount), DRAGONX_TICKER);
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
|
||||
float centerX, float centerY, float, ImU32 color)
|
||||
{
|
||||
using namespace material;
|
||||
ImFont* iconFont = Type().iconSmall();
|
||||
const char* icon = ICON_MD_CONSTRUCTION;
|
||||
if (type == "send") {
|
||||
icon = ICON_MD_CALL_MADE;
|
||||
} else if (type == "receive") {
|
||||
icon = ICON_MD_CALL_RECEIVED;
|
||||
}
|
||||
ImVec2 size = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
|
||||
drawList->AddText(iconFont, iconFont->LegacySize,
|
||||
ImVec2(centerX - size.x * 0.5f, centerY - size.y * 0.5f), color, icon);
|
||||
}
|
||||
|
||||
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
|
||||
const std::vector<double>& data, ImU32 color, float thickness)
|
||||
{
|
||||
if (data.size() < 2) return;
|
||||
double low = *std::min_element(data.begin(), data.end());
|
||||
double high = *std::max_element(data.begin(), data.end());
|
||||
double range = high - low;
|
||||
if (range < 1e-12) range = 1.0;
|
||||
|
||||
float width = max.x - min.x;
|
||||
float height = max.y - min.y;
|
||||
int count = static_cast<int>(data.size());
|
||||
|
||||
std::vector<ImVec2> points;
|
||||
points.reserve(count);
|
||||
for (int i = 0; i < count; i++) {
|
||||
float x = min.x + static_cast<float>(i) / static_cast<float>(count - 1) * width;
|
||||
float y = max.y - static_cast<float>((data[i] - low) / range) * height;
|
||||
points.push_back(ImVec2(x, y));
|
||||
}
|
||||
drawList->AddPolyline(points.data(), count, color, ImDrawFlags_None, thickness);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
27
src/ui/windows/balance_tab_helpers.h
Normal file
27
src/ui/windows/balance_tab_helpers.h
Normal file
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
std::string TrId(const char* trKey, const char* id);
|
||||
bool containsIgnoreCase(const std::string& value, const std::string& search);
|
||||
std::string timeAgo(int64_t timestamp);
|
||||
std::string truncateAddress(const std::string& address, int maxLen = 32);
|
||||
ImU32 recentTxIconColor(const std::string& type);
|
||||
ImU32 recentTxAmountColor(const std::string& type);
|
||||
std::string formatRecentTxAmount(const std::string& type, double amount);
|
||||
|
||||
void DrawTxIcon(ImDrawList* drawList, const std::string& type,
|
||||
float centerX, float centerY, float size, ImU32 color);
|
||||
void DrawSparkline(ImDrawList* drawList, const ImVec2& min, const ImVec2& max,
|
||||
const std::vector<double>& data, ImU32 color,
|
||||
float thickness = 1.5f);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
136
src/ui/windows/console_command_reference.cpp
Normal file
136
src/ui/windows/console_command_reference.cpp
Normal file
@@ -0,0 +1,136 @@
|
||||
#include "console_command_reference.h"
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
|
||||
template <size_t N>
|
||||
constexpr int CountOf(const ConsoleCommandEntry (&)[N])
|
||||
{
|
||||
return static_cast<int>(N);
|
||||
}
|
||||
|
||||
const ConsoleCommandEntry kControlCommands[] = {
|
||||
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
|
||||
{"getinfo", "Get general info about the node", ""},
|
||||
{"stop", "Stop the daemon", ""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kNetworkCommands[] = {
|
||||
{"getnetworkinfo", "Return P2P network state info", ""},
|
||||
{"getpeerinfo", "Get data about each connected peer", ""},
|
||||
{"getconnectioncount", "Get number of peer connections", ""},
|
||||
{"getnettotals", "Get network traffic statistics", ""},
|
||||
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
|
||||
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
|
||||
{"listbanned", "List all banned IPs/subnets", ""},
|
||||
{"clearbanned", "Clear all banned IPs", ""},
|
||||
{"ping", "Ping all peers to measure round-trip time", ""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kBlockchainCommands[] = {
|
||||
{"getblockchaininfo", "Get current blockchain state", ""},
|
||||
{"getblockcount", "Get number of blocks in longest chain", ""},
|
||||
{"getbestblockhash", "Get hash of the tip block", ""},
|
||||
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
|
||||
{"getblockhash", "Get block hash at a given height", "height"},
|
||||
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
|
||||
{"getdifficulty", "Get proof-of-work difficulty", ""},
|
||||
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
|
||||
{"getmempoolinfo", "Get mempool state info", ""},
|
||||
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
|
||||
{"coinsupply", "Get coin supply information", "[height]"},
|
||||
{"getchaintips", "Get all known chain tips", ""},
|
||||
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
|
||||
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
|
||||
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
|
||||
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kMiningCommands[] = {
|
||||
{"getmininginfo", "Get mining-related information", ""},
|
||||
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
|
||||
{"getgenerate", "Check if the node is mining", ""},
|
||||
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
|
||||
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
|
||||
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
|
||||
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kWalletCommands[] = {
|
||||
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
|
||||
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
|
||||
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
|
||||
{"getnewaddress", "Generate a new transparent address", ""},
|
||||
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
|
||||
{"listaddresses", "List all transparent addresses", ""},
|
||||
{"z_listaddresses", "List all z-addresses", ""},
|
||||
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
|
||||
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
|
||||
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
|
||||
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
|
||||
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
|
||||
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
|
||||
{"z_listoperationids", "List all async z operation IDs", ""},
|
||||
{"getwalletinfo", "Get wallet state info", ""},
|
||||
{"backupwallet", "Back up wallet to a file", "\"destination\""},
|
||||
{"dumpprivkey", "Dump private key for an address", "\"address\""},
|
||||
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
|
||||
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
|
||||
{"importwallet", "Import wallet from a dump file", "\"filename\""},
|
||||
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
|
||||
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
|
||||
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
|
||||
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
|
||||
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
|
||||
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
|
||||
{"settxfee", "Set the transaction fee per kB", "amount"},
|
||||
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
|
||||
{"walletlock", "Lock the wallet", ""},
|
||||
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kRawTransactionCommands[] = {
|
||||
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
|
||||
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
|
||||
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
|
||||
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
|
||||
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
|
||||
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
|
||||
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
|
||||
};
|
||||
|
||||
const ConsoleCommandEntry kUtilityCommands[] = {
|
||||
{"validateaddress", "Validate a transparent address", "\"address\""},
|
||||
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
|
||||
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
|
||||
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
|
||||
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
|
||||
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
|
||||
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories()
|
||||
{
|
||||
static const std::vector<ConsoleCommandCategory> categories = {
|
||||
{"Control", kControlCommands, CountOf(kControlCommands)},
|
||||
{"Network", kNetworkCommands, CountOf(kNetworkCommands)},
|
||||
{"Blockchain", kBlockchainCommands, CountOf(kBlockchainCommands)},
|
||||
{"Mining", kMiningCommands, CountOf(kMiningCommands)},
|
||||
{"Wallet", kWalletCommands, CountOf(kWalletCommands)},
|
||||
{"Raw Transactions", kRawTransactionCommands, CountOf(kRawTransactionCommands)},
|
||||
{"Utility", kUtilityCommands, CountOf(kUtilityCommands)},
|
||||
};
|
||||
return categories;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
23
src/ui/windows/console_command_reference.h
Normal file
23
src/ui/windows/console_command_reference.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleCommandEntry {
|
||||
const char* name;
|
||||
const char* desc;
|
||||
const char* params;
|
||||
};
|
||||
|
||||
struct ConsoleCommandCategory {
|
||||
const char* name;
|
||||
const ConsoleCommandEntry* commands;
|
||||
int count;
|
||||
};
|
||||
|
||||
const std::vector<ConsoleCommandCategory>& consoleCommandCategories();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
240
src/ui/windows/console_input_model.cpp
Normal file
240
src/ui/windows/console_input_model.cpp
Normal file
@@ -0,0 +1,240 @@
|
||||
#include "console_input_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <sstream>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
const std::vector<std::string>& ConsoleRpcCommandNames()
|
||||
{
|
||||
static const std::vector<std::string> commands = {
|
||||
"help", "getinfo", "stop",
|
||||
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
|
||||
"addnode", "setban", "listbanned", "clearbanned", "ping",
|
||||
"getblockchaininfo", "getblockcount", "getbestblockhash",
|
||||
"getblock", "getblockhash", "getblockheader", "getdifficulty",
|
||||
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
|
||||
"getmininginfo", "setgenerate", "getgenerate",
|
||||
"getnetworkhashps", "getblocksubsidy",
|
||||
"getbalance", "z_gettotalbalance", "z_getbalances",
|
||||
"getnewaddress", "z_getnewaddress",
|
||||
"listaddresses", "z_listaddresses",
|
||||
"sendtoaddress", "z_sendmany",
|
||||
"listtransactions", "listunspent", "z_listunspent",
|
||||
"z_getoperationstatus", "z_getoperationresult",
|
||||
"getwalletinfo", "backupwallet",
|
||||
"dumpprivkey", "importprivkey",
|
||||
"z_exportkey", "z_importkey",
|
||||
"signmessage", "settxfee",
|
||||
"createrawtransaction", "decoderawtransaction",
|
||||
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
|
||||
"validateaddress", "z_validateaddress", "estimatefee",
|
||||
"clear"
|
||||
};
|
||||
return commands;
|
||||
}
|
||||
|
||||
void AppendConsoleHistory(std::vector<std::string>& history,
|
||||
const std::string& command,
|
||||
std::size_t maxEntries)
|
||||
{
|
||||
if (command.empty()) return;
|
||||
if (history.empty() || history.back() != command) {
|
||||
history.push_back(command);
|
||||
while (history.size() > maxEntries) {
|
||||
history.erase(history.begin());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int NavigateConsoleHistoryIndex(int currentIndex, std::size_t historySize, bool up)
|
||||
{
|
||||
if (historySize == 0) return -1;
|
||||
if (up) {
|
||||
if (currentIndex < 0) return static_cast<int>(historySize) - 1;
|
||||
if (currentIndex > 0) return currentIndex - 1;
|
||||
return currentIndex;
|
||||
}
|
||||
if (currentIndex >= 0) {
|
||||
int next = currentIndex + 1;
|
||||
if (next >= static_cast<int>(historySize)) return -1;
|
||||
return next;
|
||||
}
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
std::string ConsoleHistoryEntry(const std::vector<std::string>& history, int historyIndex)
|
||||
{
|
||||
if (historyIndex < 0 || historyIndex >= static_cast<int>(history.size())) return {};
|
||||
return history[static_cast<std::size_t>(historyIndex)];
|
||||
}
|
||||
|
||||
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input)
|
||||
{
|
||||
ConsoleCompletionResult result;
|
||||
if (input.empty()) return result;
|
||||
|
||||
for (const auto& command : ConsoleRpcCommandNames()) {
|
||||
if (command.compare(0, input.size(), input) == 0) {
|
||||
result.matches.push_back(command);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.matches.empty()) return result;
|
||||
result.commonPrefix = result.matches.front();
|
||||
for (std::size_t matchIndex = 1; matchIndex < result.matches.size(); ++matchIndex) {
|
||||
const auto& match = result.matches[matchIndex];
|
||||
std::size_t prefixLength = 0;
|
||||
while (prefixLength < result.commonPrefix.size() &&
|
||||
prefixLength < match.size() &&
|
||||
result.commonPrefix[prefixLength] == match[prefixLength]) {
|
||||
++prefixLength;
|
||||
}
|
||||
result.commonPrefix.resize(prefixLength);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
||||
std::size_t maxLineLength)
|
||||
{
|
||||
std::vector<std::string> lines;
|
||||
std::string line = " ";
|
||||
for (std::size_t matchIndex = 0; matchIndex < matches.size(); ++matchIndex) {
|
||||
if (matchIndex > 0) line += " ";
|
||||
line += matches[matchIndex];
|
||||
if (line.length() > maxLineLength) {
|
||||
lines.push_back(line);
|
||||
line = " ";
|
||||
}
|
||||
}
|
||||
if (line.length() > 2) {
|
||||
lines.push_back(line);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command)
|
||||
{
|
||||
std::vector<std::string> args;
|
||||
std::size_t index = 0;
|
||||
while (index < command.size()) {
|
||||
while (index < command.size() && (command[index] == ' ' || command[index] == '\t')) {
|
||||
++index;
|
||||
}
|
||||
if (index >= command.size()) break;
|
||||
|
||||
std::string token;
|
||||
if (command[index] == '"' || command[index] == '\'') {
|
||||
char quote = command[index++];
|
||||
while (index < command.size() && command[index] != quote) {
|
||||
token += command[index++];
|
||||
}
|
||||
if (index < command.size()) ++index;
|
||||
} else if (command[index] == '[' || command[index] == '{') {
|
||||
char open = command[index];
|
||||
char close = (open == '[') ? ']' : '}';
|
||||
int depth = 0;
|
||||
while (index < command.size()) {
|
||||
if (command[index] == open) ++depth;
|
||||
else if (command[index] == close) --depth;
|
||||
token += command[index++];
|
||||
if (depth == 0) break;
|
||||
}
|
||||
} else {
|
||||
while (index < command.size() && command[index] != ' ' && command[index] != '\t') {
|
||||
token += command[index++];
|
||||
}
|
||||
}
|
||||
if (!token.empty()) args.push_back(token);
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command)
|
||||
{
|
||||
auto args = ParseConsoleCommandArgs(command);
|
||||
ConsoleRpcCall call;
|
||||
if (args.empty()) return call;
|
||||
|
||||
call.valid = true;
|
||||
call.method = args.front();
|
||||
|
||||
for (std::size_t argIndex = 1; argIndex < args.size(); ++argIndex) {
|
||||
const std::string& arg = args[argIndex];
|
||||
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
||||
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
||||
if (!parsed.is_discarded()) {
|
||||
call.params.push_back(parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (arg == "true") {
|
||||
call.params.push_back(true);
|
||||
} else if (arg == "false") {
|
||||
call.params.push_back(false);
|
||||
} else {
|
||||
try {
|
||||
if (arg.find('.') != std::string::npos) {
|
||||
call.params.push_back(std::stod(arg));
|
||||
} else {
|
||||
call.params.push_back(std::stoll(arg));
|
||||
}
|
||||
} catch (...) {
|
||||
call.params.push_back(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError)
|
||||
{
|
||||
if (isError) {
|
||||
return {{"Error: " + result, ConsoleResultLineRole::Error}};
|
||||
}
|
||||
|
||||
std::vector<ConsoleResultLine> lines;
|
||||
std::string normalized = (result == "null") ? "(no result)" : result;
|
||||
bool isJson = !normalized.empty() && (normalized[0] == '{' || normalized[0] == '[');
|
||||
std::istringstream stream(normalized);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
||||
if (isJson && !line.empty()) {
|
||||
std::string trimmed = line;
|
||||
std::size_t first = trimmed.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
||||
if (!trimmed.empty()) {
|
||||
unsigned char firstChar = static_cast<unsigned char>(trimmed[0]);
|
||||
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
||||
trimmed[0] == '[' || trimmed[0] == ']') {
|
||||
role = ConsoleResultLineRole::JsonBrace;
|
||||
} else if (trimmed[0] == '"') {
|
||||
if (trimmed.find("\": ") != std::string::npos ||
|
||||
trimmed.find("\":") != std::string::npos) {
|
||||
role = ConsoleResultLineRole::JsonKey;
|
||||
} else {
|
||||
role = ConsoleResultLineRole::JsonString;
|
||||
}
|
||||
} else if (std::isdigit(firstChar) || trimmed[0] == '-') {
|
||||
role = ConsoleResultLineRole::JsonNumber;
|
||||
} else if (trimmed == "true," || trimmed == "false," ||
|
||||
trimmed == "true" || trimmed == "false" ||
|
||||
trimmed == "null," || trimmed == "null") {
|
||||
role = ConsoleResultLineRole::JsonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
lines.push_back({line, role});
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
54
src/ui/windows/console_input_model.h
Normal file
54
src/ui/windows/console_input_model.h
Normal file
@@ -0,0 +1,54 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <nlohmann/json.hpp>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleCompletionResult {
|
||||
std::vector<std::string> matches;
|
||||
std::string commonPrefix;
|
||||
};
|
||||
|
||||
struct ConsoleRpcCall {
|
||||
bool valid = false;
|
||||
std::string method;
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
};
|
||||
|
||||
enum class ConsoleResultLineRole {
|
||||
Result,
|
||||
Error,
|
||||
JsonKey,
|
||||
JsonString,
|
||||
JsonNumber,
|
||||
JsonBrace
|
||||
};
|
||||
|
||||
struct ConsoleResultLine {
|
||||
std::string text;
|
||||
ConsoleResultLineRole role = ConsoleResultLineRole::Result;
|
||||
};
|
||||
|
||||
const std::vector<std::string>& ConsoleRpcCommandNames();
|
||||
void AppendConsoleHistory(std::vector<std::string>& history,
|
||||
const std::string& command,
|
||||
std::size_t maxEntries = 100);
|
||||
int NavigateConsoleHistoryIndex(int currentIndex,
|
||||
std::size_t historySize,
|
||||
bool up);
|
||||
std::string ConsoleHistoryEntry(const std::vector<std::string>& history,
|
||||
int historyIndex);
|
||||
ConsoleCompletionResult CompleteConsoleCommand(const std::string& input);
|
||||
std::vector<std::string> FormatConsoleCompletionLines(const std::vector<std::string>& matches,
|
||||
std::size_t maxLineLength = 60);
|
||||
std::vector<std::string> ParseConsoleCommandArgs(const std::string& command);
|
||||
ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
|
||||
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
|
||||
bool isError);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
33
src/ui/windows/console_output_model.cpp
Normal file
33
src/ui/windows/console_output_model.cpp
Normal file
@@ -0,0 +1,33 @@
|
||||
#include "console_output_model.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
namespace {
|
||||
std::string lowerCopy(std::string value)
|
||||
{
|
||||
std::transform(value.begin(), value.end(), value.begin(),
|
||||
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
const ConsoleOutputFilter& filter)
|
||||
{
|
||||
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
|
||||
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
|
||||
if (!filter.text.empty()) {
|
||||
std::string needle = lowerCopy(filter.text);
|
||||
std::string haystack = lowerCopy(lineText);
|
||||
if (haystack.find(needle) == std::string::npos) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
23
src/ui/windows/console_output_model.h
Normal file
23
src/ui/windows/console_output_model.h
Normal file
@@ -0,0 +1,23 @@
|
||||
#pragma once
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ConsoleOutputFilter {
|
||||
std::string text;
|
||||
bool daemonMessagesEnabled = true;
|
||||
bool errorsOnly = false;
|
||||
ImU32 daemonColor = 0;
|
||||
ImU32 errorColor = 0;
|
||||
};
|
||||
|
||||
bool consoleLinePassesFilter(const std::string& lineText,
|
||||
ImU32 lineColor,
|
||||
const ConsoleOutputFilter& filter);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -6,6 +6,10 @@
|
||||
// tab completion, daemon log display, and color-coded output.
|
||||
|
||||
#include "console_tab.h"
|
||||
#include "console_command_reference.h"
|
||||
#include "console_input_model.h"
|
||||
#include "console_output_model.h"
|
||||
#include "console_tab_helpers.h"
|
||||
#include "../material/colors.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -213,13 +217,18 @@ void ConsoleTab::render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
|
||||
// Output area (scrollable) — glass panel background
|
||||
float frameH = ImGui::GetFrameHeightWithSpacing();
|
||||
float itemSp = ImGui::GetStyle().ItemSpacing.y;
|
||||
float input_height = (Layout::spacingSm() + itemSp) // Dummy(0,sm) + spacing
|
||||
+ frameH + Layout::spacingSm() + Layout::spacingXs() + schema::UI().drawElement("tabs.console", "input-cursor-offset").size; // input glass panel + cursor offset
|
||||
float outputH = ImGui::GetContentRegionAvail().y - input_height;
|
||||
float availHeight = ImGui::GetContentRegionAvail().y;
|
||||
if (outputH < std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size)) outputH = std::max(schema::UI().drawElement("tabs.console", "output-min-height").size, availHeight * schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
|
||||
float input_height = ComputeConsoleInputHeight(
|
||||
ImGui::GetFrameHeightWithSpacing(),
|
||||
ImGui::GetStyle().ItemSpacing.y,
|
||||
Layout::spacingSm(),
|
||||
Layout::spacingXs(),
|
||||
schema::UI().drawElement("tabs.console", "input-cursor-offset").size);
|
||||
float outputH = ComputeConsoleOutputHeight(
|
||||
availHeight,
|
||||
input_height,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height").size,
|
||||
schema::UI().drawElement("tabs.console", "output-min-height-ratio").size);
|
||||
|
||||
ImDrawList* dlOut = ImGui::GetWindowDrawList();
|
||||
ImVec2 outPanelMin = ImGui::GetCursorScreenPos();
|
||||
@@ -600,28 +609,14 @@ void ConsoleTab::renderOutput()
|
||||
output_scroll_y_ = ImGui::GetScrollY();
|
||||
|
||||
// Build filtered line index list BEFORE mouse handling (so screenToTextPos works)
|
||||
std::string filter_str(filter_text_);
|
||||
bool has_text_filter = !filter_str.empty();
|
||||
bool hide_daemon = !s_daemon_messages_enabled;
|
||||
bool errors_only = s_errors_only_enabled;
|
||||
bool has_filter = has_text_filter || hide_daemon || errors_only;
|
||||
ConsoleOutputFilter outputFilter{filter_text_, s_daemon_messages_enabled,
|
||||
s_errors_only_enabled, COLOR_DAEMON, COLOR_ERROR};
|
||||
bool has_text_filter = !outputFilter.text.empty();
|
||||
bool has_filter = has_text_filter || !outputFilter.daemonMessagesEnabled || outputFilter.errorsOnly;
|
||||
visible_indices_.clear();
|
||||
if (has_filter) {
|
||||
std::string filter_lower;
|
||||
if (has_text_filter) {
|
||||
filter_lower = filter_str;
|
||||
std::transform(filter_lower.begin(), filter_lower.end(), filter_lower.begin(), ::tolower);
|
||||
}
|
||||
for (int i = 0; i < static_cast<int>(lines_.size()); i++) {
|
||||
// Skip daemon lines when daemon toggle is off
|
||||
if (hide_daemon && lines_[i].color == COLOR_DAEMON) continue;
|
||||
// When errors-only is enabled, skip non-error lines
|
||||
if (errors_only && lines_[i].color != COLOR_ERROR) continue;
|
||||
if (has_text_filter) {
|
||||
std::string lower = lines_[i].text;
|
||||
std::transform(lower.begin(), lower.end(), lower.begin(), ::tolower);
|
||||
if (lower.find(filter_lower) == std::string::npos) continue;
|
||||
}
|
||||
if (!consoleLinePassesFilter(lines_[i].text, lines_[i].color, outputFilter)) continue;
|
||||
visible_indices_.push_back(i);
|
||||
}
|
||||
} else {
|
||||
@@ -636,8 +631,7 @@ void ConsoleTab::renderOutput()
|
||||
// Each segment records which bytes of the source text appear on that visual
|
||||
// row, so hit-testing and selection highlight can map screen positions to
|
||||
// exact character offsets.
|
||||
float wrap_width = ImGui::GetContentRegionAvail().x - padX * 2;
|
||||
if (wrap_width < 50.0f) wrap_width = 50.0f;
|
||||
float wrap_width = ClampConsoleWrapWidth(ImGui::GetContentRegionAvail().x, padX);
|
||||
|
||||
ImFont* font = ImGui::GetFont();
|
||||
float fontSize = ImGui::GetFontSize();
|
||||
@@ -1169,106 +1163,37 @@ void ConsoleTab::renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker)
|
||||
if (console->command_history_.empty()) return 0;
|
||||
|
||||
int prev_index = console->history_index_;
|
||||
|
||||
if (data->EventKey == ImGuiKey_UpArrow) {
|
||||
if (console->history_index_ < 0) {
|
||||
console->history_index_ = static_cast<int>(console->command_history_.size()) - 1;
|
||||
} else if (console->history_index_ > 0) {
|
||||
console->history_index_--;
|
||||
}
|
||||
} else if (data->EventKey == ImGuiKey_DownArrow) {
|
||||
if (console->history_index_ >= 0) {
|
||||
console->history_index_++;
|
||||
if (console->history_index_ >= static_cast<int>(console->command_history_.size())) {
|
||||
console->history_index_ = -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
console->history_index_ = NavigateConsoleHistoryIndex(
|
||||
console->history_index_,
|
||||
console->command_history_.size(),
|
||||
data->EventKey == ImGuiKey_UpArrow);
|
||||
|
||||
if (prev_index != console->history_index_) {
|
||||
const char* history_str = (console->history_index_ >= 0)
|
||||
? console->command_history_[console->history_index_].c_str()
|
||||
: "";
|
||||
std::string history = ConsoleHistoryEntry(console->command_history_, console->history_index_);
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, history_str);
|
||||
data->InsertChars(0, history.c_str());
|
||||
}
|
||||
}
|
||||
else if (data->EventFlag == ImGuiInputTextFlags_CallbackCompletion) {
|
||||
// Tab completion for common RPC commands
|
||||
static const char* commands[] = {
|
||||
// Control
|
||||
"help", "getinfo", "stop",
|
||||
// Network
|
||||
"getnetworkinfo", "getpeerinfo", "getconnectioncount",
|
||||
"addnode", "setban", "listbanned", "clearbanned", "ping",
|
||||
// Blockchain
|
||||
"getblockchaininfo", "getblockcount", "getbestblockhash",
|
||||
"getblock", "getblockhash", "getblockheader", "getdifficulty",
|
||||
"getrawmempool", "gettxout", "coinsupply", "getchaintips",
|
||||
// Mining
|
||||
"getmininginfo", "setgenerate", "getgenerate",
|
||||
"getnetworkhashps", "getblocksubsidy",
|
||||
// Wallet
|
||||
"getbalance", "z_gettotalbalance", "z_getbalances",
|
||||
"getnewaddress", "z_getnewaddress",
|
||||
"listaddresses", "z_listaddresses",
|
||||
"sendtoaddress", "z_sendmany",
|
||||
"listtransactions", "listunspent", "z_listunspent",
|
||||
"z_getoperationstatus", "z_getoperationresult",
|
||||
"getwalletinfo", "backupwallet",
|
||||
"dumpprivkey", "importprivkey",
|
||||
"z_exportkey", "z_importkey",
|
||||
"signmessage", "settxfee",
|
||||
// Raw Transactions
|
||||
"createrawtransaction", "decoderawtransaction",
|
||||
"getrawtransaction", "sendrawtransaction", "signrawtransaction",
|
||||
// Utility
|
||||
"validateaddress", "z_validateaddress", "estimatefee",
|
||||
// Built-in
|
||||
"clear"
|
||||
};
|
||||
|
||||
std::string input(data->Buf);
|
||||
if (!input.empty()) {
|
||||
// Collect all matches
|
||||
std::vector<const char*> matches;
|
||||
for (const char* cmd : commands) {
|
||||
if (strncmp(cmd, input.c_str(), input.length()) == 0) {
|
||||
matches.push_back(cmd);
|
||||
}
|
||||
}
|
||||
auto completion = CompleteConsoleCommand(input);
|
||||
|
||||
if (matches.size() == 1) {
|
||||
if (completion.matches.size() == 1) {
|
||||
// Single match — complete it
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, matches[0]);
|
||||
} else if (matches.size() > 1) {
|
||||
data->InsertChars(0, completion.matches.front().c_str());
|
||||
} else if (completion.matches.size() > 1) {
|
||||
// Multiple matches — show list in console and complete common prefix
|
||||
console->addLine(TR("console_completions"), ConsoleTab::COLOR_INFO);
|
||||
std::string line = " ";
|
||||
for (size_t m = 0; m < matches.size(); m++) {
|
||||
if (m > 0) line += " ";
|
||||
line += matches[m];
|
||||
if (line.length() > 60) {
|
||||
console->addLine(line, ConsoleTab::COLOR_RESULT);
|
||||
line = " ";
|
||||
}
|
||||
}
|
||||
if (line.length() > 2) {
|
||||
for (const auto& line : FormatConsoleCompletionLines(completion.matches)) {
|
||||
console->addLine(line, ConsoleTab::COLOR_RESULT);
|
||||
}
|
||||
|
||||
// Complete to longest common prefix
|
||||
std::string prefix = matches[0];
|
||||
for (size_t m = 1; m < matches.size(); m++) {
|
||||
size_t len = 0;
|
||||
while (len < prefix.length() && len < strlen(matches[m]) &&
|
||||
prefix[len] == matches[m][len]) len++;
|
||||
prefix = prefix.substr(0, len);
|
||||
}
|
||||
if (prefix.length() > input.length()) {
|
||||
if (completion.commonPrefix.length() > input.length()) {
|
||||
data->DeleteChars(0, data->BufTextLen);
|
||||
data->InsertChars(0, prefix.c_str());
|
||||
data->InsertChars(0, completion.commonPrefix.c_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1312,117 +1237,7 @@ void ConsoleTab::renderCommandsPopup()
|
||||
ImGui::InputTextWithHint("##CmdSearch", TR("console_search_commands"), cmdFilter, sizeof(cmdFilter));
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
|
||||
// Command entries
|
||||
struct CmdEntry { const char* name; const char* desc; const char* params; };
|
||||
|
||||
static const CmdEntry controlCmds[] = {
|
||||
{"help", "List all commands, or get help for a specified command", "[\"command\"]"},
|
||||
{"getinfo", "Get general info about the node", ""},
|
||||
{"stop", "Stop the daemon", ""},
|
||||
};
|
||||
static const CmdEntry networkCmds[] = {
|
||||
{"getnetworkinfo", "Return P2P network state info", ""},
|
||||
{"getpeerinfo", "Get data about each connected peer", ""},
|
||||
{"getconnectioncount", "Get number of peer connections", ""},
|
||||
{"getnettotals", "Get network traffic statistics", ""},
|
||||
{"addnode", "Add, remove, or connect to a node", "\"node\" \"add|remove|onetry\""},
|
||||
{"setban", "Add or remove an IP/subnet from the ban list", "\"ip\" \"add|remove\" [bantime] [absolute]"},
|
||||
{"listbanned", "List all banned IPs/subnets", ""},
|
||||
{"clearbanned", "Clear all banned IPs", ""},
|
||||
{"ping", "Ping all peers to measure round-trip time", ""},
|
||||
};
|
||||
static const CmdEntry blockchainCmds[] = {
|
||||
{"getblockchaininfo", "Get current blockchain state", ""},
|
||||
{"getblockcount", "Get number of blocks in longest chain", ""},
|
||||
{"getbestblockhash", "Get hash of the tip block", ""},
|
||||
{"getblock", "Get block data for a given hash or height", "\"hash|height\" [verbosity]"},
|
||||
{"getblockhash", "Get block hash at a given height", "height"},
|
||||
{"getblockheader", "Get block header for a given hash", "\"hash\" [verbose]"},
|
||||
{"getdifficulty", "Get proof-of-work difficulty", ""},
|
||||
{"getrawmempool", "Get all txids in mempool", "[verbose]"},
|
||||
{"getmempoolinfo", "Get mempool state info", ""},
|
||||
{"gettxout", "Get details about an unspent output", "\"txid\" n [includemempool]"},
|
||||
{"coinsupply", "Get coin supply information", "[height]"},
|
||||
{"getchaintips", "Get all known chain tips", ""},
|
||||
{"getchaintxstats", "Get chain transaction statistics", "[nblocks] [\"blockhash\"]"},
|
||||
{"verifychain", "Verify the blockchain database", "[checklevel] [numblocks]"},
|
||||
{"kvsearch", "Search the blockchain key-value store", "\"key\""},
|
||||
{"kvupdate", "Update a key-value pair on-chain", "\"key\" \"value\" days"},
|
||||
};
|
||||
static const CmdEntry miningCmds[] = {
|
||||
{"getmininginfo", "Get mining-related information", ""},
|
||||
{"setgenerate", "Turn mining on or off (true/false [threads])", "generate [genproclimit]"},
|
||||
{"getgenerate", "Check if the node is mining", ""},
|
||||
{"getnetworkhashps", "Get estimated network hash rate", "[blocks] [height]"},
|
||||
{"getblocksubsidy", "Get block reward at a given height", "[height]"},
|
||||
{"getblocktemplate", "Get block template for mining", "[\"jsonrequestobject\"]"},
|
||||
{"submitblock", "Submit a mined block to the network", "\"hexdata\""},
|
||||
};
|
||||
static const CmdEntry walletCmds[] = {
|
||||
{"getbalance", "Get wallet transparent balance", "[\"account\"] [minconf]"},
|
||||
{"z_gettotalbalance", "Get total transparent + shielded balance", "[minconf]"},
|
||||
{"z_getbalances", "Get all balances (transparent + shielded)", ""},
|
||||
{"getnewaddress", "Generate a new transparent address", ""},
|
||||
{"z_getnewaddress", "Generate a new shielded address", "[\"type\"]"},
|
||||
{"listaddresses", "List all transparent addresses", ""},
|
||||
{"z_listaddresses", "List all z-addresses", ""},
|
||||
{"sendtoaddress", "Send to a specific address", "\"address\" amount"},
|
||||
{"z_sendmany", "Send to multiple z/t-addresses with shielded support", "\"fromaddress\" [{\"address\":\"...\",\"amount\":...}]"},
|
||||
{"z_shieldcoinbase", "Shield transparent coinbase funds to a z-address", "\"fromaddress\" \"tozaddress\" [fee] [limit]"},
|
||||
{"z_mergetoaddress", "Merge multiple UTXOs/notes to one address", "[\"fromaddress\",...] \"toaddress\" [fee] [limit]"},
|
||||
{"listtransactions", "List recent wallet transactions", "[\"account\"] [count] [from]"},
|
||||
{"listunspent", "List unspent transaction outputs", "[minconf] [maxconf]"},
|
||||
{"z_listunspent", "List unspent shielded notes", "[minconf] [maxconf]"},
|
||||
{"z_getoperationstatus", "Get status of async z operations", "[\"operationid\",...]"},
|
||||
{"z_getoperationresult", "Get result of completed z operations", "[\"operationid\",...]"},
|
||||
{"z_listoperationids", "List all async z operation IDs", ""},
|
||||
{"getwalletinfo", "Get wallet state info", ""},
|
||||
{"backupwallet", "Back up wallet to a file", "\"destination\""},
|
||||
{"dumpprivkey", "Dump private key for an address", "\"address\""},
|
||||
{"importprivkey", "Import a private key into the wallet", "\"privkey\" [\"label\"] [rescan]"},
|
||||
{"dumpwallet", "Dump all wallet keys to a file", "\"filename\""},
|
||||
{"importwallet", "Import wallet from a dump file", "\"filename\""},
|
||||
{"z_exportkey", "Export spending key for a z-address", "\"zaddr\""},
|
||||
{"z_importkey", "Import a z-address spending key", "\"zkey\" [rescan] [startheight]"},
|
||||
{"z_exportviewingkey", "Export viewing key for a z-address", "\"zaddr\""},
|
||||
{"z_importviewingkey", "Import a z-address viewing key", "\"vkey\" [rescan] [startheight]"},
|
||||
{"z_exportwallet", "Export all wallet keys (including z-keys) to file", "\"filename\""},
|
||||
{"signmessage", "Sign a message with an address key", "\"address\" \"message\""},
|
||||
{"settxfee", "Set the transaction fee per kB", "amount"},
|
||||
{"walletpassphrase", "Unlock the wallet with passphrase", "\"passphrase\" timeout"},
|
||||
{"walletlock", "Lock the wallet", ""},
|
||||
{"encryptwallet", "Encrypt the wallet with a passphrase", "\"passphrase\""},
|
||||
};
|
||||
static const CmdEntry rawTxCmds[] = {
|
||||
{"createrawtransaction", "Create a raw transaction spending given inputs", "[{\"txid\":\"...\",\"vout\":n},...] {\"address\":amount,...}"},
|
||||
{"decoderawtransaction", "Decode raw transaction hex string", "\"hexstring\""},
|
||||
{"decodescript", "Decode a hex-encoded script", "\"hex\""},
|
||||
{"getrawtransaction", "Get raw transaction data by txid", "\"txid\" [verbose]"},
|
||||
{"sendrawtransaction", "Submit raw transaction to the network", "\"hexstring\" [allowhighfees]"},
|
||||
{"signrawtransaction", "Sign a raw transaction with private keys", "\"hexstring\""},
|
||||
{"fundrawtransaction", "Add inputs to meet output value", "\"hexstring\""},
|
||||
};
|
||||
static const CmdEntry utilCmds[] = {
|
||||
{"validateaddress", "Validate a transparent address", "\"address\""},
|
||||
{"z_validateaddress", "Validate a z-address", "\"zaddr\""},
|
||||
{"estimatefee", "Estimate fee for a transaction", "nblocks"},
|
||||
{"verifymessage", "Verify a signed message", "\"address\" \"signature\" \"message\""},
|
||||
{"createmultisig", "Create a multisig address", "nrequired [\"key\",...]"},
|
||||
{"invalidateblock", "Mark a block as invalid", "\"hash\""},
|
||||
{"reconsiderblock", "Reconsider a previously invalidated block", "\"hash\""},
|
||||
};
|
||||
|
||||
struct CmdCategory { const char* name; const CmdEntry* commands; int count; };
|
||||
|
||||
static const CmdCategory categories[] = {
|
||||
{"Control", controlCmds, IM_ARRAYSIZE(controlCmds)},
|
||||
{"Network", networkCmds, IM_ARRAYSIZE(networkCmds)},
|
||||
{"Blockchain", blockchainCmds, IM_ARRAYSIZE(blockchainCmds)},
|
||||
{"Mining", miningCmds, IM_ARRAYSIZE(miningCmds)},
|
||||
{"Wallet", walletCmds, IM_ARRAYSIZE(walletCmds)},
|
||||
{"Raw Transactions", rawTxCmds, IM_ARRAYSIZE(rawTxCmds)},
|
||||
{"Utility", utilCmds, IM_ARRAYSIZE(utilCmds)},
|
||||
};
|
||||
const auto& categories = consoleCommandCategories();
|
||||
|
||||
std::string filter(cmdFilter);
|
||||
std::transform(filter.begin(), filter.end(), filter.begin(), ::tolower);
|
||||
@@ -1602,12 +1417,7 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
{
|
||||
using namespace material;
|
||||
// Add to history (avoid duplicates)
|
||||
if (command_history_.empty() || command_history_.back() != cmd) {
|
||||
command_history_.push_back(cmd);
|
||||
if (command_history_.size() > 100) {
|
||||
command_history_.erase(command_history_.begin());
|
||||
}
|
||||
}
|
||||
AppendConsoleHistory(command_history_, cmd, 100);
|
||||
history_index_ = -1;
|
||||
|
||||
// Echo command
|
||||
@@ -1645,77 +1455,11 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse command and arguments (shell-like: handles quotes and JSON brackets)
|
||||
std::vector<std::string> args;
|
||||
{
|
||||
size_t i = 0;
|
||||
size_t len = cmd.size();
|
||||
while (i < len) {
|
||||
// Skip whitespace
|
||||
while (i < len && (cmd[i] == ' ' || cmd[i] == '\t')) i++;
|
||||
if (i >= len) break;
|
||||
auto call = BuildConsoleRpcCall(cmd);
|
||||
if (!call.valid) return;
|
||||
|
||||
std::string tok;
|
||||
if (cmd[i] == '"' || cmd[i] == '\'') {
|
||||
// Quoted string — collect until matching close quote
|
||||
char quote = cmd[i++];
|
||||
while (i < len && cmd[i] != quote) tok += cmd[i++];
|
||||
if (i < len) i++; // skip closing quote
|
||||
} else if (cmd[i] == '[' || cmd[i] == '{') {
|
||||
// JSON array/object — collect until matching bracket
|
||||
char open = cmd[i];
|
||||
char close = (open == '[') ? ']' : '}';
|
||||
int depth = 0;
|
||||
while (i < len) {
|
||||
if (cmd[i] == open) depth++;
|
||||
else if (cmd[i] == close) depth--;
|
||||
tok += cmd[i++];
|
||||
if (depth == 0) break;
|
||||
}
|
||||
} else {
|
||||
// Unquoted token — collect until whitespace
|
||||
while (i < len && cmd[i] != ' ' && cmd[i] != '\t') tok += cmd[i++];
|
||||
}
|
||||
if (!tok.empty()) args.push_back(tok);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.empty()) return;
|
||||
|
||||
std::string method = args[0];
|
||||
nlohmann::json params = nlohmann::json::array();
|
||||
|
||||
// Convert remaining args to JSON params
|
||||
for (size_t i = 1; i < args.size(); i++) {
|
||||
const std::string& arg = args[i];
|
||||
|
||||
// Try to parse as JSON first (handles objects, arrays, etc.)
|
||||
if (!arg.empty() && (arg[0] == '{' || arg[0] == '[')) {
|
||||
auto parsed = nlohmann::json::parse(arg, nullptr, false);
|
||||
if (!parsed.is_discarded()) {
|
||||
params.push_back(parsed);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse as number or bool
|
||||
if (arg == "true") {
|
||||
params.push_back(true);
|
||||
} else if (arg == "false") {
|
||||
params.push_back(false);
|
||||
} else {
|
||||
try {
|
||||
if (arg.find('.') != std::string::npos) {
|
||||
params.push_back(std::stod(arg));
|
||||
} else {
|
||||
params.push_back(std::stoll(arg));
|
||||
}
|
||||
} catch (...) {
|
||||
// Keep as string
|
||||
params.push_back(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
std::string method = call.method;
|
||||
nlohmann::json params = call.params;
|
||||
|
||||
// Execute RPC call on worker thread to avoid blocking UI
|
||||
if (worker) {
|
||||
@@ -1726,9 +1470,6 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
bool is_error = false;
|
||||
try {
|
||||
result_str = rpc->callRaw(method, params);
|
||||
if (result_str == "null") {
|
||||
result_str = "(no result)";
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
result_str = e.what();
|
||||
is_error = true;
|
||||
@@ -1736,51 +1477,22 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
return [result_str, is_error, self]() {
|
||||
// Process results on main thread where ImGui colors are available
|
||||
using namespace material;
|
||||
if (is_error) {
|
||||
self->addLine("Error: " + result_str, COLOR_ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
bool is_json = false;
|
||||
if (!result_str.empty()) {
|
||||
char first = result_str[0];
|
||||
is_json = (first == '{' || first == '[');
|
||||
}
|
||||
ImU32 json_key_col = WithAlpha(Secondary(), 255);
|
||||
ImU32 json_str_col = WithAlpha(Success(), 255);
|
||||
ImU32 json_num_col = WithAlpha(Warning(), 255);
|
||||
ImU32 json_brace_col = IM_COL32(200, 200, 200, 150);
|
||||
|
||||
std::istringstream stream(result_str);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
if (is_json && !line.empty()) {
|
||||
std::string trimmed = line;
|
||||
size_t first = trimmed.find_first_not_of(" \t");
|
||||
if (first != std::string::npos) trimmed = trimmed.substr(first);
|
||||
|
||||
ImU32 lineCol = COLOR_RESULT;
|
||||
if (trimmed[0] == '{' || trimmed[0] == '}' ||
|
||||
trimmed[0] == '[' || trimmed[0] == ']') {
|
||||
lineCol = json_brace_col;
|
||||
} else if (trimmed[0] == '\"') {
|
||||
size_t colon = trimmed.find("\": ");
|
||||
if (colon != std::string::npos || trimmed.find("\":") != std::string::npos) {
|
||||
lineCol = json_key_col;
|
||||
} else {
|
||||
lineCol = json_str_col;
|
||||
}
|
||||
} else if (std::isdigit(trimmed[0]) || trimmed[0] == '-') {
|
||||
lineCol = json_num_col;
|
||||
} else if (trimmed == "true," || trimmed == "false," ||
|
||||
trimmed == "true" || trimmed == "false" ||
|
||||
trimmed == "null," || trimmed == "null") {
|
||||
lineCol = json_num_col;
|
||||
}
|
||||
self->addLine(line, lineCol);
|
||||
} else {
|
||||
self->addLine(line, COLOR_RESULT);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, is_error)) {
|
||||
ImU32 lineCol = COLOR_RESULT;
|
||||
switch (resultLine.role) {
|
||||
case ConsoleResultLineRole::Error: lineCol = COLOR_ERROR; break;
|
||||
case ConsoleResultLineRole::JsonKey: lineCol = json_key_col; break;
|
||||
case ConsoleResultLineRole::JsonString: lineCol = json_str_col; break;
|
||||
case ConsoleResultLineRole::JsonNumber: lineCol = json_num_col; break;
|
||||
case ConsoleResultLineRole::JsonBrace: lineCol = json_brace_col; break;
|
||||
case ConsoleResultLineRole::Result: break;
|
||||
}
|
||||
self->addLine(resultLine.text, lineCol);
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -1788,14 +1500,13 @@ void ConsoleTab::executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc
|
||||
// Fallback: synchronous execution if no worker available
|
||||
try {
|
||||
std::string result_str = rpc->callRaw(method, params);
|
||||
if (result_str == "null") result_str = "(no result)";
|
||||
std::istringstream stream(result_str);
|
||||
std::string line;
|
||||
while (std::getline(stream, line)) {
|
||||
addLine(line, COLOR_RESULT);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(result_str, false)) {
|
||||
addLine(resultLine.text, COLOR_RESULT);
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
addLine("Error: " + std::string(e.what()), COLOR_ERROR);
|
||||
for (const auto& resultLine : FormatConsoleRpcResultLines(e.what(), true)) {
|
||||
addLine(resultLine.text, COLOR_ERROR);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
src/ui/windows/console_tab_helpers.cpp
Normal file
31
src/ui/windows/console_tab_helpers.cpp
Normal file
@@ -0,0 +1,31 @@
|
||||
#include "console_tab_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
float spacingXs,
|
||||
float cursorOffset)
|
||||
{
|
||||
return spacingSm + itemSpacingY + frameHeightWithSpacing + spacingSm + spacingXs + cursorOffset;
|
||||
}
|
||||
|
||||
float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float inputHeight,
|
||||
float minHeight,
|
||||
float minHeightRatio)
|
||||
{
|
||||
return std::max(availableHeight - inputHeight, std::max(minHeight, availableHeight * minHeightRatio));
|
||||
}
|
||||
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX)
|
||||
{
|
||||
return std::max(50.0f, contentWidth - paddingX * 2.0f);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
18
src/ui/windows/console_tab_helpers.h
Normal file
18
src/ui/windows/console_tab_helpers.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
float ComputeConsoleInputHeight(float frameHeightWithSpacing,
|
||||
float itemSpacingY,
|
||||
float spacingSm,
|
||||
float spacingXs,
|
||||
float cursorOffset);
|
||||
float ComputeConsoleOutputHeight(float availableHeight,
|
||||
float inputHeight,
|
||||
float minHeight,
|
||||
float minHeightRatio);
|
||||
float ClampConsoleWrapWidth(float contentWidth, float paddingX);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -267,13 +267,12 @@ static void fetchBlockDetailByHash(App* app, const std::string& hash) {
|
||||
});
|
||||
}
|
||||
|
||||
static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
static bool fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
auto* worker = app->worker();
|
||||
auto* rpc = app->rpc();
|
||||
if (!worker || !rpc || s_pending_block_fetches > 0) return;
|
||||
if (!worker || !rpc || s_pending_block_fetches > 0) return false;
|
||||
|
||||
s_recent_blocks.clear();
|
||||
s_recent_blocks.resize(count);
|
||||
if (s_recent_blocks.empty()) s_recent_blocks.resize(count);
|
||||
s_pending_block_fetches = 1; // single batched fetch
|
||||
|
||||
worker->post([rpc, currentHeight, count]() -> rpc::RPCWorker::MainCb {
|
||||
@@ -295,11 +294,19 @@ static void fetchRecentBlocks(App* app, int currentHeight, int count = 10) {
|
||||
bs.tx_count = static_cast<int>(result["tx"].size());
|
||||
} catch (...) {}
|
||||
}
|
||||
return [results]() {
|
||||
s_recent_blocks = results;
|
||||
return [results = std::move(results)]() mutable {
|
||||
bool gotAny = false;
|
||||
for (const auto& block : results) {
|
||||
if (block.height > 0) {
|
||||
gotAny = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (gotAny) s_recent_blocks = std::move(results);
|
||||
s_pending_block_fetches = 0;
|
||||
};
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
static void fetchMempoolInfo(App* app) {
|
||||
@@ -567,10 +574,12 @@ static void renderRecentBlocks(App* app, float availWidth) {
|
||||
if (bs.height > 0) blocks.push_back(&bs);
|
||||
}
|
||||
|
||||
// Fixed card height — content scrolls inside
|
||||
// Stretch card to fill the remaining tab height; rows scroll inside.
|
||||
float maxRows = 10.0f;
|
||||
float contentH = capFont->LegacySize + Layout::spacingXs() + rowH * maxRows;
|
||||
float tableH = headerH + contentH + pad;
|
||||
float minTableH = headerH + contentH + pad;
|
||||
float remainingH = ImGui::GetContentRegionAvail().y;
|
||||
float tableH = std::max(minTableH, remainingH - Layout::spacingSm());
|
||||
|
||||
ImVec2 cardMin = ImGui::GetCursorScreenPos();
|
||||
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + tableH);
|
||||
@@ -1024,12 +1033,15 @@ void RenderExplorerTab(App* app)
|
||||
|
||||
float availWidth = ImGui::GetContentRegionAvail().x;
|
||||
|
||||
// Auto-refresh recent blocks when chain height changes
|
||||
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height) {
|
||||
s_last_known_height = state.sync.blocks;
|
||||
// Auto-refresh recent blocks when chain height changes, but avoid
|
||||
// starting expensive block fetches while the user is viewing details.
|
||||
if (state.sync.blocks > 0 && state.sync.blocks != s_last_known_height &&
|
||||
!s_show_detail_modal && !s_detail_loading && !s_tx_loading) {
|
||||
if (rpc && rpc->isConnected()) {
|
||||
fetchRecentBlocks(app, state.sync.blocks);
|
||||
fetchMempoolInfo(app);
|
||||
if (fetchRecentBlocks(app, state.sync.blocks)) {
|
||||
s_last_known_height = state.sync.blocks;
|
||||
fetchMempoolInfo(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -306,7 +306,20 @@ void RenderMarketTab(App* app)
|
||||
ImGui::SetCursorScreenPos(savedCur);
|
||||
}
|
||||
} else {
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), TR("market_price_unavailable"));
|
||||
const char* status = market.price_loading ? TR("market_price_loading") : TR("market_price_unavailable");
|
||||
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), status);
|
||||
if (!market.price_loading && !market.price_error.empty()) {
|
||||
std::string errorText = market.price_error;
|
||||
float maxErrorW = cardMax.x - cx - Layout::spacingLg();
|
||||
while (errorText.size() > 4 &&
|
||||
capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, errorText.c_str()).x > maxErrorW) {
|
||||
errorText.pop_back();
|
||||
}
|
||||
if (errorText.size() < market.price_error.size()) errorText += "...";
|
||||
dl->AddText(capFont, capFont->LegacySize,
|
||||
ImVec2(cx, cy + 10 + sub1->LegacySize + Layout::spacingXs()),
|
||||
Warning(), errorText.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
|
||||
|
||||
211
src/ui/windows/mining_benchmark.cpp
Normal file
211
src/ui/windows/mining_benchmark.cpp
Normal file
@@ -0,0 +1,211 @@
|
||||
#include "mining_benchmark.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
void ThreadBenchmark::reset()
|
||||
{
|
||||
phase = Phase::Idle;
|
||||
candidates.clear();
|
||||
current_index = 0;
|
||||
results.clear();
|
||||
phase_timer = 0.0f;
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
measure_sum = 0.0;
|
||||
measure_samples = 0;
|
||||
optimal_threads = 0;
|
||||
optimal_hashrate = 0.0;
|
||||
was_pool_running = false;
|
||||
prev_threads = 0;
|
||||
total_warmup_secs = 0.0f;
|
||||
}
|
||||
|
||||
void ThreadBenchmark::buildCandidates(int maxThreads)
|
||||
{
|
||||
candidates.clear();
|
||||
int start = std::max(1, maxThreads / 2);
|
||||
for (int threads = start; threads <= maxThreads; ++threads) {
|
||||
candidates.push_back(threads);
|
||||
}
|
||||
}
|
||||
|
||||
float ThreadBenchmark::avgWarmupSecs() const
|
||||
{
|
||||
if (current_index > 0) {
|
||||
return total_warmup_secs / static_cast<float>(current_index);
|
||||
}
|
||||
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::perTestSecs() const
|
||||
{
|
||||
return avgWarmupSecs() + MEASURE_SECS;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::totalEstimatedSecs() const
|
||||
{
|
||||
int count = static_cast<int>(candidates.size());
|
||||
if (count <= 0) return 0.0f;
|
||||
float completedTime = total_warmup_secs
|
||||
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
int remaining = count - current_index;
|
||||
float remainingTime = static_cast<float>(remaining) * (avgWarmupSecs() + MEASURE_SECS)
|
||||
+ static_cast<float>(std::max(0, remaining - 1)) * COOLDOWN_SECS;
|
||||
return completedTime + remainingTime;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::elapsedSecs() const
|
||||
{
|
||||
float completed = total_warmup_secs
|
||||
+ static_cast<float>(current_index) * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
return completed + phase_timer;
|
||||
}
|
||||
|
||||
float ThreadBenchmark::progress() const
|
||||
{
|
||||
float total = totalEstimatedSecs();
|
||||
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
|
||||
}
|
||||
|
||||
void ThreadBenchmark::resetStabilityTracking()
|
||||
{
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
}
|
||||
|
||||
bool ThreadBenchmark::active() const
|
||||
{
|
||||
return phase != Phase::Idle && phase != Phase::Done;
|
||||
}
|
||||
|
||||
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
|
||||
float deltaSeconds,
|
||||
double poolHashrate10s)
|
||||
{
|
||||
ThreadBenchmarkUpdate update;
|
||||
if (!benchmark.active()) return update;
|
||||
|
||||
benchmark.phase_timer += deltaSeconds;
|
||||
|
||||
switch (benchmark.phase) {
|
||||
case ThreadBenchmark::Phase::Starting:
|
||||
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
|
||||
int threads = benchmark.candidates[benchmark.current_index];
|
||||
update.stopPoolMining = true;
|
||||
update.startPoolMining = true;
|
||||
update.startThreads = threads;
|
||||
benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
benchmark.resetStabilityTracking();
|
||||
benchmark.measure_sum = 0.0;
|
||||
benchmark.measure_samples = 0;
|
||||
} else {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::WarmingUp: {
|
||||
bool pastMin = benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
|
||||
bool pastMax = benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
|
||||
|
||||
if (poolHashrate10s > 0.0) {
|
||||
benchmark.window_sum += poolHashrate10s;
|
||||
benchmark.window_samples++;
|
||||
}
|
||||
benchmark.window_timer += deltaSeconds;
|
||||
|
||||
bool stable = false;
|
||||
if (pastMin && benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS &&
|
||||
benchmark.window_samples > 0) {
|
||||
double currentAverage = benchmark.window_sum / benchmark.window_samples;
|
||||
if (benchmark.prev_window_avg > 0.0) {
|
||||
double change = std::abs(currentAverage - benchmark.prev_window_avg)
|
||||
/ benchmark.prev_window_avg;
|
||||
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
|
||||
benchmark.consecutive_stable++;
|
||||
else
|
||||
benchmark.consecutive_stable = 0;
|
||||
if (benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
|
||||
stable = true;
|
||||
}
|
||||
benchmark.prev_window_avg = currentAverage;
|
||||
benchmark.window_sum = 0.0;
|
||||
benchmark.window_samples = 0;
|
||||
benchmark.window_timer = 0.0f;
|
||||
}
|
||||
|
||||
if (stable || pastMax) {
|
||||
benchmark.total_warmup_secs += benchmark.phase_timer;
|
||||
benchmark.phase = ThreadBenchmark::Phase::Measuring;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
benchmark.measure_sum = 0.0;
|
||||
benchmark.measure_samples = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ThreadBenchmark::Phase::Measuring:
|
||||
if (poolHashrate10s > 0.0) {
|
||||
benchmark.measure_sum += poolHashrate10s;
|
||||
benchmark.measure_samples++;
|
||||
}
|
||||
if (benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
|
||||
int threads = benchmark.candidates[benchmark.current_index];
|
||||
double average = (benchmark.measure_samples > 0)
|
||||
? benchmark.measure_sum / benchmark.measure_samples
|
||||
: 0.0;
|
||||
benchmark.results.push_back({threads, average});
|
||||
if (average > benchmark.optimal_hashrate) {
|
||||
benchmark.optimal_hashrate = average;
|
||||
benchmark.optimal_threads = threads;
|
||||
}
|
||||
benchmark.phase = ThreadBenchmark::Phase::Advancing;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::Advancing:
|
||||
update.stopPoolMining = true;
|
||||
benchmark.current_index++;
|
||||
if (benchmark.current_index < static_cast<int>(benchmark.candidates.size())) {
|
||||
benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
} else {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
if (benchmark.optimal_threads > 0) {
|
||||
update.saveOptimalThreads = true;
|
||||
update.optimalThreads = benchmark.optimal_threads;
|
||||
if (benchmark.was_pool_running) {
|
||||
update.startPoolMining = true;
|
||||
update.startThreads = benchmark.optimal_threads;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::CoolingDown:
|
||||
if (benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
|
||||
benchmark.phase = ThreadBenchmark::Phase::Starting;
|
||||
benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
66
src/ui/windows/mining_benchmark.h
Normal file
66
src/ui/windows/mining_benchmark.h
Normal file
@@ -0,0 +1,66 @@
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
struct ThreadBenchmark {
|
||||
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
|
||||
|
||||
struct Result {
|
||||
int threads = 0;
|
||||
double hashrate = 0.0;
|
||||
};
|
||||
|
||||
static constexpr float MIN_WARMUP_SECS = 90.0f;
|
||||
static constexpr float MAX_WARMUP_SECS = 300.0f;
|
||||
static constexpr float MEASURE_SECS = 30.0f;
|
||||
static constexpr float COOLDOWN_SECS = 5.0f;
|
||||
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
|
||||
static constexpr float STABILITY_THRESHOLD = 0.05f;
|
||||
static constexpr int STABLE_WINDOWS_NEEDED = 3;
|
||||
|
||||
Phase phase = Phase::Idle;
|
||||
std::vector<int> candidates;
|
||||
int current_index = 0;
|
||||
std::vector<Result> results;
|
||||
float phase_timer = 0.0f;
|
||||
double prev_window_avg = 0.0;
|
||||
double window_sum = 0.0;
|
||||
int window_samples = 0;
|
||||
float window_timer = 0.0f;
|
||||
int consecutive_stable = 0;
|
||||
double measure_sum = 0.0;
|
||||
int measure_samples = 0;
|
||||
int optimal_threads = 0;
|
||||
double optimal_hashrate = 0.0;
|
||||
bool was_pool_running = false;
|
||||
int prev_threads = 0;
|
||||
float total_warmup_secs = 0.0f;
|
||||
|
||||
void reset();
|
||||
void buildCandidates(int maxThreads);
|
||||
float avgWarmupSecs() const;
|
||||
float perTestSecs() const;
|
||||
float totalEstimatedSecs() const;
|
||||
float elapsedSecs() const;
|
||||
float progress() const;
|
||||
void resetStabilityTracking();
|
||||
bool active() const;
|
||||
};
|
||||
|
||||
struct ThreadBenchmarkUpdate {
|
||||
bool stopPoolMining = false;
|
||||
bool startPoolMining = false;
|
||||
int startThreads = 0;
|
||||
bool saveOptimalThreads = false;
|
||||
int optimalThreads = 0;
|
||||
};
|
||||
|
||||
ThreadBenchmarkUpdate AdvanceThreadBenchmark(ThreadBenchmark& benchmark,
|
||||
float deltaSeconds,
|
||||
double poolHashrate10s);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
36
src/ui/windows/mining_pool_panel.cpp
Normal file
36
src/ui/windows/mining_pool_panel.cpp
Normal file
@@ -0,0 +1,36 @@
|
||||
#include "mining_pool_panel.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted)
|
||||
{
|
||||
return !alreadyDefaulted && (currentWorker.empty() || currentWorker == "x");
|
||||
}
|
||||
|
||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses)
|
||||
{
|
||||
for (const auto& addr : addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
return addr.address;
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value)
|
||||
{
|
||||
if (value.empty()) return false;
|
||||
return std::find(savedValues.begin(), savedValues.end(), value) != savedValues.end();
|
||||
}
|
||||
|
||||
const char* defaultPoolUrl()
|
||||
{
|
||||
return "pool.dragonx.is:3433";
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
18
src/ui/windows/mining_pool_panel.h
Normal file
18
src/ui/windows/mining_pool_panel.h
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include "../../data/wallet_state.h"
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
bool shouldDefaultPoolWorker(const std::string& currentWorker, bool alreadyDefaulted);
|
||||
std::string defaultPoolWorkerAddress(const std::vector<AddressInfo>& addresses);
|
||||
bool miningValueAlreadySaved(const std::vector<std::string>& savedValues,
|
||||
const std::string& value);
|
||||
const char* defaultPoolUrl();
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -3,6 +3,9 @@
|
||||
// Released under the GPLv3
|
||||
|
||||
#include "mining_tab.h"
|
||||
#include "mining_benchmark.h"
|
||||
#include "mining_tab_helpers.h"
|
||||
#include "mining_pool_panel.h"
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../config/version.h"
|
||||
@@ -43,130 +46,10 @@ static int s_drag_anchor_thread = 0; // thread# where drag started
|
||||
// Earnings filter: 0 = All, 1 = Solo, 2 = Pool
|
||||
static int s_earnings_filter = 0;
|
||||
|
||||
// Thread benchmark state
|
||||
struct ThreadBenchmark {
|
||||
enum class Phase { Idle, Starting, WarmingUp, Measuring, Advancing, CoolingDown, Done };
|
||||
Phase phase = Phase::Idle;
|
||||
|
||||
std::vector<int> candidates;
|
||||
int current_index = 0;
|
||||
|
||||
struct Result {
|
||||
int threads;
|
||||
double hashrate;
|
||||
};
|
||||
std::vector<Result> results;
|
||||
|
||||
float phase_timer = 0.0f;
|
||||
|
||||
// Warmup: wait at least MIN then check for hashrate stability; cap at MAX.
|
||||
// Laptops need 90s+ for thermal throttling to fully manifest.
|
||||
static constexpr float MIN_WARMUP_SECS = 90.0f;
|
||||
static constexpr float MAX_WARMUP_SECS = 300.0f;
|
||||
static constexpr float MEASURE_SECS = 30.0f;
|
||||
static constexpr float COOLDOWN_SECS = 5.0f;
|
||||
|
||||
// Stability detection — compare rolling 10s hashrate windows.
|
||||
// Require STABLE_WINDOWS_NEEDED consecutive stable readings.
|
||||
static constexpr float STABILITY_WINDOW_SECS = 10.0f;
|
||||
static constexpr float STABILITY_THRESHOLD = 0.05f; // 5% change → stable
|
||||
static constexpr int STABLE_WINDOWS_NEEDED = 3;
|
||||
double prev_window_avg = 0.0;
|
||||
double window_sum = 0.0;
|
||||
int window_samples = 0;
|
||||
float window_timer = 0.0f;
|
||||
int consecutive_stable = 0; // count of consecutive stable windows
|
||||
|
||||
// Measurement: average-based (sustained performance, not peak burst)
|
||||
double measure_sum = 0.0;
|
||||
int measure_samples = 0;
|
||||
|
||||
int optimal_threads = 0;
|
||||
double optimal_hashrate = 0.0;
|
||||
bool was_pool_running = false;
|
||||
int prev_threads = 0;
|
||||
|
||||
// Track actual warmup durations for better time estimates
|
||||
float total_warmup_secs = 0.0f;
|
||||
|
||||
void reset() {
|
||||
phase = Phase::Idle;
|
||||
candidates.clear();
|
||||
current_index = 0;
|
||||
results.clear();
|
||||
phase_timer = 0.0f;
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
measure_sum = 0.0;
|
||||
measure_samples = 0;
|
||||
optimal_threads = 0;
|
||||
optimal_hashrate = 0.0;
|
||||
was_pool_running = false;
|
||||
prev_threads = 0;
|
||||
total_warmup_secs = 0.0f;
|
||||
}
|
||||
|
||||
void buildCandidates(int max_threads) {
|
||||
candidates.clear();
|
||||
// Start at half the cores — lower counts are rarely optimal and
|
||||
// testing them first would waste time warming up the CPU before
|
||||
// reaching the thread counts that actually matter.
|
||||
int start = std::max(1, max_threads / 2);
|
||||
for (int t = start; t <= max_threads; t++)
|
||||
candidates.push_back(t);
|
||||
}
|
||||
|
||||
/// Average warmup duration based on tests completed so far
|
||||
float avgWarmupSecs() const {
|
||||
if (current_index > 0)
|
||||
return total_warmup_secs / (float)current_index;
|
||||
return (MIN_WARMUP_SECS + MAX_WARMUP_SECS) * 0.5f; // initial estimate
|
||||
}
|
||||
|
||||
/// Estimated seconds per test (uses observed warmup average)
|
||||
float perTestSecs() const {
|
||||
return avgWarmupSecs() + MEASURE_SECS;
|
||||
}
|
||||
|
||||
float totalEstimatedSecs() const {
|
||||
int n = (int)candidates.size();
|
||||
if (n <= 0) return 0.0f;
|
||||
// Completed tests use actual time; remaining use estimate
|
||||
float completed_time = total_warmup_secs
|
||||
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
int remaining = n - current_index;
|
||||
float remaining_time = (float)remaining * (avgWarmupSecs() + MEASURE_SECS)
|
||||
+ (float)std::max(0, remaining - 1) * COOLDOWN_SECS;
|
||||
return completed_time + remaining_time;
|
||||
}
|
||||
|
||||
float elapsedSecs() const {
|
||||
float completed = total_warmup_secs
|
||||
+ (float)current_index * (MEASURE_SECS + COOLDOWN_SECS);
|
||||
return completed + phase_timer;
|
||||
}
|
||||
|
||||
float progress() const {
|
||||
float total = totalEstimatedSecs();
|
||||
return (total > 0.0f) ? std::min(1.0f, elapsedSecs() / total) : 0.0f;
|
||||
}
|
||||
|
||||
void resetStabilityTracking() {
|
||||
prev_window_avg = 0.0;
|
||||
window_sum = 0.0;
|
||||
window_samples = 0;
|
||||
window_timer = 0.0f;
|
||||
consecutive_stable = 0;
|
||||
}
|
||||
};
|
||||
static ThreadBenchmark s_benchmark;
|
||||
|
||||
bool IsMiningBenchmarkActive() {
|
||||
return s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
|
||||
s_benchmark.phase != ThreadBenchmark::Phase::Done;
|
||||
return s_benchmark.active();
|
||||
}
|
||||
|
||||
// Pool mode state
|
||||
@@ -178,59 +61,6 @@ static bool s_pool_state_loaded = false;
|
||||
static bool s_show_pool_log = false; // Toggle: false=chart, true=log
|
||||
static bool s_show_solo_log = false; // Toggle: false=chart, true=log (solo mode)
|
||||
|
||||
// Get max threads based on hardware
|
||||
static int GetMaxMiningThreads()
|
||||
{
|
||||
int hw_threads = std::thread::hardware_concurrency();
|
||||
return std::max(1, hw_threads);
|
||||
}
|
||||
|
||||
// Format hashrate with appropriate units
|
||||
static std::string FormatHashrate(double hashrate)
|
||||
{
|
||||
char buf[64];
|
||||
if (hashrate >= 1e12) {
|
||||
snprintf(buf, sizeof(buf), "%.2f TH/s", hashrate / 1e12);
|
||||
} else if (hashrate >= 1e9) {
|
||||
snprintf(buf, sizeof(buf), "%.2f GH/s", hashrate / 1e9);
|
||||
} else if (hashrate >= 1e6) {
|
||||
snprintf(buf, sizeof(buf), "%.2f MH/s", hashrate / 1e6);
|
||||
} else if (hashrate >= 1e3) {
|
||||
snprintf(buf, sizeof(buf), "%.2f KH/s", hashrate / 1e3);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "%.2f H/s", hashrate);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
// Calculate estimated hours to find a block
|
||||
static double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||
{
|
||||
if (localHashrate <= 0 || networkHashrate <= 0) return 0;
|
||||
double blocksPerHour = 3600.0 / 75.0;
|
||||
double yourShare = localHashrate / networkHashrate;
|
||||
if (yourShare <= 0) return 0;
|
||||
return 1.0 / (blocksPerHour * yourShare);
|
||||
}
|
||||
|
||||
// Format estimated time
|
||||
static std::string FormatEstTime(double est_hours)
|
||||
{
|
||||
char buf[64];
|
||||
if (est_hours <= 0) {
|
||||
return "N/A";
|
||||
} else if (est_hours < 1.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.0f min", est_hours * 60.0);
|
||||
} else if (est_hours < 24.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.1f hrs", est_hours);
|
||||
} else if (est_hours < 168.0) {
|
||||
snprintf(buf, sizeof(buf), "~%.1f days", est_hours / 24.0);
|
||||
} else {
|
||||
snprintf(buf, sizeof(buf), "~%.1f weeks", est_hours / 168.0);
|
||||
}
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
static void RenderMiningTabContent(App* app);
|
||||
|
||||
void RenderMiningTab(App* app)
|
||||
@@ -279,9 +109,9 @@ static void RenderMiningTabContent(App* app)
|
||||
if (!s_threads_initialized) {
|
||||
int saved = app->settings()->getPoolThreads();
|
||||
if (mining.generate)
|
||||
s_selected_threads = std::max(1, mining.genproclimit);
|
||||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||||
else if (saved > 0)
|
||||
s_selected_threads = std::min(saved, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(saved, max_threads);
|
||||
else
|
||||
s_selected_threads = 1;
|
||||
s_threads_initialized = true;
|
||||
@@ -295,11 +125,11 @@ static void RenderMiningTabContent(App* app)
|
||||
// than threads_active from the xmrig API which lags during restarts.
|
||||
int reqThreads = app->getXmrigRequestedThreads();
|
||||
if (reqThreads > 0)
|
||||
s_selected_threads = std::min(reqThreads, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(reqThreads, max_threads);
|
||||
else if (state.pool_mining.threads_active > 0)
|
||||
s_selected_threads = std::min(state.pool_mining.threads_active, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(state.pool_mining.threads_active, max_threads);
|
||||
} else if (mining.generate && mining.genproclimit > 0) {
|
||||
s_selected_threads = std::min(mining.genproclimit, max_threads);
|
||||
s_selected_threads = ClampMiningThreads(mining.genproclimit, max_threads);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,15 +158,8 @@ static void RenderMiningTabContent(App* app)
|
||||
{
|
||||
static bool s_pool_worker_defaulted = false;
|
||||
std::string workerStr(s_pool_worker);
|
||||
if (!s_pool_worker_defaulted && !state.addresses.empty() &&
|
||||
(workerStr.empty() || workerStr == "x")) {
|
||||
std::string defaultAddr;
|
||||
for (const auto& addr : state.addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
defaultAddr = addr.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (shouldDefaultPoolWorker(workerStr, s_pool_worker_defaulted) && !state.addresses.empty()) {
|
||||
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
|
||||
if (!defaultAddr.empty()) {
|
||||
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
|
||||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||||
@@ -368,136 +191,27 @@ static void RenderMiningTabContent(App* app)
|
||||
// Determine active mining state for UI
|
||||
// Include pool mining running state even when user just switched to solo,
|
||||
// so the button shows STOP/STOPPING while xmrig shuts down.
|
||||
bool isMiningActive = s_pool_mode
|
||||
? state.pool_mining.xmrig_running
|
||||
: (mining.generate || state.pool_mining.xmrig_running);
|
||||
bool isMiningActive = IsPoolMiningActive(s_pool_mode,
|
||||
state.pool_mining.xmrig_running,
|
||||
mining.generate);
|
||||
|
||||
// ================================================================
|
||||
// Thread Benchmark state machine — runs pool mining at each candidate
|
||||
// thread count to find the optimal setting for this CPU.
|
||||
// ================================================================
|
||||
if (s_benchmark.phase != ThreadBenchmark::Phase::Idle &&
|
||||
s_benchmark.phase != ThreadBenchmark::Phase::Done) {
|
||||
float dt = ImGui::GetIO().DeltaTime;
|
||||
s_benchmark.phase_timer += dt;
|
||||
|
||||
switch (s_benchmark.phase) {
|
||||
case ThreadBenchmark::Phase::Starting:
|
||||
// Start pool mining at current candidate
|
||||
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
|
||||
int t = s_benchmark.candidates[s_benchmark.current_index];
|
||||
app->stopPoolMining();
|
||||
app->startPoolMining(t);
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::WarmingUp;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
s_benchmark.resetStabilityTracking();
|
||||
s_benchmark.measure_sum = 0.0;
|
||||
s_benchmark.measure_samples = 0;
|
||||
} else {
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::WarmingUp: {
|
||||
// Adaptive warmup: wait for hashrate to stabilize (thermal steady state).
|
||||
// After MIN_WARMUP (90s), compare rolling 10s hashrate windows.
|
||||
// Require 3 consecutive windows within 5% to confirm equilibrium.
|
||||
// Laptops can take 2-3+ minutes for thermal throttling to fully
|
||||
// manifest, so a single stable window isn't sufficient.
|
||||
bool past_min = s_benchmark.phase_timer >= ThreadBenchmark::MIN_WARMUP_SECS;
|
||||
bool past_max = s_benchmark.phase_timer >= ThreadBenchmark::MAX_WARMUP_SECS;
|
||||
|
||||
// Accumulate samples into current window
|
||||
if (state.pool_mining.hashrate_10s > 0.0) {
|
||||
s_benchmark.window_sum += state.pool_mining.hashrate_10s;
|
||||
s_benchmark.window_samples++;
|
||||
}
|
||||
s_benchmark.window_timer += dt;
|
||||
|
||||
bool stable = false;
|
||||
if (past_min && s_benchmark.window_timer >= ThreadBenchmark::STABILITY_WINDOW_SECS
|
||||
&& s_benchmark.window_samples > 0) {
|
||||
double current_avg = s_benchmark.window_sum / s_benchmark.window_samples;
|
||||
if (s_benchmark.prev_window_avg > 0.0) {
|
||||
double change = std::abs(current_avg - s_benchmark.prev_window_avg)
|
||||
/ s_benchmark.prev_window_avg;
|
||||
if (change < ThreadBenchmark::STABILITY_THRESHOLD)
|
||||
s_benchmark.consecutive_stable++;
|
||||
else
|
||||
s_benchmark.consecutive_stable = 0; // reset on instability
|
||||
if (s_benchmark.consecutive_stable >= ThreadBenchmark::STABLE_WINDOWS_NEEDED)
|
||||
stable = true;
|
||||
}
|
||||
// Shift window
|
||||
s_benchmark.prev_window_avg = current_avg;
|
||||
s_benchmark.window_sum = 0.0;
|
||||
s_benchmark.window_samples = 0;
|
||||
s_benchmark.window_timer = 0.0f;
|
||||
}
|
||||
|
||||
if (stable || past_max) {
|
||||
s_benchmark.total_warmup_secs += s_benchmark.phase_timer;
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Measuring;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
s_benchmark.measure_sum = 0.0;
|
||||
s_benchmark.measure_samples = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case ThreadBenchmark::Phase::Measuring:
|
||||
// Sample average hashrate — reflects sustained (thermally throttled) performance
|
||||
if (state.pool_mining.hashrate_10s > 0.0) {
|
||||
s_benchmark.measure_sum += state.pool_mining.hashrate_10s;
|
||||
s_benchmark.measure_samples++;
|
||||
}
|
||||
if (s_benchmark.phase_timer >= ThreadBenchmark::MEASURE_SECS) {
|
||||
int t = s_benchmark.candidates[s_benchmark.current_index];
|
||||
double avg = (s_benchmark.measure_samples > 0)
|
||||
? s_benchmark.measure_sum / s_benchmark.measure_samples
|
||||
: 0.0;
|
||||
s_benchmark.results.push_back({t, avg});
|
||||
if (avg > s_benchmark.optimal_hashrate) {
|
||||
s_benchmark.optimal_hashrate = avg;
|
||||
s_benchmark.optimal_threads = t;
|
||||
}
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Advancing;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::Advancing:
|
||||
if (s_benchmark.active()) {
|
||||
auto benchmarkUpdate = AdvanceThreadBenchmark(
|
||||
s_benchmark, ImGui::GetIO().DeltaTime, state.pool_mining.hashrate_10s);
|
||||
if (benchmarkUpdate.stopPoolMining) {
|
||||
app->stopPoolMining();
|
||||
s_benchmark.current_index++;
|
||||
if (s_benchmark.current_index < (int)s_benchmark.candidates.size()) {
|
||||
// Cool down before next test to reduce thermal throttling bias
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::CoolingDown;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
} else {
|
||||
// Done — apply optimal thread count
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Done;
|
||||
if (s_benchmark.optimal_threads > 0) {
|
||||
s_selected_threads = s_benchmark.optimal_threads;
|
||||
app->settings()->setPoolThreads(s_selected_threads);
|
||||
app->settings()->save();
|
||||
}
|
||||
// Restart mining if it was running before, using optimal count
|
||||
if (s_benchmark.was_pool_running && s_benchmark.optimal_threads > 0) {
|
||||
app->startPoolMining(s_benchmark.optimal_threads);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case ThreadBenchmark::Phase::CoolingDown:
|
||||
// Idle pause — let CPU temps drop before starting next test
|
||||
if (s_benchmark.phase_timer >= ThreadBenchmark::COOLDOWN_SECS) {
|
||||
s_benchmark.phase = ThreadBenchmark::Phase::Starting;
|
||||
s_benchmark.phase_timer = 0.0f;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (benchmarkUpdate.saveOptimalThreads) {
|
||||
s_selected_threads = benchmarkUpdate.optimalThreads;
|
||||
app->settings()->setPoolThreads(s_selected_threads);
|
||||
app->settings()->save();
|
||||
}
|
||||
if (benchmarkUpdate.startPoolMining) {
|
||||
app->startPoolMining(benchmarkUpdate.startThreads);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,10 +404,7 @@ static void RenderMiningTabContent(App* app)
|
||||
ImDrawList* dl2 = ImGui::GetWindowDrawList();
|
||||
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
|
||||
std::string currentUrl(s_pool_url);
|
||||
bool alreadySaved = false;
|
||||
for (const auto& u : app->settings()->getSavedPoolUrls()) {
|
||||
if (u == currentUrl) { alreadySaved = true; break; }
|
||||
}
|
||||
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolUrls(), currentUrl);
|
||||
if (btnHov) {
|
||||
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
|
||||
StateHover(), 4.0f * dp);
|
||||
@@ -875,10 +586,7 @@ static void RenderMiningTabContent(App* app)
|
||||
ImDrawList* dl2 = ImGui::GetWindowDrawList();
|
||||
ImVec2 btnCenter(btnPos.x + btnSize.x * 0.5f, btnPos.y + btnSize.y * 0.5f);
|
||||
std::string currentWorker(s_pool_worker);
|
||||
bool alreadySaved = false;
|
||||
for (const auto& w : app->settings()->getSavedPoolWorkers()) {
|
||||
if (w == currentWorker) { alreadySaved = true; break; }
|
||||
}
|
||||
bool alreadySaved = miningValueAlreadySaved(app->settings()->getSavedPoolWorkers(), currentWorker);
|
||||
if (btnHov) {
|
||||
dl2->AddRectFilled(btnPos, ImVec2(btnPos.x + btnSize.x, btnPos.y + btnSize.y),
|
||||
StateHover(), 4.0f * dp);
|
||||
@@ -1036,16 +744,10 @@ static void RenderMiningTabContent(App* app)
|
||||
OnSurfaceMedium(), resetIcon);
|
||||
|
||||
if (btnClk) {
|
||||
strncpy(s_pool_url, "pool.dragonx.is:3433", sizeof(s_pool_url) - 1);
|
||||
strncpy(s_pool_url, defaultPoolUrl(), sizeof(s_pool_url) - 1);
|
||||
// Default to user's first shielded (z) address for pool payouts.
|
||||
// Leave blank if no z-address exists yet.
|
||||
std::string defaultAddr;
|
||||
for (const auto& addr : state.addresses) {
|
||||
if (addr.type == "shielded" && !addr.address.empty()) {
|
||||
defaultAddr = addr.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::string defaultAddr = defaultPoolWorkerAddress(state.addresses);
|
||||
strncpy(s_pool_worker, defaultAddr.c_str(), sizeof(s_pool_worker) - 1);
|
||||
s_pool_worker[sizeof(s_pool_worker) - 1] = '\0';
|
||||
s_pool_settings_dirty = true;
|
||||
|
||||
72
src/ui/windows/mining_tab_helpers.cpp
Normal file
72
src/ui/windows/mining_tab_helpers.cpp
Normal file
@@ -0,0 +1,72 @@
|
||||
#include "mining_tab_helpers.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <thread>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
int GetMaxMiningThreads()
|
||||
{
|
||||
int hardwareThreads = static_cast<int>(std::thread::hardware_concurrency());
|
||||
return std::max(1, hardwareThreads);
|
||||
}
|
||||
|
||||
int ClampMiningThreads(int requestedThreads, int maxThreads)
|
||||
{
|
||||
int boundedMax = std::max(1, maxThreads);
|
||||
return std::clamp(requestedThreads, 1, boundedMax);
|
||||
}
|
||||
|
||||
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning)
|
||||
{
|
||||
return poolMode ? xmrigRunning : (soloMiningRunning || xmrigRunning);
|
||||
}
|
||||
|
||||
std::string FormatHashrate(double hashrate)
|
||||
{
|
||||
char buffer[64];
|
||||
if (hashrate >= 1e12) {
|
||||
snprintf(buffer, sizeof(buffer), "%.2f TH/s", hashrate / 1e12);
|
||||
} else if (hashrate >= 1e9) {
|
||||
snprintf(buffer, sizeof(buffer), "%.2f GH/s", hashrate / 1e9);
|
||||
} else if (hashrate >= 1e6) {
|
||||
snprintf(buffer, sizeof(buffer), "%.2f MH/s", hashrate / 1e6);
|
||||
} else if (hashrate >= 1e3) {
|
||||
snprintf(buffer, sizeof(buffer), "%.2f KH/s", hashrate / 1e3);
|
||||
} else {
|
||||
snprintf(buffer, sizeof(buffer), "%.2f H/s", hashrate);
|
||||
}
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty)
|
||||
{
|
||||
(void)difficulty;
|
||||
if (localHashrate <= 0.0 || networkHashrate <= 0.0) return 0.0;
|
||||
double blocksPerHour = 3600.0 / 75.0;
|
||||
double share = localHashrate / networkHashrate;
|
||||
if (share <= 0.0) return 0.0;
|
||||
return 1.0 / (blocksPerHour * share);
|
||||
}
|
||||
|
||||
std::string FormatEstTime(double estimatedHours)
|
||||
{
|
||||
char buffer[64];
|
||||
if (estimatedHours <= 0.0) {
|
||||
return "N/A";
|
||||
} else if (estimatedHours < 1.0) {
|
||||
snprintf(buffer, sizeof(buffer), "~%.0f min", estimatedHours * 60.0);
|
||||
} else if (estimatedHours < 24.0) {
|
||||
snprintf(buffer, sizeof(buffer), "~%.1f hrs", estimatedHours);
|
||||
} else if (estimatedHours < 168.0) {
|
||||
snprintf(buffer, sizeof(buffer), "~%.1f days", estimatedHours / 24.0);
|
||||
} else {
|
||||
snprintf(buffer, sizeof(buffer), "~%.1f weeks", estimatedHours / 168.0);
|
||||
}
|
||||
return std::string(buffer);
|
||||
}
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
16
src/ui/windows/mining_tab_helpers.h
Normal file
16
src/ui/windows/mining_tab_helpers.h
Normal file
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace ui {
|
||||
|
||||
int GetMaxMiningThreads();
|
||||
int ClampMiningThreads(int requestedThreads, int maxThreads);
|
||||
bool IsPoolMiningActive(bool poolMode, bool xmrigRunning, bool soloMiningRunning);
|
||||
std::string FormatHashrate(double hashrate);
|
||||
double EstimateHoursToBlock(double localHashrate, double networkHashrate, double difficulty);
|
||||
std::string FormatEstTime(double estimatedHours);
|
||||
|
||||
} // namespace ui
|
||||
} // namespace dragonx
|
||||
@@ -564,7 +564,6 @@ void RenderPeersTab(App* app)
|
||||
ImVec2 bMax(btnX + btnW, btnY + btnH);
|
||||
|
||||
bool btnHovered = material::IsRectHovered(bMin, bMax);
|
||||
bool btnClicked = btnHovered && ImGui::IsMouseClicked(0);
|
||||
|
||||
// Glass panel background
|
||||
GlassPanelSpec btnGlass;
|
||||
@@ -635,7 +634,8 @@ void RenderPeersTab(App* app)
|
||||
} else {
|
||||
lblCol = btnHovered ? OnSurface() : WithAlpha(OnSurface(), 160);
|
||||
}
|
||||
float lblX = cx + iconSz * 0.5f + Layout::spacingXs();
|
||||
float labelAreaX = bMin.x + padH + iconSz + Layout::spacingXs();
|
||||
float lblX = labelAreaX + (maxLblW - lblSz.x) * 0.5f;
|
||||
float lblY = cy - lblSz.y * 0.5f;
|
||||
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(lblX, lblY), lblCol, label);
|
||||
}
|
||||
|
||||
@@ -219,14 +219,7 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
|
||||
// Auto-select the address with the largest balance on first load
|
||||
if (!s_auto_selected && app->isConnected() && !state.addresses.empty()) {
|
||||
int bestIdx = -1;
|
||||
double bestBal = 0.0;
|
||||
for (size_t i = 0; i < state.addresses.size(); i++) {
|
||||
if (state.addresses[i].balance > bestBal && state.addresses[i].isSpendable()) {
|
||||
bestBal = state.addresses[i].balance;
|
||||
bestIdx = static_cast<int>(i);
|
||||
}
|
||||
}
|
||||
int bestIdx = bestSpendableAddressIndex(state.addresses);
|
||||
if (bestIdx >= 0) {
|
||||
s_selected_from_idx = bestIdx;
|
||||
snprintf(s_from_address, sizeof(s_from_address), "%s",
|
||||
@@ -260,16 +253,7 @@ static void RenderSourceDropdown(App* app, float width) {
|
||||
ImGui::TextDisabled("%s", TR("no_addresses_available"));
|
||||
} else {
|
||||
// Sort by balance descending, only show spendable addresses with balance
|
||||
std::vector<size_t> sortedIdx;
|
||||
sortedIdx.reserve(state.addresses.size());
|
||||
for (size_t i = 0; i < state.addresses.size(); i++) {
|
||||
if (state.addresses[i].balance > 0 && state.addresses[i].isSpendable())
|
||||
sortedIdx.push_back(i);
|
||||
}
|
||||
std::sort(sortedIdx.begin(), sortedIdx.end(),
|
||||
[&](size_t a, size_t b) {
|
||||
return state.addresses[a].balance > state.addresses[b].balance;
|
||||
});
|
||||
std::vector<size_t> sortedIdx = sortedSpendableAddressIndices(state.addresses);
|
||||
|
||||
if (sortedIdx.empty()) {
|
||||
ImGui::TextDisabled("%s", TR("send_no_balance"));
|
||||
@@ -731,14 +715,16 @@ void RenderSendConfirmPopup(App* app) {
|
||||
|
||||
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("send_amount_details"));
|
||||
ImVec2 cMin = ImGui::GetCursorScreenPos();
|
||||
float cH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
|
||||
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
|
||||
float configuredH = std::max(schema::UI().drawElement("tabs.send", "confirm-amount-card-min-height").size, schema::UI().drawElement("tabs.send", "confirm-amount-card-height").size * popVs);
|
||||
float contentH = Layout::spacingMd() * 2.0f + capFont->LegacySize * 2.0f + sub1->LegacySize + rowStep * 2.0f;
|
||||
float cH = std::max(configuredH, contentH);
|
||||
ImVec2 cMax(cMin.x + popW, cMin.y + cH);
|
||||
GlassPanelSpec gs; gs.rounding = popGlassRound;
|
||||
DrawGlassPanel(popDl, cMin, cMax, gs);
|
||||
|
||||
float cx = cMin.x + Layout::spacingMd() + Layout::spacingXs();
|
||||
float cy = cMin.y + Layout::spacingSm() + Layout::spacingXs();
|
||||
float rowStep = std::max(schema::UI().drawElement("tabs.send", "confirm-row-step-min").size, schema::UI().drawElement("tabs.send", "confirm-row-step").size * popVs);
|
||||
float cx = cMin.x + Layout::spacingLg();
|
||||
float cy = cMin.y + Layout::spacingMd();
|
||||
|
||||
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_amount"));
|
||||
snprintf(buf, sizeof(buf), "%.8f %s", s_amount, DRAGONX_TICKER);
|
||||
@@ -756,11 +742,11 @@ void RenderSendConfirmPopup(App* app) {
|
||||
snprintf(buf, sizeof(buf), "$%.6f", s_fee * market.price_usd);
|
||||
popDl->AddText(capFont, capFont->LegacySize, ImVec2(cx + usdX, cy), OnSurfaceDisabled(), buf);
|
||||
}
|
||||
cy += Layout::spacingSm();
|
||||
popDl->AddLine(ImVec2(cx, cy + Layout::spacingMd()),
|
||||
ImVec2(cx + popW - Layout::spacingXl(), cy + Layout::spacingMd()),
|
||||
cy += rowStep * 0.5f;
|
||||
popDl->AddLine(ImVec2(cx, cy),
|
||||
ImVec2(cMax.x - Layout::spacingLg(), cy),
|
||||
ImGui::GetColorU32(Divider()), S.drawElement("tabs.send", "confirm-divider-thickness").size);
|
||||
cy += rowStep;
|
||||
cy += rowStep * 0.5f;
|
||||
|
||||
popDl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceMedium(), TR("send_total"));
|
||||
snprintf(buf, sizeof(buf), "%.8f %s", total, DRAGONX_TICKER);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "../../app.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../theme.h"
|
||||
#include "../schema/ui_schema.h"
|
||||
#include "../material/draw_helpers.h"
|
||||
@@ -175,16 +176,7 @@ void TransactionDetailsDialog::render(App* app)
|
||||
|
||||
if (material::StyledButton(TR("tx_view_explorer"), ImVec2(button_width, 0), S.resolveFont(bottomBtn.font))) {
|
||||
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
|
||||
// Platform-specific URL opening
|
||||
#ifdef _WIN32
|
||||
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
#elif __APPLE__
|
||||
std::string cmd = "open \"" + url + "\"";
|
||||
system(cmd.c_str());
|
||||
#else
|
||||
std::string cmd = "xdg-open \"" + url + "\" &";
|
||||
system(cmd.c_str());
|
||||
#endif
|
||||
util::Platform::openUrl(url);
|
||||
}
|
||||
|
||||
ImGui::SameLine();
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "export_transactions_dialog.h"
|
||||
#include "../../app.h"
|
||||
#include "../../util/i18n.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "../../config/settings.h"
|
||||
#include "../../config/version.h"
|
||||
#include "../theme.h"
|
||||
@@ -722,15 +723,7 @@ void RenderTransactionsTab(App* app)
|
||||
ImGui::Separator();
|
||||
if (ImGui::MenuItem(TR("view_on_explorer"))) {
|
||||
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
|
||||
#ifdef _WIN32
|
||||
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
#elif __APPLE__
|
||||
std::string cmd = "open \"" + url + "\"";
|
||||
system(cmd.c_str());
|
||||
#else
|
||||
std::string cmd = "xdg-open \"" + url + "\" &";
|
||||
system(cmd.c_str());
|
||||
#endif
|
||||
util::Platform::openUrl(url);
|
||||
}
|
||||
if (ImGui::MenuItem(TR("view_details"))) {
|
||||
if (tx.orig_idx >= 0 && tx.orig_idx < (int)state.transactions.size())
|
||||
@@ -798,15 +791,7 @@ void RenderTransactionsTab(App* app)
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton(TrId("explorer", "detail").c_str(), S.resolveFont("button"))) {
|
||||
std::string url = app->settings()->getTxExplorerUrl() + tx.txid;
|
||||
#ifdef _WIN32
|
||||
ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
#elif __APPLE__
|
||||
std::string cmd2 = "open \"" + url + "\"";
|
||||
system(cmd2.c_str());
|
||||
#else
|
||||
std::string cmd2 = "xdg-open \"" + url + "\" &";
|
||||
system(cmd2.c_str());
|
||||
#endif
|
||||
util::Platform::openUrl(url);
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (TactileSmallButton(TrId("full_details", "detail").c_str(), S.resolveFont("button"))) {
|
||||
|
||||
20
src/util/amount_format.cpp
Normal file
20
src/util/amount_format.cpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#include "amount_format.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <iomanip>
|
||||
#include <locale>
|
||||
#include <sstream>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
std::string formatAmountFixed(double amount, int decimals)
|
||||
{
|
||||
std::ostringstream out;
|
||||
out.imbue(std::locale::classic());
|
||||
out << std::fixed << std::setprecision(std::max(0, decimals)) << amount;
|
||||
return out.str();
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
11
src/util/amount_format.h
Normal file
11
src/util/amount_format.h
Normal file
@@ -0,0 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
std::string formatAmountFixed(double amount, int decimals = 8);
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
118
src/util/async_task_manager.cpp
Normal file
118
src/util/async_task_manager.cpp
Normal file
@@ -0,0 +1,118 @@
|
||||
#include "async_task_manager.h"
|
||||
#include "logger.h"
|
||||
|
||||
#include <exception>
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
AsyncTaskManager::~AsyncTaskManager()
|
||||
{
|
||||
cancelAll();
|
||||
joinAll();
|
||||
}
|
||||
|
||||
void AsyncTaskManager::submit(std::string name, Task task)
|
||||
{
|
||||
if (!task) return;
|
||||
|
||||
reapCompleted();
|
||||
join(name);
|
||||
|
||||
auto cancelled = std::make_shared<std::atomic<bool>>(false);
|
||||
auto done = std::make_shared<std::atomic<bool>>(false);
|
||||
Token token(cancelled);
|
||||
std::string taskName = name;
|
||||
|
||||
std::thread worker([taskName, token, done, task = std::move(task)]() mutable {
|
||||
try {
|
||||
task(token);
|
||||
} catch (const std::exception& e) {
|
||||
DEBUG_LOGF("[AsyncTask:%s] failed: %s\n", taskName.c_str(), e.what());
|
||||
} catch (...) {
|
||||
DEBUG_LOGF("[AsyncTask:%s] failed with unknown exception\n", taskName.c_str());
|
||||
}
|
||||
done->store(true, std::memory_order_release);
|
||||
});
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
tasks_.push_back({std::move(name), std::move(cancelled), std::move(done), std::move(worker)});
|
||||
}
|
||||
|
||||
void AsyncTaskManager::cancelAll()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (auto& task : tasks_) {
|
||||
task.cancelled->store(true, std::memory_order_relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncTaskManager::join(const std::string& name)
|
||||
{
|
||||
std::vector<TaskEntry> toJoin;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = tasks_.begin();
|
||||
while (it != tasks_.end()) {
|
||||
if (it->name == name) {
|
||||
toJoin.push_back(std::move(*it));
|
||||
it = tasks_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& task : toJoin) {
|
||||
if (task.worker.joinable()) task.worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncTaskManager::joinAll()
|
||||
{
|
||||
std::vector<TaskEntry> toJoin;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
toJoin.swap(tasks_);
|
||||
}
|
||||
|
||||
for (auto& task : toJoin) {
|
||||
if (task.worker.joinable()) task.worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
void AsyncTaskManager::reapCompleted()
|
||||
{
|
||||
std::vector<TaskEntry> toJoin;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = tasks_.begin();
|
||||
while (it != tasks_.end()) {
|
||||
if (it->done->load(std::memory_order_acquire)) {
|
||||
toJoin.push_back(std::move(*it));
|
||||
it = tasks_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (auto& task : toJoin) {
|
||||
if (task.worker.joinable()) task.worker.join();
|
||||
}
|
||||
}
|
||||
|
||||
bool AsyncTaskManager::isRunning(const std::string& name) const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
for (const auto& task : tasks_) {
|
||||
if (task.name == name && !task.done->load(std::memory_order_acquire)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
58
src/util/async_task_manager.h
Normal file
58
src/util/async_task_manager.h
Normal file
@@ -0,0 +1,58 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
class AsyncTaskManager {
|
||||
public:
|
||||
class Token {
|
||||
public:
|
||||
Token() = default;
|
||||
explicit Token(std::shared_ptr<std::atomic<bool>> cancelled)
|
||||
: cancelled_(std::move(cancelled)) {}
|
||||
|
||||
bool cancelled() const {
|
||||
return cancelled_ && cancelled_->load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
private:
|
||||
std::shared_ptr<std::atomic<bool>> cancelled_;
|
||||
};
|
||||
|
||||
using Task = std::function<void(const Token&)>;
|
||||
|
||||
AsyncTaskManager() = default;
|
||||
~AsyncTaskManager();
|
||||
|
||||
AsyncTaskManager(const AsyncTaskManager&) = delete;
|
||||
AsyncTaskManager& operator=(const AsyncTaskManager&) = delete;
|
||||
|
||||
void submit(std::string name, Task task);
|
||||
void cancelAll();
|
||||
void join(const std::string& name);
|
||||
void joinAll();
|
||||
void reapCompleted();
|
||||
bool isRunning(const std::string& name) const;
|
||||
|
||||
private:
|
||||
struct TaskEntry {
|
||||
std::string name;
|
||||
std::shared_ptr<std::atomic<bool>> cancelled;
|
||||
std::shared_ptr<std::atomic<bool>> done;
|
||||
std::thread worker;
|
||||
};
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::vector<TaskEntry> tasks_;
|
||||
};
|
||||
|
||||
} // namespace util
|
||||
} // namespace dragonx
|
||||
@@ -68,6 +68,7 @@ Bootstrap::~Bootstrap() {
|
||||
|
||||
void Bootstrap::start(const std::string& dataDir, const std::string& url) {
|
||||
if (worker_running_) return; // already running
|
||||
if (worker_.joinable()) worker_.join();
|
||||
|
||||
cancel_requested_ = false;
|
||||
worker_running_ = true;
|
||||
@@ -175,7 +176,6 @@ void Bootstrap::start(const std::string& dataDir, const std::string& url) {
|
||||
setProgress(State::Completed, "Bootstrap complete!");
|
||||
worker_running_ = false;
|
||||
});
|
||||
worker_.detach();
|
||||
}
|
||||
|
||||
void Bootstrap::cancel() {
|
||||
|
||||
@@ -917,6 +917,7 @@ void I18n::loadBuiltinEnglish()
|
||||
strings_["market_now"] = "Now";
|
||||
strings_["market_pct_shielded"] = "%.0f%% Shielded";
|
||||
strings_["market_portfolio"] = "PORTFOLIO";
|
||||
strings_["market_price_loading"] = "Loading price data...";
|
||||
strings_["market_price_unavailable"] = "Price data unavailable";
|
||||
strings_["market_refresh_price"] = "Refresh price data";
|
||||
strings_["market_trade_on"] = "Trade on %s";
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cerrno>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
@@ -31,6 +33,8 @@
|
||||
#include <pwd.h>
|
||||
#include <dirent.h>
|
||||
#include <dlfcn.h>
|
||||
#include <spawn.h>
|
||||
#include <sys/wait.h>
|
||||
#ifdef __APPLE__
|
||||
#include <mach-o/dyld.h>
|
||||
#include <sys/sysctl.h>
|
||||
@@ -40,25 +44,83 @@
|
||||
|
||||
#include "../util/logger.h"
|
||||
|
||||
#ifndef _WIN32
|
||||
extern char **environ;
|
||||
#endif
|
||||
|
||||
namespace dragonx {
|
||||
namespace util {
|
||||
|
||||
namespace {
|
||||
|
||||
bool hasAllowedUrlScheme(const std::string& url)
|
||||
{
|
||||
auto startsWithNoCase = [&url](const char* prefix) {
|
||||
for (size_t i = 0; prefix[i] != '\0'; ++i) {
|
||||
if (i >= url.size()) return false;
|
||||
unsigned char lhs = static_cast<unsigned char>(url[i]);
|
||||
unsigned char rhs = static_cast<unsigned char>(prefix[i]);
|
||||
if (std::tolower(lhs) != std::tolower(rhs)) return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
return startsWithNoCase("http://") || startsWithNoCase("https://");
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
bool launchOpener(const char* opener, const std::string& target)
|
||||
{
|
||||
char* const argv[] = {
|
||||
const_cast<char*>(opener),
|
||||
const_cast<char*>(target.c_str()),
|
||||
nullptr
|
||||
};
|
||||
|
||||
pid_t pid = 0;
|
||||
int rc = posix_spawnp(&pid, opener, nullptr, nullptr, argv, environ);
|
||||
if (rc != 0) {
|
||||
DEBUG_LOGF("Failed to launch %s: %s\n", opener, std::strerror(rc));
|
||||
return false;
|
||||
}
|
||||
|
||||
int status = 0;
|
||||
pid_t waited = 0;
|
||||
do {
|
||||
waited = waitpid(pid, &status, 0);
|
||||
} while (waited < 0 && errno == EINTR);
|
||||
|
||||
if (waited < 0) {
|
||||
DEBUG_LOGF("Failed waiting for %s launcher: %s\n", opener, std::strerror(errno));
|
||||
return false;
|
||||
}
|
||||
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
||||
DEBUG_LOGF("Launcher %s exited with status %d\n", opener,
|
||||
WIFEXITED(status) ? WEXITSTATUS(status) : -1);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endif
|
||||
|
||||
} // namespace
|
||||
|
||||
bool Platform::openUrl(const std::string& url)
|
||||
{
|
||||
if (url.empty()) return false;
|
||||
if (!hasAllowedUrlScheme(url)) {
|
||||
DEBUG_LOGF("Refusing to open URL with unsupported scheme: %s\n", url.c_str());
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
// Windows: Use ShellExecute
|
||||
HINSTANCE result = ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
return (reinterpret_cast<intptr_t>(result) > 32);
|
||||
#elif defined(__APPLE__)
|
||||
// macOS: Use 'open' command
|
||||
std::string cmd = "open \"" + url + "\" &";
|
||||
return (system(cmd.c_str()) == 0);
|
||||
return launchOpener("open", url);
|
||||
#else
|
||||
// Linux: Use xdg-open
|
||||
std::string cmd = "xdg-open \"" + url + "\" >/dev/null 2>&1 &";
|
||||
return (system(cmd.c_str()) == 0);
|
||||
return launchOpener("xdg-open", url);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -68,15 +130,12 @@ bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if (createIfMissing) {
|
||||
#ifdef _WIN32
|
||||
// Windows: Create directory recursively
|
||||
std::string cmd = "mkdir \"" + path + "\" 2>nul";
|
||||
(void)system(cmd.c_str()); // Ignore return value - dir may already exist
|
||||
#else
|
||||
// Linux/macOS: Create directory with parents
|
||||
std::string cmd = "mkdir -p \"" + path + "\" 2>/dev/null";
|
||||
(void)system(cmd.c_str()); // Ignore return value - dir may already exist
|
||||
#endif
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(path, ec);
|
||||
if (ec) {
|
||||
DEBUG_LOGF("Failed to create folder %s: %s\n", path.c_str(), ec.message().c_str());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
@@ -84,13 +143,9 @@ bool Platform::openFolder(const std::string& path, bool createIfMissing)
|
||||
HINSTANCE result = ShellExecuteA(nullptr, "explore", path.c_str(), nullptr, nullptr, SW_SHOWNORMAL);
|
||||
return (reinterpret_cast<intptr_t>(result) > 32);
|
||||
#elif defined(__APPLE__)
|
||||
// macOS: Use 'open' command (works for folders too)
|
||||
std::string cmd = "open \"" + path + "\" &";
|
||||
return (system(cmd.c_str()) == 0);
|
||||
return launchOpener("open", path);
|
||||
#else
|
||||
// Linux: Use xdg-open (works for folders too)
|
||||
std::string cmd = "xdg-open \"" + path + "\" >/dev/null 2>&1 &";
|
||||
return (system(cmd.c_str()) == 0);
|
||||
return launchOpener("xdg-open", path);
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user