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:
+3
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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_;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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…";
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user