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:
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "Screenshot-Durchlauf ausführen",
|
||||
"screenshot_sweep_desc": "Durchläuft jedes Design über jeden Tab und speichert von jedem einen Screenshot in tab-spezifischen Unterordnern im Screenshots-Ordner des Konfigurationsverzeichnisses (überschreibt den vorherigen Durchlauf). Läuft einige Sekunden.",
|
||||
"screenshot_sweep_full": "Vollständiger UI-Durchlauf",
|
||||
"screenshot_sweep_refused": "Vollständiger UI-Durchlauf abgelehnt; siehe Debug-Log",
|
||||
"search_icons": "Symbole suchen...",
|
||||
"search_placeholder": "Suchen...",
|
||||
"sec_changing_passphrase": "Passphrase wird geändert...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "Ejecutar barrido de capturas",
|
||||
"screenshot_sweep_desc": "Recorre cada tema en cada pestaña y guarda una captura de pantalla de cada una en subcarpetas por pestaña dentro de la carpeta de capturas del directorio de configuración (sobrescribiendo el barrido anterior). Se ejecuta durante unos segundos.",
|
||||
"screenshot_sweep_full": "Barrido completo de la interfaz",
|
||||
"screenshot_sweep_refused": "Barrido completo de la interfaz rechazado; ver registro de depuración",
|
||||
"search_icons": "Buscar iconos...",
|
||||
"search_placeholder": "Buscar...",
|
||||
"sec_changing_passphrase": "Cambiando la frase de contraseña...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "Lancer la capture d'écran",
|
||||
"screenshot_sweep_desc": "Parcourt chaque thème sur chaque onglet et enregistre une capture d'écran de chacun dans des sous-dossiers par onglet, sous le dossier screenshots du répertoire de configuration (en écrasant le balayage précédent). Dure quelques secondes.",
|
||||
"screenshot_sweep_full": "Balayage complet de l'interface",
|
||||
"screenshot_sweep_refused": "Balayage complet de l'interface refusé ; voir le journal de débogage",
|
||||
"search_icons": "Rechercher des icônes...",
|
||||
"search_placeholder": "Rechercher...",
|
||||
"sec_changing_passphrase": "Modification de la phrase secrète...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "スクリーンショットスイープを実行",
|
||||
"screenshot_sweep_desc": "すべてのテーマをすべてのタブで巡回し、それぞれのスクリーンショットを設定ディレクトリの screenshots フォルダ内のタブごとのサブフォルダに保存します(前回のスイープを上書きします)。数秒間実行されます。",
|
||||
"screenshot_sweep_full": "UI全体スイープ",
|
||||
"screenshot_sweep_refused": "UI全体スイープは拒否されました。デバッグログを確認してください",
|
||||
"search_icons": "アイコンを検索...",
|
||||
"search_placeholder": "検索...",
|
||||
"sec_changing_passphrase": "パスフレーズを変更しています...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "스크린샷 스윕 실행",
|
||||
"screenshot_sweep_desc": "모든 탭에 대해 모든 테마를 순회하며 각각의 스크린샷을 설정 디렉터리의 screenshots 폴더 아래 탭별 하위 폴더에 저장합니다(이전 스윕을 덮어씀). 몇 초 동안 실행됩니다.",
|
||||
"screenshot_sweep_full": "전체 UI 스윕",
|
||||
"screenshot_sweep_refused": "전체 UI 스윕이 거부되었습니다. 디버그 로그를 확인하세요",
|
||||
"search_icons": "아이콘 검색...",
|
||||
"search_placeholder": "검색...",
|
||||
"sec_changing_passphrase": "암호문 변경 중...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "Executar varredura de capturas de tela",
|
||||
"screenshot_sweep_desc": "Percorre cada tema em cada aba e salva uma captura de tela de cada um em subpastas por aba dentro da pasta de capturas de tela do diretório de configuração (sobrescrevendo a varredura anterior). É executado por alguns segundos.",
|
||||
"screenshot_sweep_full": "Varredura completa da interface",
|
||||
"screenshot_sweep_refused": "Varredura completa da interface recusada; veja o log de depuração",
|
||||
"search_icons": "Pesquisar ícones...",
|
||||
"search_placeholder": "Pesquisar...",
|
||||
"sec_changing_passphrase": "Alterando a frase-senha...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "Запустить прогон скриншотов",
|
||||
"screenshot_sweep_desc": "Перебирает каждую тему по всем вкладкам и сохраняет скриншот каждой в подпапки по вкладкам внутри папки screenshots в каталоге конфигурации (перезаписывая предыдущий проход). Выполняется несколько секунд.",
|
||||
"screenshot_sweep_full": "Полный обход интерфейса",
|
||||
"screenshot_sweep_refused": "Полный обход интерфейса отклонён; см. журнал отладки",
|
||||
"search_icons": "Поиск значков...",
|
||||
"search_placeholder": "Поиск...",
|
||||
"sec_changing_passphrase": "Смена парольной фразы...",
|
||||
|
||||
@@ -1666,6 +1666,7 @@
|
||||
"screenshot_sweep": "运行截图批处理",
|
||||
"screenshot_sweep_desc": "遍历每个标签页的每一种主题,并将每一个的截图保存到配置目录 screenshots 文件夹下的各标签页子文件夹中(覆盖上一次的遍历)。运行几秒钟。",
|
||||
"screenshot_sweep_full": "完整 UI 遍历",
|
||||
"screenshot_sweep_refused": "完整 UI 遍历被拒绝;请查看调试日志",
|
||||
"search_icons": "搜索图标...",
|
||||
"search_placeholder": "搜索...",
|
||||
"sec_changing_passphrase": "正在更改密码短语…",
|
||||
|
||||
+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
|
||||
@@ -73,6 +73,7 @@
|
||||
#include "util/amount_format.h"
|
||||
#include "util/payment_uri.h"
|
||||
#include "util/platform.h"
|
||||
#include "util/private_temp_dir.h"
|
||||
#include "util/i18n.h"
|
||||
#include "util/xmrig_updater.h"
|
||||
#include "util/pool_registry.h"
|
||||
@@ -12361,6 +12362,268 @@ void testWalletIndex()
|
||||
#endif // !_WIN32
|
||||
}
|
||||
|
||||
// C91: the full UI sweep's persistence latches and its sandbox helper.
|
||||
static std::string sweepReadBytes(const fs::path& p)
|
||||
{
|
||||
std::ifstream in(p, std::ios::binary);
|
||||
return std::string(std::istreambuf_iterator<char>(in), std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
#ifndef _WIN32
|
||||
// Run `body` with HOME and XDG_CONFIG_HOME pointing into a fresh temp dir (the config dir follows
|
||||
// $HOME), then put both back. Not on Windows: its config dir can't be redirected.
|
||||
static void withSweepTempHome(const std::function<void()>& body)
|
||||
{
|
||||
const char* oldHome = std::getenv("HOME");
|
||||
const std::string savedHome = oldHome ? oldHome : "";
|
||||
const char* oldXdg = std::getenv("XDG_CONFIG_HOME");
|
||||
const bool hadXdg = oldXdg != nullptr;
|
||||
const std::string savedXdg = hadXdg ? oldXdg : "";
|
||||
const fs::path tmp = makeTempDir();
|
||||
setenv("HOME", tmp.string().c_str(), 1);
|
||||
setenv("XDG_CONFIG_HOME", (tmp / ".config").string().c_str(), 1);
|
||||
body();
|
||||
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
|
||||
if (hadXdg) setenv("XDG_CONFIG_HOME", savedXdg.c_str(), 1); else unsetenv("XDG_CONFIG_HOME");
|
||||
removeTree(tmp);
|
||||
}
|
||||
#endif
|
||||
|
||||
// The sweep re-runs its contacts setup every frame: only the FIRST sweepBegin may snapshot (the old
|
||||
// per-frame snapshot "restored" demo contacts), and nothing may reach addressbook.json meanwhile.
|
||||
void testSweepAddressBookLatch()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
std::printf("[skip] testSweepAddressBookLatch (Windows config dir can't be redirected)\n");
|
||||
#else
|
||||
using dragonx::data::AddressBook;
|
||||
using dragonx::data::AddressBookEntry;
|
||||
withSweepTempHome([] {
|
||||
AddressBook book;
|
||||
book.load();
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("Alice", "zs1alicereal", "", "w:abc")));
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("Bob", "zs1bobreal", "", "global")));
|
||||
const fs::path file = AddressBook::getDefaultPath();
|
||||
const std::string before = sweepReadBytes(file);
|
||||
EXPECT_TRUE(!before.empty());
|
||||
|
||||
book.sweepBegin({ AddressBookEntry("DemoA", "zs1demoa", "", "global") });
|
||||
book.sweepBegin({ AddressBookEntry("DemoB", "zs1demob", "", "legacyhash") }); // next frame
|
||||
EXPECT_TRUE(book.sweepActive());
|
||||
EXPECT_EQ(book.size(), (size_t)1);
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("DemoC", "zs1democ", "", "global")));
|
||||
EXPECT_EQ(book.reattachLegacyScopes("w:x"), 1); // re-scopes DemoB; its save is latched
|
||||
EXPECT_TRUE(book.load()); // latched: keeps the demo list and the snapshot
|
||||
EXPECT_EQ(book.size(), (size_t)2);
|
||||
EXPECT_EQ(sweepReadBytes(file), before);
|
||||
|
||||
book.sweepEnd();
|
||||
book.sweepEnd(); // idempotent
|
||||
EXPECT_TRUE(!book.sweepActive());
|
||||
EXPECT_EQ(book.size(), (size_t)2);
|
||||
EXPECT_TRUE(book.findByAddress("zs1alicereal") >= 0 && book.findByAddress("zs1bobreal") >= 0);
|
||||
EXPECT_TRUE(book.findByAddress("zs1demob") < 0 && book.findByAddress("zs1democ") < 0);
|
||||
EXPECT_EQ(sweepReadBytes(file), before); // the disk never saw the demo book
|
||||
|
||||
AddressBook fresh;
|
||||
EXPECT_TRUE(fresh.load());
|
||||
EXPECT_EQ(fresh.size(), (size_t)2);
|
||||
const int ia = fresh.findByAddress("zs1alicereal");
|
||||
EXPECT_TRUE(ia >= 0 && fresh.entries()[(size_t)ia].scope == "w:abc");
|
||||
|
||||
// After the sweep, saves work again.
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("Carol", "zs1carolreal", "", "global")));
|
||||
AddressBook fresh2;
|
||||
EXPECT_TRUE(fresh2.load());
|
||||
EXPECT_EQ(fresh2.size(), (size_t)3);
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
// The sweep's demo upsert drops the real entry's scopeId, and activeWalletScopeId() would then mint and
|
||||
// save a new one (hiding "w:"-scoped contacts for good). The latch keeps wallets.json and restores the id.
|
||||
void testSweepWalletIndexLatch()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
std::printf("[skip] testSweepWalletIndexLatch (Windows config dir can't be redirected)\n");
|
||||
#else
|
||||
using dragonx::data::WalletIndex;
|
||||
using dragonx::data::WalletIndexEntry;
|
||||
withSweepTempHome([] {
|
||||
WalletIndex idx;
|
||||
idx.load();
|
||||
WalletIndexEntry real; real.fileName = "wallet.dat"; real.displayName = "Main";
|
||||
real.scopeId = "w:abc"; real.lastOpenedEpoch = 1720000000;
|
||||
idx.upsert(real);
|
||||
idx.addExtraFolder("/real/wallet-folder");
|
||||
EXPECT_TRUE(idx.save()); // no writer attached: synchronous
|
||||
const fs::path file = WalletIndex::getDefaultPath();
|
||||
const std::string before = sweepReadBytes(file);
|
||||
std::error_code ec;
|
||||
const auto mtime = fs::last_write_time(file, ec);
|
||||
EXPECT_TRUE(!before.empty() && !ec);
|
||||
|
||||
idx.sweepBegin();
|
||||
WalletIndexEntry demo; demo.fileName = "wallet.dat"; demo.displayName = "wallet.dat";
|
||||
demo.cachedBalance = 15.7526; demo.syncedHere = true;
|
||||
EXPECT_TRUE(idx.upsert(demo)); // replaces the entry: scopeId lost
|
||||
EXPECT_TRUE(idx.find("wallet.dat") && idx.find("wallet.dat")->scopeId.empty());
|
||||
demo.scopeId = "w:minted"; idx.upsert(demo); // what activeWalletScopeId() would mint
|
||||
EXPECT_TRUE(idx.addExtraFolder("/tmp/sweep-sandbox/extra-wallets"));
|
||||
EXPECT_TRUE(idx.save()); // latched
|
||||
EXPECT_EQ(sweepReadBytes(file), before);
|
||||
|
||||
idx.sweepEnd();
|
||||
idx.sweepEnd(); // idempotent
|
||||
EXPECT_TRUE(!idx.sweepActive());
|
||||
const auto* w = idx.find("wallet.dat");
|
||||
EXPECT_TRUE(w != nullptr);
|
||||
if (w) {
|
||||
EXPECT_EQ(w->scopeId, std::string("w:abc"));
|
||||
EXPECT_EQ(w->displayName, std::string("Main"));
|
||||
}
|
||||
EXPECT_TRUE(idx.extraFolders() == std::vector<std::string>{"/real/wallet-folder"});
|
||||
EXPECT_EQ(sweepReadBytes(file), before);
|
||||
EXPECT_TRUE(fs::last_write_time(file, ec) == mtime);
|
||||
|
||||
// With the App's background writer attached, a latched save queues nothing either.
|
||||
{
|
||||
dragonx::util::CoalescingWriter writer;
|
||||
idx.setFileWriter(&writer);
|
||||
idx.sweepBegin();
|
||||
idx.upsert(demo);
|
||||
EXPECT_TRUE(idx.save());
|
||||
EXPECT_EQ(writer.pendingCount(), (size_t)0);
|
||||
idx.sweepEnd();
|
||||
idx.setFileWriter(nullptr);
|
||||
}
|
||||
EXPECT_EQ(sweepReadBytes(file), before);
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
// Settings: the demo active-wallet file / contacts view never reach disk; sweepEnd restores them and
|
||||
// flushes once only when a save was suppressed (so a real change made meanwhile still lands).
|
||||
void testSweepSettingsLatch()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
std::printf("[skip] testSweepSettingsLatch (Windows config dir can't be redirected)\n");
|
||||
#else
|
||||
using dragonx::config::Settings;
|
||||
withSweepTempHome([] {
|
||||
Settings s; // no writer attached: synchronous saves
|
||||
s.load(); // no file yet: defaults
|
||||
s.setActiveWalletFile("x.dat");
|
||||
s.setContactsViewMode(2);
|
||||
s.setUIOpacity(0.55f);
|
||||
EXPECT_TRUE(s.save());
|
||||
const fs::path file = Settings::getDefaultPath();
|
||||
const std::string before = sweepReadBytes(file);
|
||||
EXPECT_TRUE(!before.empty());
|
||||
|
||||
s.sweepBegin();
|
||||
s.setActiveWalletFile("wallet.dat"); // what the wallets surfaces set
|
||||
s.setContactsViewMode(1);
|
||||
s.setPoolWorker("zs1demoprimaryshieldedaddressforuisweep"); // the Mining tab's demo default
|
||||
s.setUIOpacity(0.65f); // a real change made during the sweep
|
||||
EXPECT_TRUE(s.save()); // suppressed
|
||||
EXPECT_EQ(sweepReadBytes(file), before);
|
||||
s.sweepEnd();
|
||||
EXPECT_TRUE(!s.sweepActive());
|
||||
EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat"));
|
||||
EXPECT_EQ(s.getContactsViewMode(), 2);
|
||||
EXPECT_TRUE(s.getPoolWorker().empty());
|
||||
{
|
||||
Settings back;
|
||||
EXPECT_TRUE(back.load());
|
||||
EXPECT_EQ(back.getActiveWalletFile(), std::string("x.dat"));
|
||||
EXPECT_EQ(back.getContactsViewMode(), 2);
|
||||
EXPECT_TRUE(back.getPoolWorker().empty());
|
||||
EXPECT_NEAR(back.getUIOpacity(), 0.65, 1e-6); // the one flush carried the real change
|
||||
}
|
||||
|
||||
// No suppressed save: sweepEnd restores and writes nothing.
|
||||
const std::string bytes0 = sweepReadBytes(file);
|
||||
std::error_code ec;
|
||||
const auto t0 = fs::last_write_time(file, ec);
|
||||
s.sweepBegin();
|
||||
s.setActiveWalletFile("wallet.dat");
|
||||
s.setContactsViewMode(0);
|
||||
s.sweepEnd();
|
||||
s.sweepEnd(); // idempotent
|
||||
EXPECT_EQ(s.getActiveWalletFile(), std::string("x.dat"));
|
||||
EXPECT_EQ(s.getContactsViewMode(), 2);
|
||||
EXPECT_EQ(sweepReadBytes(file), bytes0);
|
||||
EXPECT_TRUE(fs::last_write_time(file, ec) == t0);
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
// The sandbox: a fresh random owner-only dir; a pre-existing path or a symlink (even dangling) is refused,
|
||||
// and the remover only touches a path that still carries the prefix.
|
||||
void testSweepSandboxDir()
|
||||
{
|
||||
using dragonx::util::createExclusivePrivateDir;
|
||||
using dragonx::util::makePrivateTempDir;
|
||||
using dragonx::util::removePrivateTempDir;
|
||||
const fs::path parent = makeTempDir();
|
||||
const std::string prefix = "obsidiandragon-sweep-";
|
||||
|
||||
const std::string a = makePrivateTempDir(parent, prefix);
|
||||
const std::string b = makePrivateTempDir(parent, prefix);
|
||||
EXPECT_TRUE(!a.empty() && !b.empty());
|
||||
EXPECT_TRUE(a != b);
|
||||
EXPECT_TRUE(fs::is_directory(fs::symlink_status(a)) && fs::is_directory(fs::symlink_status(b)));
|
||||
EXPECT_EQ(fs::path(a).filename().string().size(), prefix.size() + 16);
|
||||
EXPECT_EQ(fs::path(a).filename().string().rfind(prefix, 0), (size_t)0);
|
||||
#ifndef _WIN32
|
||||
EXPECT_TRUE(fs::status(a).permissions() == fs::perms::owner_all);
|
||||
#endif
|
||||
|
||||
// A pre-existing directory or file is refused.
|
||||
EXPECT_TRUE(!createExclusivePrivateDir(a));
|
||||
writeTestFile(parent / "plain-file", "x");
|
||||
EXPECT_TRUE(!createExclusivePrivateDir(parent / "plain-file"));
|
||||
EXPECT_TRUE(!createExclusivePrivateDir(fs::path()));
|
||||
EXPECT_TRUE(makePrivateTempDir(fs::path(), prefix).empty());
|
||||
|
||||
#ifndef _WIN32
|
||||
// A symlink planted at the name (e.g. pointing at a victim's datadir) is refused, and its target is
|
||||
// neither reused nor re-permissioned.
|
||||
const fs::path victim = parent / "victim";
|
||||
fs::create_directories(victim);
|
||||
fs::permissions(victim, fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec);
|
||||
writeTestFile(victim / "wallet.dat", "real");
|
||||
const fs::path link = parent / (prefix + "0123456789abcdef");
|
||||
fs::create_directory_symlink(victim, link);
|
||||
EXPECT_TRUE(!createExclusivePrivateDir(link));
|
||||
EXPECT_TRUE(fs::is_symlink(fs::symlink_status(link)));
|
||||
EXPECT_TRUE(fs::status(victim).permissions() ==
|
||||
(fs::perms::owner_all | fs::perms::group_read | fs::perms::group_exec));
|
||||
const fs::path dangling = parent / (prefix + "fedcba9876543210");
|
||||
fs::create_symlink(parent / "nowhere", dangling);
|
||||
EXPECT_TRUE(!createExclusivePrivateDir(dangling));
|
||||
EXPECT_TRUE(!fs::exists(parent / "nowhere"));
|
||||
|
||||
// Removing the link unlinks it and never follows it into the target.
|
||||
EXPECT_TRUE(removePrivateTempDir(link.string(), prefix));
|
||||
EXPECT_TRUE(fs::exists(victim / "wallet.dat"));
|
||||
EXPECT_EQ(sweepReadBytes(victim / "wallet.dat"), std::string("real"));
|
||||
#endif
|
||||
|
||||
// The remover: refuses a path without the prefix, removes the whole sandbox tree otherwise.
|
||||
fs::create_directories(fs::path(a) / "DRAGONX");
|
||||
writeTestFile(fs::path(a) / "DRAGONX" / "wallet.dat", std::string(64, '\0'));
|
||||
EXPECT_TRUE(!removePrivateTempDir(parent.string(), prefix));
|
||||
EXPECT_TRUE(fs::exists(parent));
|
||||
EXPECT_TRUE(!removePrivateTempDir(std::string(), prefix));
|
||||
EXPECT_TRUE(removePrivateTempDir(a, prefix));
|
||||
EXPECT_TRUE(!fs::exists(a));
|
||||
EXPECT_TRUE(removePrivateTempDir(a, prefix)); // already gone: nothing left
|
||||
EXPECT_TRUE(fs::exists(b));
|
||||
removeTree(parent);
|
||||
}
|
||||
|
||||
// Tracked sends that only paid the wallet itself (shield, merge/consolidate, chat note split) show no
|
||||
// outgoing output in z_viewtransaction. They used to stay tracked for the whole session, forcing a full
|
||||
// history refresh past the scan throttle on every tab; once mined or conflicted they are now retired.
|
||||
@@ -15782,6 +16045,10 @@ int main()
|
||||
testTransactionHistoryCacheBackgroundWrite();
|
||||
testAddressBookScope();
|
||||
testWalletIndex();
|
||||
testSweepAddressBookLatch();
|
||||
testSweepWalletIndexLatch();
|
||||
testSweepSettingsLatch();
|
||||
testSweepSandboxDir();
|
||||
testTransactionHistoryCacheRefreshApply();
|
||||
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
||||
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
||||
|
||||
Reference in New Issue
Block a user