fix(sweep): keep the UI sweep away from real wallet data

The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.

- Preflight: a full sweep is refused (toast + log line) while a real
  workflow is in flight: a pending seed_migration_* setting or open
  migrate dialog, a wallet switch, an encryption/import workflow, an
  encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
  The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
  save/load latched while a demo-contacts surface is shown), WalletIndex
  and Settings (active wallet file, contacts view mode, pool URL/worker)
  for the whole sweep. Settings::sweepEnd() is the last statement of
  clearDemoWalletData and flushes once only if a save was suppressed; a
  quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
  zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
  persisted id or "" and never mints one, so the Contacts tab cannot
  move legacy-scoped contacts to a throwaway id. The only address-book
  write a sweep can still cause is the one-time legacy re-attach to the
  wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
  under the temp dir (exclusive create, a pre-existing path or symlink
  is refused), removed at sweep end. The teardown that deleted those six
  wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
  conversations and a fabricated demo chat identity are reset at sweep
  end; the seed-backup surface always shows the demo phrase and a real
  z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
  language files.

Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.

Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 3ac46abe1a
commit 62a5577491
22 changed files with 625 additions and 40 deletions
+3 -1
View File
@@ -4559,7 +4559,9 @@ void App::renderSeedBackupDialog()
seed_backup_status_.clear();
exportSeedPhrase([this](bool ok, bool noMnemonic, const std::string& phrase,
const std::string& error) {
if (!show_seed_backup_) return; // dialog closed while the fetch was in flight
// Dialog closed while the fetch was in flight; or a UI sweep holds it open with the demo
// phrase, and a real mnemonic landing now would be rendered into its screenshots.
if (!show_seed_backup_ || capture_mode_) return;
seed_backup_loading_ = false;
if (ok) {
seed_backup_phrase_ = phrase; // working copy — wiped on close
+11 -3
View File
@@ -293,8 +293,9 @@ public:
const std::string& text);
// Debug/sweep convenience: give the Chat tab a demo identity + a few sample conversations
// (in-memory only, not persisted) so the screenshot sweep captures the populated UI. No-op when
// the chat feature is off.
void seedChatDemoData();
// the chat feature is off. Returns true when it had to fabricate the (random) demo identity
// because none was provisioned, so the sweep can drop it again at the end.
bool seedChatDemoData();
// Reason the lite wallet failed to auto-open this session (empty if none / opened OK).
const std::string& liteOpenError() const { return lite_open_error_; }
// Show the lite send-time unlock modal (called when a spend is attempted on a locked wallet).
@@ -320,7 +321,8 @@ public:
// Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts).
// Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from
// the mutable address set — so creating addresses / locking / disconnecting never changes it.
// Empty only when there is no active wallet file. Establishes + persists the id on first call.
// Empty only when there is no active wallet file. Establishes + persists the id on first call,
// except during a full UI sweep (capture_mode_), where it returns the existing id or "".
std::string activeWalletScopeId();
data::WalletIndex& walletIndex() { return wallet_index_; }
@@ -1593,9 +1595,14 @@ private:
// RPC, no auto-lock, no async pump. New async paths that could run mid-sweep must guard on it.
bool capture_mode_ = false;
void startSweepImpl(bool full);
// Why a full sweep must not start now (a real wallet workflow is in flight), or nullptr.
const char* fullSweepBlockedReason() const;
void buildSweepCatalog();
void installDemoWalletData();
void clearDemoWalletData();
// The full sweep's private temp dir (demo wallet files and folders); "" when none.
std::string sweep_sandbox_;
bool sweep_chat_identity_fabricated_ = false; // seedChatDemoData() made the demo chat identity
void applyHealthyDemoState(); // reset the connection/encryption flags to the healthy demo values
void writeSweepManifest() const;
// Snapshot of the state_ fields the demo installer mutates, restored at sweep end. Kept as a
@@ -1611,6 +1618,7 @@ private:
std::vector<TransactionInfo> transactions;
double market_price_usd=0;
double market_change_24h=0;
WizardPhase wizard_phase = WizardPhase::None; // the demo state dismisses the wizard
} sweep_state_snapshot_;
bool sidebar_user_toggled_ = false; // user manually toggled — suppress auto-collapse
float sidebar_width_anim_ = 0.0f; // animated width (0 = uninitialized)
+8 -2
View File
@@ -1445,6 +1445,9 @@ std::string App::activeWalletScopeId()
if (const auto* existing = wallet_index_.find(file)) {
if (!existing->scopeId.empty()) return existing->scopeId;
}
// Never mint during a UI sweep: the demo index is thrown away at sweep end, and the Contacts tab
// would re-attach legacy-scoped contacts to the throwaway id and save the address book for real.
if (capture_mode_) return std::string();
// Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy
// 64-hex address-hash scopes we migrate away from.
@@ -5196,9 +5199,9 @@ bool App::lockLiteWallet()
return ok;
}
void App::seedChatDemoData()
bool App::seedChatDemoData()
{
if (!chat::hushChatFeatureEnabledAtBuild()) return;
if (!chat::hushChatFeatureEnabledAtBuild()) return false;
// Demo identity so the tab renders the populated UI (hasIdentity()==true). Injected messages go
// straight into the in-memory store (no DB attached here → not persisted, gone on restart).
@@ -5209,12 +5212,14 @@ void App::seedChatDemoData()
// and DECRYPTED as incoming. Only fabricate an identity when there is no real one.
// 2. Use a RANDOM secret, never a fixed string. A constant demo secret is the SAME public key on
// every install/wallet, i.e. everyone who ran the demo shared one cryptographic identity.
bool fabricated = false;
if (!chat_service_.hasIdentity()) {
chat::ChatKeyPair keys;
const std::string demoSecret = generateChatLocalId("demo-chat:", 24); // random, per-run
if (chat::deriveChatIdentityFromSecret(demoSecret, keys).status
== chat::ChatIdentityStatus::Ready) {
chat_service_.setIdentity(keys);
fabricated = true;
}
chat::wipeChatKeyPair(keys);
}
@@ -5244,6 +5249,7 @@ void App::seedChatDemoData()
// An outgoing contact request awaiting a reply (no peer key → "waiting" composer state).
add("demo-3", "zs1demopeerawaitingtheirfirstreplybeforewecanmessagethemyet00", "",
chat::ChatDirection::Outgoing, chat::ChatMessageKind::ContactRequest, "hey, let's chat", 1751100000, "demo:c1");
return fabricated;
}
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
+151 -26
View File
@@ -34,8 +34,13 @@
#include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/key_export_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/windows/chat_tab.h"
#include "ui/windows/folder_picker.h"
#include "ui/pages/settings_page.h"
#include "chat/chat_protocol.h"
#include "util/i18n.h"
#include "util/platform.h"
#include "util/private_temp_dir.h"
#include "wallet/wallet_capabilities.h"
#include "imgui.h"
@@ -95,12 +100,11 @@ const char* kDemoZAddr =
const char* kDemoTxid = "b647077c471fdd2877d35c9d8b70e3c785547fae618458b4068d913ee3d1dc4f";
// Contacts-view sweep: seed a few demo contacts (Z + T types, some global) so the Cards/List/Table
// modes render with data, and restore the real book afterward. Uses sweepSetEntries (no disk write),
// so the user's persisted address book is never touched even if the sweep is interrupted.
std::vector<data::AddressBookEntry> s_contactsSweepBackup;
// modes render with data, and restore the real book afterward. sweepBegin() snapshots the real book on
// its first call only (this setup re-runs every frame) and latches save()/load(), so the user's
// persisted address book is never touched, even if the sweep is interrupted.
void seedSweepContacts(App& a)
{
s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("mining rig", "t1DemoTransparentAddressForUiSweep00000", ""); // T (mining only)
@@ -109,14 +113,17 @@ void seedSweepContacts(App& a)
e2.scope = a.activeWalletIdentityHash();
data::AddressBookEntry e3("cold savings",
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000", "long-term storage"); // Z, global
a.addressBook().sweepSetEntries({ e1, e2, e3 });
a.addressBook().sweepBegin({ e1, e2, e3 });
}
// The contacts view mode is restored by Settings::sweepEnd() at sweep end.
void restoreSweepContacts(App& a)
{
a.addressBook().sweepSetEntries(s_contactsSweepBackup);
s_contactsSweepBackup.clear();
if (a.settings()) a.settings()->setContactsViewMode(0);
a.addressBook().sweepEnd();
}
// The full sweep's sandbox: a fresh owner-only dir under the temp dir. Its demo wallet files live in
// <sandbox>/DRAGONX (and DRAGONX-many, extra-wallets, picker-demo), never in the real datadir or config dir.
const char* kSweepSandboxPrefix = "obsidiandragon-sweep-";
} // namespace
std::string App::screenshotDir() const
@@ -132,6 +139,11 @@ std::string App::screenshotFullDir() const
// ── Demo data (full sweep only): make every data-dependent screen render offline ─────────────
void App::installDemoWalletData()
{
// Persistence latches first: until clearDemoWalletData(), nothing the demo surfaces put into
// Settings or the wallet index (or, via seedSweepContacts, the address book) can reach disk.
if (settings_) settings_->sweepBegin();
wallet_index_.sweepBegin();
// 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;
@@ -146,6 +158,7 @@ void App::installDemoWalletData()
s.transactions = state_.transactions;
s.market_price_usd = state_.market.price_usd;
s.market_change_24h = state_.market.change_24h;
s.wizard_phase = wizard_phase_;
s.valid = true;
applyHealthyDemoState();
@@ -229,7 +242,7 @@ void App::installDemoWalletData()
settings_->setPortfolioStyle(0);
}
seedChatDemoData();
sweep_chat_identity_fabricated_ = seedChatDemoData();
}
void App::applyHealthyDemoState()
@@ -247,6 +260,30 @@ void App::applyHealthyDemoState()
wizard_phase_ = WizardPhase::None;
}
// Refuse a full sweep while any real workflow it would fake is in flight: its surfaces overwrite the
// migration, wallet-switch, security-workflow and seed-backup state in memory, and its teardown resets it.
const char* App::fullSweepBlockedReason() const
{
if (settings_ && (settings_->getSeedMigrationPending() || settings_->getSeedMigrationSweepSubmitted() ||
!settings_->getSeedMigrationSweepTxid().empty() ||
!settings_->getSeedMigrationSweepOpid().empty()))
return "a migrate-to-seed is pending (seed_migration_* settings)";
if (show_seed_migration_ || seed_migration_step_ != SeedMigrationStep::Intro || seed_migration_in_flight_)
return "the migrate-to-seed dialog is open";
if (wallet_switch_phase_.load() != 0 || wallet_switch_dialog_open_.load() ||
!pending_switch_wallet_file_.empty())
return "a wallet switch is in progress";
if (wallet_security_workflow_.inProgress() || wallet_security_workflow_.importActive())
return "an encryption workflow is in progress";
if (encrypt_in_progress_ || pin_in_progress_ || lock_unlock_in_progress_)
return "an encrypt, PIN or lock/unlock operation is in progress";
if (show_seed_backup_ || !seed_backup_phrase_.empty())
return "the seed-phrase backup dialog is open";
if (bootstrap_ || bootstrap_downloading_)
return "a bootstrap is in progress";
return nullptr;
}
void App::clearDemoWalletData()
{
auto& s = sweep_state_snapshot_;
@@ -267,8 +304,63 @@ void App::clearDemoWalletData()
state_.market.change_24h = s.market_change_24h;
state_.market.price_history = g_marketHistorySnapshot;
g_marketHistorySnapshot.clear();
wizard_phase_ = s.wizard_phase; // applyHealthyDemoState() dismissed it
s = SweepStateSnapshot{}; // invalidate
// Sandbox: close what shows it, then delete it (error_code overloads only; at worst temp debris,
// e.g. a file a Windows probe thread still holds).
ui::WalletsDialog::hide();
ui::FolderPicker::close();
ui::WalletsDialog::setSweepDataDir({});
if (!sweep_sandbox_.empty()) {
if (!util::removePrivateTempDir(sweep_sandbox_, kSweepSandboxPrefix))
DEBUG_LOGF("[Sweep] could not fully remove the sandbox %s\n", sweep_sandbox_.c_str());
sweep_sandbox_.clear();
}
// Migrate-to-seed state the mig() surfaces faked. Safe to reset: the preflight refused the sweep
// while a real migration was pending or its dialog was open.
show_seed_migration_ = false;
seed_migration_step_ = SeedMigrationStep::Intro;
if (!seed_migration_seed_.empty())
sodium_memzero(&seed_migration_seed_[0], seed_migration_seed_.size());
seed_migration_seed_.clear();
seed_migration_dest_.clear();
seed_migration_sweep_txid_.clear();
seed_migration_status_.clear();
seed_migration_precheck_ = SeedMigrationPrecheck::Pending;
seed_migration_precheck_started_ = false;
seed_migration_backed_up_ = false;
seed_migration_balance_ = 0.0;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
seed_migration_sweep_confs_ = 0;
seed_migration_legacy_remaining_ = -1.0;
// Chat: drop the demo conversations (in-memory only). If the sweep had to fabricate the demo
// identity, drop it too and re-arm provisioning, or chat would run on that throwaway key for the rest
// of the session. No real identity existed, so the DB was never unlocked under it.
if (chat::hushChatFeatureEnabledAtBuild()) {
for (const char* cid : {"demo-1", "demo-2", "demo-3"})
chat_service_.store().eraseConversation(cid);
if (sweep_chat_identity_fabricated_) {
chat_service_.clearIdentity();
chat_service_.store().clear();
chat_seen_watermark_.clear();
ui::ResetChatTab();
chat_db_.lock();
chat_identity_provisioned_ = false;
chat_identity_fetch_in_flight_ = false;
chat_identity_unavailable_ = false;
++chat_session_generation_; // discard any identity fetch that was in flight
}
}
sweep_chat_identity_fabricated_ = false;
// Address book and wallet index: back to the real data (no disk write).
addressBook().sweepEnd();
wallet_index_.sweepEnd();
// Restore the user's real portfolio (demo groups were in-memory only).
if (g_pfSnapshotValid && settings_) {
settings_->setPortfolioEntries(g_pfSnapshot);
@@ -276,6 +368,10 @@ void App::clearDemoWalletData()
}
g_pfSnapshot.clear();
g_pfSnapshotValid = false;
// LAST: restore the latched settings and flush once if a save was suppressed. Anything restored
// after this point would be flushed with its demo value.
if (settings_) settings_->sweepEnd();
}
// ── Catalog ──────────────────────────────────────────────────────────────────────────────────
@@ -457,7 +553,13 @@ void App::buildSweepCatalog()
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;
// Always the demo phrase: anything else here could be a real mnemonic, and it would be
// written into this surface's screenshots for every skin.
if (a.seed_backup_phrase_ != kDemoMnemonic) {
if (!a.seed_backup_phrase_.empty())
sodium_memzero(&a.seed_backup_phrase_[0], a.seed_backup_phrase_.size());
a.seed_backup_phrase_ = kDemoMnemonic;
}
},
[](App& a) {
a.show_seed_backup_ = false; a.seed_backup_fetch_started_ = false;
@@ -511,12 +613,13 @@ void App::buildSweepCatalog()
mig("modal-migrate-error", SeedMigrationStep::Error,
[](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; });
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway)
// datadir + cache their metadata so the list renders populated.
// Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the sandbox's
// datadir + cache their metadata (latched, never saved) so the list renders populated.
add("modal-wallets", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX";
fs::create_directories(dd, ec);
if (!fs::exists(dd + "/wallet.dat", ec))
std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0');
@@ -531,26 +634,26 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e1);
a.walletIndex().upsert(e2);
// An out-of-datadir wallet (in an extra folder) so the Import action shows too.
const std::string extra = util::Platform::getConfigDir() + "extra-wallets";
const std::string extra = a.sweep_sandbox_ + "/extra-wallets";
fs::create_directories(extra, ec);
if (!fs::exists(extra + "/my-wallet.dat", ec))
std::ofstream(extra + "/my-wallet.dat", std::ios::binary) << std::string(80000, '\0');
a.walletIndex().addExtraFolder(extra);
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App&) { ui::WalletsDialog::hide(); });
// Many wallets — exercises the viewport-cap path: the card can't fit all rows, so the list
// must scroll internally while the create/scan/footer controls stay pinned (esp. at 150%).
// Teardown removes the extra files it dropped so the plain modal-wallets surface stays a
// clean 3-wallet reference (both surfaces share the one throwaway datadir).
static const char* kManyExtra[] = {"wallet-cold.dat", "wallet-trading.dat", "wallet-mining.dat",
"wallet-donations.dat", "wallet-2023.dat", "wallet-payroll.dat"};
// Its own sandbox datadir keeps the plain modal-wallets surface a clean 3-wallet reference;
// nothing is ever deleted here (the whole sandbox goes at sweep end).
add("modal-wallets-many", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
const std::string dd = a.sweep_sandbox_ + "/DRAGONX-many";
fs::create_directories(dd, ec);
const char* names[] = {"wallet.dat", "wallet-savings.dat", "wallet-cold.dat",
"wallet-trading.dat", "wallet-mining.dat", "wallet-donations.dat",
@@ -564,14 +667,10 @@ void App::buildSweepCatalog()
a.walletIndex().upsert(e);
}
if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat");
ui::WalletsDialog::setSweepDataDir(dd);
ui::WalletsDialog::show(&a);
},
[](App& a) {
ui::WalletsDialog::hide();
std::error_code ec;
const std::string dd = util::Platform::getDragonXDataDir();
for (const char* n : kManyExtra) fs::remove(dd + "/" + n, ec);
});
[](App&) { ui::WalletsDialog::hide(); });
}
// First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown).
@@ -702,13 +801,15 @@ void App::buildSweepCatalog()
// list shows sub-folders + wallet files, then open the wallets dialog + the picker over it.
add("modal-folder-picker", ui::NavPage::Settings,
[](App& a) {
if (a.sweep_sandbox_.empty()) return;
std::error_code ec;
const std::string demo = util::Platform::getConfigDir() + "picker-demo";
const std::string demo = a.sweep_sandbox_ + "/picker-demo";
for (const char* sub : {"Documents", "Downloads", "Backups", "wallet-archive"})
fs::create_directories(demo + "/" + sub, ec);
for (const char* f : {"wallet-cold.dat", "wallet-2023.dat"})
if (!fs::exists(demo + "/" + f, ec))
std::ofstream(demo + "/" + f, std::ios::binary) << std::string(66000, '\0');
ui::WalletsDialog::setSweepDataDir(a.sweep_sandbox_ + "/DRAGONX");
ui::WalletsDialog::show(&a);
ui::FolderPicker::open(demo, [](const std::string&) {});
},
@@ -728,6 +829,15 @@ void App::startSweepImpl(bool full)
// Sentinel: a release binary must not contain this literal (strings <bin> | grep -c ... == 0).
DEBUG_LOGF("[Sweep] DRAGONX_UI_SWEEP_COMPILED_IN\n");
// The full sweep fakes wallet, migration, switch and security state: never over a real workflow.
if (full) {
if (const char* why = fullSweepBlockedReason()) {
DEBUG_LOGF("[Sweep] full sweep refused: %s\n", why);
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
}
sweep_skins_.clear();
for (const auto& sk : ui::schema::SkinManager::instance().available())
if (sk.valid) sweep_skins_.push_back(sk.id);
@@ -744,6 +854,21 @@ void App::startSweepImpl(bool full)
std::string sweepOnlyStr = sweepOnly ? sweepOnly : "";
if (!sweepOnlyStr.empty()) sweep_skins_.assign(1, std::string("dark"));
if (full) {
// A fresh private sandbox for the demo wallet files: never the real datadir or config dir.
std::error_code tec;
const fs::path tmp = fs::temp_directory_path(tec);
sweep_sandbox_ = tec ? std::string() : util::makePrivateTempDir(tmp, kSweepSandboxPrefix);
if (sweep_sandbox_.empty()) {
DEBUG_LOGF("[Sweep] full sweep refused: could not create a private sandbox under %s\n",
tmp.string().c_str());
ui::Notifications::instance().warning(TR("screenshot_sweep_refused"));
return;
}
ui::WalletsDialog::setSweepDataDir(sweep_sandbox_ + "/DRAGONX");
DEBUG_LOGF("[Sweep] sandbox %s\n", sweep_sandbox_.c_str());
}
sweep_full_ = full;
if (full) { capture_mode_ = true; installDemoWalletData(); }
buildSweepCatalog();
+2
View File
@@ -474,6 +474,8 @@ bool Settings::save()
bool Settings::save(const std::string& path)
{
// UI sweep: nothing is written until sweepEnd(), which restores the real values and saves once.
if (sweep_active_) { sweep_suppressed_save_ = true; return true; }
json j;
j["theme"] = theme_;
+37
View File
@@ -586,7 +586,44 @@ public:
bool needsUpgradeSave() const { return needs_upgrade_save_; }
void clearUpgradeSave() { needs_upgrade_save_ = false; }
// UI-sweep ONLY. sweepBegin() snapshots the values the full sweep's demo surfaces change: the active
// wallet file, the contacts view mode, and the pool URL/worker (the Mining tab defaults an empty
// worker to the first DEMO z-address). Until sweepEnd(), save() returns true without writing, so
// neither those nor the demo portfolio can reach disk, even through the shutdown save if the app
// quits mid-sweep. sweepEnd() restores the snapshot and, only if a save was suppressed, saves once
// so a real change made meanwhile still lands. The caller restores everything else it changed
// (the portfolio) BEFORE sweepEnd(). Both are idempotent.
void sweepBegin() {
if (sweep_active_) return;
sweep_active_wallet_file_ = active_wallet_file_;
sweep_contacts_view_mode_ = contacts_view_mode_;
sweep_pool_url_ = pool_url_;
sweep_pool_worker_ = pool_worker_;
sweep_suppressed_save_ = false;
sweep_active_ = true;
}
void sweepEnd() {
if (!sweep_active_) return;
sweep_active_ = false;
active_wallet_file_ = sweep_active_wallet_file_;
contacts_view_mode_ = sweep_contacts_view_mode_;
pool_url_ = sweep_pool_url_;
pool_worker_ = sweep_pool_worker_;
if (sweep_suppressed_save_) {
sweep_suppressed_save_ = false;
save();
}
}
bool sweepActive() const { return sweep_active_; }
private:
// UI-sweep latch (see sweepBegin()).
bool sweep_active_ = false;
bool sweep_suppressed_save_ = false;
std::string sweep_active_wallet_file_;
int sweep_contacts_view_mode_ = 0;
std::string sweep_pool_url_, sweep_pool_worker_;
std::string settings_path_;
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous saves (tests, tools, early startup)
// Armed by the fund/security-critical setters (seed-migration stage, encryption_pending, pin_enabled,
+2
View File
@@ -31,6 +31,7 @@ std::string AddressBook::getDefaultPath()
bool AddressBook::load()
{
if (sweep_active_) return true; // UI sweep: keep the demo list and the real-book snapshot
file_path_ = getDefaultPath();
std::ifstream file(file_path_);
@@ -79,6 +80,7 @@ bool AddressBook::load()
bool AddressBook::save()
{
if (sweep_active_) return true; // UI sweep: demo contacts never reach disk
if (file_path_.empty()) {
file_path_ = getDefaultPath();
}
+20 -3
View File
@@ -130,10 +130,25 @@ public:
std::uint64_t revision() const { return revision_; }
/**
* @brief UI-sweep ONLY: replace the in-memory entries WITHOUT persisting to disk, so the sweep can
* seed demo contacts and restore the real book without a disk write. Do not use outside the sweep.
* @brief UI-sweep ONLY: show `demo` instead of the real book. The first call snapshots the real
* entries; later calls (the sweep re-runs its setup every frame) only swap the demo list. While
* active, save() and load() return true without touching disk or the snapshot, so nothing (an
* add/update/remove, reattachLegacyScopes) can persist demo contacts. Do not use outside the sweep.
*/
void sweepSetEntries(std::vector<AddressBookEntry> e) { entries_ = std::move(e); ++revision_; }
void sweepBegin(std::vector<AddressBookEntry> demo) {
if (!sweep_active_) { sweep_backup_ = std::move(entries_); sweep_active_ = true; }
entries_ = std::move(demo);
++revision_;
}
/** @brief UI-sweep ONLY: put the real book back (no disk write). Idempotent. */
void sweepEnd() {
if (!sweep_active_) return;
entries_ = std::move(sweep_backup_);
sweep_backup_.clear();
sweep_active_ = false;
++revision_;
}
bool sweepActive() const { return sweep_active_; }
/**
* @brief Check if empty
@@ -144,6 +159,8 @@ private:
std::vector<AddressBookEntry> entries_;
std::uint64_t revision_ = 0;
std::string file_path_;
std::vector<AddressBookEntry> sweep_backup_; // the real book while a UI sweep shows demo contacts
bool sweep_active_ = false;
};
} // namespace data
+1
View File
@@ -89,6 +89,7 @@ bool WalletIndex::load()
bool WalletIndex::save()
{
if (sweep_active_) return true; // UI sweep: demo entries never reach disk
if (file_path_.empty()) file_path_ = getDefaultPath();
try {
+26
View File
@@ -5,6 +5,7 @@
#pragma once
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
@@ -64,11 +65,36 @@ public:
bool addExtraFolder(const std::string& dir);
bool removeExtraFolder(const std::string& dir);
/**
* @brief UI-sweep ONLY: snapshot the entries and extra folders; until sweepEnd(), save() returns
* true without writing or queueing. The sweep's demo upserts drop the real entry's scopeId, and
* activeWalletScopeId() would then mint and save a new one, hiding the user's "w:"-scoped contacts.
*/
void sweepBegin() {
if (sweep_active_) return;
sweep_entries_ = entries_;
sweep_extra_folders_ = extra_folders_;
sweep_active_ = true;
}
/** @brief UI-sweep ONLY: restore the snapshot (no disk write). Idempotent. */
void sweepEnd() {
if (!sweep_active_) return;
entries_ = std::move(sweep_entries_);
extra_folders_ = std::move(sweep_extra_folders_);
sweep_entries_.clear();
sweep_extra_folders_.clear();
sweep_active_ = false;
}
bool sweepActive() const { return sweep_active_; }
private:
std::vector<WalletIndexEntry> entries_;
std::vector<std::string> extra_folders_;
std::string file_path_;
util::CoalescingWriter* writer_ = nullptr; // null -> synchronous writes
std::vector<WalletIndexEntry> sweep_entries_; // the real index while a UI sweep runs
std::vector<std::string> sweep_extra_folders_;
bool sweep_active_ = false;
};
} // namespace data
+13 -5
View File
@@ -66,6 +66,13 @@ public:
static void hide() { s_open = false; }
// UI sweep ONLY: list and create wallets in this directory (the sweep's sandbox) instead of the real
// datadir. Empty (the default) means the real datadir.
static void setSweepDataDir(std::string dir) { s_sweepDataDir = std::move(dir); }
static std::string dataDir() {
return s_sweepDataDir.empty() ? util::Platform::getDragonXDataDir() : s_sweepDataDir;
}
static void render() {
if (!s_open || !s_app) return;
// The folder picker (opened by "Scan another folder") takes over the modal surface while
@@ -485,7 +492,7 @@ public:
std::error_code ec;
if (name.empty() || isLinkName(name)) { // reserve the in-place-link prefix
Notifications::instance().warning(TR("wallets_name_invalid"));
} else if (std::filesystem::exists(util::Platform::getDragonXDataDir() + "/" + name, ec)) {
} else if (std::filesystem::exists(dataDir() + "/" + name, ec)) {
Notifications::instance().warning(TR("wallets_exists"));
} else {
s_newName[0] = '\0';
@@ -501,7 +508,7 @@ public:
// Start in the DRAGONX data directory (where the datadir wallets live) so the user can
// navigate up/out from a familiar anchor to find wallets in other folders.
if (TactileButton(TR("wallets_scan_folder"), ImVec2(listW, 0))) {
FolderPicker::open(util::Platform::getDragonXDataDir(), [app](const std::string& dir) {
FolderPicker::open(dataDir(), [app](const std::string& dir) {
std::error_code ec;
if (!dir.empty() && std::filesystem::is_directory(dir, ec)) {
if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save();
@@ -577,7 +584,7 @@ public:
}
ImGui::SameLine();
if (TactileButton(TR("wallets_reveal"), ImVec2(150.0f * dp, 0)))
util::Platform::openFolder(util::Platform::getDragonXDataDir());
util::Platform::openFolder(dataDir());
ImGui::SameLine();
if (TactileButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false;
@@ -680,7 +687,7 @@ private:
static void openInPlace(App* app, const WalletRow& r) {
namespace fs = std::filesystem;
std::error_code ec;
const std::string datadir = util::Platform::getDragonXDataDir();
const std::string datadir = dataDir();
fs::create_directories(datadir, ec);
const std::string src = r.dir + "/" + r.fileName;
// The row could be stale (file moved/deleted between the scan and this click). Don't link a ghost:
@@ -834,7 +841,7 @@ private:
s_scanning = false;
std::unordered_set<std::string> seen; // one row per physical wallet; the datadir is first, so it wins
s_rows = util::scanDatadirWallets(util::Platform::getDragonXDataDir(), seen);
s_rows = util::scanDatadirWallets(dataDir(), seen);
s_probe = std::make_shared<ProbeBatch>();
s_probe->results.resize(s_rows.size());
startProbe(s_probe, 0); // datadir badges start filling in at once
@@ -875,6 +882,7 @@ private:
static inline bool s_open = false;
static inline App* s_app = nullptr;
static inline std::string s_sweepDataDir; // UI sweep sandbox datadir ("" = the real one)
static inline bool s_needScan = false;
static inline bool s_forceScan = false; // next scan bypasses the folder-walk cache
static inline bool s_scanning = false; // an external folder walk is in flight
+1
View File
@@ -539,6 +539,7 @@ void I18n::loadBuiltinEnglish()
strings_["screenshot_open_dir"] = "Open location";
strings_["sweep_current_theme_only"] = "Current theme only";
strings_["tt_sweep_current_theme_only"] = "Sweep only the active theme instead of cycling every theme";
strings_["screenshot_sweep_refused"] = "Full UI sweep refused; see the debug log";
strings_["screenshot_sweep_desc"] = "Cycles every theme across every tab and saves a screenshot of each into per-tab subfolders under the config directory's screenshots folder (overwriting the previous sweep). Runs for a few seconds.";
strings_["mine_when_idle"] = "Mine when idle";
strings_["setup_wizard"] = "Run Setup Wizard…";
+75
View File
@@ -0,0 +1,75 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#pragma once
// A fresh, owner-only temporary directory that nobody else could have prepared. The full UI sweep uses
// one as its sandbox: its demo wallet files and folders go there, never into the real datadir or config
// dir. Header-only so the test suite can exercise it without the app's UI sources.
#include <sodium.h>
#include <filesystem>
#include <string>
#include <system_error>
#ifndef _WIN32
#include <sys/stat.h>
#include <sys/types.h>
#endif
namespace dragonx {
namespace util {
// Create exactly `dir`. Fails when anything already exists at that path, a symlink (dangling or not)
// included, so a name someone pre-created in a shared temp dir is refused rather than reused. On success
// the directory is owner-only (0700 on POSIX).
inline bool createExclusivePrivateDir(const std::filesystem::path& dir)
{
namespace fs = std::filesystem;
if (dir.empty()) return false;
std::error_code ec;
#ifdef _WIN32
if (!fs::create_directory(dir, ec) || ec) return false; // false: something already exists there
#else
if (::mkdir(dir.c_str(), 0700) != 0) return false; // EEXIST covers files, dirs and symlinks
#endif
// Check what is there now without following links: a real directory, not a symlink.
const fs::file_status st = fs::symlink_status(dir, ec);
if (ec || fs::is_symlink(st) || !fs::is_directory(st)) return false;
fs::permissions(dir, fs::perms::owner_all, fs::perm_options::replace, ec);
if (ec) { fs::remove(dir, ec); return false; }
return true;
}
// "<parent>/<prefix><16 random hex chars>", made with createExclusivePrivateDir. Returns the path, or ""
// when it could not be created (the caller must then not run whatever needed the sandbox).
inline std::string makePrivateTempDir(const std::filesystem::path& parent, const std::string& prefix)
{
if (parent.empty() || sodium_init() < 0) return std::string();
unsigned char rnd[8];
randombytes_buf(rnd, sizeof(rnd));
static const char* kHex = "0123456789abcdef";
std::string name = prefix;
for (unsigned char b : rnd) { name.push_back(kHex[b >> 4]); name.push_back(kHex[b & 0x0F]); }
const std::filesystem::path dir = parent / name;
return createExclusivePrivateDir(dir) ? dir.string() : std::string();
}
// Remove a directory made by makePrivateTempDir, and only that: its last path component must still start
// with `prefix`. A symlink in its place is unlinked, never followed. True when nothing is left there.
inline bool removePrivateTempDir(const std::string& dir, const std::string& prefix)
{
namespace fs = std::filesystem;
if (dir.empty() || prefix.empty()) return false;
const fs::path p(dir);
if (p.filename().string().rfind(prefix, 0) != 0) return false;
std::error_code ec;
fs::remove_all(p, ec);
std::error_code sec;
return fs::symlink_status(p, sec).type() == fs::file_type::not_found;
}
} // namespace util
} // namespace dragonx