From 22f48753a367187a1d08bc34d20e5b75bdfc01b8 Mon Sep 17 00:00:00 2001 From: pythcoiner Date: Wed, 4 Dec 2024 11:53:35 +0100 Subject: [PATCH] gui: add and export transactions feature --- Cargo.lock | 469 +++++++++++++++++++++++- liana-gui/Cargo.toml | 1 + liana-gui/src/app/message.rs | 2 + liana-gui/src/app/state/export.rs | 130 +++++++ liana-gui/src/app/state/mod.rs | 1 + liana-gui/src/app/state/transactions.rs | 72 +++- liana-gui/src/app/view/export.rs | 88 +++++ liana-gui/src/app/view/message.rs | 3 +- liana-gui/src/app/view/mod.rs | 1 + liana-gui/src/app/view/transactions.rs | 24 +- liana-gui/src/export.rs | 386 +++++++++++++++++++ liana-gui/src/lib.rs | 1 + 12 files changed, 1155 insertions(+), 23 deletions(-) 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/Cargo.lock b/Cargo.lock index 09fe41afd..ecb52ad53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -195,6 +195,76 @@ dependencies = [ "libloading 0.7.4", ] +[[package]] +name = "ashpd" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols 0.32.5", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-hwi" version = "0.0.24" @@ -221,6 +291,101 @@ dependencies = [ "tokio-serial", ] +[[package]] +name = "async-io" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" +dependencies = [ + "async-lock", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "tracing", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-lock" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", + "tracing", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "async-signal" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "637e00349800c0bdf8bfc21ebbc0b6524abea702b0da4168ac00d070d0c0b9f3" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.59.0", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.83" @@ -583,6 +748,19 @@ dependencies = [ "objc2 0.5.2", ] +[[package]] +name = "blocking" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -1403,6 +1581,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d8a32ae18130a3c84dd492d4215c3d913c3b07c6b63c2eb3eb7ff1101ab7bf" + +[[package]] +name = "enumflags2" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d232db7f5956f3f14313dc2f87985c58bd2c695ce124c8cdd984e08e15ac133d" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1444,6 +1649,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6032be9bd27023a771701cc49f9f053c751055f71efb2e0ae5c15809093675ba" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.73.0" @@ -1683,6 +1909,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cef40d21ae2c515b51041df9ed313ed21e572df340ea58a922a0aefe7e8891a1" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.31" @@ -2845,6 +3084,7 @@ dependencies = [ "lianad", "log", "reqwest", + "rfd", "rust-ini", "serde", "serde_json", @@ -3114,6 +3354,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.27.0" @@ -3200,7 +3449,7 @@ checksum = "20a4c60ca5c9c0e114b3bd66ff4aa5f9b2b175442be51ca6c4365d687a97a2ac" dependencies = [ "log", "mio 0.8.11", - "nix", + "nix 0.26.4", "serialport", "winapi", ] @@ -3276,10 +3525,23 @@ dependencies = [ "bitflags 1.3.2", "cfg-if", "libc", - "memoffset", + "memoffset 0.7.1", "pin-utils", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.6.0", + "cfg-if", + "cfg_aliases 0.2.1", + "libc", + "memoffset 0.9.1", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -3476,6 +3738,7 @@ checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.6.0", "block2 0.5.1", + "dispatch", "libc", "objc2 0.5.2", ] @@ -3560,6 +3823,16 @@ dependencies = [ "hashbrown 0.13.2", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "ouroboros" version = "0.18.4" @@ -3624,6 +3897,12 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.11.2" @@ -3754,6 +4033,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3798,6 +4088,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + [[package]] name = "poly1305" version = "0.8.0" @@ -4226,6 +4522,28 @@ dependencies = [ "subtle", ] +[[package]] +name = "rfd" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46f6f80a9b882647d9014673ca9925d30ffc9750f2eed2b4490e189eaebd01e8" +dependencies = [ + "ashpd", + "block2 0.5.1", + "js-sys", + "log", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.48.0", +] + [[package]] name = "rgb" version = "0.8.50" @@ -4554,6 +4872,17 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_repr" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4579,7 +4908,7 @@ dependencies = [ "io-kit-sys", "libudev", "mach2", - "nix", + "nix 0.26.4", "scopeguard", "unescaper", "winapi", @@ -5286,6 +5615,17 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset 0.9.1", + "tempfile", + "winapi", +] + [[package]] name = "unescaper" version = "0.1.5" @@ -5395,8 +5735,15 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "usvg" version = "0.36.0" @@ -6292,6 +6639,16 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef33da6b1660b4ddbfb3aef0ade110c8b8a781a3b6382fa5f2b5b040fd55f61" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -6371,6 +6728,69 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb67eadba43784b6fb14857eba0d8fc518686d3ee537066eb6086dc318e2c8a1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d49ebc960ceb660f2abe40a5904da975de6986f2af0d7884b39eec6528c57" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856b7a38811f71846fd47856ceee8bccaec8399ff53fb370247e66081ace647b" +dependencies = [ + "serde", + "static_assertions", + "winnow", + "zvariant", +] + [[package]] name = "zeno" version = "0.2.3" @@ -6482,3 +6902,46 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" dependencies = [ "simd-adler32", ] + +[[package]] +name = "zvariant" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1200ee6ac32f1e5a312e455a949a4794855515d34f9909f4a3e082d14e1a56f" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "url", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687e3b97fae6c9104fbbd36c73d27d149abf04fb874e2efbd84838763daa8916" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d1d011a38f12360e5fcccceeff5e2c42a8eb7f27f0dcba97a0862ede05c9c6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.87", + "winnow", +] 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/message.rs b/liana-gui/src/app/message.rs index 33d6cde69..676446aca 100644 --- a/liana-gui/src/app/message.rs +++ b/liana-gui/src/app/message.rs @@ -11,6 +11,7 @@ use lianad::config::Config as DaemonConfig; use crate::{ app::{cache::Cache, error::Error, view, wallet::Wallet}, daemon::model::*, + export::ExportMessage, hw::HardwareWalletMessage, }; @@ -46,4 +47,5 @@ pub enum Message { LabelsUpdated(Result>, Error>), BroadcastModal(Result, Error>), RbfModal(Box, bool, Result, Error>), + Export(ExportMessage), } diff --git a/liana-gui/src/app/state/export.rs b/liana-gui/src/app/state/export.rs new file mode 100644 index 000000000..56de4565e --- /dev/null +++ b/liana-gui/src/app/state/export.rs @@ -0,0 +1,130 @@ +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}, + }, + daemon::Daemon, + export::{self, get_path, ExportMessage, ExportProgress, ExportState}, +}; + +#[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(&self) -> Command { + Command::perform(get_path(), |m| { + Message::View(view::Message::Export(ExportMessage::Path(m))) + }) + } + + pub fn update(&mut self, message: Message) -> Command { + if let 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::Finished | 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("poisoned").abort(); + self.state = state; + } + } + + pub fn subscription(&self) -> Option> { + if let Some(path) = &self.path { + match &self.state { + ExportState::Started | ExportState::Progress(_) => { + Some(iced::subscription::unfold( + "transactions", + export::State::new(self.daemon.clone(), Box::new(path.to_path_buf())), + export::export_subscription, + )) + } + _ => None, + } + } else { + None + } + } +} diff --git a/liana-gui/src/app/state/mod.rs b/liana-gui/src/app/state/mod.rs index 2168c89a0..d4f637b33 100644 --- a/liana-gui/src/app/state/mod.rs +++ b/liana-gui/src/app/state/mod.rs @@ -1,4 +1,5 @@ mod coins; +mod export; mod label; mod psbt; mod psbts; diff --git a/liana-gui/src/app/state/transactions.rs b/liana-gui/src/app/state/transactions.rs index ed41c1661..5aa619222 100644 --- a/liana-gui/src/app/state/transactions.rs +++ b/liana-gui/src/app/state/transactions.rs @@ -28,6 +28,7 @@ use crate::{ wallet::Wallet, }, daemon::model::{self, LabelsLoader}, + export::ExportMessage, }; use crate::daemon::{ @@ -35,13 +36,22 @@ use crate::daemon::{ Daemon, }; +use super::export::ExportModal; + +#[derive(Debug)] +pub enum TransactionsModal { + CreateRbf(CreateRbfModal), + Export(ExportModal), + None, +} + pub struct TransactionsPanel { wallet: Arc, txs: Vec, labels_edited: LabelsEdited, selected_tx: Option, warning: Option, - create_rbf_modal: Option, + modal: TransactionsModal, is_last_page: bool, processing: bool, } @@ -54,7 +64,7 @@ impl TransactionsPanel { txs: Vec::new(), labels_edited: LabelsEdited::default(), warning: None, - create_rbf_modal: None, + modal: TransactionsModal::None, is_last_page: false, processing: false, } @@ -63,7 +73,7 @@ impl TransactionsPanel { pub fn preselect(&mut self, tx: HistoryTransaction) { self.selected_tx = Some(tx); self.warning = None; - self.create_rbf_modal = None; + self.modal = TransactionsModal::None; } } @@ -76,19 +86,22 @@ impl State for TransactionsPanel { self.labels_edited.cache(), self.warning.as_ref(), ); - if let Some(modal) = &self.create_rbf_modal { - modal.view(content) - } else { - content + match &self.modal { + TransactionsModal::CreateRbf(rbf) => rbf.view(content), + _ => content, } } else { - view::transactions::transactions_view( + let content = view::transactions::transactions_view( cache, &self.txs, self.warning.as_ref(), self.is_last_page, self.processing, - ) + ); + match &self.modal { + TransactionsModal::Export(export) => export.view(content), + _ => content, + } } } @@ -134,7 +147,7 @@ impl State for TransactionsPanel { Message::RbfModal(tx, is_cancel, res) => match res { Ok(descendant_txids) => { let modal = CreateRbfModal::new(*tx, is_cancel, descendant_txids); - self.create_rbf_modal = Some(modal); + self.modal = TransactionsModal::CreateRbf(modal); } Err(e) => { self.warning = e.into(); @@ -146,16 +159,16 @@ impl State for TransactionsPanel { Message::View(view::Message::Select(i)) => { self.selected_tx = self.txs.get(i).cloned(); // Clear modal if it's for a different tx. - if let Some(modal) = &self.create_rbf_modal { + if let TransactionsModal::CreateRbf(modal) = &self.modal { if Some(modal.tx.tx.txid()) != self.selected_tx.as_ref().map(|selected| selected.tx.txid()) { - self.create_rbf_modal = None; + self.modal = TransactionsModal::None; } } } Message::View(view::Message::CreateRbf(view::CreateRbfMessage::Cancel)) => { - self.create_rbf_modal = None; + self.modal = TransactionsModal::None; } Message::View(view::Message::CreateRbf(view::CreateRbfMessage::New(is_cancel))) => { if let Some(tx) = &self.selected_tx { @@ -249,11 +262,26 @@ impl State for TransactionsPanel { ); } } - _ => { - if let Some(modal) = &mut self.create_rbf_modal { - return modal.update(daemon, _cache, message); + Message::View(view::Message::Export(ExportMessage::Open)) => { + if let TransactionsModal::None = &self.modal { + self.modal = TransactionsModal::Export(ExportModal::new(daemon)); + if let TransactionsModal::Export(m) = &self.modal { + return m.launch(); + } } } + Message::View(view::Message::Export(ExportMessage::Close)) => { + if let TransactionsModal::Export(_) = &self.modal { + self.modal = TransactionsModal::None; + } + } + _ => { + return match &mut self.modal { + TransactionsModal::CreateRbf(modal) => modal.update(daemon, _cache, message), + TransactionsModal::Export(modal) => modal.update(message), + TransactionsModal::None => Command::none(), + }; + } }; Command::none() } @@ -284,6 +312,17 @@ impl State for TransactionsPanel { Message::HistoryTransactions, )]) } + + fn subscription(&self) -> iced::Subscription { + if let TransactionsModal::Export(modal) = &self.modal { + if let Some(sub) = modal.subscription() { + return sub.map(|m| { + Message::View(view::Message::Export(ExportMessage::ExportProgress(m))) + }); + } + } + iced::Subscription::none() + } } impl From for Box { @@ -292,6 +331,7 @@ impl From for Box { } } +#[derive(Debug)] pub struct CreateRbfModal { /// Transaction to replace. tx: model::HistoryTransaction, diff --git a/liana-gui/src/app/view/export.rs b/liana-gui/src/app/view/export.rs new file mode 100644 index 000000000..beb6bc0a9 --- /dev/null +++ b/liana-gui/src/app/view/export.rs @@ -0,0 +1,88 @@ +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::export::{Error, ExportMessage}; +use crate::{app::view::message::Message, export::ExportState}; + +/// 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 successful!".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/app/view/message.rs b/liana-gui/src/app/view/message.rs index e8bbd11f6..345dd1dd0 100644 --- a/liana-gui/src/app/view/message.rs +++ b/liana-gui/src/app/view/message.rs @@ -1,4 +1,4 @@ -use crate::{app::menu::Menu, node::bitcoind::RpcAuthType}; +use crate::{app::menu::Menu, export::ExportMessage, node::bitcoind::RpcAuthType}; use liana::miniscript::bitcoin::{bip32::Fingerprint, OutPoint}; #[derive(Debug, Clone)] @@ -19,6 +19,7 @@ pub enum Message { SelectHardwareWallet(usize), CreateRbf(CreateRbfMessage), ShowQrCode(usize), + Export(ExportMessage), } #[derive(Debug, Clone)] diff --git a/liana-gui/src/app/view/mod.rs b/liana-gui/src/app/view/mod.rs index 66e25af41..d6ba29ee7 100644 --- a/liana-gui/src/app/view/mod.rs +++ b/liana-gui/src/app/view/mod.rs @@ -3,6 +3,7 @@ mod message; mod warning; pub mod coins; +pub mod export; pub mod home; pub mod hw; pub mod psbt; diff --git a/liana-gui/src/app/view/transactions.rs b/liana-gui/src/app/view/transactions.rs index dc51f9193..0fa57f3a1 100644 --- a/liana-gui/src/app/view/transactions.rs +++ b/liana-gui/src/app/view/transactions.rs @@ -1,7 +1,11 @@ use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Local, Utc}; -use iced::{alignment, widget::tooltip, Alignment, Length}; +use iced::{ + alignment, + widget::{tooltip, Space}, + Alignment, Length, +}; use liana_ui::{ color, @@ -15,9 +19,14 @@ use crate::{ cache::Cache, error::Error, menu::Menu, - view::{dashboard, label, message::CreateRbfMessage, message::Message, warning::warn}, + view::{ + dashboard, label, + message::{CreateRbfMessage, Message}, + warning::warn, + }, }, daemon::model::{HistoryTransaction, Txid}, + export::ExportMessage, }; pub fn transactions_view<'a>( @@ -32,7 +41,16 @@ pub fn transactions_view<'a>( cache, warning, Column::new() - .push(Container::new(h3("Transactions")).width(Length::Fill)) + .push( + Row::new() + .push(Container::new(h3("Transactions"))) + .push(Space::with_width(Length::Fill)) + .push( + Button::new("Export") + .on_press(ExportMessage::Open.into()) + .style(theme::Button::Secondary), + ), + ) .push( Column::new() .spacing(10) diff --git a/liana-gui/src/export.rs b/liana-gui/src/export.rs new file mode 100644 index 000000000..ed8d2219a --- /dev/null +++ b/liana-gui/src/export.rs @@ -0,0 +1,386 @@ +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::{ + app::view, + 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 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, 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 Status { + Init, + Running, + Stopped, +} + +#[derive(Debug, Clone)] +pub enum ExportProgress { + Started(Arc>>), + Progress(f32), + Ended, + Finished, + Error(Error), + None, +} + +pub struct State { + pub receiver: Receiver, + pub sender: Option>, + pub handle: Option>>>, + pub daemon: Arc, + pub path: Box, +} + +impl State { + pub fn new(daemon: Arc, path: Box) -> Self { + let (sender, receiver) = channel(); + State { + 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; + } + + // 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(0, 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 max = match daemon.backend() { + DaemonBackend::RemoteBackend => DEFAULT_LIMIT as u64, + _ => 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(0, 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 fetched + 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.last().expect("checked").time { + t + } else { + send_error!(sender, TxTimeMissing); + return; + }; + // limit too low, all tx are in the same timestamp + // we 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 txid = tx.txid.to_string(); + let fee = tx.fee_amount.unwrap_or(Amount::ZERO).to_sat() as i128; + let mut inputs_amount = 0; + tx.coins.iter().for_each(|(_, coin)| { + inputs_amount += coin.amount.to_sat() as i128; + }); + let value = tx.incoming_amount.to_sat() as i128 - inputs_amount; + let value = value as f64 / 100_000_000.0; + let fee = fee as f64 / 100_000_000.0; + 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) -> Status { + match (&self.sender, &self.handle) { + (Some(_), None) => Status::Init, + (None, Some(_)) => Status::Running, + (None, None) => Status::Stopped, + _ => unreachable!(), + } + } +} + +pub async fn export_subscription(mut state: State) -> (ExportProgress, State) { + match state.state() { + Status::Init => { + state.start().await; + } + Status::Stopped => { + sleep(time::Duration::from_millis(1000)).await; + return (ExportProgress::None, state); + } + Status::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::Finished, 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;