feat(migrate): daemon-update prompt + migrate-to-seed UX polish + i18n

Fixes the Windows migrate-to-seed failure (the old bundled daemon lacked
z_exportmnemonic) and rounds out the flow:

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 22:16:47 -05:00
parent 48c79567d7
commit 82e61da62f
17 changed files with 418 additions and 22 deletions

View File

@@ -467,6 +467,11 @@ bool App::init()
// Idempotent; a missing file is fine (returns true). Purely local — no daemon/RPC dependency.
address_book_.load();
// If this wallet build bundles a newer daemon than the one already installed, offer to update it
// (once per version). Fixes the trap where a wallet update carrying a newer node silently kept
// the old binary — e.g. an old daemon lacking z_exportmnemonic blocking migrate-to-seed.
maybeOfferDaemonUpdate();
DEBUG_LOGF("Initialization complete\n");
return true;
}
@@ -1931,6 +1936,10 @@ void App::render()
renderSeedMigrationDialog();
}
if (show_daemon_update_prompt_ && !capture_mode_) {
renderDaemonUpdatePrompt();
}
// Security overlay dialogs (encrypt, decrypt, PIN) are rendered AFTER ImGui::End()
// to ensure they appear on top of all other content
@@ -2963,6 +2972,52 @@ void App::renderBackupDialog()
ui::material::EndOverlayDialog();
}
void App::maybeOfferDaemonUpdate()
{
if (!supportsFullNodeLifecycleActions()) return; // lite / no embedded-daemon build
if (capture_mode_ || !settings_) return; // never during a UI sweep
auto inst = resources::getInstalledDaemonInfo();
auto bun = resources::getBundledDaemonInfo();
// Only when a daemon is ALREADY installed and its size differs from the bundled one — an absent
// daemon is extracted normally, and a same-size match needs nothing. Fire once per bundled
// version (never re-nag a user running a custom node): key on the bundled size last offered.
if (!inst.exists || !bun.available || inst.size == bun.size) return;
if ((unsigned long long)settings_->getDaemonUpdatePromptedSize() == bun.size) return;
daemon_update_bundled_size_ = bun.size;
show_daemon_update_prompt_ = true;
}
void App::renderDaemonUpdatePrompt()
{
if (!ui::material::BeginOverlayDialog(TR("daemon_update_title"), &show_daemon_update_prompt_, 520.0f, 0.94f))
return;
const float dp = ui::Layout::dpiScale();
ui::material::Type().text(ui::material::TypeStyle::H6, TR("daemon_update_title"));
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
ImGui::TextWrapped("%s", TR("daemon_update_body"));
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("daemon_update_safe"));
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::Separator();
if (ui::material::StyledButton(TR("daemon_update_now"), ImVec2(170 * dp, 0))) {
show_daemon_update_prompt_ = false;
reinstallBundledDaemon(); // stops the node (managed or external), overwrites, restarts
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("daemon_update_keep"), ImVec2(150 * dp, 0))) {
show_daemon_update_prompt_ = false;
}
ui::material::EndOverlayDialog();
// Any close path (Update / Keep / Esc / X): record the bundled size so we don't prompt again for
// this wallet version — a user keeping a custom daemon is asked at most once per version.
if (!show_daemon_update_prompt_ && settings_) {
settings_->setDaemonUpdatePromptedSize((long long)daemon_update_bundled_size_);
settings_->save();
}
}
void App::renderSeedBackupDialog()
{
// Wipe the revealed phrase and reset the dialog's state.
@@ -3101,22 +3156,72 @@ void App::renderSeedMigrationDialog()
ImGui::Dummy(ImVec2(0, ui::Layout::spacingSm()));
switch (seed_migration_step_) {
case SeedMigrationStep::Intro:
ImGui::TextWrapped("%s", TR("mig_intro"));
ImGui::Spacing();
ImGui::Separator();
if (ui::material::StyledButton(TR("mig_create"), ImVec2(180 * dp, 0)))
beginCreateSeedWallet();
ImGui::SameLine();
if (ui::material::StyledButton(TR("cancel"), ImVec2(120 * dp, 0)))
close();
case SeedMigrationStep::Intro: {
// Pre-flight needs a connected, unlocked wallet — gate on that, then branch on what the
// probe found so we never offer a pointless (already-seeded) or impossible (old daemon) run.
if (!isConnected()) {
ImGui::TextWrapped("%s", TR("mig_not_connected"));
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
}
if (state_.isLocked()) {
ImGui::TextWrapped("%s", TR("mig_unlock_to_migrate"));
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
}
if (!seed_migration_precheck_started_ && !capture_mode_) beginSeedMigrationPrecheck();
switch (seed_migration_precheck_) {
case SeedMigrationPrecheck::Pending:
ImGui::TextColored(kMedium, "%s%s", TR("mig_precheck_checking"), ui::material::LoadingDots());
break;
case SeedMigrationPrecheck::AlreadyMnemonic:
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.3f, 0.8f, 0.3f, 1.0f));
ImGui::TextWrapped("%s", TR("mig_already_mnemonic"));
ImGui::PopStyleColor();
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("mig_backup_instead"), ImVec2(200 * dp, 0))) {
close();
showSeedBackupDialog();
}
ImGui::SameLine();
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
case SeedMigrationPrecheck::DaemonTooOld:
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::Error());
ImGui::TextWrapped("%s", TR("mig_daemon_too_old"));
ImGui::PopStyleColor();
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
case SeedMigrationPrecheck::CheckFailed:
ImGui::TextWrapped("%s", TR("mig_check_failed"));
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("mig_recheck"), ImVec2(120 * dp, 0)))
seed_migration_precheck_started_ = false; // re-probe next frame
ImGui::SameLine();
if (ui::material::StyledButton(TR("close"), ImVec2(120 * dp, 0))) close();
break;
case SeedMigrationPrecheck::Legacy:
default:
ImGui::TextWrapped("%s", TR("mig_intro"));
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("mig_create"), ImVec2(180 * dp, 0)))
beginCreateSeedWallet();
ImGui::SameLine();
if (ui::material::StyledButton(TR("cancel"), ImVec2(120 * dp, 0)))
close();
break;
}
break;
}
case SeedMigrationStep::Working:
ImGui::TextWrapped("%s", TR("mig_working"));
ImGui::Spacing();
if (!seed_migration_status_.empty())
ImGui::TextColored(kMedium, "%s", seed_migration_status_.c_str());
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
break;
case SeedMigrationStep::ShowSeed: {
@@ -3153,6 +3258,8 @@ void App::renderSeedMigrationDialog()
if (ui::material::StyledButton(TR("mig_continue_sweep"), ImVec2(170 * dp, 0))) {
wipeSeed(); // the sweep/adopt steps only use the address, never the seed itself
seed_migration_step_ = SeedMigrationStep::Sweep;
seed_migration_balance_loaded_ = false;
seed_migration_nofunds_confirmed_ = false;
refreshSeedMigrationBalance();
}
if (!seed_migration_backed_up_) ImGui::EndDisabled();
@@ -3177,6 +3284,37 @@ void App::renderSeedMigrationDialog()
}
case SeedMigrationStep::Sweep: {
// Wait for the balance to load before deciding funds vs no-funds (0.0 is also the initial
// value, so gate on the loaded flag to avoid flashing "no funds" while it's still fetching).
if (!seed_migration_balance_loaded_) {
ImGui::TextColored(kMedium, "%s%s", TR("mig_checking_balance"), ui::material::LoadingDots());
ImGui::Spacing(); ImGui::Separator();
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0))) close();
break;
}
if (seed_migration_balance_ <= DRAGONX_DEFAULT_FEE) {
// Nothing to sweep — adopt the fresh seed wallet directly. Its wallet.dat replaces the
// current (empty) one; adopt still moves the legacy wallet aside to a timestamped backup.
ImGui::TextWrapped("%s", TR("mig_no_funds"));
ImGui::Spacing();
ImGui::TextWrapped("%s%s", TR("mig_to"), seed_migration_dest_.c_str());
ImGui::Spacing();
// Adopt swaps wallet.dat — gate it behind an explicit acknowledgement even though no
// funds are at risk (the legacy wallet is still moved aside to a backup on adopt).
ImGui::Checkbox(TR("mig_nofunds_confirm"), &seed_migration_nofunds_confirmed_);
ImGui::Spacing(); ImGui::Separator();
if (!seed_migration_nofunds_confirmed_) ImGui::BeginDisabled();
if (ui::material::StyledButton(TR("mig_adopt_now"), ImVec2(190 * dp, 0)))
beginAdoptSeedWallet();
if (!seed_migration_nofunds_confirmed_) ImGui::EndDisabled();
ImGui::SameLine();
if (ui::material::StyledButton(TR("refresh"), ImVec2(110 * dp, 0)))
refreshSeedMigrationBalance();
ImGui::SameLine();
if (ui::material::StyledButton(TR("mig_later"), ImVec2(100 * dp, 0)))
close();
break;
}
ImGui::TextWrapped("%s", TR("mig_sweep_intro"));
ImGui::Spacing();
char balbuf[96]; snprintf(balbuf, sizeof(balbuf), TR("mig_balance"), seed_migration_balance_);
@@ -3209,8 +3347,7 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::Sweeping:
ImGui::TextWrapped("%s", TR("mig_sweeping"));
ImGui::Spacing();
if (!seed_migration_status_.empty())
ImGui::TextColored(kMedium, "%s", seed_migration_status_.c_str());
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
break;
case SeedMigrationStep::Confirming: {
@@ -3254,8 +3391,11 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::Adopting:
ImGui::TextWrapped("%s", TR("mig_adopting"));
ImGui::Spacing();
if (!seed_migration_status_.empty())
ImGui::TextColored(kMedium, "%s", seed_migration_status_.c_str());
ImGui::TextColored(kMedium, "%s%s", seed_migration_status_.c_str(), ui::material::LoadingDots());
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ui::material::OnSurfaceMedium());
ImGui::TextWrapped("%s", TR("mig_adopting_note"));
ImGui::PopStyleColor();
break;
case SeedMigrationStep::Done:
@@ -3717,8 +3857,12 @@ void App::reinstallBundledDaemon()
ui::Notifications::instance().warning("This build has no bundled daemon to install");
return;
}
if (!daemon_controller_ || !isUsingEmbeddedDaemon()) {
ui::Notifications::instance().warning("Reinstalling the daemon requires the embedded daemon");
// Require embedded-daemon *support*, but NOT an active daemon_controller_. When the wallet is
// attached to an external or leftover dragonxd (controller null, so stopEmbeddedDaemon() no-ops)
// we can still RPC-stop that node, overwrite the binaries, and bring up our own managed daemon —
// which is exactly the state that previously blocked "Install bundled" with a cryptic warning.
if (!supportsEmbeddedDaemon()) {
ui::Notifications::instance().warning("This build has no embedded daemon to install");
return;
}
@@ -3733,7 +3877,13 @@ void App::reinstallBundledDaemon()
async_tasks_.submit("reinstall-daemon", [this](const util::AsyncTaskManager::Token& token) {
AppDaemonLifecycleRuntime runtime(*this);
daemon::AsyncLifecycleTaskContext ctx(token, shutting_down_);
// Stop the daemon so its binary is no longer locked (Windows) / in use (ETXTBSY on Linux).
// Stop the running node so its binary is free. stopDaemonWithPolicy() covers a wallet-managed
// daemon; if we're instead attached to an external/leftover dragonxd (daemon_controller_ is
// null, so stopEmbeddedDaemon() returns immediately), send an explicit RPC stop so that node
// exits too — otherwise it keeps the RPC port and the fresh daemon can't start.
if (!daemon_controller_ && rpc_ && rpc_->isConnected()) {
sendStopCommandSafely(*rpc_, "reinstall-daemon");
}
runtime.stopDaemonWithPolicy();
// Wait (bounded, ~12s) for the process to exit and release the binary before overwriting.
for (int i = 0; i < 120 && daemon::EmbeddedDaemon::isRpcPortInUse()