From 7920eeaa9cae640f3e761dadb66564634d3ef900 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 4 Dec 2024 11:53:35 +0100 Subject: [PATCH] gui: add subscription to export transactions --- liana-gui/Cargo.toml | 1 + liana-gui/src/app/mod.rs | 1 + liana-gui/src/app/state/export.rs | 159 +++++++++++++ liana-gui/src/app/view/export.rs | 89 ++++++++ liana-gui/src/export.rs | 365 ++++++++++++++++++++++++++++++ liana-gui/src/lib.rs | 1 + 6 files changed, 616 insertions(+) create mode 100644 liana-gui/src/app/state/export.rs create mode 100644 liana-gui/src/app/view/export.rs create mode 100644 liana-gui/src/export.rs diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 98e97ca9b..e5b397a82 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -50,6 +50,7 @@ base64 = "0.21" bitcoin_hashes = "0.12" reqwest = { version = "0.11", default-features=false, features = ["json", "rustls-tls"] } rust-ini = "0.19.0" +rfd = "0.15.1" [target.'cfg(windows)'.dependencies] diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 6d6536fde..3a0f553b8 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -6,6 +6,7 @@ pub mod settings; pub mod state; pub mod view; pub mod wallet; +pub mod export; mod error; diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs new file mode 100644 index 000000000..46ec1672a --- /dev/null +++ b/liana-gui/src/app/state/export.rs @@ -0,0 +1,159 @@ +use std::{ + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use iced::{Command, Subscription}; +use liana_ui::{component::modal::Modal, widget::Element}; +use tokio::task::JoinHandle; + +use crate::app::{ + message::Message, + view::{self, export::export_modal}, +}; +use crate::daemon::Daemon; +use crate::export::{self, get_path, ExportProgress}; + +#[derive(Debug, Clone)] +pub enum ExportMessage { + Open, + ExportProgress(ExportProgress), + TimedOut, + UserStop, + Path(Option), + Close, +} + +impl From for view::Message { + fn from(value: ExportMessage) -> Self { + Self::Export(value) + } +} + +#[derive(Debug, PartialEq)] +pub enum ExportState { + Init, + ChoosePath, + Path(PathBuf), + Started, + Progress(f32), + TimedOut, + Aborted, + Ended, + Closed, +} + +#[derive(Debug)] +pub struct ExportModal { + path: Option, + handle: Option>>>, + state: ExportState, + error: Option, + daemon: Arc, +} + +impl ExportModal { + #[allow(clippy::new_without_default)] + pub fn new(daemon: Arc) -> Self { + Self { + path: None, + handle: None, + state: ExportState::Init, + error: None, + daemon, + } + } + + pub fn launch(&mut self) -> Command { + Command::perform(get_path(), |m| { + Message::View(view::Message::Export(ExportMessage::Path(m))) + }) + } + + pub fn update(&mut self, message: crate::app::message::Message) -> Command { + if let crate::app::message::Message::View(view::Message::Export(m)) = message { + match m { + ExportMessage::ExportProgress(m) => match m { + ExportProgress::Started(handle) => { + self.handle = Some(handle); + self.state = ExportState::Progress(0.0); + } + ExportProgress::Progress(p) => { + if let ExportState::Progress(_) = self.state { + self.state = ExportState::Progress(p); + } + } + ExportProgress::Finnished | ExportProgress::Ended => { + self.state = ExportState::Ended + } + ExportProgress::Error(e) => self.error = Some(e), + ExportProgress::None => {} + }, + ExportMessage::TimedOut => { + self.stop(ExportState::TimedOut); + } + ExportMessage::UserStop => { + self.stop(ExportState::Aborted); + } + ExportMessage::Path(p) => { + if let Some(path) = p { + self.path = Some(path); + self.start(); + } else { + return Command::perform(async {}, |_| { + Message::View(view::Message::Export(ExportMessage::Close)) + }); + } + } + ExportMessage::Close | ExportMessage::Open => { /* unreachable */ } + } + Command::none() + } else { + Command::none() + } + } + pub fn view<'a>(&'a self, content: Element<'a, view::Message>) -> Element { + let modal = Modal::new( + content, + export_modal(&self.state, self.error.as_ref(), "Transactions"), + ); + match self.state { + ExportState::TimedOut + | ExportState::Aborted + | ExportState::Ended + | ExportState::Closed => modal.on_blur(Some(view::Message::Close)), + _ => modal, + } + .into() + } + + pub fn start(&mut self) { + self.state = ExportState::Started; + } + + pub fn stop(&mut self, state: ExportState) { + if let Some(handle) = self.handle.take() { + handle.lock().expect("poisoined").abort(); + self.state = state; + } else { + log::warn!("no handle !!!!!!!!!!!!!!!!!!!!!!!!"); + } + } + + pub fn subscription(&self) -> Option> { + if let Some(path) = &self.path { + match &self.state { + ExportState::Started | ExportState::Progress(_) => { + Some(iced::subscription::unfold( + "transactions", + export::ExportState::new(self.daemon.clone(), Box::new(path.to_path_buf())), + export::export_subscription, + )) + } + _ => None, + } + } else { + None + } + } +} diff --git a/liana-gui/src/app/view/export.rs b/liana-gui/src/app/view/export.rs new file mode 100644 index 000000000..40c47d75c --- /dev/null +++ b/liana-gui/src/app/view/export.rs @@ -0,0 +1,89 @@ +use iced::{ + widget::{progress_bar, Button, Column, Container, Row, Space}, + Length, +}; +use liana_ui::{ + component::{ + card, + text::{h4_bold, text}, + }, + theme, + widget::Element, +}; + +use crate::app::state::export::ExportState; +use crate::app::{state::export::ExportMessage, view::message::Message}; +use crate::export::Error; + +/// Return the modal view for an export task +pub fn export_modal<'a>( + state: &ExportState, + error: Option<&'a Error>, + export_type: &str, +) -> Element<'a, Message> { + let button = match state { + ExportState::Started | ExportState::Progress(_) => { + Some(Button::new("Cancel").on_press(ExportMessage::UserStop.into())) + } + ExportState::Ended | ExportState::TimedOut | ExportState::Aborted => { + Some(Button::new("Close").on_press(ExportMessage::Close.into())) + } + _ => None, + } + .map(|b| b.height(32).style(theme::Button::Primary)); + let msg = if let Some(error) = error { + format!("{:?}", error) + } else { + match state { + ExportState::Init => "".to_string(), + ExportState::ChoosePath => { + "Select the path you want to export in the popup window...".into() + } + ExportState::Path(_) => "".into(), + ExportState::Started => "Starting export...".into(), + ExportState::Progress(p) => format!("Progress: {}%", p.round()), + ExportState::TimedOut => "Export failed: timeout".into(), + ExportState::Aborted => "Export canceled".into(), + ExportState::Ended => "Export successfull!".into(), + ExportState::Closed => "".into(), + } + }; + let p = match state { + ExportState::Init => 0.0, + ExportState::ChoosePath | ExportState::Path(_) | ExportState::Started => 5.0, + ExportState::Progress(p) => *p, + ExportState::TimedOut | ExportState::Aborted | ExportState::Ended | ExportState::Closed => { + 100.0 + } + }; + let progress_bar_row = Row::new() + .push(Space::with_width(30)) + .push(progress_bar(0.0..=100.0, p)) + .push(Space::with_width(30)); + let button_row = button.map(|b| { + Row::new() + .push(Space::with_width(Length::Fill)) + .push(b) + .push(Space::with_width(Length::Fill)) + }); + card::simple( + Column::new() + .spacing(10) + .push(Container::new(h4_bold(format!("Export {export_type}"))).width(Length::Fill)) + .push(Space::with_height(Length::Fill)) + .push(progress_bar_row) + .push(Space::with_height(Length::Fill)) + .push( + Row::new() + .push(Space::with_width(Length::Fill)) + .push(text(msg)) + .push(Space::with_width(Length::Fill)), + ) + .push(Space::with_height(Length::Fill)) + .push_maybe(button_row) + .push(Space::with_height(5)), + ) + .width(Length::Fixed(500.0)) + .height(Length::Fixed(220.0)) + .into() +} diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs new file mode 100644 index 000000000..a3ac3fa84 --- /dev/null +++ b/liana-gui/src/export.rs @@ -0,0 +1,365 @@ +use std::{ + collections::HashMap, + fs::{self, File}, + io::Write, + path::PathBuf, + sync::{ + mpsc::{channel, Receiver, Sender}, + Arc, Mutex, + }, + time::{self}, +}; + +use chrono::{DateTime, Duration, Utc}; +use liana::miniscript::bitcoin::{Amount, Txid}; +use tokio::{ + task::{JoinError, JoinHandle}, + time::sleep, +}; + +use crate::{ + daemon::{ + model::{HistoryTransaction, Labelled}, + Daemon, DaemonBackend, DaemonError, + }, + lianalite::client::backend::api::DEFAULT_LIMIT, +}; + +macro_rules! send_error { + ($sender:ident, $error:ident) => { + if let Err(e) = $sender.send(ExportProgress::Error(Error::$error)) { + tracing::error!("ExportState::start() fail to send msg: {}", e); + } + }; + ($sender:ident, $error:expr) => { + if let Err(e) = $sender.send(ExportProgress::Error($error)) { + tracing::error!("ExportState::start() fail to send msg: {}", e); + } + }; +} + +macro_rules! send_progress { + ($sender:ident, $progress:ident) => { + if let Err(e) = $sender.send(ExportProgress::$progress) { + tracing::error!("ExportState::start() fail to send msg: {}", e); + } + }; + ($sender:ident, $progress:ident($val:expr)) => { + if let Err(e) = $sender.send(ExportProgress::$progress($val)) { + tracing::error!("ExportState::start() fail to send msg: {}", e); + } + }; +} + +#[derive(Debug, Clone)] +pub enum Error { + Io(String), + HandleLost, + UnexpectedEnd, + JoinError(String), + ChannelLost, + NoParentDir, + Daemon(String), + TxTimeMissing, +} + +impl From for Error { + fn from(value: JoinError) -> Self { + Error::JoinError(format!("{:?}", value)) + } +} + +impl From for Error { + fn from(value: std::io::Error) -> Self { + Error::Io(format!("{:?}", value)) + } +} + +impl From for Error { + fn from(value: DaemonError) -> Self { + Error::Daemon(format!("{:?}", value)) + } +} + +#[derive(Debug)] +pub enum State { + Init, + Running, + Stopped, +} + +#[derive(Debug, Clone)] +pub enum ExportProgress { + Started(Arc>>), + Progress(f32), + Ended, + Finnished, + Error(Error), + None, +} + +pub struct ExportState { + pub receiver: Receiver, + pub sender: Option>, + pub handle: Option>>>, + pub daemon: Arc, + pub path: Box, +} + +impl ExportState { + pub fn new(daemon: Arc, path: Box) -> Self { + let (sender, receiver) = channel(); + ExportState { + receiver, + sender: Some(sender), + handle: None, + daemon, + path, + } + } + + pub async fn start(&mut self) { + if let (true, Some(sender)) = (self.handle.is_none(), self.sender.take()) { + let daemon = self.daemon.clone(); + let path = self.path.clone(); + + let cloned_sender = sender.clone(); + let handle = tokio::spawn(async move { + let dir = match path.parent() { + Some(dir) => dir, + None => { + send_error!(sender, NoParentDir); + return; + } + }; + if !dir.exists() { + if let Err(e) = fs::create_dir_all(dir) { + send_error!(sender, e.into()); + return; + } + } + let mut file = match File::create(path.as_path()) { + Ok(f) => f, + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + + let header = "Date,Label,Value,Fee,Txid,Block\n".to_string(); + if let Err(e) = file.write_all(header.as_bytes()) { + send_error!(sender, e.into()); + return; + } + + let info = daemon.get_info().await; + let start = match info { + Ok(info) => info.timestamp, + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + // look 2 hour forward + // https://github.com/bitcoin/bitcoin/blob/62bd61de110b057cbfd6e31e4d0b727d93119c72/src/chain.h#L29 + let mut end = ((Utc::now() + Duration::hours(2)).timestamp()) as u32; + let total_txs = daemon.list_confirmed_txs(start, end, u32::MAX as u64).await; + let total_txs = match total_txs { + Ok(r) => r.transactions.len(), + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + + if total_txs == 0 { + send_progress!(sender, Ended); + } else { + send_progress!(sender, Progress(5.0)); + } + + let (start, max) = match daemon.backend() { + DaemonBackend::RemoteBackend => (0, DEFAULT_LIMIT as u64), + _ => (start, u32::MAX as u64), + }; + + // store txs in a map to avoid duplicates + let mut map = HashMap::::new(); + let mut limit = max; + + loop { + let history = daemon.list_history_txs(start, end, limit).await; + let history_txs = match history { + Ok(h) => h, + Err(e) => { + send_error!(sender, e.into()); + return; + } + }; + let dl = map.len() + history_txs.len(); + if dl > 0 { + let progress = (dl as f32) / (total_txs as f32) * 80.0; + send_progress!(sender, Progress(progress)); + } + // all txs have been fetch + if history_txs.is_empty() { + break; + } + if history_txs.len() == limit as usize { + let first = if let Some(t) = history_txs.first().expect("checked").time { + t + } else { + send_error!(sender, TxTimeMissing); + return; + }; + let last = if let Some(t) = history_txs.first().expect("checked").time { + t + } else { + send_error!(sender, TxTimeMissing); + return; + }; + // limit too low, all tx are in the same timestamp + // whe must increase limit and retry + if first == last { + limit += DEFAULT_LIMIT as u64; + continue; + } else { + // add txs to map + for tx in history_txs { + let txid = tx.txid; + map.insert(txid, tx); + } + limit = max; + end = first.min(last); + continue; + } + } else + /* history_txs.len() < limit */ + { + // add txs to map + for tx in history_txs { + let txid = tx.txid; + map.insert(txid, tx); + } + break; + } + } + + let mut txs: Vec<_> = map.into_values().collect(); + txs.sort_by(|a, b| a.compare(b)); + + for mut tx in txs { + let date_time = tx + .time + .map(|t| { + let mut str = DateTime::from_timestamp(t as i64, 0) + .expect("bitcoin timestamp") + .to_rfc3339(); + //str has the form `1996-12-19T16:39:57-08:00` + // ^ ^^^^^^ + // replace `T` by ` `| | drop this part + str = str.replace("T", " "); + str[0..(str.len() - 6)].to_string() + }) + .unwrap_or("".to_string()); + + let txid = tx.txid.clone().to_string(); + let txid_label = tx.labels().get(&txid).cloned(); + let mut label = if let Some(txid) = txid_label { + txid + } else { + "".to_string() + }; + if !label.is_empty() { + label = format!("\"{}\"", label); + } + let fee = tx.fee_amount.unwrap_or(Amount::ZERO).to_sat(); + let mut inputs_amount = 0; + tx.coins.iter().for_each(|(_, coin)| { + if coin.is_from_self { + inputs_amount += coin.amount.to_sat(); + } + }); + let value = tx.incoming_amount.to_sat() - inputs_amount; + let value = value as f64 / 100_000_000.0; + let txid = tx.txid.to_string(); + let block = tx.height.map(|h| h.to_string()).unwrap_or("".to_string()); + + let line = format!( + "{},{},{},{},{},{}\n", + date_time, label, value, fee, txid, block + ); + if let Err(e) = file.write_all(line.as_bytes()) { + send_error!(sender, e.into()); + return; + } + } + send_progress!(sender, Progress(100.0)); + send_progress!(sender, Ended); + }); + let handle = Arc::new(Mutex::new(handle)); + + // we send the handle to the GUI so we can kill the thread on timeout + // or user cancel action + send_progress!(cloned_sender, Started(handle.clone())); + self.handle = Some(handle); + } else { + tracing::error!("ExportState can start only once!"); + } + } + pub fn state(&self) -> State { + match (&self.sender, &self.handle) { + (Some(_), None) => State::Init, + (None, Some(_)) => State::Running, + (None, None) => State::Stopped, + _ => unreachable!(), + } + } +} + +pub async fn export_subscription(mut state: ExportState) -> (ExportProgress, ExportState) { + match state.state() { + State::Init => { + state.start().await; + } + State::Stopped => { + sleep(time::Duration::from_millis(1000)).await; + return (ExportProgress::None, state); + } + State::Running => { /* continue */ } + } + let msg = state.receiver.try_recv(); + let disconnected = match msg { + Ok(m) => return (m, state), + Err(e) => match e { + std::sync::mpsc::TryRecvError::Empty => false, + std::sync::mpsc::TryRecvError::Disconnected => true, + }, + }; + + let handle = match state.handle.take() { + Some(h) => h, + None => return (ExportProgress::Error(Error::HandleLost), state), + }; + { + let h = handle.lock().expect("should not fail"); + if h.is_finished() { + return (ExportProgress::Finnished, state); + } else if disconnected { + return (ExportProgress::Error(Error::ChannelLost), state); + } + } // => release handle lock + state.handle = Some(handle); + + sleep(time::Duration::from_millis(100)).await; + (ExportProgress::None, state) +} + +pub async fn get_path() -> Option { + rfd::AsyncFileDialog::new() + .set_title("Choose a location to export...") + .set_file_name("liana.csv") + .save_file() + .await + .map(|fh| fh.path().to_path_buf()) +} diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index a932e09e6..c68ddbd69 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -2,6 +2,7 @@ pub mod app; pub mod daemon; pub mod datadir; pub mod download; +pub mod export; pub mod hw; pub mod installer; pub mod launcher;