Files
ObsidianDragon/src/app_sweep.cpp
DanS 82e61da62f feat(migrate): daemon-update prompt + migrate-to-seed UX polish + i18n
Fixes the Windows migrate-to-seed failure (the old bundled daemon lacked
z_exportmnemonic) and rounds out the flow:

- seed_wallet_creator: actionable "daemon too old" message instead of the
  raw "Method not found" RPC error.
- Startup daemon-update prompt: if the wallet bundles a newer node than the
  installed one, offer to update it (once per version, keyed on a new
  daemon_update_prompted_size setting; never silently clobbers a custom
  node). "Install bundled" now also works when attached to an external /
  leftover daemon (RPC-stops it, then swaps + restarts).
- Migrate-to-seed Intro pre-flight: detect an already-mnemonic wallet
  (offer backup instead), an old daemon, or a locked/disconnected wallet
  before offering to create anything.
- No-funds path: a zero-balance wallet can adopt the new seed wallet
  directly (behind an acknowledgement checkbox) instead of a dead sweep.
- Loading spinners on the Working/Sweeping/Adopting steps; sweep coverage
  for the new intro / no-funds surfaces.
- Translations for all 18 new strings across 8 languages (+ rebuilt CJK
  subset font).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 22:16:47 -05:00

405 lines
19 KiB
C++

// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Screenshot sweep: capture the UI under every skin. Two modes share one state machine:
// - startScreenshotSweep() — the legacy tab-only sweep (<config>/screenshots).
// - startFullUiSweep() — ALSO drives every modal / dialog / multi-step flow / state overlay
// into view (offline, with injected demo data, firing no live ops) and captures each under
// every skin (<config>/screenshots-full/<surface>/<skin>.png + index.md).
// The capture unit is a "surface" (SweepTarget): a base tab, optionally with a setup() that forces
// a modal/step/state on top and a teardown() that clears it. main.cpp reads the framebuffer on the
// settled frame and calls onScreenshotCaptured() to advance.
#include "app.h"
#include "ui/schema/skin_manager.h"
#include "ui/notifications.h"
#include "ui/sidebar.h"
#include "ui/windows/send_tab.h"
#include "util/platform.h"
#include "wallet/wallet_capabilities.h"
#include <sodium.h>
#include <filesystem>
#include <fstream>
namespace dragonx {
namespace {
namespace fs = std::filesystem;
// Filesystem-safe one-word tab name.
const char* sweepPageName(ui::NavPage page)
{
switch (page) {
case ui::NavPage::Overview: return "overview";
case ui::NavPage::Send: return "send";
case ui::NavPage::Receive: return "receive";
case ui::NavPage::History: return "history";
case ui::NavPage::Contacts: return "contacts";
case ui::NavPage::Chat: return "chat";
case ui::NavPage::Mining: return "mining";
case ui::NavPage::Market: return "market";
case ui::NavPage::Console: return "console";
case ui::NavPage::LiteConsole: return "console";
case ui::NavPage::Peers: return "network";
case ui::NavPage::LiteNetwork: return "network";
case ui::NavPage::Explorer: return "explorer";
case ui::NavPage::Settings: return "settings";
default: return "page";
}
}
bool sweepPageEnabled(ui::NavPage page)
{
return wallet::isUiSurfaceAvailable(wallet::currentWalletCapabilities(), ui::NavPageSurface(page));
}
// Deterministic demo secrets/addresses (NON-secret — safe to hold in memory + display).
const char* kDemoMnemonic =
"select milk exit banana type alcohol comic moral drama federal just green "
"elevator render stumble lesson convince organ category caution panther misery pelican immune";
const char* kDemoZAddr =
"zs1demoseedwalletreceiveaddressforuisweepcaptures00000000000000000000000000";
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
} // namespace
std::string App::screenshotDir() const
{
return (fs::path(util::Platform::getObsidianDragonDir()) / "screenshots").string();
}
std::string App::screenshotFullDir() const
{
return (fs::path(util::Platform::getObsidianDragonDir()) / "screenshots-full").string();
}
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
void App::installDemoWalletData()
{
// Snapshot the state_ fields we mutate (WalletState isn't copy-assignable — reference aliases).
auto& s = sweep_state_snapshot_;
s.connected = state_.connected; s.warming_up = state_.warming_up;
s.daemon_initializing = state_.daemon_initializing;
s.encrypted = state_.encrypted; s.locked = state_.locked;
s.encryption_state_known = state_.encryption_state_known;
s.warmup_status = state_.warmup_status; s.warmup_description = state_.warmup_description;
s.sync = state_.sync;
s.privateBalance = state_.privateBalance; s.transparentBalance = state_.transparentBalance;
s.totalBalance = state_.totalBalance; s.unconfirmedBalance = state_.unconfirmedBalance;
s.addresses = state_.addresses; s.z_addresses = state_.z_addresses; s.t_addresses = state_.t_addresses;
s.transactions = state_.transactions;
s.market_price_usd = state_.market.price_usd;
s.valid = true;
applyHealthyDemoState();
state_.sync.blocks = state_.sync.headers = 3124322;
state_.sync.verification_progress = 1.0; state_.sync.syncing = false;
state_.privateBalance = 12.50000000; state_.transparentBalance = 3.25000000;
state_.totalBalance = 15.75000000; state_.unconfirmedBalance = 0.50000000;
state_.market.price_usd = 0.01336200;
auto zaddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "shielded"; i.label = label; return i;
};
auto taddr = [](const char* a, double bal, const char* label) {
AddressInfo i; i.address = a; i.balance = bal; i.type = "transparent"; i.label = label; return i;
};
state_.z_addresses = {
zaddr("zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", 12.0, "Savings"),
zaddr("zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", 0.5, ""),
};
state_.t_addresses = {
taddr("t1DemoTransparentAddressForUiSweep00000", 3.25, "Mining payouts"),
taddr("t1DemoSecondTransparentAddressForUiSw00", 0.0, ""),
};
state_.rebuildAddressList();
auto tx = [](const char* id, const char* type, double amt, int64_t ts, int conf,
const char* addr, const char* memo) {
TransactionInfo t; t.txid = id; t.type = type; t.amount = amt; t.timestamp = ts;
t.confirmations = conf; t.address = addr; t.memo = memo; return t;
};
state_.transactions = {
tx(kDemoTxid, "receive", 15.75000000, 1751286000, 42,
"zs1demoprimaryshieldedaddressforuisweep000000000000000000000000000", "welcome to DragonX"),
tx("a1b2c3d4e5f600000000000000000000000000000000000000000000000000000000", "send", 2.50000000,
1751200000, 120, "zs1demopeerreceivingaddressabcdef0000000000000000000000000000000", ""),
tx("c0ffee00000000000000000000000000000000000000000000000000000000000000", "mined", 0.30000000,
1751100000, 300, "t1DemoTransparentAddressForUiSweep00000", ""),
tx("deadbeef000000000000000000000000000000000000000000000000000000000000", "receive", 0.50000000,
1751290000, 0, "zs1demosecondaryshieldedaddressforuisweep0000000000000000000000000", "pending"),
};
seedChatDemoData();
}
void App::applyHealthyDemoState()
{
state_.connected = true;
state_.warming_up = false;
state_.daemon_initializing = false;
state_.encryption_state_known = true;
state_.encrypted = false;
state_.locked = false;
state_.warmup_status.clear();
state_.warmup_description.clear();
// Dismiss any first-run wizard so the base tabs/modals aren't occluded (the wizard targets set
// wizard_phase_ themselves and restore None on teardown).
wizard_phase_ = WizardPhase::None;
}
void App::clearDemoWalletData()
{
auto& s = sweep_state_snapshot_;
if (!s.valid) return;
state_.connected = s.connected; state_.warming_up = s.warming_up;
state_.daemon_initializing = s.daemon_initializing;
state_.encrypted = s.encrypted; state_.locked = s.locked;
state_.encryption_state_known = s.encryption_state_known;
state_.warmup_status = s.warmup_status; state_.warmup_description = s.warmup_description;
state_.sync = s.sync;
state_.privateBalance = s.privateBalance; state_.transparentBalance = s.transparentBalance;
state_.totalBalance = s.totalBalance; state_.unconfirmedBalance = s.unconfirmedBalance;
state_.addresses = s.addresses; state_.z_addresses = s.z_addresses; state_.t_addresses = s.t_addresses;
state_.transactions = s.transactions;
state_.market.price_usd = s.market_price_usd;
s = SweepStateSnapshot{}; // invalidate
}
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
// Lambdas defined in this member function may touch App's private members through the App& arg.
void App::buildSweepCatalog()
{
sweep_targets_.clear();
// Tabs (both sweeps).
for (int p = 0; p < static_cast<int>(ui::NavPage::Count_); ++p) {
ui::NavPage pg = static_cast<ui::NavPage>(p);
if (sweepPageEnabled(pg)) sweep_targets_.push_back({ sweepPageName(pg), pg, nullptr, nullptr, 4 });
}
if (!sweep_full_) return;
// Full sweep: modals / flows / states. Blur overlays need more settle frames.
const int kOverlaySettle = 8;
auto add = [&](const char* name, ui::NavPage pg, std::function<void(App&)> setup,
std::function<void(App&)> teardown) {
sweep_targets_.push_back({ name, pg, std::move(setup), std::move(teardown), kOverlaySettle });
};
// Simple bool-flag modals.
add("modal-import-key", ui::NavPage::Overview,
[](App& a) { a.show_import_key_ = true; }, [](App& a) { a.show_import_key_ = false; });
add("modal-export-key", ui::NavPage::Overview,
[](App& a) { a.show_export_key_ = true; }, [](App& a) { a.show_export_key_ = false; });
add("modal-backup", ui::NavPage::Overview,
[](App& a) { a.show_backup_ = true; }, [](App& a) { a.show_backup_ = false; a.backup_status_.clear(); });
add("modal-about", ui::NavPage::Overview,
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
add("modal-settings", ui::NavPage::Settings,
[](App& a) { a.show_settings_ = true; }, [](App& a) { a.show_settings_ = false; });
// Seed-backup: pre-set fetch + a demo phrase so it renders the word grid without any RPC.
add("modal-seed-backup", ui::NavPage::Overview,
[](App& a) {
a.show_seed_backup_ = true; a.seed_backup_fetch_started_ = true;
a.seed_backup_loading_ = false; a.seed_backup_no_mnemonic_ = false;
a.seed_backup_status_.clear();
if (a.seed_backup_phrase_.empty()) a.seed_backup_phrase_ = kDemoMnemonic;
},
[](App& a) {
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
if (!a.seed_backup_phrase_.empty())
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
a.seed_backup_phrase_.clear();
});
// Migrate-to-seed — one surface per step (full-node only). Setting the step directly never
// fires the async create/sweep/adopt (those are button-triggered).
if (supportsFullNodeLifecycleActions()) {
auto mig = [&](const char* name, SeedMigrationStep step, std::function<void(App&)> extra) {
add(name, ui::NavPage::Settings,
[step, extra](App& a) {
a.show_seed_migration_ = true; a.seed_migration_step_ = step;
a.seed_migration_dest_ = kDemoZAddr;
if (extra) extra(a);
},
[](App& a) {
a.show_seed_migration_ = false;
if (!a.seed_migration_seed_.empty())
sodium_memzero(&a.seed_migration_seed_[0], a.seed_migration_seed_.size());
a.seed_migration_seed_.clear(); a.seed_migration_status_.clear();
});
};
// Intro pre-flight branches: pre-set the probe result so the sweep captures each variant
// (the probe itself is guarded off during capture_mode_).
mig("modal-migrate-intro", SeedMigrationStep::Intro, [](App& a) {
a.seed_migration_precheck_ = SeedMigrationPrecheck::Legacy;
a.seed_migration_precheck_started_ = true;
});
mig("modal-migrate-intro-seeded", SeedMigrationStep::Intro, [](App& a) {
a.seed_migration_precheck_ = SeedMigrationPrecheck::AlreadyMnemonic;
a.seed_migration_precheck_started_ = true;
});
mig("modal-migrate-intro-oldnode", SeedMigrationStep::Intro, [](App& a) {
a.seed_migration_precheck_ = SeedMigrationPrecheck::DaemonTooOld;
a.seed_migration_precheck_started_ = true;
});
mig("modal-migrate-showseed", SeedMigrationStep::ShowSeed,
[](App& a) { a.seed_migration_seed_ = kDemoMnemonic; a.seed_migration_backed_up_ = false; });
mig("modal-migrate-sweep", SeedMigrationStep::Sweep,
[](App& a) { a.seed_migration_balance_ = 15.75000000; a.seed_migration_balance_loaded_ = true; });
mig("modal-migrate-nofunds", SeedMigrationStep::Sweep,
[](App& a) { a.seed_migration_balance_ = 0.0; a.seed_migration_balance_loaded_ = true; });
mig("modal-migrate-confirming", SeedMigrationStep::Confirming, [](App& a) {
a.seed_migration_sweep_confs_ = 1; a.seed_migration_sweep_txid_ = kDemoTxid;
a.seed_migration_legacy_remaining_ = 0.0;
});
mig("modal-migrate-done", SeedMigrationStep::Done, nullptr);
mig("modal-migrate-error", SeedMigrationStep::Error,
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
}
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
if (!isLiteBuild()) {
auto wiz = [&](const char* name, WizardPhase phase) {
add(name, ui::NavPage::Overview,
[phase](App& a) { a.wizard_phase_ = phase; },
[](App& a) { a.wizard_phase_ = WizardPhase::None; });
};
wiz("wizard-appearance", WizardPhase::Appearance);
wiz("wizard-bootstrap", WizardPhase::BootstrapOffer);
wiz("wizard-encrypt", WizardPhase::EncryptOffer);
wiz("wizard-pin", WizardPhase::PinSetup);
}
// App-state overlays. Teardown restores the healthy demo flags (full restore at sweep end).
add("overlay-lock", ui::NavPage::Overview,
[](App& a) { a.state_.encrypted = true; a.state_.locked = true; },
[](App& a) { a.applyHealthyDemoState(); });
add("overlay-warmup", ui::NavPage::Overview,
[](App& a) {
a.state_.warming_up = true;
a.state_.warmup_status = "Processing blocks…";
a.state_.warmup_description = "The node is loading the block index.";
},
[](App& a) { a.applyHealthyDemoState(); });
add("overlay-not-ready", ui::NavPage::Overview,
[](App& a) { a.state_.connected = false; a.state_.encryption_state_known = false; },
[](App& a) { a.applyHealthyDemoState(); });
// Send-confirm popup (state lives in send_tab statics → driven via a debug hook).
add("popup-send-confirm", ui::NavPage::Send,
[](App& a) { ui::SweepShowSendConfirm(&a, true); },
[](App& a) { ui::SweepShowSendConfirm(&a, false); });
}
// ── State machine ───────────────────────────────────────────────────────────────────────────
void App::startSweepImpl(bool full)
{
if (screenshot_sweep_active_) return;
sweep_skins_.clear();
for (const auto& sk : ui::schema::SkinManager::instance().available())
if (sk.valid) sweep_skins_.push_back(sk.id);
if (sweep_skins_.empty()) return;
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();
if (sweep_targets_.empty()) { if (full) { clearDemoWalletData(); capture_mode_ = false; sweep_full_ = false; } return; }
sweep_dir_ = full ? screenshotFullDir() : screenshotDir();
std::error_code ec; fs::create_directories(sweep_dir_, ec);
sweep_saved_skin_ = ui::schema::SkinManager::instance().activeSkinId();
sweep_saved_page_ = current_page_;
sweep_skin_idx_ = 0; sweep_target_idx_ = 0;
screenshot_sweep_active_ = true;
sweep_capture_this_frame_ = false;
ui::schema::SkinManager::instance().setActiveSkin(sweep_skins_[0]);
applySweepTarget();
ui::Notifications::instance().info(full ? "Full UI sweep running…" : "Screenshot sweep running…");
DEBUG_LOGF("[Sweep] %s -> %s (%d skins x %d surfaces)\n", full ? "FULL" : "tabs",
sweep_dir_.c_str(), (int)sweep_skins_.size(), (int)sweep_targets_.size());
}
void App::startScreenshotSweep() { startSweepImpl(false); }
void App::startFullUiSweep() { startSweepImpl(true); }
void App::applySweepTarget()
{
const SweepTarget& t = sweep_targets_[sweep_target_idx_];
current_page_ = t.page;
page_alpha_ = 1.0f; // skip the page-switch fade so the shot isn't captured mid-animation
if (t.setup) t.setup(*this);
// <dir>/<surface>/<skin>.png — one subfolder per surface, one PNG per theme, overwritten in
// place next sweep. (writePng in main.cpp creates the parent subfolder.)
sweep_current_path_ = (fs::path(sweep_dir_) / t.name / (sweep_skins_[sweep_skin_idx_] + ".png")).string();
sweep_settle_frames_ = t.settle;
sweep_capture_this_frame_ = false;
}
void App::updateScreenshotSweep()
{
if (!screenshot_sweep_active_) return;
const SweepTarget& t = sweep_targets_[sweep_target_idx_];
current_page_ = t.page; // keep the surface pinned each frame
page_alpha_ = 1.0f;
// Re-run setup every frame: idempotent for flags/enums, and required for OpenPopup-based popups
// (must re-fire while open) + to keep the surface state fixed against any refresh.
if (t.setup) t.setup(*this);
if (sweep_settle_frames_ > 0) { sweep_settle_frames_--; sweep_capture_this_frame_ = false; }
else sweep_capture_this_frame_ = true; // settled — main.cpp captures this frame
}
void App::onScreenshotCaptured()
{
sweep_capture_this_frame_ = false;
if (!screenshot_sweep_active_) return;
{ const SweepTarget& t = sweep_targets_[sweep_target_idx_]; if (t.teardown) t.teardown(*this); }
if (++sweep_target_idx_ >= static_cast<int>(sweep_targets_.size())) {
sweep_target_idx_ = 0;
if (++sweep_skin_idx_ >= static_cast<int>(sweep_skins_.size())) {
// Done — restore the original skin + page, and (full) the real state.
const bool wasFull = sweep_full_;
if (wasFull) writeSweepManifest();
ui::schema::SkinManager::instance().setActiveSkin(sweep_saved_skin_);
current_page_ = sweep_saved_page_;
page_alpha_ = 1.0f;
if (wasFull) { clearDemoWalletData(); capture_mode_ = false; }
sweep_full_ = false;
screenshot_sweep_active_ = false;
ui::Notifications::instance().success(
(wasFull ? std::string("Full UI screenshots saved to ") : std::string("Screenshots saved to ")) + sweep_dir_);
DEBUG_LOGF("[Sweep] done -> %s\n", sweep_dir_.c_str());
return;
}
ui::schema::SkinManager::instance().setActiveSkin(sweep_skins_[sweep_skin_idx_]);
}
applySweepTarget();
}
void App::writeSweepManifest() const
{
std::error_code ec; fs::create_directories(sweep_dir_, ec);
std::ofstream out((fs::path(sweep_dir_) / "index.md").string(), std::ios::trunc);
if (!out) return;
out << "# Full UI sweep\n\n";
out << sweep_targets_.size() << " surfaces x " << sweep_skins_.size() << " skins\n\n";
for (const auto& t : sweep_targets_) {
out << "## " << t.name << " (base: " << sweepPageName(t.page) << ")\n\n";
for (const auto& skin : sweep_skins_)
out << "- " << skin << ": `" << t.name << "/" << skin << ".png`\n";
out << "\n";
}
}
} // namespace dragonx