feat(lite): make the Console tab interactive (run backend commands)

The lite backend's litelib_execute() is the same command interface as
silentdragonxlite-cli (balance, info, height, list, notes, addresses, sync,
syncstatus, new, send, shield, encrypt, …), so the lite Console can be a real
interactive console — like the full-node RPC console — instead of a read-only
diagnostics log.

Controller: add an async arbitrary-command runner mirroring the broadcast
pattern — runConsoleCommand() splits "<command> [args]" (the first token is the
command, the remainder is passed as the single arg string litelib_execute
expects, since it does NOT whitespace-split), runs the bridge call on a detached
thread that captures the shared bridge (never `this`), and delivers the result
to a main-thread slot drained by takeConsoleResult(). Results are NEVER routed
through LiteDiagnostics (seed/export can return secrets).

Console tab: a command input (Enter to run, Up/Down history via the shared
console_input_model helpers) over a unified scroll buffer that interleaves the
automatic diagnostics events with user command I/O, colour-coded, with the live
status header preserved. The input is disabled while a command runs.

Two backend footguns are intercepted at the UI layer before forwarding:
`clear` (the backend command WIPES wallet tx history — re-bound to clearing the
view, what the user expects) and `quit`/`exit` (would only save; the embedded
backend must stay running with the app).

Test: runConsoleCommand drives the fake backend (info -> raw response; "new zs"
-> exercises the command/arg split; blank line rejected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:32:03 -05:00
parent c8183241c3
commit 4a65dce947
5 changed files with 294 additions and 26 deletions

View File

@@ -272,6 +272,8 @@ LiteWalletController::~LiteWalletController()
// Likewise the broadcast thread (send/shield proving): it captures shared refs (bridge +
// running flag + result slot), never `this`, so detaching is safe.
if (broadcastThread_.joinable()) broadcastThread_.detach();
// The interactive-console command thread follows the same shared-lifetime pattern.
if (consoleThread_.joinable()) consoleThread_.detach();
// The async-open failover thread captures only shared refs (bridge + running flag + result
// slot), never `this`, so detaching is safe if it's still trying servers at shutdown.
if (openThread_.joinable()) openThread_.detach();
@@ -753,6 +755,69 @@ bool LiteWalletController::takeBroadcastResult(LiteBroadcastResult& out)
return true;
}
bool LiteWalletController::runConsoleCommand(std::string commandLine)
{
if (!bridge_ || consoleRunning_->load()) return false;
// Split into command (first token) + the remainder as a single arg string: litelib_execute
// passes args through as ONE element (it does not whitespace-split), matching how send uses
// the JSON-array form.
const size_t cmdBegin = commandLine.find_first_not_of(" \t");
if (cmdBegin == std::string::npos) return false; // blank line
const size_t cmdEnd = commandLine.find_first_of(" \t", cmdBegin);
std::string command = commandLine.substr(
cmdBegin, cmdEnd == std::string::npos ? std::string::npos : cmdEnd - cmdBegin);
std::string args;
if (cmdEnd != std::string::npos) {
const size_t argBegin = commandLine.find_first_not_of(" \t", cmdEnd);
if (argBegin != std::string::npos) args = commandLine.substr(argBegin);
}
if (consoleThread_.joinable()) consoleThread_.join(); // a prior command has fully finished
{
std::lock_guard<std::mutex> lk(*consoleResultMutex_);
consoleResult_->reset();
}
consoleRunning_->store(true);
// Capture only the shared bridge + flags/slot (never `this`) so the thread can outlive us.
auto bridge = bridge_;
auto running = consoleRunning_;
auto mutex = consoleResultMutex_;
auto slot = consoleResult_;
consoleThread_ = std::thread(
[bridge, command, args, echo = std::move(commandLine), running, mutex, slot]() {
LiteConsoleResult r;
r.command = echo;
if (bridge) {
const auto call = bridge->execute(command, args);
r.ok = call.ok;
r.response = call.ok ? call.value
: (call.error.empty() ? "command failed" : call.error);
} else {
r.response = "lite backend unavailable";
}
{
std::lock_guard<std::mutex> lk(*mutex);
*slot = std::move(r);
}
running->store(false);
});
return true;
}
bool LiteWalletController::takeConsoleResult(LiteConsoleResult& out)
{
{
std::lock_guard<std::mutex> lk(*consoleResultMutex_);
if (!consoleResult_->has_value()) return false;
out = std::move(**consoleResult_);
consoleResult_->reset();
}
if (consoleThread_.joinable()) consoleThread_.join(); // producer set its result, then exits
return true;
}
LiteImportResult LiteWalletController::importKey(std::string spendingOrViewingKey)
{
LiteImportResult out;