Merge #22: gui: Home and Receive panels

7f9fd84beead64ef8fe259f1174e01cbc0dd4cfa Add balance to panel home (edouard)
157d9b39333e9eae1b1fc231bc6abd2f6661bca6 Add coins to cache (edouard)
f32b4d52d2facf409d73b8e27a67d05e13255373 daemon: add list_coins method (edouard)
647b74ebcf789e8df7bf530e83258a3a38286be9 Add unit test for receive panel state (edouard)
ebc239733c3a508f08f5539a862d3029d45c753f Add sandbox and daemon mock to utils mod (edouard)
928294b32e2afa0ae4472817f8bbb1e477b8afdb Add receive panel (edouard)
1026d2b487c3f0d9955b93d1cf1b79a0a8ad94bf Add get_new_address to daemon trait (edouard)
2286a19e20cfde07d4c7eb4bdd3190abea3a09d1 Update minisafe master:#4a802890 (edouard)

Pull request description:

ACKs for top commit:
  edouardparis:
    Self ACK 7f9fd84beead64ef8fe259f1174e01cbc0dd4cfa

Tree-SHA512: b0c0b89540ede0f7f895197595af8b97fa515d5e4b6b7f3dcd4e29b02bfcb0a9ce338f3ee5644e486892ab132c456fca29ad4189a3f71f6420f535f99dbe77ba
This commit is contained in:
edouard 2022-09-07 15:25:02 +02:00
commit f5387f7ed9
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
20 changed files with 432 additions and 29 deletions

2
gui/Cargo.lock generated
View File

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

View File

@ -1,3 +1,15 @@
use crate::daemon::model::Coin;
pub struct Cache {
pub blockheight: i32,
pub coins: Vec<Coin>,
}
impl Default for Cache {
fn default() -> Self {
Self {
blockheight: 0,
coins: Vec::new(),
}
}
}

View File

@ -1,5 +1,6 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Menu {
Home,
Receive,
Settings,
}

View File

@ -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<DaemonConfig>),
DaemonConfigLoaded(Result<(), Error>),
BlockHeight(Result<i32, Error>),
ReceiveAddress(Result<bitcoin::Address, Error>),
Coins(Result<Vec<Coin>, Error>),
}

View File

@ -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<dyn Daemon + Sync + Send>,
) -> (App, Command<Message>) {
let state: Box<dyn State> = Home {}.into();
let state: Box<dyn State> = 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<Message> {
// 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))

View File

@ -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<dyn Daemon + Send + Sync>,
daemon: Arc<dyn Daemon + Sync + Send>,
cache: &Cache,
message: Message,
) -> Command<Message>;
@ -27,20 +26,44 @@ pub trait State {
}
}
pub struct Home {}
pub struct Home {
balance: Amount,
}
impl Home {
pub fn new(coins: &Vec<Coin>) -> 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<dyn Daemon + Send + Sync>,
_daemon: Arc<dyn Daemon + Sync + Send>,
_cache: &Cache,
_message: Message,
) -> Command<Message> {
Command::none()
}
fn load(&self, daemon: Arc<dyn Daemon + Sync + Send>) -> Command<Message> {
let daemon = daemon.clone();
Command::perform(
async move {
daemon
.list_coins()
.map(|res| res.coins)
.map_err(|e| e.into())
},
Message::Coins,
)
}
}
impl From<Home> for Box<dyn State> {
@ -48,3 +71,101 @@ impl From<Home> for Box<dyn State> {
Box::new(s)
}
}
#[derive(Default)]
pub struct ReceivePanel {
address: Option<bitcoin::Address>,
qr_code: Option<qr_code::State>,
warning: Option<Error>,
}
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<dyn Daemon + Sync + Send>,
_cache: &Cache,
message: Message,
) -> Command<Message> {
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<dyn Daemon + Sync + Send>) -> Command<Message> {
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<ReceivePanel> for Box<dyn State> {
fn from(s: ReceivePanel) -> Box<dyn State> {
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::<Request>::None})),
Ok(json!(GetAddressResult {
address: addr.clone()
})),
)]);
let sandbox: Sandbox<ReceivePanel> = 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));
}
}

17
gui/src/app/view/home.rs Normal file
View File

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

View File

@ -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<Message> {
.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<Message> {
.spacing(10),
)
.push(home_button)
.push(receive_button)
.spacing(20)
.height(Length::Fill),
)

View File

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

View File

@ -62,6 +62,14 @@ impl<C: Client + Debug> Daemon for Minisafed<C> {
fn get_info(&self) -> Result<GetInfoResult, DaemonError> {
self.call("getinfo", Option::<Request>::None)
}
fn get_new_address(&self) -> Result<GetAddressResult, DaemonError> {
self.call("getnewaddress", Option::<Request>::None)
}
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
self.call("listcoins", Option::<Request>::None)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@ -69,4 +69,26 @@ impl Daemon for EmbeddedDaemon {
.control
.get_info())
}
fn get_new_address(&self) -> Result<GetAddressResult, DaemonError> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.get_new_address())
}
fn list_coins(&self) -> Result<ListCoinsResult, DaemonError> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.list_coins())
}
}

View File

@ -45,4 +45,8 @@ pub trait Daemon: Debug {
fn stop(&mut self) -> Result<(), DaemonError>;
fn get_info(&self) -> Result<model::GetInfoResult, DaemonError>;
fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
fn list_coins(&self) -> Result<model::ListCoinsResult, DaemonError>;
}

View File

@ -1 +1,3 @@
pub use minisafe::commands::GetInfoResult;
pub use minisafe::commands::{GetAddressResult, GetInfoResult, ListCoinsEntry, ListCoinsResult};
pub type Coin = ListCoinsEntry;

View File

@ -3,3 +3,4 @@ pub mod daemon;
pub mod installer;
pub mod loader;
pub mod ui;
pub mod utils;

View File

@ -39,7 +39,7 @@ enum Step {
pub enum Message {
Event(iced_native::Event),
Syncing(Result<GetInfoResult, DaemonError>),
Synced(GetInfoResult, Arc<dyn Daemon + Sync + Send>),
Synced(GetInfoResult, Vec<Coin>, Arc<dyn Daemon + Sync + Send>),
Started(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Loaded(Result<Arc<dyn Daemon + Sync + Send>, 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
}

View File

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

View File

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

93
gui/src/utils/mock.rs Normal file
View File

@ -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<Value>, Receiver<Result<Value, DaemonError>>)>,
}
impl Client for DaemonClient {
type Error = DaemonError;
fn request<S: Serialize + Debug, D: DeserializeOwned + Debug>(
&self,
method: &str,
params: Option<S>,
) -> Result<D, Self::Error> {
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<Value>, Result<Value, DaemonError>)>,
}
impl Daemon {
pub fn new(requests: Vec<(Option<Value>, Result<Value, DaemonError>)>) -> 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()
}

5
gui/src/utils/mod.rs Normal file
View File

@ -0,0 +1,5 @@
#[cfg(test)]
pub mod sandbox;
#[cfg(test)]
pub mod mock;

51
gui/src/utils/sandbox.rs Normal file
View File

@ -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<S: State> {
state: S,
}
impl<S: State + Send + 'static> Sandbox<S> {
pub fn new(state: S) -> Self {
return Self { state };
}
pub fn state(&self) -> &S {
&self.state
}
pub async fn update(
mut self,
daemon: Arc<dyn Daemon + Sync + Send>,
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<dyn Daemon + Sync + Send>, 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
}
}