From f41de56e38164d9a9bad78a0abffaebdd92e6768 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Mon, 10 Mar 2025 14:35:55 +0100 Subject: [PATCH] gui: add import/export features in settings --- liana-gui/src/app/message.rs | 6 + liana-gui/src/app/mod.rs | 6 +- liana-gui/src/app/state/export.rs | 23 ++- liana-gui/src/app/state/mod.rs | 2 +- liana-gui/src/app/state/settings/mod.rs | 154 ++++++++++++++++++++- liana-gui/src/app/state/settings/wallet.rs | 149 +++++++++++++++++--- liana-gui/src/app/state/transactions.rs | 2 +- liana-gui/src/app/view/export.rs | 2 +- liana-gui/src/app/view/message.rs | 8 ++ liana-gui/src/app/view/settings.rs | 75 ++++++++++ liana-gui/src/backup.rs | 5 +- liana-gui/src/export.rs | 6 +- 12 files changed, 394 insertions(+), 44 deletions(-) diff --git a/liana-gui/src/app/message.rs b/liana-gui/src/app/message.rs index 96c68dc6..e7afe426 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -49,3 +49,9 @@ pub enum Message { RbfModal(Box, bool, Result, Error>), Export(ImportExportMessage), } + +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 80680ebd..004a5193 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -62,6 +62,7 @@ impl Panels { data_dir: PathBuf, daemon_backend: DaemonBackend, internal_bitcoind: Option<&Bitcoind>, + config: Arc, ) -> Panels { Self { current: Menu::Home, @@ -93,6 +94,7 @@ impl Panels { wallet.clone(), daemon_backend, internal_bitcoind.is_some(), + config.clone(), ), } } @@ -132,7 +134,7 @@ impl Panels { pub struct App { cache: Cache, - config: Config, + config: Arc, wallet: Arc, daemon: Arc, internal_bitcoind: Option, @@ -149,12 +151,14 @@ impl App { data_dir: PathBuf, internal_bitcoind: Option, ) -> (App, Task) { + let config = Arc::new(config); let mut panels = Panels::new( &cache, wallet.clone(), data_dir, daemon.backend(), internal_bitcoind.as_ref(), + config.clone(), ); let cmd = panels.home.reload(daemon.clone(), wallet.clone()); ( diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs index 163c5eea..036b55dd 100644 --- a/liana-gui/src/app/state/export.rs +++ b/liana-gui/src/app/state/export.rs @@ -8,10 +8,7 @@ use liana_ui::{component::modal::Modal, widget::Element}; use tokio::task::JoinHandle; use crate::{ - app::{ - self, - view::{self, export::export_modal}, - }, + app::view::{self, export::export_modal}, daemon::Daemon, export::{self, get_path, ImportExportMessage, ImportExportState, ImportExportType, Progress}, }; @@ -58,16 +55,20 @@ impl ExportModal { ImportExportType::ImportPsbt => "psbt.psbt".into(), ImportExportType::ImportDescriptor => "descriptor.descriptor".into(), ImportExportType::ExportLabels => format!("liana-labels-{date}.csv"), + ImportExportType::ExportBackup(_) => format!("liana-backup-{date}.json"), } } - pub fn launch(&self) -> Task { - Task::perform(get_path(self.default_filename()), |m| { - app::message::Message::View(view::Message::ImportExport(ImportExportMessage::Path(m))) + pub fn launch + Send + 'static>(&self) -> Task { + Task::perform(get_path(self.default_filename()), move |m| { + ImportExportMessage::Path(m).into() }) } - pub fn update(&mut self, message: ImportExportMessage) -> Task { + pub fn update + Send + 'static>( + &mut self, + message: ImportExportMessage, + ) -> Task { match message { ImportExportMessage::Progress(m) => match m { Progress::Started(handle) => { @@ -106,11 +107,7 @@ impl ExportModal { self.path = Some(path); self.start(); } else { - return Task::perform(async {}, |_| { - app::message::Message::View(view::Message::ImportExport( - ImportExportMessage::Close, - )) - }); + return Task::perform(async {}, |_| ImportExportMessage::Close.into()); } } ImportExportMessage::Close | ImportExportMessage::Open => { /* unreachable */ } diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index e76c274b..a54f0397 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -1,5 +1,5 @@ mod coins; -mod export; +pub mod export; mod label; mod psbt; mod psbts; diff --git a/liana-gui/src/app/state/settings/mod.rs b/liana-gui/src/app/state/settings/mod.rs index 64ec0b27..9a22cd9d 100644 --- a/liana-gui/src/app/state/settings/mod.rs +++ b/liana-gui/src/app/state/settings/mod.rs @@ -20,16 +20,22 @@ use crate::{ state::State, view::{self}, wallet::Wallet, + Config, }, + backup::Backup, daemon::{Daemon, DaemonBackend}, + export::{ImportExportMessage, ImportExportType}, }; +use super::export::ExportModal; + pub struct SettingsState { data_dir: PathBuf, wallet: Arc, setting: Option>, daemon_backend: DaemonBackend, internal_bitcoind: bool, + config: Arc, } impl SettingsState { @@ -38,6 +44,7 @@ impl SettingsState { wallet: Arc, daemon_backend: DaemonBackend, internal_bitcoind: bool, + config: Arc, ) -> Self { Self { data_dir, @@ -45,6 +52,7 @@ impl SettingsState { setting: None, daemon_backend, internal_bitcoind, + config, } } } @@ -79,6 +87,12 @@ impl State for SettingsState { self.setting = Some(BackendSettingsState::new().into()); Task::none() } + Message::View(view::Message::Settings(view::SettingsMessage::ImportExportSection)) => { + self.setting = Some( + ImportExportSettingsState::new(self.wallet.clone(), self.config.clone()).into(), + ); + Task::none() + } Message::View(view::Message::Settings(view::SettingsMessage::AboutSection)) => { self.setting = Some(AboutSettingsState::default().into()); let wallet = self.wallet.clone(); @@ -89,7 +103,12 @@ impl State for SettingsState { } Message::View(view::Message::Settings(view::SettingsMessage::EditWalletSettings)) => { self.setting = Some( - WalletSettingsState::new(self.data_dir.clone(), self.wallet.clone()).into(), + WalletSettingsState::new( + self.data_dir.clone(), + self.wallet.clone(), + self.config.clone(), + ) + .into(), ); let wallet = self.wallet.clone(); self.setting @@ -145,6 +164,139 @@ impl From for Box { } } +pub struct ImportExportSettingsState { + warning: Option, + modal: Option, + wallet: Arc, + config: Arc, +} + +impl ImportExportSettingsState { + pub fn new(wallet: Arc, config: Arc) -> Self { + Self { + warning: None, + modal: None, + wallet, + config, + } + } +} + +macro_rules! launch { + ($s:ident, $m: ident) => { + let launch = $m.launch(); + $s.modal = Some($m); + return launch + }; +} + +impl State for ImportExportSettingsState { + fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> { + let content = view::settings::import_export(cache, self.warning.as_ref()); + if let Some(modal) = &self.modal { + modal.view(content) + } else { + content + } + } + + fn subscription(&self) -> iced::Subscription { + if let Some(modal) = &self.modal { + if let Some(sub) = modal.subscription() { + return sub.map(|m| { + Message::View(view::Message::Settings( + view::SettingsMessage::ImportExport(ImportExportMessage::Progress(m)), + )) + }); + } + } + iced::Subscription::none() + } + + fn update( + &mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Task { + match message { + Message::View(view::Message::ImportExport(ImportExportMessage::Close)) => { + self.modal = None; + } + Message::View(view::Message::ImportExport(m)) => { + if let Some(modal) = self.modal.as_mut() { + return modal.update(m); + }; + } + Message::View(view::Message::Settings(view::SettingsMessage::ImportExport(m))) => { + if let Some(modal) = self.modal.as_mut() { + return modal.update(m); + }; + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportDescriptor)) => { + if self.modal.is_none() { + let modal = ExportModal::new( + daemon, + ImportExportType::Descriptor(self.wallet.main_descriptor.clone()), + ); + launch!(self, modal); + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportTransactions)) => { + if self.modal.is_none() { + let modal = ExportModal::new(daemon, ImportExportType::Transactions); + launch!(self, modal); + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportLabels)) => { + if self.modal.is_none() { + let modal = ExportModal::new(daemon, ImportExportType::ExportLabels); + launch!(self, modal); + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportWallet)) => { + if self.modal.is_none() { + let datadir = cache.datadir_path.clone(); + let network = cache.network; + let config = self.config.clone(); + let wallet = self.wallet.clone(); + let daemon = daemon.clone(); + return Task::perform( + async move { + let backup = + Backup::from_app(datadir, network, config, wallet, daemon).await; + let backup = backup.unwrap(); + serde_json::to_string_pretty(&backup).unwrap() + // TODO: do not unwrap, return an error message instead + }, + |s| { + Message::View(view::Message::Settings( + view::SettingsMessage::ExportBackup(s), + )) + }, + ); + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportBackup(backup))) => { + let modal = ExportModal::new(daemon, ImportExportType::ExportBackup(backup)); + launch!(self, modal); + } + Message::View(view::Message::Settings(view::SettingsMessage::ImportWallet)) => { + // TODO: + } + _ => {} + } + + Task::none() + } +} + +impl From for Box { + fn from(s: ImportExportSettingsState) -> Box { + Box::new(s) + } +} + #[derive(Default)] pub struct AboutSettingsState { daemon_version: Option, diff --git a/liana-gui/src/app/state/settings/wallet.rs b/liana-gui/src/app/state/settings/wallet.rs index f5281fae..21e546d6 100644 --- a/liana-gui/src/app/state/settings/wallet.rs +++ b/liana-gui/src/app/state/settings/wallet.rs @@ -17,34 +17,57 @@ use liana_ui::{ use crate::{ app::{ - cache::Cache, error::Error, message::Message, settings, state::State, view, wallet::Wallet, + cache::Cache, + error::Error, + message::Message, + settings, + state::{export::ExportModal, State}, + view, + wallet::Wallet, + Config, }, + backup::Backup, daemon::{Daemon, DaemonBackend}, + export::{ImportExportMessage, ImportExportType}, hw::{HardwareWallet, HardwareWalletConfig, HardwareWallets}, }; +enum Modal { + None, + RegisterWallet(RegisterWalletModal), + ImportExport(ExportModal), +} + +impl Modal { + fn is_none(&self) -> bool { + matches!(self, Modal::None) + } +} + pub struct WalletSettingsState { data_dir: PathBuf, warning: Option, descriptor: LianaDescriptor, keys_aliases: Vec<(Fingerprint, form::Value)>, wallet: Arc, - modal: Option, + modal: Modal, processing: bool, updated: bool, + config: Arc, } impl WalletSettingsState { - pub fn new(data_dir: PathBuf, wallet: Arc) -> Self { + pub fn new(data_dir: PathBuf, wallet: Arc, config: Arc) -> Self { WalletSettingsState { data_dir, descriptor: wallet.main_descriptor.clone(), keys_aliases: Self::keys_aliases(&wallet), wallet, warning: None, - modal: None, + modal: Modal::None, processing: false, updated: false, + config, } } @@ -86,20 +109,31 @@ impl State for WalletSettingsState { self.processing, self.updated, ); - if let Some(m) = &self.modal { - modal::Modal::new(content, m.view()) + + match &self.modal { + Modal::None => content, + Modal::RegisterWallet(m) => modal::Modal::new(content, m.view()) .on_blur(Some(view::Message::Close)) - .into() - } else { - content + .into(), + Modal::ImportExport(m) => m.view(content), } } fn subscription(&self) -> Subscription { - if let Some(modal) = &self.modal { - modal.subscription() - } else { - Subscription::none() + match &self.modal { + Modal::None => Subscription::none(), + Modal::RegisterWallet(modal) => modal.subscription(), + Modal::ImportExport(modal) => { + if let Some(sub) = modal.subscription() { + sub.map(|m| { + Message::View(view::Message::Settings( + view::SettingsMessage::ImportExport(ImportExportMessage::Progress(m)), + )) + }) + } else { + Subscription::none() + } + } } } @@ -112,7 +146,7 @@ impl State for WalletSettingsState { match message { Message::WalletUpdated(res) => { self.processing = false; - if let Some(modal) = &mut self.modal { + if let Modal::RegisterWallet(modal) = &mut self.modal { modal.update(daemon, cache, Message::WalletUpdated(res)) } else { match res { @@ -139,7 +173,7 @@ impl State for WalletSettingsState { Task::none() } Message::View(view::Message::Settings(view::SettingsMessage::Save)) => { - self.modal = None; + self.modal = Modal::None; self.processing = true; self.updated = false; Task::perform( @@ -157,22 +191,93 @@ impl State for WalletSettingsState { ) } Message::View(view::Message::Close) => { - self.modal = None; + self.modal = Modal::None; Task::none() } Message::View(view::Message::Settings(view::SettingsMessage::RegisterWallet)) => { - self.modal = Some(RegisterWalletModal::new( + self.modal = Modal::RegisterWallet(RegisterWalletModal::new( self.data_dir.clone(), self.wallet.clone(), cache.network, )); Task::none() } - _ => self - .modal - .as_mut() - .map(|m| m.update(daemon, cache, message)) - .unwrap_or_else(Task::none), + + Message::View(view::Message::ImportExport(ImportExportMessage::Close)) => { + if let Modal::ImportExport(_) = &self.modal { + self.modal = Modal::None; + } + Task::none() + } + Message::View(view::Message::ImportExport(m)) => { + if let Modal::ImportExport(modal) = &mut self.modal { + modal.update(m) + } else { + Task::none() + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ImportExport(m))) => { + if let Modal::ImportExport(modal) = &mut self.modal { + modal.update(m) + } else { + Task::none() + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportWallet)) => { + if self.modal.is_none() { + let datadir = cache.datadir_path.clone(); + let network = cache.network; + let config = self.config.clone(); + let wallet = self.wallet.clone(); + let daemon = daemon.clone(); + Task::perform( + async move { + let backup = + Backup::from_app(datadir, network, config, wallet, daemon).await; + let backup = backup.unwrap(); + serde_json::to_string_pretty(&backup).unwrap() + // TODO: do not unwrap, return an error message instead + }, + |s| { + Message::View(view::Message::Settings( + view::SettingsMessage::ExportBackup(s), + )) + }, + ) + } else { + Task::none() + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ExportBackup(backup))) => { + if self.modal.is_none() { + let modal = + // ExportModal::new(Some(daemon), ImportExportType::ExportBackup(backup)); + ExportModal::new(daemon, ImportExportType::ExportBackup(backup)); + let launch = modal.launch(); + self.modal = Modal::ImportExport(modal); + launch + } else { + Task::none() + } + } + Message::View(view::Message::Settings(view::SettingsMessage::ImportWallet)) => { + if self.modal.is_none() { + let modal = ExportModal::new( + // Some(daemon), + daemon, + ImportExportType::Descriptor(self.wallet.main_descriptor.clone()), + ); + let launch = modal.launch(); + self.modal = Modal::ImportExport(modal); + launch + } else { + Task::none() + } + } + _ => match &mut self.modal { + Modal::RegisterWallet(m) => m.update(daemon, cache, message), + _ => Task::none(), + }, } } diff --git a/liana-gui/src/app/state/transactions.rs b/liana-gui/src/app/state/transactions.rs index dfbf4353..a871cc8f 100644 --- a/liana-gui/src/app/state/transactions.rs +++ b/liana-gui/src/app/state/transactions.rs @@ -287,7 +287,7 @@ impl State for TransactionsPanel { TransactionsModal::CreateRbf(modal) => modal.update(daemon, _cache, message), TransactionsModal::Export(modal) => { if let Message::View(view::Message::ImportExport(m)) = msg { - modal.update(m.clone()) + modal.update::(m.clone()) } else { Task::none() } diff --git a/liana-gui/src/app/view/export.rs b/liana-gui/src/app/view/export.rs index 900cee8b..5a5673fe 100644 --- a/liana-gui/src/app/view/export.rs +++ b/liana-gui/src/app/view/export.rs @@ -64,7 +64,7 @@ pub fn export_modal<'a>( card::simple( Column::new() .spacing(10) - .push(Container::new(h4_bold(format!("Export {export_type}"))).width(Length::Fill)) + .push(Container::new(h4_bold(export_type)).width(Length::Fill)) .push(Space::with_height(Length::Fill)) .push(progress_bar_row) .push(Space::with_height(Length::Fill)) diff --git a/liana-gui/src/app/view/message.rs b/liana-gui/src/app/view/message.rs index a1e796c8..d7a46010 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -70,9 +70,17 @@ pub enum SettingsMessage { BitcoindSettings(SettingsEditMessage), ElectrumSettings(SettingsEditMessage), RescanSettings(SettingsEditMessage), + ImportExport(ImportExportMessage), EditRemoteBackendSettings, RemoteBackendSettings(RemoteBackendSettingsMessage), EditWalletSettings, + ImportExportSection, + ExportDescriptor, + ExportTransactions, + ExportLabels, + ExportWallet, + ExportBackup(String), + ImportWallet, AboutSection, RegisterWallet, FingerprintAliasEdited(Fingerprint, String), diff --git a/liana-gui/src/app/view/settings.rs b/liana-gui/src/app/view/settings.rs index ec0d2627..6835ea7c 100644 --- a/liana-gui/src/app/view/settings.rs +++ b/liana-gui/src/app/view/settings.rs @@ -106,6 +106,13 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element { Message::Settings(SettingsMessage::EditWalletSettings), ); + let import_export = settings_section( + "Import/export", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ImportExportSection), + ); + let recovery = settings_section( "Recovery", Some("In case of loss of the main key, the recovery key can move the funds after a certain time."), @@ -130,6 +137,7 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element { .push(header) .push(if !is_remote_backend { node } else { backend }) .push(wallet) + .push(import_export) .push(recovery) .push(about), ) @@ -153,6 +161,60 @@ pub fn bitcoind_settings<'a>( ) } +pub fn import_export<'a>(cache: &'a Cache, warning: Option<&Error>) -> Element<'a, Message> { + let header = header("Import/Export", SettingsMessage::ImportExportSection); + + let export_descriptor = settings_section( + "Export descriptor", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ExportDescriptor), + ); + + let export_transactions = settings_section( + "Export transactions", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ExportTransactions), + ); + + let export_labels = settings_section( + "Export labels", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ExportLabels), + ); + + let export_wallet = settings_section( + "Back Up Wallet", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ExportWallet), + ); + + let import_wallet = settings_section( + "Restore wallet", + None, + icon::wallet_icon(), + Message::Settings(SettingsMessage::ImportWallet), + ); + + dashboard( + &Menu::Settings, + cache, + warning, + Column::new() + .spacing(20) + .push(header) + .push(export_descriptor) + .push(export_transactions) + .push(export_labels) + .push(export_wallet) + .push(import_wallet) + .width(Length::Fill), + ) +} + pub fn about_section<'a>( cache: &'a Cache, warning: Option<&Error>, @@ -856,6 +918,18 @@ pub fn wallet_settings<'a>( ) -> Element<'a, Message> { let header = header("Wallet", SettingsMessage::EditWalletSettings); + let import_export = Row::new() + .push( + button::secondary(Some(icon::wallet_icon()), "Backup") + .on_press(Message::Settings(SettingsMessage::ExportWallet)), + ) + .push(Space::with_width(10)) + .push( + button::secondary(Some(icon::wallet_icon()), "Restore") + .on_press(Message::Settings(SettingsMessage::ImportWallet)), + ) + .push(Space::with_width(Length::Fill)); + let descr = card::simple( Column::new() .push(text("Wallet descriptor:").bold()) @@ -943,6 +1017,7 @@ pub fn wallet_settings<'a>( Column::new() .spacing(20) .push(header) + .push(import_export) .push(descr) .push( card::simple(display_policy( diff --git a/liana-gui/src/backup.rs b/liana-gui/src/backup.rs index d8577dac..86c4398d 100644 --- a/liana-gui/src/backup.rs +++ b/liana-gui/src/backup.rs @@ -46,6 +46,7 @@ pub struct Backup { pub proprietary: serde_json::Map, } +#[derive(Debug)] pub enum Error { DescriptorMissing, NotSingleWallet, @@ -148,7 +149,7 @@ impl Backup { pub async fn from_app( datadir: PathBuf, network: Network, - config: Config, + config: Arc, wallet: Arc, daemon: Arc, ) -> Result { @@ -165,7 +166,7 @@ impl Backup { } } - if let Ok(config) = serde_json::to_value(config) { + if let Ok(config) = serde_json::to_value((*config).clone()) { proprietary.insert(CONFIG_KEY.to_string(), config); } diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs index 57baf7cc..7b114663 100644 --- a/liana-gui/src/export.rs +++ b/liana-gui/src/export.rs @@ -135,6 +135,7 @@ pub enum Error { pub enum ImportExportType { Transactions, ExportPsbt(String), + ExportBackup(String), Descriptor(LianaDescriptor), ExportLabels, ImportPsbt, @@ -218,11 +219,12 @@ impl Export { ) { match export_type { ImportExportType::Transactions => export_transactions(sender, daemon, path).await, - ImportExportType::ExportPsbt(psbt) => export_psbt(sender, path, psbt), + ImportExportType::ExportPsbt(str) => export_string(sender, path, str), ImportExportType::Descriptor(descriptor) => export_descriptor(sender, path, descriptor), ImportExportType::ExportLabels => export_labels(sender, daemon, path).await, ImportExportType::ImportPsbt => import_psbt(sender, path), ImportExportType::ImportDescriptor => import_descriptor(sender, path), + ImportExportType::ExportBackup(str) => export_string(sender, path, str), }; } @@ -497,7 +499,7 @@ pub fn export_descriptor(sender: Sender, path: PathBuf, descriptor: Li send_progress!(sender, Ended); } -pub fn export_psbt(sender: Sender, path: PathBuf, psbt: String) { +pub fn export_string(sender: Sender, path: PathBuf, psbt: String) { let mut file = open_file!(path, sender); if let Err(e) = file.write_all(psbt.as_bytes()) {