Merge #1496: gui: tx export feature

22f48753a367187a1d08bc34d20e5b75bdfc01b8 gui: add and export transactions feature (pythcoiner)

Pull request description:

  This PR add a feature to export transactions:
  Done:
   - [x] Subscription to run the  feature in a detached thread w/ possibility to send update about the ongoing progress.
   - [x] Let user choose the path using `rfd` crate
   - [x] Add a modal that show progress of the process
   - [x] estimate the progress
   - [x] cancel feature

ACKs for top commit:
  jp1ac4:
    Tested ACK 22f48753a367187a1d08bc34d20e5b75bdfc01b8.

Tree-SHA512: 5cf271d52878c4845347c5951a562e08e7f7efea08f0dc702d0500e41d6ad8eab7cb31f7e8a7b4edba48916648759ce97a9f591d1bcb4564a0c7067d5274fa08
This commit is contained in:
edouardparis 2024-12-30 09:12:07 +01:00
commit cfe15b360d
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
12 changed files with 1155 additions and 23 deletions

469
Cargo.lock generated
View File

@ -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",
]

View File

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

View File

@ -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<HashMap<String, Option<String>>, Error>),
BroadcastModal(Result<HashSet<Txid>, Error>),
RbfModal(Box<HistoryTransaction>, bool, Result<HashSet<Txid>, Error>),
Export(ExportMessage),
}

View File

@ -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<PathBuf>,
handle: Option<Arc<Mutex<JoinHandle<()>>>>,
state: ExportState,
error: Option<export::Error>,
daemon: Arc<dyn Daemon + Sync + Send>,
}
impl ExportModal {
#[allow(clippy::new_without_default)]
pub fn new(daemon: Arc<dyn Daemon + Sync + Send>) -> Self {
Self {
path: None,
handle: None,
state: ExportState::Init,
error: None,
daemon,
}
}
pub fn launch(&self) -> Command<Message> {
Command::perform(get_path(), |m| {
Message::View(view::Message::Export(ExportMessage::Path(m)))
})
}
pub fn update(&mut self, message: Message) -> Command<Message> {
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<view::Message> {
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<Subscription<export::ExportProgress>> {
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
}
}
}

View File

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

View File

@ -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<Wallet>,
txs: Vec<HistoryTransaction>,
labels_edited: LabelsEdited,
selected_tx: Option<HistoryTransaction>,
warning: Option<Error>,
create_rbf_modal: Option<CreateRbfModal>,
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<Message> {
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<TransactionsPanel> for Box<dyn State> {
@ -292,6 +331,7 @@ impl From<TransactionsPanel> for Box<dyn State> {
}
}
#[derive(Debug)]
pub struct CreateRbfModal {
/// Transaction to replace.
tx: model::HistoryTransaction,

View File

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

View File

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

View File

@ -3,6 +3,7 @@ mod message;
mod warning;
pub mod coins;
pub mod export;
pub mod home;
pub mod hw;
pub mod psbt;

View File

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

386
liana-gui/src/export.rs Normal file
View File

@ -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<PathBuf>),
Close,
}
impl From<ExportMessage> 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<JoinError> for Error {
fn from(value: JoinError) -> Self {
Error::JoinError(format!("{:?}", value))
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Error::Io(format!("{:?}", value))
}
}
impl From<DaemonError> 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<Mutex<JoinHandle<()>>>),
Progress(f32),
Ended,
Finished,
Error(Error),
None,
}
pub struct State {
pub receiver: Receiver<ExportProgress>,
pub sender: Option<Sender<ExportProgress>>,
pub handle: Option<Arc<Mutex<JoinHandle<()>>>>,
pub daemon: Arc<dyn Daemon + Sync + Send>,
pub path: Box<PathBuf>,
}
impl State {
pub fn new(daemon: Arc<dyn Daemon + Sync + Send>, path: Box<PathBuf>) -> 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::<Txid, HistoryTransaction>::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<PathBuf> {
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())
}

View File

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