diff --git a/gui/Cargo.lock b/gui/Cargo.lock index c5a56114..19bb550e 100644 --- a/gui/Cargo.lock +++ b/gui/Cargo.lock @@ -1390,7 +1390,7 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "minisafe" version = "0.0.1" -source = "git+https://github.com/revault/minisafe?branch=master#b548451292f4f497a5a837244b6432e473e6cd55" +source = "git+https://github.com/revault/minisafe?branch=master#4a802890634f60d77e4da5a56f9189024ef99500" dependencies = [ "backtrace", "dirs", diff --git a/gui/src/app/cache.rs b/gui/src/app/cache.rs index 01636ca5..c403e043 100644 --- a/gui/src/app/cache.rs +++ b/gui/src/app/cache.rs @@ -1,3 +1,15 @@ +use crate::daemon::model::Coin; + pub struct Cache { pub blockheight: i32, + pub coins: Vec, +} + +impl Default for Cache { + fn default() -> Self { + Self { + blockheight: 0, + coins: Vec::new(), + } + } } diff --git a/gui/src/app/menu.rs b/gui/src/app/menu.rs index cca33768..ddeb4b09 100644 --- a/gui/src/app/menu.rs +++ b/gui/src/app/menu.rs @@ -1,5 +1,6 @@ #[derive(Debug, Clone, PartialEq, Eq)] pub enum Menu { Home, + Receive, Settings, } diff --git a/gui/src/app/message.rs b/gui/src/app/message.rs index 6183977f..0c66e434 100644 --- a/gui/src/app/message.rs +++ b/gui/src/app/message.rs @@ -1,6 +1,9 @@ use minisafe::config::Config as DaemonConfig; -use crate::app::{error::Error, view}; +use crate::{ + app::{error::Error, view}, + daemon::model::*, +}; #[derive(Debug)] pub enum Message { @@ -10,4 +13,6 @@ pub enum Message { LoadDaemonConfig(Box), DaemonConfigLoaded(Result<(), Error>), BlockHeight(Result), + ReceiveAddress(Result), + Coins(Result, Error>), } diff --git a/gui/src/app/mod.rs b/gui/src/app/mod.rs index f6dfcccf..79c29fbb 100644 --- a/gui/src/app/mod.rs +++ b/gui/src/app/mod.rs @@ -21,7 +21,7 @@ pub use minisafe::config::Config as DaemonConfig; pub use config::Config; pub use message::Message; -use state::{Home, State}; +use state::{Home, ReceivePanel, State}; use crate::{ app::{cache::Cache, error::Error, menu::Menu}, @@ -42,7 +42,7 @@ impl App { config: Config, daemon: Arc, ) -> (App, Command) { - let state: Box = Home {}.into(); + let state: Box = Home::new(&cache.coins).into(); let cmd = state.load(daemon.clone()); ( Self { @@ -62,7 +62,8 @@ impl App { state::SettingsState::new(self.daemon.config().clone(), self.daemon.is_external()) .into() } - menu::Menu::Home => Home {}.into(), + menu::Menu::Home => Home::new(&self.cache.coins).into(), + menu::Menu::Receive => ReceivePanel::default().into(), }; self.state.load(self.daemon.clone()) } @@ -94,6 +95,18 @@ impl App { } pub fn update(&mut self, message: Message) -> Command { + // Update cache when values are passing by. + // State will handle the error case. + match &message { + Message::Coins(Ok(coins)) => { + self.cache.coins = coins.clone(); + } + Message::BlockHeight(Ok(blockheight)) => { + self.cache.blockheight = blockheight.clone(); + } + _ => {} + }; + match message { Message::Tick => { let daemon = self.daemon.clone(); @@ -107,12 +120,6 @@ impl App { Message::BlockHeight, ) } - Message::BlockHeight(res) => { - if let Ok(blockheight) = res { - self.cache.blockheight = blockheight; - } - Command::none() - } Message::LoadDaemonConfig(cfg) => { let res = self.load_daemon_config(*cfg); self.update(Message::DaemonConfigLoaded(res)) diff --git a/gui/src/app/state/mod.rs b/gui/src/app/state/mod.rs index aa87e7b2..6f2203bd 100644 --- a/gui/src/app/state/mod.rs +++ b/gui/src/app/state/mod.rs @@ -2,20 +2,19 @@ mod settings; use std::sync::Arc; +use bitcoin::Amount; use iced::pure::{column, Element}; -use iced::{Command, Subscription}; - -use super::{cache::Cache, menu::Menu, message::Message, view}; +use iced::{widget::qr_code, Command, Subscription}; +use super::{cache::Cache, error::Error, menu::Menu, message::Message, view}; +use crate::daemon::{model::Coin, Daemon}; pub use settings::SettingsState; -use crate::daemon::Daemon; - pub trait State { fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message>; fn update( &mut self, - daemon: Arc, + daemon: Arc, cache: &Cache, message: Message, ) -> Command; @@ -27,20 +26,44 @@ pub trait State { } } -pub struct Home {} +pub struct Home { + balance: Amount, +} + +impl Home { + pub fn new(coins: &Vec) -> Self { + Self { + balance: Amount::from_sat(coins.iter().map(|coin| coin.amount.as_sat()).sum()), + } + } +} impl State for Home { - fn view<'a>(&self, _cache: &'a Cache) -> Element<'a, view::Message> { - view::dashboard(&Menu::Home, None, column()) + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + view::dashboard(&Menu::Home, None, view::home::home_view(&self.balance)) } + fn update( &mut self, - _daemon: Arc, + _daemon: Arc, _cache: &Cache, _message: Message, ) -> Command { Command::none() } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .list_coins() + .map(|res| res.coins) + .map_err(|e| e.into()) + }, + Message::Coins, + ) + } } impl From for Box { @@ -48,3 +71,101 @@ impl From for Box { Box::new(s) } } + +#[derive(Default)] +pub struct ReceivePanel { + address: Option, + qr_code: Option, + warning: Option, +} + +impl State for ReceivePanel { + fn view<'a>(&'a self, _cache: &'a Cache) -> Element<'a, view::Message> { + if let Some(address) = &self.address { + view::dashboard( + &Menu::Receive, + self.warning.as_ref(), + view::receive::receive(address, self.qr_code.as_ref().unwrap()), + ) + } else { + view::dashboard(&Menu::Receive, self.warning.as_ref(), column()) + } + } + fn update( + &mut self, + _daemon: Arc, + _cache: &Cache, + message: Message, + ) -> Command { + if let Message::ReceiveAddress(res) = message { + match res { + Ok(address) => { + self.warning = None; + self.qr_code = Some(qr_code::State::new(&address.to_qr_uri()).unwrap()); + self.address = Some(address); + } + Err(e) => self.warning = Some(e), + } + }; + Command::none() + } + + fn load(&self, daemon: Arc) -> Command { + let daemon = daemon.clone(); + Command::perform( + async move { + daemon + .get_new_address() + .map(|res| res.address) + .map_err(|e| e.into()) + }, + Message::ReceiveAddress, + ) + } +} + +impl From for Box { + fn from(s: ReceivePanel) -> Box { + Box::new(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + app::cache::Cache, + daemon::{ + client::{Minisafed, Request}, + model::*, + }, + utils::{ + mock::{fake_daemon_config, Daemon}, + sandbox::Sandbox, + }, + }; + + use bitcoin::Address; + use serde_json::json; + use std::str::FromStr; + + #[tokio::test] + async fn test_receive_panel() { + let addr = + Address::from_str("tb1qkldgvljmjpxrjq2ev5qxe8dvhn0dph9q85pwtfkjeanmwdue2akqj4twxj") + .unwrap(); + let daemon = Daemon::new(vec![( + Some(json!({"method": "getnewaddress", "params": Option::::None})), + Ok(json!(GetAddressResult { + address: addr.clone() + })), + )]); + + let sandbox: Sandbox = Sandbox::new(ReceivePanel::default()); + let client = Arc::new(Minisafed::new(daemon.run(), fake_daemon_config())); + let sandbox = sandbox.load(client, &Cache::default()).await; + + let panel = sandbox.state(); + assert_eq!(panel.address, Some(addr)); + } +} diff --git a/gui/src/app/view/home.rs b/gui/src/app/view/home.rs new file mode 100644 index 00000000..17fb1107 --- /dev/null +++ b/gui/src/app/view/home.rs @@ -0,0 +1,17 @@ +use iced::{ + pure::{column, Element}, + Alignment, +}; + +use crate::ui::component::text::*; + +use super::message::Message; + +pub fn home_view<'a>(balance: &'a bitcoin::Amount) -> Element<'a, Message> { + column() + .push(column().padding(40)) + .push(text(&format!("{} BTC", balance.as_btc())).bold().size(50)) + .align_items(Alignment::Center) + .spacing(20) + .into() +} diff --git a/gui/src/app/view/mod.rs b/gui/src/app/view/mod.rs index d2b66791..9814bc64 100644 --- a/gui/src/app/view/mod.rs +++ b/gui/src/app/view/mod.rs @@ -1,6 +1,8 @@ mod message; mod warning; +pub mod home; +pub mod receive; pub mod settings; pub use message::*; @@ -14,7 +16,7 @@ use iced::{ use crate::ui::{ color, component::{button, separation, text::*}, - icon::{home_icon, settings_icon}, + icon::{home_icon, receive_icon, settings_icon}, }; use crate::app::{error::Error, menu::Menu}; @@ -29,6 +31,17 @@ pub fn sidebar(menu: &Menu) -> widget::Container { .on_press(Message::Menu(Menu::Home)) .width(iced::Length::Units(200)) }; + + let receive_button = if *menu == Menu::Receive { + button::primary(Some(receive_icon()), "Receive") + .on_press(Message::Reload) + .width(iced::Length::Units(200)) + } else { + button::transparent(Some(receive_icon()), "Receive") + .on_press(Message::Menu(Menu::Receive)) + .width(iced::Length::Units(200)) + }; + let settings_button = if *menu == Menu::Settings { button::primary(Some(settings_icon()), "Settings") .on_press(Message::Menu(Menu::Settings)) @@ -51,6 +64,7 @@ pub fn sidebar(menu: &Menu) -> widget::Container { .spacing(10), ) .push(home_button) + .push(receive_button) .spacing(20) .height(Length::Fill), ) diff --git a/gui/src/app/view/receive.rs b/gui/src/app/view/receive.rs new file mode 100644 index 00000000..0d931d78 --- /dev/null +++ b/gui/src/app/view/receive.rs @@ -0,0 +1,32 @@ +use iced::{ + pure::{column, row, widget::Button, Element}, + widget::qr_code::{self, QRCode}, + Alignment, +}; + +use crate::ui::{ + component::{button, card, text::*}, + icon, +}; + +use super::message::Message; + +pub fn receive<'a>(address: &'a bitcoin::Address, qr: &'a qr_code::State) -> Element<'a, Message> { + card::simple( + column() + .push(QRCode::new(qr).cell_size(10)) + .push( + row() + .push(text(&address.to_string()).small()) + .push( + Button::new(icon::clipboard_icon()) + .on_press(Message::Clipboard(address.to_string())) + .style(button::Style::TransparentBorder), + ) + .align_items(Alignment::Center), + ) + .align_items(Alignment::Center) + .spacing(20), + ) + .into() +} diff --git a/gui/src/daemon/client/mod.rs b/gui/src/daemon/client/mod.rs index b4eedfd0..4db519a9 100644 --- a/gui/src/daemon/client/mod.rs +++ b/gui/src/daemon/client/mod.rs @@ -62,6 +62,14 @@ impl Daemon for Minisafed { fn get_info(&self) -> Result { self.call("getinfo", Option::::None) } + + fn get_new_address(&self) -> Result { + self.call("getnewaddress", Option::::None) + } + + fn list_coins(&self) -> Result { + self.call("listcoins", Option::::None) + } } #[derive(Debug, Clone, Deserialize, Serialize)] diff --git a/gui/src/daemon/embedded.rs b/gui/src/daemon/embedded.rs index 798d51ae..924c1699 100644 --- a/gui/src/daemon/embedded.rs +++ b/gui/src/daemon/embedded.rs @@ -69,4 +69,26 @@ impl Daemon for EmbeddedDaemon { .control .get_info()) } + + fn get_new_address(&self) -> Result { + Ok(self + .handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .lock() + .unwrap() + .control + .get_new_address()) + } + + fn list_coins(&self) -> Result { + Ok(self + .handle + .as_ref() + .ok_or(DaemonError::NoAnswer)? + .lock() + .unwrap() + .control + .list_coins()) + } } diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 950ec8b8..32e6ab40 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -45,4 +45,8 @@ pub trait Daemon: Debug { fn stop(&mut self) -> Result<(), DaemonError>; fn get_info(&self) -> Result; + + fn get_new_address(&self) -> Result; + + fn list_coins(&self) -> Result; } diff --git a/gui/src/daemon/model.rs b/gui/src/daemon/model.rs index 65dbc0f0..33178c77 100644 --- a/gui/src/daemon/model.rs +++ b/gui/src/daemon/model.rs @@ -1 +1,3 @@ -pub use minisafe::commands::GetInfoResult; +pub use minisafe::commands::{GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult}; + +pub type Coin = ListCoinsEntry; diff --git a/gui/src/lib.rs b/gui/src/lib.rs index 36ca6d86..883f95ce 100644 --- a/gui/src/lib.rs +++ b/gui/src/lib.rs @@ -3,3 +3,4 @@ pub mod daemon; pub mod installer; pub mod loader; pub mod ui; +pub mod utils; diff --git a/gui/src/loader.rs b/gui/src/loader.rs index 5cf275dc..1812a116 100644 --- a/gui/src/loader.rs +++ b/gui/src/loader.rs @@ -39,7 +39,7 @@ enum Step { pub enum Message { Event(iced_native::Event), Syncing(Result), - Synced(GetInfoResult, Arc), + Synced(GetInfoResult, Vec, Arc), Started(Result, Error>), Loaded(Result, Error>), Failure(DaemonError), @@ -118,9 +118,16 @@ impl Loader { Ok(info) => { if (info.sync - 1.0_f64).abs() < f64::EPSILON { let daemon = daemon.clone(); - return Command::perform(async move { (info, daemon) }, |res| { - Message::Synced(res.0, res.1) - }); + return Command::perform( + async move { + let coins = daemon + .list_coins() + .map(|res| res.coins) + .unwrap_or_else(|_| Vec::new()); + (info, coins, daemon) + }, + |res| Message::Synced(res.0, res.1, res.2), + ); } else { *progress = info.sync } diff --git a/gui/src/main.rs b/gui/src/main.rs index e6a7b1ef..349c92b5 100644 --- a/gui/src/main.rs +++ b/gui/src/main.rs @@ -161,9 +161,10 @@ impl Application for GUI { } } (State::Loader(loader), Message::Load(msg)) => { - if let loader::Message::Synced(info, minisafed) = *msg { + if let loader::Message::Synced(info, coins, minisafed) = *msg { let cache = Cache { blockheight: info.blockheight, + coins, }; let (app, command) = App::new(cache, loader.gui_config.clone(), minisafed); diff --git a/gui/src/ui/icon.rs b/gui/src/ui/icon.rs index 497bd5af..a769cb00 100644 --- a/gui/src/ui/icon.rs +++ b/gui/src/ui/icon.rs @@ -50,7 +50,7 @@ pub fn connected_device_icon() -> Text { icon('\u{F350}') } -pub fn deposit_icon() -> Text { +pub fn receive_icon() -> Text { icon('\u{F123}') } diff --git a/gui/src/utils/mock.rs b/gui/src/utils/mock.rs new file mode 100644 index 00000000..05e3c01d --- /dev/null +++ b/gui/src/utils/mock.rs @@ -0,0 +1,93 @@ +use crate::daemon::{client::Client, DaemonError}; +use minisafe::config::Config; +use serde::{de::DeserializeOwned, Serialize}; +use serde_json::{json, Value}; +use std::fmt::Debug; +use std::sync::{ + mpsc::{channel, Receiver, Sender}, + Mutex, +}; +use std::thread; + +#[derive(Debug)] +pub struct DaemonClient { + transport: Mutex<(Sender, Receiver>)>, +} + +impl Client for DaemonClient { + type Error = DaemonError; + fn request( + &self, + method: &str, + params: Option, + ) -> Result { + let req = json!({"method": method, "params": params}); + let connection = self.transport.lock().expect("Failed to unlock"); + connection + .0 + .send(req) + .expect("Mock client failed to send request"); + connection + .1 + .recv() + .expect("Mock client failed to receive response") + .map(|value| serde_json::from_value(value).unwrap()) + } +} + +pub struct Daemon { + requests: Vec<(Option, Result)>, +} + +impl Daemon { + pub fn new(requests: Vec<(Option, Result)>) -> Self { + Self { requests } + } + + pub fn run(self) -> DaemonClient { + let (client_sender, daemon_receiver) = channel(); + let (daemon_sender, client_receiver) = channel(); + + thread::spawn(move || { + let mut requests = self.requests.into_iter(); + while let Ok(msg) = daemon_receiver.recv() { + let request = requests + .next() + .expect("Mock Daemon must have all requests mocked in the right order"); + if let Some(body) = request.0 { + assert_eq!(body, msg); + } + daemon_sender + .send(request.1) + .expect("Mock daemon failed to send response") + } + // close the daemon -> client channel after + // the client -> daemon channel is closed. + // (client -> daemon channel is closed when DaemonClient is dropped) + drop(daemon_sender); + // Readable with `cargo test -- --nocapture` + println!("The daemon has stopped!"); + }); + + DaemonClient { + transport: Mutex::new((client_sender, client_receiver)), + } + } +} + +pub fn fake_daemon_config() -> Config { + toml::from_str( +r#" +data_dir = "/home/edouard/code/revault/demo/minisafe/datadir" +main_descriptor = "wsh(or_d(pk(tpubDCbK3Ysvk8HjcF6mPyrgMu3KgLiaaP19RjKpNezd8GrbAbNg6v5BtWLaCt8FNm6QkLseopKLf5MNYQFtochDTKHdfgG6iqJ8cqnLNAwtXuP/*),and_v(v:pkh(tpubDDtb2WPYwEWw2WWDV7reLV348iJHw2HmhzvPysKKrJw3hYmvrd4jasyoioVPdKGQqjyaBMEvTn1HvHWDSVqQ6amyyxRZ5YjpPBBGjJ8yu8S/*),older(100))))#459t6xxr" + +[bitcoin_config] +network = "regtest" +poll_interval_secs = 30 + +[bitcoind_config] +addr = "127.0.0.1:9001" +cookie_path = "/home/edouard/code/revault/demo/minisafe/regtest/bcdir1/regtest/.cookie" +"# + ).unwrap() +} diff --git a/gui/src/utils/mod.rs b/gui/src/utils/mod.rs new file mode 100644 index 00000000..084f16d9 --- /dev/null +++ b/gui/src/utils/mod.rs @@ -0,0 +1,5 @@ +#[cfg(test)] +pub mod sandbox; + +#[cfg(test)] +pub mod mock; diff --git a/gui/src/utils/sandbox.rs b/gui/src/utils/sandbox.rs new file mode 100644 index 00000000..4c8defd2 --- /dev/null +++ b/gui/src/utils/sandbox.rs @@ -0,0 +1,51 @@ +use std::sync::Arc; + +use iced_native::command::Action; + +use crate::{ + app::{cache::Cache, message::Message, state::State}, + daemon::Daemon, +}; + +pub struct Sandbox { + state: S, +} + +impl Sandbox { + pub fn new(state: S) -> Self { + return Self { state }; + } + + pub fn state(&self) -> &S { + &self.state + } + + pub async fn update( + mut self, + daemon: Arc, + cache: &Cache, + message: Message, + ) -> Self { + let cmd = self.state.update(daemon.clone(), cache, message); + for action in cmd.actions() { + if let Action::Future(f) = action { + let msg = f.await; + let _cmd = self.state.update(daemon.clone(), cache, msg); + } + } + + self + } + + pub async fn load(mut self, daemon: Arc, cache: &Cache) -> Self { + let cmd = self.state.load(daemon.clone()); + for action in cmd.actions() { + if let Action::Future(f) = action { + let msg = f.await; + self = self.update(daemon.clone(), cache, msg).await; + } + } + + self + } +}