gui: add import/export features in settings

This commit is contained in:
pythcoiner 2025-03-10 14:35:55 +01:00
parent f1b62074d3
commit f41de56e38
No known key found for this signature in database
GPG Key ID: C1048AEEDF303B88
12 changed files with 394 additions and 44 deletions

View File

@ -49,3 +49,9 @@ pub enum Message {
RbfModal(Box<HistoryTransaction>, bool, Result<HashSet<Txid>, Error>),
Export(ImportExportMessage),
}
impl From<ImportExportMessage> for Message {
fn from(value: ImportExportMessage) -> Self {
Message::View(view::Message::ImportExport(value))
}
}

View File

@ -62,6 +62,7 @@ impl Panels {
data_dir: PathBuf,
daemon_backend: DaemonBackend,
internal_bitcoind: Option<&Bitcoind>,
config: Arc<Config>,
) -> 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<Config>,
wallet: Arc<Wallet>,
daemon: Arc<dyn Daemon + Sync + Send>,
internal_bitcoind: Option<Bitcoind>,
@ -149,12 +151,14 @@ impl App {
data_dir: PathBuf,
internal_bitcoind: Option<Bitcoind>,
) -> (App, Task<Message>) {
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());
(

View File

@ -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<app::message::Message> {
Task::perform(get_path(self.default_filename()), |m| {
app::message::Message::View(view::Message::ImportExport(ImportExportMessage::Path(m)))
pub fn launch<M: From<ImportExportMessage> + Send + 'static>(&self) -> Task<M> {
Task::perform(get_path(self.default_filename()), move |m| {
ImportExportMessage::Path(m).into()
})
}
pub fn update(&mut self, message: ImportExportMessage) -> Task<app::message::Message> {
pub fn update<M: From<ImportExportMessage> + Send + 'static>(
&mut self,
message: ImportExportMessage,
) -> Task<M> {
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 */ }

View File

@ -1,5 +1,5 @@
mod coins;
mod export;
pub mod export;
mod label;
mod psbt;
mod psbts;

View File

@ -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<Wallet>,
setting: Option<Box<dyn State>>,
daemon_backend: DaemonBackend,
internal_bitcoind: bool,
config: Arc<Config>,
}
impl SettingsState {
@ -38,6 +44,7 @@ impl SettingsState {
wallet: Arc<Wallet>,
daemon_backend: DaemonBackend,
internal_bitcoind: bool,
config: Arc<Config>,
) -> 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<SettingsState> for Box<dyn State> {
}
}
pub struct ImportExportSettingsState {
warning: Option<Error>,
modal: Option<ExportModal>,
wallet: Arc<Wallet>,
config: Arc<Config>,
}
impl ImportExportSettingsState {
pub fn new(wallet: Arc<Wallet>, config: Arc<Config>) -> 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<Message> {
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<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Task<Message> {
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<ImportExportSettingsState> for Box<dyn State> {
fn from(s: ImportExportSettingsState) -> Box<dyn State> {
Box::new(s)
}
}
#[derive(Default)]
pub struct AboutSettingsState {
daemon_version: Option<String>,

View File

@ -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<Error>,
descriptor: LianaDescriptor,
keys_aliases: Vec<(Fingerprint, form::Value<String>)>,
wallet: Arc<Wallet>,
modal: Option<RegisterWalletModal>,
modal: Modal,
processing: bool,
updated: bool,
config: Arc<Config>,
}
impl WalletSettingsState {
pub fn new(data_dir: PathBuf, wallet: Arc<Wallet>) -> Self {
pub fn new(data_dir: PathBuf, wallet: Arc<Wallet>, config: Arc<Config>) -> 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<Message> {
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(),
},
}
}

View File

@ -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::<Message>(m.clone())
} else {
Task::none()
}

View File

@ -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))

View File

@ -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),

View File

@ -106,6 +106,13 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<Message> {
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<Message> {
.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(

View File

@ -46,6 +46,7 @@ pub struct Backup {
pub proprietary: serde_json::Map<String, serde_json::Value>,
}
#[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<Config>,
wallet: Arc<Wallet>,
daemon: Arc<dyn Daemon + Sync + Send>,
) -> Result<Self, Error> {
@ -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);
}

View File

@ -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<Progress>, path: PathBuf, descriptor: Li
send_progress!(sender, Ended);
}
pub fn export_psbt(sender: Sender<Progress>, path: PathBuf, psbt: String) {
pub fn export_string(sender: Sender<Progress>, path: PathBuf, psbt: String) {
let mut file = open_file!(path, sender);
if let Err(e) = file.write_all(psbt.as_bytes()) {