diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 0568bf504..2fe7761ba 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -2,9 +2,10 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use liana::miniscript::bitcoin::{ + address, bip32::{ChildNumber, Fingerprint}, psbt::Psbt, - Address, Txid, + Address, Amount, Txid, }; use lianad::config::Config as DaemonConfig; @@ -16,6 +17,7 @@ use crate::{ wallet::Wallet, }, daemon::model::*, + daemon::Daemon, export::ImportExportMessage, hw::HardwareWalletMessage, services::fiat::{ @@ -28,12 +30,14 @@ use crate::{ pub enum Message { Tick, RedirectLianaConnectLogin, + RemoteBackendAliasUpdated(Result<(), String>), UpdateDaemonCache(Result), CacheUpdated, Fiat(FiatMessage), UpdatePanelCache(/* is current panel */ bool), View(view::Message), LoadDaemonConfig(Box), + DaemonConfigReloaded(Result, Error>), DaemonConfigLoaded(Result<(), Error>), LoadWallet(Wallet), Info(Result), @@ -49,6 +53,7 @@ pub enum Message { Labels(Result, Error>), SpendTxs(Result, Error>), Psbt(Result<(Psbt, Vec), Error>), + RedraftSpend(u64, RedraftSpendResult), RbfPsbt(Result), Recovery(Result), Signed(Fingerprint, Result), @@ -60,6 +65,8 @@ pub enum Message { HardwareWallets(HardwareWalletMessage), HistoryTransactionsExtension(Result, Error>), HistoryTransactions(Result, Error>), + PreselectedHistoryTransaction(Txid, Result, Error>), + PreselectedSpendTransaction(Txid, Result, Error>), Payments(Result, Error>), PaymentsExtension(Result, Error>), Payment(Result<(HistoryTransaction, usize), Error>), @@ -69,6 +76,17 @@ pub enum Message { Export(ImportExportMessage), } +#[derive(Debug)] +pub struct RedraftSpendResult { + pub send_max_to_recipient: Option, + pub total_recipients: usize, + pub max_address: Address, + pub is_user_coin_selection: bool, + pub is_recovery: bool, + pub result: Result, + pub amount_left_to_select: Option, +} + impl From for Message { fn from(value: ImportExportMessage) -> Self { Message::View(view::Message::ImportExport(value)) diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 7f953a847..2b2fe2e48 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -17,7 +17,6 @@ use std::sync::Arc; use std::time::Duration; use iced::{clipboard, Subscription, Task}; -use tokio::runtime::Handle; use tracing::{error, info, warn}; pub use liana::miniscript::bitcoin; @@ -247,31 +246,37 @@ impl App { match &menu { menu::Menu::TransactionPreSelected(txid) => { - if let Ok(Some(tx)) = Handle::current().block_on(async { - self.daemon - .get_history_txs(&[*txid]) - .await - .map(|txs| txs.first().cloned()) - }) { - self.panels.transactions.preselect(tx); - self.panels.current = menu; - return Task::none(); - }; + let txid = *txid; + let daemon = self.daemon.clone(); + self.panels.current = menu; + return Task::perform( + async move { + daemon + .get_history_txs(&[txid]) + .await + .map(|txs| txs.first().cloned()) + .map_err(Error::from) + }, + move |res| Message::PreselectedHistoryTransaction(txid, res), + ); } menu::Menu::PsbtPreSelected(txid) => { // Get preselected spend from DB in case it's not yet in the cache. // We only need this single spend as we will go straight to its view and not show the PSBTs list. // In case of any error loading the spend or if it doesn't exist, load PSBTs list in usual way. - if let Ok(Some(spend_tx)) = Handle::current().block_on(async { - self.daemon - .list_spend_transactions(Some(&[*txid])) - .await - .map(|txs| txs.first().cloned()) - }) { - self.panels.psbts.preselect(spend_tx); - self.panels.current = menu; - return Task::none(); - }; + let txid = *txid; + let daemon = self.daemon.clone(); + self.panels.current = menu; + return Task::perform( + async move { + daemon + .list_spend_transactions(Some(&[txid])) + .await + .map(|txs| txs.first().cloned()) + .map_err(Error::from) + }, + move |res| Message::PreselectedSpendTransaction(txid, res), + ); } menu::Menu::SettingsPreSelected(setting) => { self.panels.current = menu.clone(); @@ -324,14 +329,21 @@ impl App { pub fn stop(&mut self) { info!("Close requested"); if self.daemon.backend().is_embedded() { - if let Err(e) = Handle::current().block_on(async { self.daemon.stop().await }) { - error!("{}", e); - } else { - info!("Internal daemon stopped"); - } - if let Some(bitcoind) = self.internal_bitcoind.take() { - bitcoind.stop(); - } + let daemon = self.daemon.clone(); + let bitcoind = self.internal_bitcoind.take(); + tokio::spawn(async move { + if let Err(e) = daemon.stop().await { + error!("{}", e); + } else { + info!("Internal daemon stopped"); + } + + if let Some(bitcoind) = bitcoind { + if let Err(e) = tokio::task::spawn_blocking(move || bitcoind.stop()).await { + error!("Internal bitcoind shutdown task failed: {}", e); + } + } + }); } } @@ -479,6 +491,12 @@ impl App { pub fn update(&mut self, message: Message) -> Task { match message { + Message::RemoteBackendAliasUpdated(result) => { + if let Err(e) = result { + tracing::error!("Failed to update wallet settings with remote alias: {}", e); + } + Task::none() + } Message::Fiat(FiatMessage::GetPriceResult(fiat_price)) => { let relevant = self.wallet.fiat_price_is_relevant(&fiat_price); tracing::trace!( @@ -542,8 +560,27 @@ impl App { Task::batch(commands) } Message::LoadDaemonConfig(cfg) => { - let res = self.load_daemon_config(self.cache.datadir_path.clone(), *cfg); - self.update(Message::DaemonConfigLoaded(res)) + let daemon = self.daemon.clone(); + let datadir_path = self.cache.datadir_path.clone(); + let wallet_id = self.wallet.id(); + Task::perform( + reload_daemon_config(datadir_path, wallet_id, daemon, *cfg), + Message::DaemonConfigReloaded, + ) + } + Message::DaemonConfigReloaded(res) => { + let res = match res { + Ok(daemon) => { + self.daemon = daemon; + Ok(()) + } + Err(e) => Err(e), + }; + self.panels.current_mut().update( + self.daemon.clone(), + &self.cache, + Message::DaemonConfigLoaded(res), + ) } Message::WalletUpdated(Ok(wallet)) => { self.wallet = wallet.clone(); @@ -553,6 +590,36 @@ impl App { Message::WalletUpdated(Ok(wallet)), ) } + Message::PreselectedHistoryTransaction(txid, Ok(Some(tx))) + if self.panels.current == Menu::TransactionPreSelected(txid) => + { + self.panels.transactions.preselect(tx); + Task::none() + } + Message::PreselectedHistoryTransaction(txid, _) => { + if self.panels.current == Menu::TransactionPreSelected(txid) { + return self + .panels + .current_mut() + .reload(self.daemon.clone(), self.wallet.clone()); + } + Task::none() + } + Message::PreselectedSpendTransaction(txid, Ok(Some(spend_tx))) + if self.panels.current == Menu::PsbtPreSelected(txid) => + { + self.panels.psbts.preselect(spend_tx); + Task::none() + } + Message::PreselectedSpendTransaction(txid, _) => { + if self.panels.current == Menu::PsbtPreSelected(txid) { + return self + .panels + .current_mut() + .reload(self.daemon.clone(), self.wallet.clone()); + } + Task::none() + } Message::View(view::Message::Menu(menu)) => self.set_current_panel(menu), Message::View(view::Message::OpenUrl(url)) => { if let Err(e) = open::that_detached(&url) { @@ -568,24 +635,39 @@ impl App { } } - pub fn load_daemon_config( - &mut self, - datadir_path: LianaDirectory, - cfg: DaemonConfig, - ) -> Result<(), Error> { - Handle::current().block_on(async { self.daemon.stop().await })?; + pub fn view(&self) -> Element<'_, Message> { + let content = self.panels.current().view(&self.cache).map(Message::View); + if self.cache.network != bitcoin::Network::Bitcoin { + Column::with_children(vec![network_banner(self.cache.network).into(), content]).into() + } else { + content + } + } + + pub fn datadir_path(&self) -> &LianaDirectory { + &self.cache.datadir_path + } +} + +async fn reload_daemon_config( + datadir_path: LianaDirectory, + wallet_id: WalletId, + old_daemon: Arc, + cfg: DaemonConfig, +) -> Result, Error> { + old_daemon.stop().await?; + tokio::task::spawn_blocking(move || { let network = cfg.bitcoin_config.network; let daemon = EmbeddedDaemon::start(cfg)?; - self.daemon = Arc::new(daemon); let mut daemon_config_path = datadir_path .network_directory(network) - .lianad_data_directory(&self.wallet.id()) + .lianad_data_directory(&wallet_id) .path() .to_path_buf(); daemon_config_path.push("daemon.toml"); let content = - toml::to_string(&self.daemon.config()).map_err(|e| Error::Config(e.to_string()))?; + toml::to_string(&daemon.config()).map_err(|e| Error::Config(e.to_string()))?; OpenOptions::new() .write(true) @@ -596,21 +678,12 @@ impl App { .map_err(|e| { warn!("failed to write to file: {:?}", e); Error::Config(e.to_string()) - }) - } - - pub fn view(&self) -> Element<'_, Message> { - let content = self.panels.current().view(&self.cache).map(Message::View); - if self.cache.network != bitcoin::Network::Bitcoin { - Column::with_children(vec![network_banner(self.cache.network).into(), content]).into() - } else { - content - } - } + })?; - pub fn datadir_path(&self) -> &LianaDirectory { - &self.cache.datadir_path - } + Ok(Arc::new(daemon) as Arc) + }) + .await + .map_err(|e| Error::Unexpected(format!("Daemon config reload task failed: {e}")))? } fn new_recovery_panel(wallet: Arc, cache: &Cache) -> CreateSpendPanel { diff --git a/liana-gui/src/app/state/spend/step.rs b/liana-gui/src/app/state/spend/step.rs index 2b04a101e..c2cbbcf8d 100644 --- a/liana-gui/src/app/state/spend/step.rs +++ b/liana-gui/src/app/state/spend/step.rs @@ -27,7 +27,7 @@ use crate::{ app::{ cache::Cache, error::Error, - message::Message, + message::{Message, RedraftSpendResult}, state::{fiat_converter_for_wallet, psbt}, view::{self, fiat::FiatAmount}, wallet::Wallet, @@ -166,6 +166,7 @@ pub struct DefineSpend { /// Whether this is the first step of the spend creation. /// Required in order to know whether the user can navigate to a previous step. is_first_step: bool, + redraft_request_id: u64, } impl DefineSpend { @@ -205,6 +206,7 @@ impl DefineSpend { amount_left_to_select: None, warning: None, is_first_step, + redraft_request_id: 0, } } @@ -284,7 +286,10 @@ impl DefineSpend { } /// redraft calculates the amount left to select and auto selects coins /// if the user did not select a coin manually - fn redraft(&mut self, daemon: Arc) { + fn redraft(&mut self, daemon: Arc) -> Task { + self.redraft_request_id = self.redraft_request_id.wrapping_add(1); + let redraft_request_id = self.redraft_request_id; + if !self.form_values_are_valid(true) || self.exists_duplicate() { // The current form details are not valid to draft a spend, so remove any previously // calculated amount as it will no longer be valid and could be misleading, e.g. if @@ -304,7 +309,7 @@ impl DefineSpend { ); } self.fee_amount = None; - return; + return Task::none(); } let is_self_transfer = self.recipients.is_empty(); // Define the destinations for a primary path spend from all non-max recipients. @@ -384,7 +389,7 @@ impl DefineSpend { // Note that for a recovery, the amount left to select is ignored by the view. self.amount_left_to_select = Some(Amount::from_sat(destinations.values().sum())); self.fee_amount = None; - return; + return Task::none(); } outpoints } else if !self.is_user_coin_selection && self.send_max_to_recipient.is_some() { @@ -418,32 +423,77 @@ impl DefineSpend { let feerate_vb = self.feerate.value.parse::().expect("Checked before"); let recovery_timelock = self.recovery_timelock; - match tokio::runtime::Handle::current().block_on(async { + let send_max_to_recipient = self.send_max_to_recipient; + let is_user_coin_selection = self.is_user_coin_selection; + let is_recovery = recovery_timelock.is_some(); + let max_address_for_result = max_address.clone(); + + Task::perform( + async move { + let result = if let Some(reco_tl) = recovery_timelock { + daemon + .create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl)) + .await + // Map the PSBT to `CreateSpendResult` result. We only need the PSBT below. + .map(|psbt| CreateSpendResult::Success { + psbt, + warnings: vec![], + }) + .map_err(|e| e.into()) + } else { + daemon + .create_spend_tx( + &outpoints, + &destinations, + feerate_vb, + Some(max_address.clone()), + ) + .await + .map_err(|e| e.into()) + }; + + RedraftSpendResult { + send_max_to_recipient, + total_recipients, + max_address: max_address_for_result, + is_user_coin_selection, + is_recovery, + result, + amount_left_to_select: Some(Amount::from_sat(0)), + } + }, + move |result| Message::RedraftSpend(redraft_request_id, result), + ) + } + + fn apply_redraft(&mut self, redraft: RedraftSpendResult) { + let RedraftSpendResult { + send_max_to_recipient, + total_recipients, + max_address, + is_user_coin_selection, + is_recovery, + result, + amount_left_to_select, + } = redraft; + + let recipient_with_max = if let Some(i) = send_max_to_recipient { + Some(( + i, + self.recipients + .get_mut(i) + .expect("max has been requested for this recipient so it must exist"), + )) + } else { + None + }; + + match result { // If recovery timelock is set, create a recovery transaction. Otherwise, a regular spend. - if let Some(reco_tl) = recovery_timelock { - daemon - .create_recovery(max_address.clone(), &outpoints, feerate_vb, Some(reco_tl)) - .await - // Map the PSBT to `CreateSpendResult` result. We only need the PSBT below. - .map(|psbt| CreateSpendResult::Success { - psbt, - warnings: vec![], - }) - } else { - daemon - .create_spend_tx( - &outpoints, - &destinations, - feerate_vb, - Some(max_address.clone()), - ) - .await - } - }) { Ok(CreateSpendResult::Success { psbt, .. }) => { self.fee_amount = Some(psbt.fee().expect("Valid fees")); // Update selected coins for auto-selection (non-recovery case). - if !self.is_user_coin_selection && self.recovery_timelock.is_none() { + if !is_user_coin_selection && !is_recovery { let selected_coins: Vec = psbt .unsigned_tx .input @@ -456,7 +506,7 @@ impl DefineSpend { } } // As coin selection was successful, we can assume there is nothing left to select. - self.amount_left_to_select = Some(Amount::from_sat(0)); + self.amount_left_to_select = amount_left_to_select; if let Some((i, recipient)) = recipient_with_max { // If there's no change output, any excess must be below the dust threshold // and so the max available for this recipient is 0. @@ -478,8 +528,8 @@ impl DefineSpend { } Ok(CreateSpendResult::InsufficientFunds { missing }) => handle_max_under_dust( &mut self.fee_amount, - self.is_user_coin_selection, - self.recovery_timelock.is_none(), + is_user_coin_selection, + !is_recovery, &mut self.coins, recipient_with_max, &mut self.amount_left_to_select, @@ -489,7 +539,7 @@ impl DefineSpend { self.network, ), Err(e) => { - self.warning = Some(e.into()); + self.warning = Some(e); self.fee_amount = None; } } @@ -768,8 +818,15 @@ impl Step for DefineSpend { // - all form values have been added and validated // - not a self-send // - user has not yet selected coins manually - self.redraft(daemon); + let redraft = self.redraft(daemon); self.check_valid(); + return redraft; + } + Message::RedraftSpend(redraft_request_id, redraft) => { + if redraft_request_id == self.redraft_request_id { + self.apply_redraft(redraft); + self.check_valid(); + } } Message::Psbt(res) => match res { Ok(psbt) => { @@ -805,8 +862,9 @@ impl Step for DefineSpend { // In case some selected coins are not spendable anymore and // new coins make more sense to be selected. A redraft is triggered // if all forms are valid (checked in the redraft method) - self.redraft(daemon); + let redraft = self.redraft(daemon); self.check_valid(); + return redraft; } (Err(e), _) | (Ok(_), Err(e)) => self.warning = Some(e), }, diff --git a/liana-gui/src/daemon/embedded.rs b/liana-gui/src/daemon/embedded.rs index 5b867bfdf..2736d7b44 100644 --- a/liana-gui/src/daemon/embedded.rs +++ b/liana-gui/src/daemon/embedded.rs @@ -1,7 +1,9 @@ use lianad::bip329::Labels; use lianad::commands::UpdateDerivIndexesResult; -use std::collections::{HashMap, HashSet}; -use tokio::sync::Mutex; +use std::{ + collections::{HashMap, HashSet}, + sync::{Arc, Mutex}, +}; use super::{model::*, node, Daemon, DaemonBackend, DaemonError}; use crate::dir::LianaDirectory; @@ -17,7 +19,7 @@ use lianad::{ pub struct EmbeddedDaemon { config: Config, - handle: Mutex>, + handle: Arc>>, } impl EmbeddedDaemon { @@ -25,20 +27,24 @@ impl EmbeddedDaemon { let handle = DaemonHandle::start_default(config.clone(), false).map_err(DaemonError::Start)?; Ok(Self { - handle: Mutex::new(Some(handle)), + handle: Arc::new(Mutex::new(Some(handle))), config, }) } pub async fn command(&self, method: F) -> Result where - F: FnOnce(&mut DaemonControl) -> Result, + T: Send + 'static, + F: FnOnce(&mut DaemonControl) -> Result + Send + 'static, { - match self.handle.lock().await.as_mut() { + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || match handle.lock()?.as_mut() { Some(DaemonHandle::Controller { control, .. }) => method(control), Some(_) => unreachable!("No lianad rpc server must be started"), None => Err(DaemonError::DaemonStopped), - } + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } } @@ -74,27 +80,37 @@ impl Daemon for EmbeddedDaemon { _datadir: &LianaDirectory, _network: Network, ) -> Result<(), DaemonError> { - let mut handle = self.handle.lock().await; - if let Some(h) = handle.as_ref() { - if h.is_alive() { - return Ok(()); + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || { + let mut handle = handle.lock()?; + if let Some(h) = handle.as_ref() { + if h.is_alive() { + return Ok(()); + } } - } - // if the daemon poller is not alive, we try to terminate it to fetch the error. - if let Some(h) = handle.take() { - h.stop() - .map_err(|e| DaemonError::Unexpected(e.to_string()))?; - } - Ok(()) + // if the daemon poller is not alive, we try to terminate it to fetch the error. + if let Some(h) = handle.take() { + h.stop() + .map_err(|e| DaemonError::Unexpected(e.to_string()))?; + } + Ok(()) + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } async fn stop(&self) -> Result<(), DaemonError> { - let mut handle = self.handle.lock().await; - if let Some(h) = handle.take() { - h.stop() - .map_err(|e| DaemonError::Unexpected(e.to_string()))?; - } - Ok(()) + let handle = self.handle.clone(); + tokio::task::spawn_blocking(move || { + let mut handle = handle.lock()?; + if let Some(h) = handle.take() { + h.stop() + .map_err(|e| DaemonError::Unexpected(e.to_string()))?; + } + Ok(()) + }) + .await + .map_err(|e| DaemonError::Unexpected(format!("Embedded daemon task failed: {e}")))? } async fn get_info(&self) -> Result { @@ -112,7 +128,7 @@ impl Daemon for EmbeddedDaemon { limit: usize, start_index: Option, ) -> Result { - self.command(|daemon| { + self.command(move |daemon| { daemon .list_revealed_addresses(is_change, exclude_used, limit, start_index) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -125,7 +141,7 @@ impl Daemon for EmbeddedDaemon { receive: Option, change: Option, ) -> Result { - self.command(|daemon| { + self.command(move |daemon| { daemon .update_deriv_indexes(receive, change) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -138,7 +154,9 @@ impl Daemon for EmbeddedDaemon { statuses: &[CoinStatus], outpoints: &[OutPoint], ) -> Result { - self.command(|daemon| Ok(daemon.list_coins(statuses, outpoints))) + let statuses = statuses.to_vec(); + let outpoints = outpoints.to_vec(); + self.command(move |daemon| Ok(daemon.list_coins(&statuses, &outpoints))) .await } @@ -157,12 +175,13 @@ impl Daemon for EmbeddedDaemon { end: u32, limit: u64, ) -> Result { - self.command(|daemon| Ok(daemon.list_confirmed_transactions(start, end, limit))) + self.command(move |daemon| Ok(daemon.list_confirmed_transactions(start, end, limit))) .await } async fn list_txs(&self, txids: &[Txid]) -> Result { - self.command(|daemon| Ok(daemon.list_transactions(txids))) + let txids = txids.to_vec(); + self.command(move |daemon| Ok(daemon.list_transactions(&txids))) .await } @@ -173,9 +192,11 @@ impl Daemon for EmbeddedDaemon { feerate_vb: u64, change_address: Option>, ) -> Result { - self.command(|daemon| { + let coins_outpoints = coins_outpoints.to_vec(); + let destinations = destinations.clone(); + self.command(move |daemon| { daemon - .create_spend(destinations, coins_outpoints, feerate_vb, change_address) + .create_spend(&destinations, &coins_outpoints, feerate_vb, change_address) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await @@ -187,42 +208,46 @@ impl Daemon for EmbeddedDaemon { is_cancel: bool, feerate_vb: Option, ) -> Result { - self.command(|daemon| { + let txid = *txid; + self.command(move |daemon| { daemon - .rbf_psbt(txid, is_cancel, feerate_vb) + .rbf_psbt(&txid, is_cancel, feerate_vb) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn update_spend_tx(&self, psbt: &Psbt) -> Result<(), DaemonError> { - self.command(|daemon| { + let psbt = psbt.clone(); + self.command(move |daemon| { daemon - .update_spend(psbt.clone()) + .update_spend(psbt) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn delete_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> { - self.command(|daemon| { - daemon.delete_spend(txid); + let txid = *txid; + self.command(move |daemon| { + daemon.delete_spend(&txid); Ok(()) }) .await } async fn broadcast_spend_tx(&self, txid: &Txid) -> Result<(), DaemonError> { - self.command(|daemon| { + let txid = *txid; + self.command(move |daemon| { daemon - .broadcast_spend(txid) + .broadcast_spend(&txid) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) .await } async fn start_rescan(&self, t: u32) -> Result<(), DaemonError> { - self.command(|daemon| { + self.command(move |daemon| { daemon .start_rescan(t) .map_err(|e| DaemonError::Unexpected(e.to_string())) @@ -237,9 +262,10 @@ impl Daemon for EmbeddedDaemon { feerate_vb: u64, sequence: Option, ) -> Result { - self.command(|daemon| { + let coins_outpoints = coins_outpoints.to_vec(); + self.command(move |daemon| { daemon - .create_recovery(address, coins_outpoints, feerate_vb, sequence) + .create_recovery(address, &coins_outpoints, feerate_vb, sequence) .map(|res| res.psbt) .map_err(|e| DaemonError::Unexpected(e.to_string())) }) @@ -250,7 +276,8 @@ impl Daemon for EmbeddedDaemon { &self, items: &HashSet, ) -> Result, DaemonError> { - self.command(|daemon| Ok(daemon.get_labels(items).labels)) + let items = items.clone(); + self.command(move |daemon| Ok(daemon.get_labels(&items).labels)) .await } @@ -258,15 +285,16 @@ impl Daemon for EmbeddedDaemon { &self, items: &HashMap>, ) -> Result<(), DaemonError> { - self.command(|daemon| { - daemon.update_labels(items); + let items = items.clone(); + self.command(move |daemon| { + daemon.update_labels(&items); Ok(()) }) .await } async fn get_labels_bip329(&self, offset: u32, limit: u32) -> Result { - self.command(|daemon| Ok(daemon.get_labels_bip329(offset, limit).labels)) + self.command(move |daemon| Ok(daemon.get_labels_bip329(offset, limit).labels)) .await } } diff --git a/liana-gui/src/gui/tab.rs b/liana-gui/src/gui/tab.rs index a354959a8..7f997420f 100644 --- a/liana-gui/src/gui/tab.rs +++ b/liana-gui/src/gui/tab.rs @@ -586,24 +586,31 @@ pub fn create_app_with_remote_backend( // If someone modified the wallet_alias on Liana-Connect, // then the new alias is imported and stored in the settings file. - if wallet.metadata.wallet_alias != wallet_settings.alias { - if let Err(e) = tokio::runtime::Handle::current().block_on(async { - update_settings_file(&network_directory, |mut settings: LianaSettings| { - if let Some(w) = settings - .wallets - .iter_mut() - .find(|w| w.wallet_id() == wallet_id) - { - w.alias = wallet.metadata.wallet_alias.clone(); - tracing::info!("Wallet alias was changed. Settings updated."); - } - settings - }) - .await - }) { - tracing::error!("Failed to update wallet settings with remote alias: {}", e); - } - } + let remote_alias = wallet.metadata.wallet_alias.clone(); + let alias_update_task = if remote_alias != wallet_settings.alias { + let network_directory = network_directory.clone(); + let wallet_id = wallet_id.clone(); + Task::perform( + async move { + update_settings_file(&network_directory, |mut settings: LianaSettings| { + if let Some(w) = settings + .wallets + .iter_mut() + .find(|w| w.wallet_id() == wallet_id) + { + w.alias = remote_alias.clone(); + tracing::info!("Wallet alias was changed. Settings updated."); + } + settings + }) + .await + .map_err(|e| e.to_string()) + }, + app::Message::RemoteBackendAliasUpdated, + ) + } else { + Task::none() + }; let hws: Vec = wallet .metadata @@ -636,7 +643,7 @@ pub fn create_app_with_remote_backend( .map(|pk| (pk.fingerprint, pk.into())) .collect(); - Ok(app::App::new( + let (app, task) = app::App::new( Cache { variant: liana_ui::Variant::Liana, network, @@ -677,7 +684,9 @@ pub fn create_app_with_remote_backend( liana_dir, None, false, - )) + ); + + Ok((app, Task::batch([task, alias_update_task]))) } /// Connect to backend for liana-business using cached tokens. diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index 70dc2f4fb..b55b6f5dd 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -47,6 +47,7 @@ pub enum Message { Select(usize), UseHotSigner, Installed(settings::WalletId, Result), + FailedInstallCleaned(String, Result<(), String>), CreateTaprootDescriptor(bool), SelectDescriptorTemplate(context::DescriptorTemplate), SelectBackend(SelectBackend), diff --git a/liana-gui/src/installer/mod.rs b/liana-gui/src/installer/mod.rs index 0342420c6..43e747874 100644 --- a/liana-gui/src/installer/mod.rs +++ b/liana-gui/src/installer/mod.rs @@ -15,7 +15,6 @@ use liana_ui::{ }; use lianad::config::{BitcoinBackend, BitcoindConfig, BitcoindRpcAuth, Config}; use std::{collections::HashMap, fmt::Debug, ops::Deref}; -use tokio::runtime::Handle; use tracing::{error, info, warn}; use std::io::Write; @@ -326,28 +325,39 @@ impl LianaInstaller { .context .liana_directory .network_directory(self.context.bitcoin_config.network); - // In case of failure during install, block the thread to - // deleted the data_dir/network directory in order to start clean again. + let cleanup_network_directory = network_directory.clone(); + let cleanup_wallet_id = wallet_id.clone(); + let cleanup_path = network_directory.path().to_string_lossy().to_string(); + warn!("Installation failed. Cleaning up the network directory."); - if let Err(e) = Handle::current().block_on(delete::delete_failed_install( - &network_directory, - &wallet_id, - )) { - error!( - "Failed to completely clean the network directory (path: '{}'): {}", - network_directory.path().to_string_lossy(), - e - ); - } else { - warn!( - "Successfully cleaned network directory at '{}'.", - network_directory.path().to_string_lossy() - ); - }; - self.steps + let step_task = self + .steps .get_mut(self.current) .expect("There is always a step") - .update(&mut self.hws, Message::Installed(wallet_id, Err(e))) + .update(&mut self.hws, Message::Installed(wallet_id, Err(e))); + let cleanup_task = Task::perform( + async move { + delete::delete_failed_install( + &cleanup_network_directory, + &cleanup_wallet_id, + ) + .await + .map_err(|e| e.to_string()) + }, + move |result| Message::FailedInstallCleaned(cleanup_path.clone(), result), + ); + + Task::batch([step_task, cleanup_task]) + } + Message::FailedInstallCleaned(path, result) => { + match result { + Ok(()) => warn!("Successfully cleaned network directory at '{}'.", path), + Err(e) => error!( + "Failed to completely clean the network directory (path: '{}'): {}", + path, e + ), + } + Task::none() } _ => self .steps diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 03fe6c42c..a05e8c23b 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -11,7 +11,6 @@ use liana_ui::{ widget::{modal::Modal, Column, ColumnExt, Container, Element, Row, RowExt, SpaceExt}, }; use lianad::config::ConfigError; -use tokio::runtime::Handle; use crate::{ app::{ @@ -132,13 +131,15 @@ impl Launcher { } else { None }; - self.delete_wallet_modal = Some(DeleteWalletModal::new( + let (modal, task) = DeleteWalletModal::new( self.network, wallet_datadir, wallets[i].clone(), internal_bitcoind, self.backend_type, - )); + ); + self.delete_wallet_modal = Some(modal); + return task; } Task::none() } @@ -443,6 +444,8 @@ pub enum Message { Install(LianaDirectory, Network, UserFlow), Checked(Result), Run(LianaDirectory, app::config::Config, Network, WalletSettings), + DeleteWalletMembership(WalletId, Result, String>), + WalletDeleted(WalletId, Result<(), String>), } #[derive(Debug, Clone)] @@ -467,14 +470,30 @@ pub enum DeleteWalletMessage { Deleted, } +#[derive(Debug, Clone, Copy)] +pub enum DeleteWalletUserRole { + Owner, + Member, +} + +impl From for DeleteWalletUserRole { + fn from(value: UserRole) -> Self { + match value { + UserRole::Owner => Self::Owner, + UserRole::Member => Self::Member, + } + } +} + struct DeleteWalletModal { network: Network, network_directory: NetworkDirectory, wallet_settings: WalletSettings, - warning: Option, + warning: Option, deleted: bool, + deleting: bool, delete_liana_connect: bool, - user_role: Option, + user_role: Option, // `None` means we were not able to determine whether wallet uses internal bitcoind. internal_bitcoind: Option, backend_type: BackendType, @@ -487,57 +506,92 @@ impl DeleteWalletModal { wallet_settings: WalletSettings, internal_bitcoind: Option, backend_type: BackendType, - ) -> Self { - let mut modal = Self { + ) -> (Self, Task) { + let modal = Self { network, wallet_settings, network_directory, warning: None, deleted: false, + deleting: false, delete_liana_connect: false, internal_bitcoind, user_role: None, backend_type, }; - if let Some(auth) = &modal.wallet_settings.remote_backend_auth { - match Handle::current().block_on(check_membership( - modal.network, - &modal.network_directory, - auth, - modal.backend_type, - )) { - Err(e) => { - modal.warning = Some(e); - } - Ok(user_role) => { - modal.user_role = user_role; - } - } - } - modal + let task = if let Some(auth) = modal.wallet_settings.remote_backend_auth.clone() { + let network = modal.network; + let network_directory = modal.network_directory.clone(); + let backend_type = modal.backend_type; + let wallet_id = modal.wallet_settings.wallet_id(); + Task::perform( + async move { + check_membership(network, &network_directory, &auth, backend_type) + .await + .map(|role| role.map(DeleteWalletUserRole::from)) + .map_err(|e| e.to_string()) + }, + move |res| Message::DeleteWalletMembership(wallet_id.clone(), res), + ) + } else { + Task::none() + }; + + (modal, task) } fn update(&mut self, message: Message) -> Task { match message { + Message::DeleteWalletMembership(wallet_id, result) => { + if wallet_id == self.wallet_settings.wallet_id() { + match result { + Ok(user_role) => self.user_role = user_role, + Err(e) => self.warning = Some(e), + } + } + } + Message::WalletDeleted(wallet_id, result) => { + if wallet_id == self.wallet_settings.wallet_id() { + self.deleting = false; + match result { + Ok(()) => { + self.deleted = true; + return Task::perform(async {}, |_| { + Message::View(ViewMessage::DeleteWallet( + DeleteWalletMessage::Deleted, + )) + }); + } + Err(e) => self.warning = Some(e), + } + } + } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm(wallet_id))) => { if wallet_id != self.wallet_settings.wallet_id() { return Task::none(); } self.warning = None; - if let Err(e) = Handle::current().block_on(delete_wallet( - self.network, - &self.network_directory, - &self.wallet_settings, - self.delete_liana_connect, - self.backend_type, - )) { - self.warning = Some(e); - } else { - self.deleted = true; - return Task::perform(async {}, |_| { - Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Deleted)) - }); - }; + self.deleting = true; + + let network = self.network; + let network_directory = self.network_directory.clone(); + let wallet_settings = self.wallet_settings.clone(); + let delete_liana_connect = self.delete_liana_connect; + let backend_type = self.backend_type; + return Task::perform( + async move { + delete_wallet( + network, + &network_directory, + &wallet_settings, + delete_liana_connect, + backend_type, + ) + .await + .map_err(|e| e.to_string()) + }, + move |result| Message::WalletDeleted(wallet_id.clone(), result), + ); } Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::DeleteLianaConnect( delete, @@ -553,7 +607,7 @@ impl DeleteWalletModal { let mut confirm_button = button::secondary(None, "Delete wallet") .width(Length::Fixed(200.0)) .style(theme::button::destructive); - if self.warning.is_none() { + if self.warning.is_none() && !self.deleting { confirm_button = confirm_button.on_press(ViewMessage::DeleteWallet( DeleteWalletMessage::Confirm(self.wallet_settings.wallet_id()), )); @@ -610,8 +664,8 @@ impl DeleteWalletModal { .push_maybe(self.wallet_settings.remote_backend_auth.as_ref().map(|a| { checkbox(self.delete_liana_connect) .label(match self.user_role { - Some(UserRole::Owner) | None => "Also permanently delete this wallet from Liana Connect (for all members).".to_string(), - Some(UserRole::Member) => format!("Also disassociate {} from this Liana Connect wallet.", a.email), + Some(DeleteWalletUserRole::Owner) | None => "Also permanently delete this wallet from Liana Connect (for all members).".to_string(), + Some(DeleteWalletUserRole::Member) => format!("Also disassociate {} from this Liana Connect wallet.", a.email), }) .on_toggle_maybe(if !self.deleted { Some(|v| { diff --git a/liana-gui/src/loader.rs b/liana-gui/src/loader.rs index 26aa4fcfe..a10b699ce 100644 --- a/liana-gui/src/loader.rs +++ b/liana-gui/src/loader.rs @@ -8,7 +8,6 @@ use std::time::Duration; use iced::futures::{SinkExt, Stream}; use iced::stream::channel; use iced::{Alignment, Length, Subscription, Task}; -use tokio::runtime::Handle; use tracing::{debug, info, warn}; use liana::miniscript::bitcoin; @@ -290,21 +289,31 @@ impl Loader { pub fn stop(&mut self) { info!("Close requested"); - if let Step::Syncing { daemon, .. } = &mut self.step { - if daemon.backend().is_embedded() { - info!("Stopping internal daemon..."); - if let Err(e) = Handle::current().block_on(async { daemon.stop().await }) { - warn!("Internal daemon failed to stop: {}", e); - } else { - info!("Internal daemon stopped"); - } - } - } + let daemon = match &self.step { + Step::Syncing { daemon, .. } if daemon.backend().is_embedded() => Some(daemon.clone()), + _ => None, + }; // NOTE: we take() the internal_bitcoind here to make sure the debug.log reader // subscription is dropped. - if let Some(bitcoind) = self.internal_bitcoind.take() { - bitcoind.stop(); + let bitcoind = self.internal_bitcoind.take(); + if daemon.is_some() || bitcoind.is_some() { + tokio::spawn(async move { + if let Some(daemon) = daemon { + info!("Stopping internal daemon..."); + if let Err(e) = daemon.stop().await { + warn!("Internal daemon failed to stop: {}", e); + } else { + info!("Internal daemon stopped"); + } + } + + if let Some(bitcoind) = bitcoind { + if let Err(e) = tokio::task::spawn_blocking(move || bitcoind.stop()).await { + warn!("Internal bitcoind shutdown task failed: {}", e); + } + } + }); } } @@ -602,7 +611,7 @@ async fn sync( sleep: bool, ) -> Result { if sleep { - std::thread::sleep(std::time::Duration::from_secs(1)); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; } daemon.get_info().await } diff --git a/liana-ui/src/theme/card.rs b/liana-ui/src/theme/card.rs index a7f223033..7bee970ac 100644 --- a/liana-ui/src/theme/card.rs +++ b/liana-ui/src/theme/card.rs @@ -55,7 +55,7 @@ fn card_with_shadow(palette: &ContainerPalette, btn: bool) -> Style { } pub fn simple(theme: &Theme) -> Style { - card_with_shadow(&theme.colors.cards.simple, false) + card(&theme.colors.cards.simple) } pub fn button_simple(theme: &Theme) -> Style {