diff --git a/locales/de.yml b/locales/de.yml index 8fd7db47..43edbe39 100644 --- a/locales/de.yml +++ b/locales/de.yml @@ -366,6 +366,7 @@ goblin: node_synced: "Node synchronisiert" syncing: "Synchronisiere…" balance_updating: "Guthaben wird aktualisiert…" + balance_stale: "Knoten nicht erreichbar · letzter bekannter Saldo" listening: "Wartet auf Zahlungen" block: "Block %{height}" waiting_for_chain: "Warte auf Chain…" diff --git a/locales/en.yml b/locales/en.yml index db98e9f8..18eb2618 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -366,6 +366,7 @@ goblin: node_synced: "Node synced" syncing: "Syncing…" balance_updating: "Balance updating…" + balance_stale: "Can't reach node · last known balance" listening: "Listening for payments" block: "Block %{height}" waiting_for_chain: "Waiting for chain…" diff --git a/locales/fr.yml b/locales/fr.yml index cd66d94d..65006c3b 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -366,6 +366,7 @@ goblin: node_synced: "Nœud synchronisé" syncing: "Synchronisation…" balance_updating: "Solde en cours de mise à jour…" + balance_stale: "Nœud injoignable · dernier solde connu" listening: "En attente de paiements" block: "Bloc %{height}" waiting_for_chain: "En attente de la chaîne…" diff --git a/locales/ru.yml b/locales/ru.yml index dfedf524..ed609ba2 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -366,6 +366,7 @@ goblin: node_synced: "Узел синхронизирован" syncing: "Синхронизация…" balance_updating: "Баланс обновляется…" + balance_stale: "Узел недоступен · последний известный баланс" listening: "Ожидание платежей" block: "Блок %{height}" waiting_for_chain: "Ожидание цепочки…" diff --git a/locales/tr.yml b/locales/tr.yml index 30fe1e52..cfdef5a2 100644 --- a/locales/tr.yml +++ b/locales/tr.yml @@ -366,6 +366,7 @@ goblin: node_synced: "Düğüm eşitlendi" syncing: "Eşitleniyor…" balance_updating: "Bakiye güncelleniyor…" + balance_stale: "Düğüme ulaşılamıyor · son bilinen bakiye" listening: "Ödemeler bekleniyor" block: "Blok %{height}" waiting_for_chain: "Zincir bekleniyor…" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e12133bb..475f6a11 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -366,6 +366,7 @@ goblin: node_synced: "节点已同步" syncing: "同步中…" balance_updating: "余额更新中…" + balance_stale: "无法连接节点 · 上次已知余额" listening: "正在监听付款" block: "区块 %{height}" waiting_for_chain: "等待链数据…" diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 314024c5..e41568be 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -1024,11 +1024,15 @@ impl GoblinWalletView { .map(|d| d.info.amount_locked + d.info.amount_awaiting_finalization) .unwrap_or(0); let updating = total == 0 && (in_flight > 0 || wallet.syncing()); + // Distinguish "still updating" from "can't reach the node" so a + // node outage never renders as a silent zero (see balance_hero). + let error = wallet.sync_error(); w::balance_hero( ui, total, spendable, updating, + error, wallet.info_sync_progress(), fiat_line(&data).as_deref(), 56.0, @@ -3430,6 +3434,8 @@ impl GoblinWalletView { ui.add_space(10.0); if !active && row.response.interact(Sense::click()).clicked() { wallet.update_connection(&ConnectionMethod::Integrated); + // Apply to the running session now, not on next unlock. + wallet.reconnect_node(); } } for conn in ConnectionsConfig::ext_conn_list() { @@ -3472,6 +3478,8 @@ impl GoblinWalletView { conn.id, conn.url.clone(), )); + // Apply to the running session now, not on next unlock. + wallet.reconnect_node(); } } }); @@ -3513,9 +3521,11 @@ impl GoblinWalletView { } }; let conn = ExternalConnection::new(url, None, secret); + ConnectionsConfig::add_ext_conn(conn.clone()); wallet .update_connection(&ConnectionMethod::External(conn.id, conn.url.clone())); - ConnectionsConfig::add_ext_conn(conn); + // Apply to the running session now, not on next unlock. + wallet.reconnect_node(); self.node_url_input.clear(); self.node_secret_input.clear(); } diff --git a/src/gui/views/goblin/widgets.rs b/src/gui/views/goblin/widgets.rs index 08eb78b4..f32abfaf 100644 --- a/src/gui/views/goblin/widgets.rs +++ b/src/gui/views/goblin/widgets.rs @@ -464,11 +464,45 @@ pub fn field_well(ui: &mut Ui, content: impl FnOnce(&mut Ui)) { /// A balance hero block: kicker, big number + ツ, optional fiat line. /// `updating` marks a zero balance that is only zero because funds are in /// flight or the first sync is still running. +/// Honest subline shown under the balance figure. A wallet that can't reach a +/// node must never present a bare `0` (or a silently-stale number) as if it were +/// a live, confirmed balance. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum BalanceSubline { + /// Nothing to add: the shown balance is live and non-zero. + None, + /// Balance reads 0 while a sync/first-scan is in progress or funds are in + /// flight — say "updating", not "empty". + Updating, + /// Balance reads 0 and the node is unreachable with nothing cached — say + /// "can't reach node", never a bare 0. + Unreachable, + /// A cached (last-known) balance is shown but the node is currently + /// unreachable — flag it as possibly stale. + Stale, +} + +/// Pure decision for the balance subline. `updating` means a sync is in progress +/// (or funds are in flight); `error` means the wallet currently can't reach a +/// node. Priority: updating > unreachable > stale. +pub fn balance_subline(total: u64, updating: bool, error: bool) -> BalanceSubline { + if total == 0 && updating { + BalanceSubline::Updating + } else if total == 0 && error { + BalanceSubline::Unreachable + } else if error { + BalanceSubline::Stale + } else { + BalanceSubline::None + } +} + pub fn balance_hero( ui: &mut Ui, total: u64, spendable: u64, updating: bool, + error: bool, sync_pct: u8, fiat: Option<&str>, size: f32, @@ -498,24 +532,51 @@ pub fn balance_hero( ); }); } - // A fresh sync or funds in flight leave a stark 0 that reads as "funds - // vanished" — say the balance is still updating, with the scan % when the - // node reports one (1..99), so the user sees progress without opening the - // wallet switcher. - if total == 0 && updating { - let label = if (1..100).contains(&sync_pct) { - format!("{} {sync_pct}%", t!("goblin.home.balance_updating")) - } else { - t!("goblin.home.balance_updating").to_string() - }; - ui.add_space(4.0); - ui.vertical_centered(|ui| { - ui.label( - RichText::new(label) - .font(FontId::new(12.5, fonts::medium())) - .color(t.text_dim), - ); - }); + // A stark 0 (or a stale number) reads as "funds vanished". Pick the honest + // subline: still-updating, node-unreachable, or last-known-balance. See + // [`balance_subline`] for the pure state machine. + match balance_subline(total, updating, error) { + BalanceSubline::Updating => { + let label = if (1..100).contains(&sync_pct) { + format!("{} {sync_pct}%", t!("goblin.home.balance_updating")) + } else { + t!("goblin.home.balance_updating").to_string() + }; + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(label) + .font(FontId::new(12.5, fonts::medium())) + .color(t.text_dim), + ); + }); + } + BalanceSubline::Unreachable => { + // Node unreachable and nothing cached yet: a bare 0 would claim the + // wallet is empty. Say the truth so the user switches nodes instead + // of assuming funds vanished. + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.home.cant_reach_node")) + .font(FontId::new(12.5, fonts::medium())) + .color(t.neg), + ); + }); + } + BalanceSubline::Stale => { + // A cached balance is shown but we can't currently reach a node: + // flag it as possibly stale rather than presenting it as live. + ui.add_space(4.0); + ui.vertical_centered(|ui| { + ui.label( + RichText::new(t!("goblin.home.balance_stale")) + .font(FontId::new(12.5, fonts::medium())) + .color(t.text_dim), + ); + }); + } + BalanceSubline::None => {} } if let Some(fiat) = fiat { ui.add_space(4.0); @@ -938,3 +999,41 @@ impl HoldToSend { false } } + +#[cfg(test)] +mod tests { + use super::{BalanceSubline, balance_subline}; + + // A live, non-zero balance needs no subline. + #[test] + fn live_balance_has_no_subline() { + assert_eq!(balance_subline(1_000, false, false), BalanceSubline::None); + } + + // Zero while syncing / funds in flight is "updating", not "empty". + #[test] + fn zero_while_updating_says_updating() { + assert_eq!(balance_subline(0, true, false), BalanceSubline::Updating); + } + + // Zero with an unreachable node and nothing cached must say so, never a + // bare 0 that reads as "wallet empty" (the silent-zero incident). + #[test] + fn zero_with_node_error_says_unreachable() { + assert_eq!(balance_subline(0, false, true), BalanceSubline::Unreachable); + } + + // A cached balance shown during a node outage is flagged stale, not passed + // off as a live figure. + #[test] + fn cached_balance_with_error_is_stale() { + assert_eq!(balance_subline(500, false, true), BalanceSubline::Stale); + } + + // Updating wins over error while the balance is still zero: a fresh switch + // to a new node shows progress, not a scary red banner, until it errors. + #[test] + fn updating_takes_priority_over_error_at_zero() { + assert_eq!(balance_subline(0, true, true), BalanceSubline::Updating); + } +} diff --git a/src/wallet/wallet.rs b/src/wallet/wallet.rs index 5376e6fb..4791bf94 100644 --- a/src/wallet/wallet.rs +++ b/src/wallet/wallet.rs @@ -250,14 +250,17 @@ impl Wallet { None } - /// Create [`HTTPNodeClient`] from provided config. - fn create_node_client(config: &WalletConfig) -> Result { + /// Resolve the node API URL and secret for the connection saved in `config`. + /// Shared by [`Wallet::create_node_client`] (wallet open) and + /// [`Wallet::reconnect_node`] (live node switch) so both always target the + /// same node for a given config. + fn node_url_secret(config: &WalletConfig) -> (String, Option) { let integrated = || { let api_url = format!("http://{}", NodeConfig::get_api_address()); let api_secret = NodeConfig::get_api_secret(true); (api_url, api_secret) }; - let (node_api_url, node_secret) = if let Some(id) = config.ext_conn_id { + if let Some(id) = config.ext_conn_id { if let Some(conn) = ConnectionsConfig::ext_conn(id) { (conn.url, conn.secret) } else { @@ -265,7 +268,12 @@ impl Wallet { } } else { integrated() - }; + } + } + + /// Create [`HTTPNodeClient`] from provided config. + fn create_node_client(config: &WalletConfig) -> Result { + let (node_api_url, node_secret) = Self::node_url_secret(config); let client = if AppConfig::use_proxy() { let socks = AppConfig::use_socks_proxy(); let url = if socks { @@ -780,6 +788,57 @@ impl Wallet { w_config.save(); } + /// Apply the saved connection to the RUNNING wallet session immediately. + /// + /// A node selection persisted by [`Wallet::update_connection`] only reaches + /// an open wallet on the next open, because the node client is baked into the + /// wallet instance at open time. This swaps the live node client's URL and + /// secret in place (no close/reopen, so the password is not needed again), + /// updates the runtime connection so the UI reflects the switch at once, then + /// wakes the sync thread to refresh the balance against the new node. + /// + /// If the new node is unreachable the sync simply errors and the honest + /// "can't reach node" state surfaces (with the last-known balance retained), + /// so the user is never stranded on a silent zero. + pub fn reconnect_node(&self) { + // Reflect the switch in the runtime connection right away so the picker + // and status cards stop showing the previous node. + let conn = self.get_config().connection(); + { + let mut w_conn = self.connection.write(); + *w_conn = conn.clone(); + } + // Clear the stale sync error/progress so the surface shows the honest + // "updating" state while the new node is contacted. + self.set_sync_error(false); + self.reset_sync_attempts(); + self.info_sync_progress.store(0, Ordering::Relaxed); + + // Swap the live node client, then kick a fresh sync. Done on a worker + // thread: locking the instance can briefly contend with an in-flight + // sync, and this is called from the UI thread. + let wallet = self.clone(); + thread::spawn(move || { + let has_instance = { wallet.instance.read().is_some() }; + if wallet.is_open() && has_instance { + let (url, secret) = Self::node_url_secret(&wallet.get_config()); + let instance = { wallet.instance.read().clone().unwrap() }; + let mut wallet_lock = instance.lock(); + if let Ok(lc) = wallet_lock.lc_provider() { + if let Ok(backend) = lc.wallet_inst() { + let client = backend.w2n_client(); + client.set_node_url(&url); + client.set_node_api_secret(secret); + } + } + } + // Resume node polling (may be paused on Android) and wake the sync + // thread so the balance refreshes against the new node now. + wallet.resume_node_polling(); + wallet.sync(); + }); + } + /// Get external connection URL applied to [`WalletInstance`] /// after wallet opening if sync is running or get it from config. pub fn get_current_connection(&self) -> ConnectionMethod {