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:
2026-04-29 12:47:57 -05:00
parent ee8a08e569
commit 9edab31728
95 changed files with 8776 additions and 37563 deletions

View File

@@ -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);
}
}
}
}