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:
186
src/app.cpp
186
src/app.cpp
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user