gui: installer and loader

This commit is contained in:
edouard 2022-07-21 15:21:41 +02:00
parent b548451292
commit 0b24104b45
51 changed files with 6531 additions and 0 deletions

1
.gitignore vendored
View File

@ -7,3 +7,4 @@ regtest/
venv/
pytest.log
TODO
**/target

2977
gui/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

41
gui/Cargo.toml Normal file
View File

@ -0,0 +1,41 @@
[package]
name = "minisafe-gui"
version = "0.0.1"
readme = "README.md"
description = "Minisafe GUI"
repository = "https://github.com/revault/minisafe"
license = "BSD-3-Clause"
authors = ["Edouard Paris <m@edouard.paris>", "Daniela Brozzoni <danielabrozzoni@protonmail.com>"]
edition = "2018"
resolver = "2"
[[bin]]
name = "minisafe-gui"
path = "src/main.rs"
[dependencies]
bitcoin = { version = "0.27", features = ["base64", "use-serde"] }
minisafe = { git = "https://github.com/revault/minisafe", branch = "master", default-features = false}
backtrace = "0.3"
iced = { version = "0.4", default-features= false, features = ["tokio", "wgpu", "svg", "qr_code", "pure"] }
iced_native = "0.5"
tokio = {version = "1.9.0", features = ["signal"]}
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Logging stuff
log = "0.4"
fern = "0.6"
dirs = "3.0.1"
toml = "0.5"
chrono = "0.4"
[target.'cfg(windows)'.dependencies]
uds_windows = "0.1.5"
[dev-dependencies]
tokio = {version = "1.9.0", features = ["rt", "macros"]}

View File

@ -0,0 +1,38 @@
# minisafe-gui
Revault GUI is an user graphical interface written in rust for the
[Minisafe daemon](https://github.com/revault/minisafe).
## Dependencies
- `fontconfig` (On Debian/Ubuntu `apt install libfontconfig1-dev`)
- [`pkg-config`](https://www.freedesktop.org/wiki/Software/pkg-config/) (On Debian/Ubuntu `apt install pkg-config`)
- [`libxkbcommon`](https://xkbcommon.org/) for the dummy signer (On Debian/Ubuntu `apt install libxkbcommon-dev`)
- Vulkan drivers (On Debian/Ubuntu `apt install mesa-vulkan-drivers libvulkan-dev`)
- `libudev-dev` (On Debian/Ubuntu `apt install libudev-dev`)
We are striving to remove dependencies, especially the 3D ones.
## Usage
`minisafe-gui --datadir <datadir> --<network>`
The default `datadir` is the default `minisafed` `datadir` (`~/.minisafe`
for linux) and the default `network` is the bitcoin mainnet.
If no argument is provided, the GUI checks in the default `datadir`
the configuration file for the bitcoin mainnet.
If the provided `datadir` is empty or does not have the configuration
file for the targeted `network`, the GUI starts with the installer mode.
Instead of using `--datadir` and `--<network>`, a direct path to
the GUI configuration file can be provided with `--conf`.
After start up, The GUI will connect to the running minisafed.
A command starting minisafed is launched if no connection is made.
## Troubleshooting
- If you encounter layout issue on `X11`, try to start the GUI with
`WINIT_X11_SCALE_FACTOR` manually set to 1

92
gui/src/app/config.rs Normal file
View File

@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
/// Path to minisafed configuration file.
pub minisafed_config_path: PathBuf,
/// log level, can be "info", "debug", "trace".
pub log_level: Option<String>,
/// Use iced debug feature if true.
pub debug: Option<bool>,
}
pub const DEFAULT_FILE_NAME: &str = "gui.toml";
impl Config {
pub fn new(minisafed_config_path: PathBuf) -> Self {
Self {
minisafed_config_path,
log_level: None,
debug: None,
}
}
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let config = std::fs::read(path)
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => ConfigError::NotFound,
_ => ConfigError::ReadingFile(format!("Reading configuration file: {}", e)),
})
.and_then(|file_content| {
toml::from_slice::<Config>(&file_content).map_err(|e| {
ConfigError::ReadingFile(format!("Parsing configuration file: {}", e))
})
})?;
Ok(config)
}
pub fn default_path() -> Result<PathBuf, ConfigError> {
let mut datadir = default_datadir().map_err(|_| {
ConfigError::Unexpected("Could not locate the default datadir directory.".to_owned())
})?;
datadir.push(DEFAULT_FILE_NAME);
Ok(datadir)
}
}
#[derive(PartialEq, Eq, Debug, Clone)]
pub enum ConfigError {
NotFound,
ReadingFile(String),
Unexpected(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::NotFound => write!(f, "Config file not found"),
Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e),
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}
impl std::error::Error for ConfigError {}
// Get the absolute path to the minisafe configuration folder.
///
/// This a "minisafe" directory in the XDG standard configuration directory for all OSes but
/// Linux-based ones, for which it's `~/.minisafe`.
/// Rationale: we want to have the database, RPC socket, etc.. in the same folder as the
/// configuration file but for Linux the XDG specify a data directory (`~/.local/share/`) different
/// from the configuration one (`~/.config/`).
pub fn default_datadir() -> Result<PathBuf, Box<dyn std::error::Error>> {
#[cfg(target_os = "linux")]
let configs_dir = dirs::home_dir();
#[cfg(not(target_os = "linux"))]
let configs_dir = dirs::config_dir();
if let Some(mut path) = configs_dir {
#[cfg(target_os = "linux")]
path.push(".minisafe");
#[cfg(not(target_os = "linux"))]
path.push("Minisafe");
return Ok(path);
}
Err("Failed to get default data directory".into())
}

75
gui/src/app/context.rs Normal file
View File

@ -0,0 +1,75 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::sync::Arc;
use minisafe::config::Config as DaemonConfig;
use crate::{
app::{config, error::Error, menu::Menu},
conversion::Converter,
daemon::Daemon,
};
/// Context is an object passing general information
/// and service clients through the application components.
pub struct Context {
pub config: ConfigContext,
pub blockheight: i32,
pub daemon: Arc<dyn Daemon + Sync + Send>,
pub converter: Converter,
pub menu: Menu,
pub managers_threshold: usize,
}
impl Context {
pub fn new(
config: ConfigContext,
daemon: Arc<dyn Daemon + Sync + Send>,
converter: Converter,
menu: Menu,
) -> Self {
Self {
config,
blockheight: 0,
daemon,
converter,
menu,
managers_threshold: 0,
}
}
pub fn network(&self) -> bitcoin::Network {
self.config.daemon.bitcoin_config.network
}
pub fn load_daemon_config(&mut self, cfg: DaemonConfig) -> Result<(), Error> {
loop {
if let Some(daemon) = Arc::get_mut(&mut self.daemon) {
daemon.load_config(cfg)?;
break;
}
}
let mut daemon_config_file = OpenOptions::new()
.write(true)
.open(&self.config.gui.minisafed_config_path)
.map_err(|e| Error::Config(e.to_string()))?;
let content =
toml::to_string(&self.config.daemon).map_err(|e| Error::Config(e.to_string()))?;
daemon_config_file
.write_all(content.as_bytes())
.map_err(|e| {
log::warn!("failed to write to file: {:?}", e);
Error::Config(e.to_string())
})?;
Ok(())
}
}
pub struct ConfigContext {
pub daemon: DaemonConfig,
pub gui: config::Config,
}

52
gui/src/app/error.rs Normal file
View File

@ -0,0 +1,52 @@
use crate::daemon::DaemonError;
use minisafe::config::ConfigError;
use std::convert::From;
use std::io::ErrorKind;
#[derive(Debug, Clone)]
pub enum Error {
Config(String),
Daemon(DaemonError),
Unexpected(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Config(e) => write!(f, "{}", e),
Self::Daemon(e) => match e {
DaemonError::Unexpected(e) => write!(f, "{}", e),
DaemonError::NoAnswer => write!(f, "Daemon did not answer"),
DaemonError::Transport(Some(ErrorKind::ConnectionRefused), _) => {
write!(f, "Failed to connect to daemon")
}
DaemonError::Transport(kind, e) => {
if let Some(k) = kind {
write!(f, "{} [{:?}]", e, k)
} else {
write!(f, "{}", e)
}
}
DaemonError::Start(e) => {
write!(f, "Failed to start daemon: {}", e)
}
DaemonError::Rpc(code, e) => {
write!(f, "[{:?}] {}", code, e)
}
},
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
}
}
}
impl From<ConfigError> for Error {
fn from(error: ConfigError) -> Self {
Error::Config(error.to_string())
}
}
impl From<DaemonError> for Error {
fn from(error: DaemonError) -> Self {
Error::Daemon(error)
}
}

4
gui/src/app/menu.rs Normal file
View File

@ -0,0 +1,4 @@
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Menu {
Home,
}

14
gui/src/app/message.rs Normal file
View File

@ -0,0 +1,14 @@
use minisafe::config::Config as DaemonConfig;
use crate::app::{error::Error, menu::Menu};
#[derive(Debug, Clone)]
pub enum Message {
Reload,
Tick,
Event(iced_native::Event),
Clipboard(String),
Menu(Menu),
LoadDaemonConfig(Box<DaemonConfig>),
DaemonConfigLoaded(Result<(), Error>),
}

96
gui/src/app/mod.rs Normal file
View File

@ -0,0 +1,96 @@
pub mod config;
pub mod context;
pub mod menu;
pub mod message;
pub mod state;
mod error;
use std::sync::Arc;
use std::time::Duration;
use iced::pure::Element;
use iced::{clipboard, time, Command, Subscription};
use iced_native::{window, Event};
pub use config::Config;
pub use message::Message;
use state::{Home, State};
use crate::app::context::Context;
pub struct App {
should_exit: bool,
state: Box<dyn State>,
context: Context,
}
pub fn new_state(_context: &Context) -> Box<dyn State> {
Home {}.into()
}
impl App {
pub fn new(context: Context) -> (App, Command<Message>) {
let state = new_state(&context);
let cmd = state.load(&context);
(
Self {
should_exit: false,
state,
context,
},
cmd,
)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
iced_native::subscription::events().map(Message::Event),
time::every(Duration::from_secs(30)).map(|_| Message::Tick),
self.state.subscription(),
])
}
pub fn should_exit(&self) -> bool {
self.should_exit
}
pub fn stop(&mut self) {
log::info!("Close requested");
if !self.context.daemon.is_external() {
log::info!("Stopping internal daemon...");
if let Some(d) = Arc::get_mut(&mut self.context.daemon) {
d.stop().expect("Daemon is internal");
log::info!("Internal daemon stopped");
self.should_exit = true;
}
} else {
self.should_exit = true;
}
}
pub fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::LoadDaemonConfig(cfg) => {
let res = self.context.load_daemon_config(*cfg);
self.update(Message::DaemonConfigLoaded(res))
}
Message::Menu(menu) => {
self.context.menu = menu;
self.state = new_state(&self.context);
self.state.load(&self.context)
}
Message::Clipboard(text) => clipboard::write(text),
Message::Event(Event::Window(window::Event::CloseRequested)) => {
self.stop();
Command::none()
}
_ => self.state.update(&self.context, message),
}
}
pub fn view(&self) -> Element<Message> {
self.state.view(&self.context)
}
}

32
gui/src/app/state.rs Normal file
View File

@ -0,0 +1,32 @@
use iced::pure::{column, Element};
use iced::{Command, Subscription};
use super::{context::Context, message::Message};
pub trait State {
fn view(&self, ctx: &Context) -> Element<Message>;
fn update(&mut self, ctx: &Context, message: Message) -> Command<Message>;
fn subscription(&self) -> Subscription<Message> {
Subscription::none()
}
fn load(&self, _ctx: &Context) -> Command<Message> {
Command::none()
}
}
pub struct Home {}
impl State for Home {
fn view(&self, _ctx: &Context) -> Element<Message> {
column().into()
}
fn update(&mut self, _ctx: &Context, _message: Message) -> Command<Message> {
Command::none()
}
}
impl From<Home> for Box<dyn State> {
fn from(s: Home) -> Box<dyn State> {
Box::new(s)
}
}

43
gui/src/conversion.rs Normal file
View File

@ -0,0 +1,43 @@
use bitcoin::Network;
/// Converter purpose is to give a Conversion from a given amount in satoshis according to its
/// parameters.
pub struct Converter {
pub unit: Unit,
}
impl Converter {
pub fn new(bitcoin_network: Network) -> Self {
let unit = match bitcoin_network {
Network::Testnet => Unit::TestnetBitcoin,
Network::Bitcoin => Unit::Bitcoin,
Network::Regtest => Unit::RegtestBitcoin,
Network::Signet => Unit::SignetBitcoin,
};
Self { unit }
}
/// converts amount in satoshis to BTC float.
pub fn converts(&self, amount: bitcoin::Amount) -> String {
format!("{:.8}", amount.as_btc())
}
}
/// Unit is the bitcoin ticker according to the network used.
pub enum Unit {
TestnetBitcoin,
RegtestBitcoin,
SignetBitcoin,
Bitcoin,
}
impl std::fmt::Display for Unit {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::TestnetBitcoin => write!(f, "tBTC"),
Self::RegtestBitcoin => write!(f, "rBTC"),
Self::SignetBitcoin => write!(f, "sBTC"),
Self::Bitcoin => write!(f, "BTC"),
}
}
}

View File

@ -0,0 +1,104 @@
// Rust JSON-RPC Library
// Written in 2015 by
// Andrew Poelstra <apoelstra@wpsoftware.net>
//
// To the extent possible under law, the author(s) have dedicated all
// copyright and related and neighboring rights to this software to
// the public domain worldwide. This software is distributed without
// any warranty.
//
// You should have received a copy of the CC0 Public Domain Dedication
// along with this software.
// If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
//
//! Error handling
//!
//! Some useful methods for creating Error objects
//!
use std::io;
use std::{error, fmt};
use serde::{Deserialize, Serialize};
#[allow(dead_code)]
#[derive(Debug)]
#[allow(non_camel_case_types)]
pub enum RpcErrorCode {
// Standard errors defined by JSON-RPC 2.0 standard
/// Invalid request
JSONRPC2_INVALID_REQUEST = -32600,
/// Method not found
JSONRPC2_METHOD_NOT_FOUND = -32601,
/// Invalid parameters
JSONRPC2_INVALID_PARAMS = -32602,
}
/// A library error
#[derive(Debug)]
pub enum Error {
/// Json error
Json(serde_json::Error),
/// IO Error
Io(io::Error),
/// Error response
Rpc(RpcError),
/// Response has neither error nor result
NoErrorOrResult,
/// Response to a request did not have the expected nonce
NonceMismatch,
/// Response to a request had a jsonrpc field other than "2.0"
VersionMismatch,
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Error {
Error::Json(e)
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Error {
Error::Io(e)
}
}
impl From<RpcError> for Error {
fn from(e: RpcError) -> Error {
Error::Rpc(e)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Error::Json(ref e) => write!(f, "JSON decode error: {}", e),
Error::Io(ref e) => write!(f, "IO error response: {}", e),
Error::Rpc(ref r) => write!(f, "RPC error response: {:?}", r),
Error::NoErrorOrResult => write!(f, "Malformed RPC response"),
Error::NonceMismatch => write!(f, "Nonce of response did not match nonce of request"),
Error::VersionMismatch => write!(f, "`jsonrpc` field set to non-\"2.0\""),
}
}
}
impl error::Error for Error {
fn cause(&self) -> Option<&dyn error::Error> {
match *self {
Error::Json(ref e) => Some(e),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
/// A JSONRPC error object
pub struct RpcError {
/// The integer identifier of the error
pub code: i32,
/// A string describing the error
pub message: String,
/// Additional data specific to the error
pub data: Option<serde_json::Value>,
}

View File

@ -0,0 +1,255 @@
// Rust JSON-RPC Library
// Written by
// Andrew Poelstra <apoelstra@wpsoftware.net>
// Wladimir J. van der Laan <laanwj@gmail.com>
//
// To the extent possible under law, the author(s) have dedicated all
// copyright and related and neighboring rights to this software to
// the public domain worldwide. This software is distributed without
// any warranty.
//
// You should have received a copy of the CC0 Public Domain Dedication
// along with this software.
// If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
//
//! Client support
//!
//! Support for connecting to JSONRPC servers over UNIX socets, sending requests,
//! and parsing responses
//!
#[cfg(windows)]
use uds_windows::UnixStream;
#[cfg(not(windows))]
use std::os::unix::net::UnixStream;
use std::fmt::Debug;
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::{error, fmt, io};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::{to_writer, Deserializer};
use log::debug;
/// A handle to a remote JSONRPC server
#[derive(Debug, Clone)]
pub struct JsonRPCClient {
sockpath: PathBuf,
timeout: Option<Duration>,
}
impl super::Client for JsonRPCClient {
type Error = Error;
fn request<S: Serialize + Debug, D: DeserializeOwned + Debug>(
&self,
method: &str,
params: Option<S>,
) -> Result<D, Self::Error> {
self.send_request(method, params)
.and_then(|res| res.into_result())
}
}
impl JsonRPCClient {
/// Creates a new client
pub fn new<P: AsRef<Path>>(sockpath: P) -> JsonRPCClient {
JsonRPCClient {
sockpath: sockpath.as_ref().to_path_buf(),
timeout: None,
}
}
/// Set an optional timeout for requests
#[allow(dead_code)]
pub fn set_timeout(&mut self, timeout: Option<Duration>) {
self.timeout = timeout;
}
/// Sends a request to a client
pub fn send_request<S: Serialize + Debug, D: DeserializeOwned + Debug>(
&self,
method: &str,
params: Option<S>,
) -> Result<Response<D>, Error> {
// Setup connection
let mut stream = UnixStream::connect(&self.sockpath)?;
stream.set_read_timeout(self.timeout)?;
stream.set_write_timeout(self.timeout)?;
let request = Request {
method,
params,
id: std::process::id(),
jsonrpc: "2.0",
};
debug!("Sending to minisafed: {:#?}", request);
to_writer(&mut stream, &request)?;
let response: Response<D> = Deserializer::from_reader(&mut stream)
.into_iter()
.next()
.map_or(Err(Error::NoErrorOrResult), |res| Ok(res?))?;
if response
.jsonrpc
.as_ref()
.map_or(false, |version| version != "2.0")
{
return Err(Error::VersionMismatch);
}
if response.id != request.id {
return Err(Error::NonceMismatch);
}
debug!("Received from minisafed: {:#?}", response);
Ok(response)
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
/// A JSONRPC request object
pub struct Request<'f, T: Serialize> {
/// The name of the RPC call
pub method: &'f str,
/// Parameters to the RPC call
pub params: Option<T>,
/// Identifier for this Request, which should appear in the response
pub id: u32,
/// jsonrpc field, MUST be "2.0"
pub jsonrpc: &'f str,
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
/// A JSONRPC response object
pub struct Response<T> {
/// A result if there is one, or null
pub result: Option<T>,
/// An error if there is one, or null
pub error: Option<RpcError>,
/// Identifier for this Request, which should match that of the request
pub id: u32,
/// jsonrpc field, MUST be "2.0"
pub jsonrpc: Option<String>,
}
impl<T> Response<T> {
/// Extract the result from a response, consuming the response
pub fn into_result(self) -> Result<T, Error> {
if let Some(e) = self.error {
return Err(Error::Rpc(e));
}
self.result.ok_or(Error::NoErrorOrResult)
}
/// Returns whether or not the `result` field is empty
#[allow(dead_code)]
pub fn is_none(&self) -> bool {
self.result.is_none()
}
}
#[allow(dead_code)]
#[derive(Debug)]
#[allow(non_camel_case_types)]
pub enum RpcErrorCode {
// Standard errors defined by JSON-RPC 2.0 standard
/// Invalid request
JSONRPC2_INVALID_REQUEST = -32600,
/// Method not found
JSONRPC2_METHOD_NOT_FOUND = -32601,
/// Invalid parameters
JSONRPC2_INVALID_PARAMS = -32602,
}
/// A library error
#[derive(Debug)]
pub enum Error {
/// Json error
Json(serde_json::Error),
/// IO Error
Io(io::Error),
/// Error response
Rpc(RpcError),
/// Response has neither error nor result
NoErrorOrResult,
/// Response to a request did not have the expected nonce
NonceMismatch,
/// Response to a request had a jsonrpc field other than "2.0"
VersionMismatch,
}
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Error {
Error::Json(e)
}
}
impl From<io::Error> for Error {
fn from(e: io::Error) -> Error {
Error::Io(e)
}
}
impl From<RpcError> for Error {
fn from(e: RpcError) -> Error {
Error::Rpc(e)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Error::Json(ref e) => write!(f, "JSON decode error: {}", e),
Error::Io(ref e) => write!(f, "IO error response: {}", e),
Error::Rpc(ref r) => write!(f, "RPC error response: {:?}", r),
Error::NoErrorOrResult => write!(f, "Malformed RPC response"),
Error::NonceMismatch => write!(f, "Nonce of response did not match nonce of request"),
Error::VersionMismatch => write!(f, "`jsonrpc` field set to non-\"2.0\""),
}
}
}
impl error::Error for Error {
fn cause(&self) -> Option<&dyn error::Error> {
match *self {
Error::Json(ref e) => Some(e),
_ => None,
}
}
}
impl From<Error> for super::DaemonError {
fn from(e: Error) -> super::DaemonError {
match e {
Error::Io(e) => super::DaemonError::Transport(Some(e.kind()), format!("io: {:?}", e)),
Error::Json(e) => super::DaemonError::Transport(None, format!("json decode: {}", e)),
Error::NonceMismatch => {
super::DaemonError::Transport(None, format!("transport: {}", e))
}
Error::VersionMismatch => {
super::DaemonError::Transport(None, format!("transport: {}", e))
}
Error::NoErrorOrResult => super::DaemonError::NoAnswer,
Error::Rpc(e) => super::DaemonError::Rpc(e.code, e.message),
}
}
}
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)]
/// A JSONRPC error object
pub struct RpcError {
/// The integer identifier of the error
pub code: i32,
/// A string describing the error
pub message: String,
/// Additional data specific to the error
pub data: Option<serde_json::Value>,
}

View File

@ -0,0 +1,61 @@
use std::fmt::Debug;
use log::{error, info};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
pub mod error;
pub mod jsonrpc;
use super::{model::*, Daemon, DaemonError};
pub trait Client {
type Error: Into<DaemonError> + Debug;
fn request<S: Serialize + Debug, D: DeserializeOwned + Debug>(
&self,
method: &str,
params: Option<S>,
) -> Result<D, Self::Error>;
}
#[derive(Debug, Clone)]
pub struct Minisafed<C: Client> {
client: C,
}
impl<C: Client> Minisafed<C> {
pub fn new(client: C) -> Minisafed<C> {
Minisafed { client }
}
/// Generic call function for RPC calls.
fn call<T: Serialize + Debug, U: DeserializeOwned + Debug>(
&self,
method: &str,
input: Option<T>,
) -> Result<U, DaemonError> {
info!("{}", method);
self.client.request(method, input).map_err(|e| {
error!("method {} failed: {:?}", method, e);
e.into()
})
}
}
impl<C: Client + Debug> Daemon for Minisafed<C> {
fn is_external(&self) -> bool {
true
}
fn stop(&mut self) -> Result<(), DaemonError> {
let _res: serde_json::value::Value = self.call("stop", Option::<Request>::None)?;
Ok(())
}
fn get_info(&self) -> Result<GetInfoResult, DaemonError> {
self.call("getinfo", Option::<Request>::None)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Request {}

View File

@ -0,0 +1,61 @@
use std::sync::Mutex;
use super::{model::*, Daemon, DaemonError};
use minisafe::{config::Config, DaemonHandle};
#[derive(Default)]
pub struct EmbeddedDaemon {
handle: Option<Mutex<DaemonHandle>>,
}
impl EmbeddedDaemon {
pub fn start(&mut self, config: Config) -> Result<(), DaemonError> {
let handle =
DaemonHandle::start_default(config).map_err(|e| DaemonError::Start(e.to_string()))?;
self.handle = Some(Mutex::new(handle));
Ok(())
}
}
impl std::fmt::Debug for EmbeddedDaemon {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DaemonHandle").finish()
}
}
impl Daemon for EmbeddedDaemon {
fn is_external(&self) -> bool {
false
}
fn load_config(&mut self, cfg: Config) -> Result<(), DaemonError> {
if self.handle.is_none() {
return Ok(());
}
let next =
DaemonHandle::start_default(cfg).map_err(|e| DaemonError::Start(e.to_string()))?;
self.handle.take().unwrap().into_inner().unwrap().shutdown();
self.handle = Some(Mutex::new(next));
Ok(())
}
fn stop(&mut self) -> Result<(), DaemonError> {
if let Some(h) = self.handle.take() {
let handle = h.into_inner().unwrap();
handle.shutdown();
}
Ok(())
}
fn get_info(&self) -> Result<GetInfoResult, DaemonError> {
Ok(self
.handle
.as_ref()
.ok_or(DaemonError::NoAnswer)?
.lock()
.unwrap()
.control
.get_info())
}
}

46
gui/src/daemon/mod.rs Normal file
View File

@ -0,0 +1,46 @@
pub mod client;
pub mod embedded;
pub mod model;
use std::fmt::Debug;
use std::io::ErrorKind;
use minisafe::config::Config;
#[derive(Debug, Clone)]
pub enum DaemonError {
/// Something was wrong with the request.
Rpc(i32, String),
/// Something was wrong with the communication.
Transport(Option<ErrorKind>, String),
/// Something unexpected happened.
Unexpected(String),
/// No response.
NoAnswer,
// Error at start up.
Start(String),
}
impl std::fmt::Display for DaemonError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Rpc(code, e) => write!(f, "Daemon error rpc call: [{:?}] {}", code, e),
Self::NoAnswer => write!(f, "Daemon returned no answer"),
Self::Transport(kind, e) => write!(f, "Daemon transport error: [{:?}] {}", kind, e),
Self::Unexpected(e) => write!(f, "Daemon unexpected error: {}", e),
Self::Start(e) => write!(f, "Daemon did not start: {}", e),
}
}
}
pub trait Daemon: Debug {
fn is_external(&self) -> bool;
fn load_config(&mut self, _cfg: Config) -> Result<(), DaemonError> {
Ok(())
}
fn stop(&mut self) -> Result<(), DaemonError>;
fn get_info(&self) -> Result<model::GetInfoResult, DaemonError>;
}

1
gui/src/daemon/model.rs Normal file
View File

@ -0,0 +1 @@
pub use minisafe::commands::GetInfoResult;

View File

@ -0,0 +1,80 @@
use std::convert::TryFrom;
use bitcoin::Network;
use minisafe::{
config::{BitcoinConfig, BitcoindConfig, Config as MinisafeConfig},
miniscript::{Descriptor, DescriptorPublicKey},
};
use serde::Serialize;
use std::{net::SocketAddr, path::PathBuf, time::Duration};
/// Static informations we require to operate
/// fields with default values are not present, see minisafe::config.
#[derive(Debug, Clone, Serialize)]
pub struct Config {
#[serde(serialize_with = "serialize_option_to_string")]
pub main_descriptor: Option<Descriptor<DescriptorPublicKey>>,
pub bitcoin_config: BitcoinConfig,
/// Everything we need to know to talk to bitcoind
pub bitcoind_config: BitcoindConfig,
/// An optional custom data directory
pub data_dir: Option<PathBuf>,
}
impl Config {
pub const DEFAULT_FILE_NAME: &'static str = "daemon.toml";
/// returns a minisafed config with empty or dummy values
pub fn new() -> Config {
Self {
main_descriptor: None,
bitcoin_config: BitcoinConfig {
network: Network::Bitcoin,
poll_interval_secs: Duration::from_secs(30),
},
bitcoind_config: BitcoindConfig {
cookie_path: PathBuf::new(),
addr: SocketAddr::new(
std::net::IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)),
8080,
),
},
data_dir: None,
}
}
}
impl Default for Config {
fn default() -> Self {
Self::new()
}
}
pub fn serialize_option_to_string<T: std::fmt::Display, S: serde::Serializer>(
field: &Option<T>,
s: S,
) -> Result<S::Ok, S::Error> {
match field {
Some(field) => s.serialize_str(&field.to_string()),
None => s.serialize_none(),
}
}
impl TryFrom<Config> for MinisafeConfig {
type Error = &'static str;
fn try_from(cfg: Config) -> Result<Self, Self::Error> {
if cfg.main_descriptor.is_none() {
return Err("config does not have a main Descriptor");
}
Ok(MinisafeConfig {
#[cfg(unix)]
daemon: false,
log_level: log::LevelFilter::Info,
main_descriptor: cfg.main_descriptor.unwrap(),
data_dir: cfg.data_dir,
bitcoin_config: cfg.bitcoin_config,
bitcoind_config: Some(cfg.bitcoind_config),
})
}
}

View File

@ -0,0 +1,30 @@
use std::path::PathBuf;
use super::Error;
#[derive(Debug, Clone)]
pub enum Message {
Event(iced_native::Event),
Exit(PathBuf),
Next,
Previous,
Install,
Installed(Result<PathBuf, Error>),
Network(bitcoin::Network),
DefineBitcoind(DefineBitcoind),
DefineDescriptor(DefineDescriptor),
}
#[derive(Debug, Clone)]
pub enum DefineBitcoind {
CookiePathEdited(String),
AddressEdited(String),
}
#[derive(Debug, Clone)]
pub enum DefineDescriptor {
ImportDescriptor(String),
UserXpubEdited(String),
HeirXpubEdited(String),
SequenceEdited(String),
}

209
gui/src/installer/mod.rs Normal file
View File

@ -0,0 +1,209 @@
mod config;
mod message;
mod step;
mod view;
use iced::pure::Element;
use iced::{Command, Subscription};
use iced_native::{window, Event};
use std::convert::TryInto;
use std::io::Write;
use std::path::PathBuf;
use crate::{app::config as gui_config, installer::config::Config as DaemonConfig};
pub use message::Message;
use step::{Context, DefineBitcoind, DefineDescriptor, Final, Step, Welcome};
pub struct Installer {
should_exit: bool,
current: usize,
steps: Vec<Box<dyn Step>>,
/// Context is data passed through each step.
context: Context,
config: DaemonConfig,
}
impl Installer {
fn next(&mut self) {
if self.current < self.steps.len() - 1 {
self.current += 1;
}
}
fn previous(&mut self) {
if self.current > 0 {
self.current -= 1;
}
}
pub fn new(
destination_path: PathBuf,
network: bitcoin::Network,
) -> (Installer, Command<Message>) {
let mut config = DaemonConfig::new();
config.data_dir = Some(destination_path);
(
Installer {
should_exit: false,
config,
current: 0,
steps: vec![
Welcome::new(network).into(),
DefineDescriptor::new().into(),
DefineBitcoind::new().into(),
Final::new().into(),
],
context: Context::new(network),
},
Command::none(),
)
}
pub fn subscription(&self) -> Subscription<Message> {
iced_native::subscription::events().map(Message::Event)
}
pub fn should_exit(&self) -> bool {
self.should_exit
}
pub fn stop(&mut self) {
self.should_exit = true;
}
pub fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Next => {
let current_step = self
.steps
.get_mut(self.current)
.expect("There is always a step");
if current_step.apply(&mut self.context, &mut self.config) {
self.next();
// skip the step according to the current context.
while self
.steps
.get(self.current)
.expect("There is always a step")
.skip(&self.context)
{
self.next();
}
// calculate new current_step.
let current_step = self
.steps
.get_mut(self.current)
.expect("There is always a step");
current_step.load_context(&self.context);
}
}
Message::Previous => {
self.previous();
}
Message::Install => {
self.steps
.get_mut(self.current)
.expect("There is always a step")
.update(message);
return Command::perform(
install(self.context.clone(), self.config.clone()),
Message::Installed,
);
}
Message::Event(Event::Window(window::Event::CloseRequested)) => {
self.stop();
return Command::none();
}
_ => {
self.steps
.get_mut(self.current)
.expect("There is always a step")
.update(message);
}
};
Command::none()
}
pub fn view(&self) -> Element<Message> {
self.steps
.get(self.current)
.expect("There is always a step")
.view()
}
}
pub async fn install(_ctx: Context, mut cfg: DaemonConfig) -> Result<PathBuf, Error> {
// Start Daemon to check correctness of installation
let daemon =
minisafe::DaemonHandle::start_default(cfg.clone().try_into().unwrap()).map_err(|e| {
Error::Unexpected(format!("Failed to start daemon with entered config: {}", e))
})?;
daemon.shutdown();
cfg.data_dir =
Some(cfg.data_dir.unwrap().canonicalize().map_err(|e| {
Error::Unexpected(format!("Failed to canonicalize datadir path: {}", e))
})?);
let mut datadir_path = cfg.data_dir.clone().unwrap();
datadir_path.push(cfg.bitcoin_config.network.to_string());
// create minisafed configuration file
let mut minisafed_config_path = datadir_path.clone();
minisafed_config_path.push(DaemonConfig::DEFAULT_FILE_NAME);
let mut minisafed_config_file = std::fs::File::create(&minisafed_config_path)
.map_err(|e| Error::CannotCreateFile(e.to_string()))?;
// Step needed because of ValueAfterTable error in the toml serialize implementation.
let minisafed_config =
toml::Value::try_from(&cfg).expect("daemon::Config has a proper Serialize implementation");
minisafed_config_file
.write_all(minisafed_config.to_string().as_bytes())
.map_err(|e| Error::CannotWriteToFile(e.to_string()))?;
// create minisafe GUI configuration file
let mut gui_config_path = datadir_path;
gui_config_path.push(gui_config::DEFAULT_FILE_NAME);
let mut gui_config_file = std::fs::File::create(&gui_config_path)
.map_err(|e| Error::CannotCreateFile(e.to_string()))?;
gui_config_file
.write_all(
toml::to_string(&gui_config::Config::new(
minisafed_config_path.canonicalize().map_err(|e| {
Error::Unexpected(format!(
"Failed to canonicalize minisafed config path: {}",
e
))
})?,
))
.unwrap()
.as_bytes(),
)
.map_err(|e| Error::CannotWriteToFile(e.to_string()))?;
Ok(gui_config_path)
}
#[derive(Debug, Clone)]
pub enum Error {
CannotCreateDatadir(String),
CannotCreateFile(String),
CannotWriteToFile(String),
Unexpected(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::CannotCreateDatadir(e) => write!(f, "Failed to create datadir: {}", e),
Self::CannotWriteToFile(e) => write!(f, "Failed to write to file: {}", e),
Self::CannotCreateFile(e) => write!(f, "Failed to create file: {}", e),
Self::Unexpected(e) => write!(f, "Unexpected: {}", e),
}
}
}

View File

@ -0,0 +1,396 @@
use std::path::PathBuf;
use std::str::FromStr;
use iced::pure::Element;
use minisafe::{
descriptors::inheritance_descriptor,
miniscript::descriptor::{Descriptor, DescriptorPublicKey},
};
use crate::ui::component::form;
use crate::installer::{
config,
message::{self, Message},
view,
};
pub trait Step {
fn update(&mut self, message: Message);
fn view(&self) -> Element<Message>;
fn load_context(&mut self, _ctx: &Context) {}
fn skip(&self, _ctx: &Context) -> bool {
false
}
fn apply(&mut self, _ctx: &mut Context, _config: &mut config::Config) -> bool {
true
}
}
#[derive(Clone)]
pub struct Context {
pub network: bitcoin::Network,
}
impl Context {
pub fn new(network: bitcoin::Network) -> Self {
Self { network }
}
}
impl Default for Context {
fn default() -> Self {
Self::new(bitcoin::Network::Bitcoin)
}
}
pub struct Welcome {
network: bitcoin::Network,
}
impl Welcome {
pub fn new(network: bitcoin::Network) -> Self {
Self { network }
}
}
impl Step for Welcome {
fn update(&mut self, message: Message) {
if let message::Message::Network(network) = message {
self.network = network;
}
}
fn apply(&mut self, ctx: &mut Context, config: &mut config::Config) -> bool {
ctx.network = self.network;
config.bitcoin_config.network = self.network;
true
}
fn view(&self) -> Element<Message> {
view::welcome(&self.network)
}
}
impl Default for Welcome {
fn default() -> Self {
Self::new(bitcoin::Network::Bitcoin)
}
}
impl From<Welcome> for Box<dyn Step> {
fn from(s: Welcome) -> Box<dyn Step> {
Box::new(s)
}
}
pub struct DefineDescriptor {
imported_descriptor: form::Value<String>,
user_xpub: form::Value<String>,
heir_xpub: form::Value<String>,
sequence: form::Value<String>,
error: Option<String>,
}
impl DefineDescriptor {
pub fn new() -> Self {
Self {
imported_descriptor: form::Value::default(),
user_xpub: form::Value::default(),
heir_xpub: form::Value::default(),
sequence: form::Value::default(),
error: None,
}
}
}
impl Step for DefineDescriptor {
// form value is set as valid each time it is edited.
// Verification of the values is happening when the user click on Next button.
fn update(&mut self, message: Message) {
if let Message::DefineDescriptor(msg) = message {
match msg {
message::DefineDescriptor::ImportDescriptor(desc) => {
self.imported_descriptor.value = desc;
self.imported_descriptor.valid = true;
}
message::DefineDescriptor::UserXpubEdited(xpub) => {
self.user_xpub.value = xpub;
self.user_xpub.valid = true;
}
message::DefineDescriptor::HeirXpubEdited(xpub) => {
self.heir_xpub.value = xpub;
self.heir_xpub.valid = true;
}
message::DefineDescriptor::SequenceEdited(seq) => {
self.sequence.valid = true;
if seq.is_empty() || seq.parse::<u32>().is_ok() {
self.sequence.value = seq;
}
}
};
};
}
fn apply(&mut self, _ctx: &mut Context, config: &mut config::Config) -> bool {
// descriptor forms for import or creation cannot be both empty or filled.
if self.imported_descriptor.value.is_empty()
== (self.user_xpub.value.is_empty()
|| self.heir_xpub.value.is_empty()
|| self.sequence.value.is_empty())
{
if !self.user_xpub.value.is_empty() {
self.user_xpub.valid = DescriptorPublicKey::from_str(&self.user_xpub.value).is_ok();
}
if !self.heir_xpub.value.is_empty() {
self.heir_xpub.valid = DescriptorPublicKey::from_str(&self.heir_xpub.value).is_ok();
}
if !self.sequence.value.is_empty() {
self.sequence.valid = self.sequence.value.parse::<u32>().is_ok();
}
if !self.imported_descriptor.value.is_empty() {
self.imported_descriptor.valid =
Descriptor::<DescriptorPublicKey>::from_str(&self.imported_descriptor.value)
.is_ok();
}
false
} else if !self.imported_descriptor.value.is_empty() {
if let Ok(desc) =
Descriptor::<DescriptorPublicKey>::from_str(&self.imported_descriptor.value)
{
config.main_descriptor = Some(desc);
true
} else {
self.imported_descriptor.valid = false;
false
}
} else {
let user_key = DescriptorPublicKey::from_str(&self.user_xpub.value);
self.user_xpub.valid = user_key.is_ok();
let heir_key = DescriptorPublicKey::from_str(&self.heir_xpub.value);
self.user_xpub.valid = user_key.is_ok();
let sequence = self.sequence.value.parse::<u32>();
self.sequence.valid = sequence.is_ok();
if !self.user_xpub.valid || !self.heir_xpub.valid || !self.sequence.valid {
return false;
}
match inheritance_descriptor(user_key.unwrap(), heir_key.unwrap(), sequence.unwrap()) {
Ok(desc) => {
config.main_descriptor = Some(desc);
true
}
Err(e) => {
self.error = Some(e.to_string());
false
}
}
}
}
fn view(&self) -> Element<Message> {
view::define_descriptor(
&self.imported_descriptor,
&self.user_xpub,
&self.heir_xpub,
&self.sequence,
self.error.as_ref(),
)
}
}
impl Default for DefineDescriptor {
fn default() -> Self {
Self::new()
}
}
impl From<DefineDescriptor> for Box<dyn Step> {
fn from(s: DefineDescriptor) -> Box<dyn Step> {
Box::new(s)
}
}
pub struct DefineBitcoind {
cookie_path: form::Value<String>,
address: form::Value<String>,
}
fn bitcoind_default_cookie_path(network: &bitcoin::Network) -> Option<String> {
#[cfg(target_os = "linux")]
let configs_dir = dirs::home_dir();
#[cfg(not(target_os = "linux"))]
let configs_dir = dirs::config_dir();
if let Some(mut path) = configs_dir {
#[cfg(target_os = "linux")]
path.push(".bitcoin");
#[cfg(not(target_os = "linux"))]
path.push("Bitcoin");
match network {
bitcoin::Network::Bitcoin => {
path.push(".cookie");
}
bitcoin::Network::Testnet => {
path.push("testnet3/.cookie");
}
bitcoin::Network::Regtest => {
path.push("regtest/.cookie");
}
bitcoin::Network::Signet => {
path.push("signet/.cookie");
}
}
return path.to_str().map(|s| s.to_string());
}
None
}
fn bitcoind_default_address(network: &bitcoin::Network) -> String {
match network {
bitcoin::Network::Bitcoin => "127.0.0.1:8332".to_string(),
bitcoin::Network::Testnet => "127.0.0.1:18332".to_string(),
bitcoin::Network::Regtest => "127.0.0.1:18443".to_string(),
bitcoin::Network::Signet => "127.0.0.1:38332".to_string(),
}
}
impl DefineBitcoind {
pub fn new() -> Self {
Self {
cookie_path: form::Value::default(),
address: form::Value::default(),
}
}
}
impl Step for DefineBitcoind {
fn load_context(&mut self, ctx: &Context) {
if self.cookie_path.value.is_empty() {
self.cookie_path.value = bitcoind_default_cookie_path(&ctx.network).unwrap_or_default()
}
if self.address.value.is_empty() {
self.address.value = bitcoind_default_address(&ctx.network);
}
}
fn update(&mut self, message: Message) {
if let Message::DefineBitcoind(msg) = message {
match msg {
message::DefineBitcoind::AddressEdited(address) => {
self.address.value = address;
self.address.valid = true;
}
message::DefineBitcoind::CookiePathEdited(path) => {
self.cookie_path.value = path;
self.address.valid = true;
}
};
};
}
fn apply(&mut self, _ctx: &mut Context, config: &mut config::Config) -> bool {
match (
PathBuf::from_str(&self.cookie_path.value),
std::net::SocketAddr::from_str(&self.address.value),
) {
(Err(_), Ok(_)) => {
self.cookie_path.valid = false;
false
}
(Ok(_), Err(_)) => {
self.address.valid = false;
false
}
(Err(_), Err(_)) => {
self.cookie_path.valid = false;
self.address.valid = false;
false
}
(Ok(path), Ok(addr)) => {
config.bitcoind_config.cookie_path = path;
config.bitcoind_config.addr = addr;
true
}
}
}
fn view(&self) -> Element<Message> {
view::define_bitcoin(&self.address, &self.cookie_path)
}
}
impl Default for DefineBitcoind {
fn default() -> Self {
Self::new()
}
}
impl From<DefineBitcoind> for Box<dyn Step> {
fn from(s: DefineBitcoind) -> Box<dyn Step> {
Box::new(s)
}
}
pub struct Final {
generating: bool,
warning: Option<String>,
config_path: Option<PathBuf>,
}
impl Final {
pub fn new() -> Self {
Self {
generating: false,
warning: None,
config_path: None,
}
}
}
impl Step for Final {
fn update(&mut self, message: Message) {
match message {
Message::Installed(res) => {
self.generating = false;
match res {
Err(e) => {
self.config_path = None;
self.warning = Some(e.to_string());
}
Ok(path) => self.config_path = Some(path),
}
}
Message::Install => {
self.generating = true;
self.config_path = None;
self.warning = None;
}
_ => {}
};
}
fn view(&self) -> Element<Message> {
view::install(
self.generating,
self.config_path.as_ref(),
self.warning.as_ref(),
)
}
}
impl Default for Final {
fn default() -> Self {
Self::new()
}
}
impl From<Final> for Box<dyn Step> {
fn from(s: Final) -> Box<dyn Step> {
Box::new(s)
}
}

247
gui/src/installer/view.rs Normal file
View File

@ -0,0 +1,247 @@
use iced::pure::{column, container, pick_list, row, scrollable, Element};
use iced::{Alignment, Length};
use crate::ui::{
component::{
button, form,
text::{text, Text},
},
util::Collection,
};
use crate::installer::message::{self, Message};
const NETWORKS: [bitcoin::Network; 4] = [
bitcoin::Network::Bitcoin,
bitcoin::Network::Testnet,
bitcoin::Network::Signet,
bitcoin::Network::Regtest,
];
pub fn welcome(network: &bitcoin::Network) -> Element<Message> {
container(container(
column()
.push(container(
pick_list(&NETWORKS[..], Some(*network), message::Message::Network).padding(10),
))
.push(
button::primary(None, "Install")
.on_press(Message::Next)
.width(Length::Units(200)),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(100)
.spacing(50)
.align_items(Alignment::Center),
))
.center_y()
.center_x()
.height(Length::Fill)
.width(Length::Fill)
.into()
}
pub fn define_descriptor<'a>(
imported_descriptor: &form::Value<String>,
user_xpub: &form::Value<String>,
heir_xpub: &form::Value<String>,
sequence: &form::Value<String>,
error: Option<&String>,
) -> Element<'a, Message> {
let col_descriptor = column()
.push(text("Descriptor:").bold())
.push(
form::Form::new("Descriptor", imported_descriptor, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::ImportDescriptor(msg))
})
.warning("Please enter correct descriptor")
.size(20)
.padding(10),
)
.spacing(10);
let col_user_xpub = column()
.push(text("Your xpub:").bold())
.push(
form::Form::new("Xpub", user_xpub, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::UserXpubEdited(msg))
})
.warning("Please enter correct xpub")
.size(20)
.padding(10),
)
.spacing(10);
let col_heir_xpub = column()
.push(text("Heir xpub:").bold())
.push(
form::Form::new("Xpub", heir_xpub, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::HeirXpubEdited(msg))
})
.warning("Please enter correct xpub")
.size(20)
.padding(10),
)
.spacing(10);
let col_sequence = column()
.push(text("Number of block").bold())
.push(
form::Form::new("Number of block", sequence, |msg| {
Message::DefineDescriptor(message::DefineDescriptor::SequenceEdited(msg))
})
.warning("Please enter correct block number")
.size(20)
.padding(10),
)
.spacing(10);
layout(
column()
.push(text("Create the descriptor").bold().size(50))
.push(
column()
.push(col_user_xpub)
.push(
row()
.push(col_sequence.width(Length::FillPortion(1)))
.push(col_heir_xpub.width(Length::FillPortion(4)))
.spacing(20),
)
.spacing(20),
)
.push(text("or import it").bold().size(25))
.push(col_descriptor)
.push(
if !imported_descriptor.value.is_empty()
&& (!user_xpub.value.is_empty()
|| !heir_xpub.value.is_empty()
|| !sequence.value.is_empty())
{
button::primary(None, "Next").width(Length::Units(200))
} else {
button::primary(None, "Next")
.width(Length::Units(200))
.on_press(Message::Next)
},
)
.push_maybe(error.map(|e| text(e).size(15)))
.width(Length::Fill)
.height(Length::Fill)
.padding(100)
.spacing(50)
.align_items(Alignment::Center),
)
}
pub fn define_bitcoin<'a>(
address: &form::Value<String>,
cookie_path: &form::Value<String>,
) -> Element<'a, Message> {
let col_address = column()
.push(text("Address:").bold())
.push(
form::Form::new("Address", address, |msg| {
Message::DefineBitcoind(message::DefineBitcoind::AddressEdited(msg))
})
.warning("Please enter correct address")
.size(20)
.padding(10),
)
.spacing(10);
let col_cookie = column()
.push(text("Cookie path:").bold())
.push(
form::Form::new("Cookie path", cookie_path, |msg| {
Message::DefineBitcoind(message::DefineBitcoind::CookiePathEdited(msg))
})
.warning("Please enter correct path")
.size(20)
.padding(10),
)
.spacing(10);
layout(
column()
.push(
text("Set up connection to the Bitcoin full node")
.bold()
.size(50),
)
.push(col_address)
.push(col_cookie)
.push(
button::primary(None, "Next")
.on_press(Message::Next)
.width(Length::Units(200)),
)
.width(Length::Fill)
.height(Length::Fill)
.padding(100)
.spacing(50)
.align_items(Alignment::Center),
)
}
pub fn install<'a>(
generating: bool,
config_path: Option<&std::path::PathBuf>,
warning: Option<&String>,
) -> Element<'a, Message> {
let mut col = column()
.width(Length::Fill)
.height(Length::Fill)
.padding(100)
.spacing(50)
.align_items(Alignment::Center);
if let Some(error) = warning {
col = col.push(text(error));
}
if generating {
col = col.push(button::primary(None, "Installing ...").width(Length::Units(200)))
} else if let Some(path) = config_path {
col = col.push(
container(
column()
.push(container(text("Installed !")))
.push(container(
button::primary(None, "Start")
.on_press(Message::Exit(path.clone()))
.width(Length::Units(200)),
))
.align_items(Alignment::Center)
.spacing(20),
)
.padding(50)
.width(Length::Fill)
.center_x(),
);
} else {
col = col.push(
button::primary(None, "Finalize installation")
.on_press(Message::Install)
.width(Length::Units(200)),
);
}
layout(col)
}
fn layout<'a>(content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
container(scrollable(
column()
.push(
container(button::transparent(None, "< Previous").on_press(Message::Previous))
.padding(5),
)
.push(container(content).width(Length::Fill).center_x()),
))
.center_x()
.height(Length::Fill)
.width(Length::Fill)
.into()
}

6
gui/src/lib.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod app;
pub mod conversion;
pub mod daemon;
pub mod installer;
pub mod loader;
pub mod ui;

281
gui/src/loader.rs Normal file
View File

@ -0,0 +1,281 @@
use std::convert::From;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::sync::Arc;
use iced::pure::{column, text, Element};
use iced::{Alignment, Command, Subscription};
use iced_native::{window, Event};
use log::{debug, info};
use minisafe::config::{Config, ConfigError};
use crate::{
app::config::{default_datadir, Config as GUIConfig},
daemon::{client, embedded::EmbeddedDaemon, model::*, Daemon, DaemonError},
};
type Minisafed = client::Minisafed<client::jsonrpc::JsonRPCClient>;
pub struct Loader {
pub gui_config: GUIConfig,
pub daemon_config: Config,
pub daemon_started: bool,
should_exit: bool,
step: Step,
}
enum Step {
Connecting,
StartingDaemon,
Syncing {
daemon: Arc<dyn Daemon + Sync + Send>,
progress: f64,
},
Error(Box<Error>),
}
#[derive(Debug)]
pub enum Message {
Event(iced_native::Event),
Syncing(Result<GetInfoResult, DaemonError>),
Synced(GetInfoResult, Arc<dyn Daemon + Sync + Send>),
Started(Result<Arc<dyn Daemon + Sync + Send>, Error>),
Loaded(Result<Arc<dyn Daemon + Sync + Send>, Error>),
DaemonStarted(EmbeddedDaemon),
Failure(DaemonError),
}
impl Loader {
pub fn new(gui_config: GUIConfig, daemon_config: Config) -> (Self, Command<Message>) {
let path = socket_path(
&daemon_config.data_dir,
daemon_config.bitcoin_config.network,
)
.unwrap();
(
Loader {
daemon_config,
gui_config,
step: Step::Connecting,
should_exit: false,
daemon_started: false,
},
Command::perform(connect(path), Message::Loaded),
)
}
fn on_load(&mut self, res: Result<Arc<dyn Daemon + Sync + Send>, Error>) -> Command<Message> {
match res {
Ok(daemon) => {
self.step = Step::Syncing {
daemon: daemon.clone(),
progress: 0.0,
};
return Command::perform(sync(daemon, false), Message::Syncing);
}
Err(e) => match e {
Error::Config(_) => {
self.step = Step::Error(Box::new(e));
}
Error::Daemon(DaemonError::Transport(Some(ErrorKind::ConnectionRefused), _))
| Error::Daemon(DaemonError::Transport(Some(ErrorKind::NotFound), _)) => {
self.step = Step::StartingDaemon;
self.daemon_started = true;
return Command::perform(
start_daemon(self.gui_config.minisafed_config_path.clone()),
Message::Started,
);
}
_ => {
self.step = Step::Error(Box::new(e));
}
},
}
Command::none()
}
fn on_start(&mut self, res: Result<Arc<dyn Daemon + Sync + Send>, Error>) -> Command<Message> {
match res {
Ok(minisafed) => {
self.step = Step::Syncing {
daemon: minisafed.clone(),
progress: 0.0,
};
Command::perform(sync(minisafed, false), Message::Syncing)
}
Err(e) => {
self.step = Step::Error(Box::new(e));
Command::none()
}
}
}
fn on_sync(&mut self, res: Result<GetInfoResult, DaemonError>) -> Command<Message> {
match &mut self.step {
Step::Syncing {
daemon, progress, ..
} => {
match res {
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)
});
} else {
*progress = info.sync
}
}
Err(e) => {
self.step = Step::Error(Box::new(e.into()));
return Command::none();
}
};
Command::perform(sync(daemon.clone(), true), Message::Syncing)
}
_ => Command::none(),
}
}
pub fn stop(&mut self) {
log::info!("Close requested");
if let Step::Syncing { daemon, .. } = &mut self.step {
if !daemon.is_external() {
log::info!("Stopping internal daemon...");
if let Some(d) = Arc::get_mut(daemon) {
d.stop().expect("Daemon is internal");
log::info!("Internal daemon stopped");
self.should_exit = true;
}
} else {
self.should_exit = true;
}
} else {
self.should_exit = true;
}
}
pub fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Started(res) => self.on_start(res),
Message::Loaded(res) => self.on_load(res),
Message::Syncing(res) => self.on_sync(res),
Message::Failure(_) => {
self.daemon_started = false;
Command::none()
}
Message::Event(Event::Window(window::Event::CloseRequested)) => {
self.stop();
Command::none()
}
_ => Command::none(),
}
}
pub fn subscription(&self) -> Subscription<Message> {
iced_native::subscription::events().map(Message::Event)
}
pub fn should_exit(&self) -> bool {
self.should_exit
}
pub fn view(&self) -> Element<Message> {
match &self.step {
Step::StartingDaemon => cover(text("Starting daemon...")),
Step::Connecting => cover(text("Connecting to daemon...")),
Step::Syncing { progress, .. } => cover(text(&format!("Syncing... {}%", progress))),
Step::Error(error) => cover(text(&format!("Error: {}", error))),
}
}
}
pub fn cover<'a, T: 'a, C: Into<Element<'a, T>>>(content: C) -> Element<'a, T> {
column()
.push(content)
.width(iced::Length::Fill)
.height(iced::Length::Fill)
.padding(50)
.spacing(50)
.align_items(Alignment::Center)
.into()
}
async fn connect(socket_path: PathBuf) -> Result<Arc<dyn Daemon + Sync + Send>, Error> {
let client = client::jsonrpc::JsonRPCClient::new(socket_path);
let minisafed = Minisafed::new(client);
debug!("Searching for external daemon");
minisafed.get_info()?;
info!("Connected to external daemon");
Ok(Arc::new(minisafed))
}
// Daemon can start only if a config path is given.
pub async fn start_daemon(config_path: PathBuf) -> Result<Arc<dyn Daemon + Sync + Send>, Error> {
debug!("starting minisafe daemon");
let config = Config::from_file(Some(config_path))
.map_err(|e| DaemonError::Start(format!("Error parsing config: {}", e)))?;
let mut daemon = EmbeddedDaemon::default();
daemon.start(config)?;
Ok(Arc::new(daemon))
}
async fn sync(
minisafed: Arc<dyn Daemon + Sync + Send>,
sleep: bool,
) -> Result<GetInfoResult, DaemonError> {
if sleep {
std::thread::sleep(std::time::Duration::from_secs(1));
}
minisafed.get_info()
}
#[allow(clippy::large_enum_variant)]
#[derive(Debug)]
pub enum Error {
Config(ConfigError),
Daemon(DaemonError),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::Config(e) => write!(f, "Config error: {}", e),
Self::Daemon(e) => write!(f, "RevaultD error: {}", e),
}
}
}
impl From<ConfigError> for Error {
fn from(error: ConfigError) -> Self {
Error::Config(error)
}
}
impl From<DaemonError> for Error {
fn from(error: DaemonError) -> Self {
Error::Daemon(error)
}
}
/// default minisafed socket path is .minisafe/bitcoin/minisafed_rpc
fn socket_path(
datadir: &Option<PathBuf>,
network: bitcoin::Network,
) -> Result<PathBuf, ConfigError> {
let mut path = if let Some(ref datadir) = datadir {
datadir.clone()
} else {
default_datadir().map_err(|_| ConfigError::DatadirNotFound)?
};
path.push(network.to_string());
path.push("minisafed_rpc");
Ok(path)
}

362
gui/src/main.rs Normal file
View File

@ -0,0 +1,362 @@
use std::{error::Error, path::PathBuf, str::FromStr};
use iced::pure::{Application, Element};
use iced::{executor, Command, Settings, Subscription};
extern crate serde;
extern crate serde_json;
use minisafe::config::Config as DaemonConfig;
use minisafe_gui::{
app::{
self,
config::{default_datadir, ConfigError},
context::{ConfigContext, Context},
menu::Menu,
App,
},
conversion::Converter,
installer::{self, Installer},
loader::{self, Loader},
};
#[derive(Debug, PartialEq)]
enum Arg {
ConfigPath(PathBuf),
DatadirPath(PathBuf),
Network(bitcoin::Network),
}
fn parse_args(args: Vec<String>) -> Result<Vec<Arg>, Box<dyn Error>> {
let mut res = Vec::new();
for (i, arg) in args.iter().enumerate() {
if arg == "--conf" {
if let Some(a) = args.get(i + 1) {
res.push(Arg::ConfigPath(PathBuf::from(a)));
} else {
return Err("missing arg to --conf".into());
}
} else if arg == "--datadir" {
if let Some(a) = args.get(i + 1) {
res.push(Arg::DatadirPath(PathBuf::from(a)));
} else {
return Err("missing arg to --datadir".into());
}
} else if arg.contains("--") {
let network = bitcoin::Network::from_str(args[i].trim_start_matches("--"))?;
res.push(Arg::Network(network));
}
}
Ok(res)
}
fn log_level_from_config(config: &app::Config) -> Result<log::LevelFilter, Box<dyn Error>> {
if let Some(level) = &config.log_level {
match level.as_ref() {
"info" => Ok(log::LevelFilter::Info),
"debug" => Ok(log::LevelFilter::Debug),
"trace" => Ok(log::LevelFilter::Trace),
_ => Err(format!("Unknown loglevel '{:?}'.", level).into()),
}
} else if let Some(true) = config.debug {
Ok(log::LevelFilter::Debug)
} else {
Ok(log::LevelFilter::Info)
}
}
pub struct GUI {
state: State,
}
enum State {
Installer(Installer),
Loader(Loader),
App(App),
}
#[derive(Debug)]
pub enum Message {
CtrlC,
Install(Box<installer::Message>),
Load(Box<loader::Message>),
Run(Box<app::Message>),
}
async fn ctrl_c() -> Result<(), ()> {
if let Err(e) = tokio::signal::ctrl_c().await {
log::error!("{}", e);
};
log::info!("Signal received, exiting");
Ok(())
}
impl Application for GUI {
type Executor = executor::Default;
type Message = Message;
type Flags = Config;
fn title(&self) -> String {
match self.state {
State::Installer(_) => String::from("Revault Installer"),
State::App(_) => String::from("Revault GUI"),
State::Loader(..) => String::from("Revault"),
}
}
fn new(config: Config) -> (GUI, Command<Self::Message>) {
match config {
Config::Install(config_path, network) => {
let (install, command) = Installer::new(config_path, network);
(
Self {
state: State::Installer(install),
},
Command::batch(vec![
command.map(|msg| Message::Install(Box::new(msg))),
Command::perform(ctrl_c(), |_| Message::CtrlC),
]),
)
}
Config::Run(cfg) => {
let daemon_cfg =
DaemonConfig::from_file(Some(cfg.minisafed_config_path.clone())).unwrap();
let (loader, command) = Loader::new(cfg, daemon_cfg);
(
Self {
state: State::Loader(loader),
},
Command::batch(vec![
command.map(|msg| Message::Load(Box::new(msg))),
Command::perform(ctrl_c(), |_| Message::CtrlC),
]),
)
}
}
}
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
match (&mut self.state, message) {
(State::Installer(i), Message::CtrlC) => {
i.stop();
Command::none()
}
(State::Loader(i), Message::CtrlC) => {
i.stop();
Command::none()
}
(State::App(i), Message::CtrlC) => {
i.stop();
Command::none()
}
(State::Installer(i), Message::Install(msg)) => {
if let installer::Message::Exit(path) = *msg {
let cfg = app::Config::from_file(&path).unwrap();
let daemon_cfg =
DaemonConfig::from_file(Some(cfg.minisafed_config_path.clone())).unwrap();
let (loader, command) = Loader::new(cfg, daemon_cfg);
self.state = State::Loader(loader);
command.map(|msg| Message::Load(Box::new(msg)))
} else {
i.update(*msg).map(|msg| Message::Install(Box::new(msg)))
}
}
(State::Loader(loader), Message::Load(msg)) => {
if let loader::Message::Synced(_info, minisafed) = *msg {
let config = ConfigContext {
gui: loader.gui_config.clone(),
daemon: loader.daemon_config.clone(),
};
let converter = Converter::new(config.daemon.bitcoin_config.network);
let context = Context::new(config, minisafed, converter, Menu::Home);
let (app, command) = App::new(context);
self.state = State::App(app);
command.map(|msg| Message::Run(Box::new(msg)))
} else {
loader.update(*msg).map(|msg| Message::Load(Box::new(msg)))
}
}
(State::App(i), Message::Run(msg)) => {
i.update(*msg).map(|msg| Message::Run(Box::new(msg)))
}
_ => Command::none(),
}
}
fn should_exit(&self) -> bool {
match &self.state {
State::Installer(v) => v.should_exit(),
State::Loader(v) => v.should_exit(),
State::App(v) => v.should_exit(),
}
}
fn subscription(&self) -> Subscription<Self::Message> {
match &self.state {
State::Installer(v) => v.subscription().map(|msg| Message::Install(Box::new(msg))),
State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))),
State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))),
}
}
fn view(&self) -> Element<Self::Message> {
match &self.state {
State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))),
State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))),
State::Loader(v) => v.view().map(|msg| Message::Load(Box::new(msg))),
}
}
fn scale_factor(&self) -> f64 {
1.0
}
}
pub enum Config {
Run(app::Config),
Install(PathBuf, bitcoin::Network),
}
impl Config {
pub fn new(datadir_path: PathBuf, network: bitcoin::Network) -> Result<Self, Box<dyn Error>> {
let mut path = datadir_path.clone();
path.push(network.to_string());
path.push(app::config::DEFAULT_FILE_NAME);
match app::Config::from_file(&path) {
Ok(cfg) => Ok(Config::Run(cfg)),
Err(ConfigError::NotFound) => Ok(Config::Install(datadir_path, network)),
Err(e) => Err(format!("Failed to read configuration file: {}", e).into()),
}
}
}
fn main() -> Result<(), Box<dyn Error>> {
let args = parse_args(std::env::args().collect())?;
let config = match args.as_slice() {
[] => {
let datadir_path = default_datadir().unwrap();
Config::new(datadir_path, bitcoin::Network::Bitcoin)
}
[Arg::Network(network)] => {
let datadir_path = default_datadir().unwrap();
Config::new(datadir_path, *network)
}
[Arg::ConfigPath(path)] => Ok(Config::Run(app::Config::from_file(path)?)),
[Arg::DatadirPath(datadir_path)] => {
Config::new(datadir_path.clone(), bitcoin::Network::Bitcoin)
}
[Arg::DatadirPath(datadir_path), Arg::Network(network)]
| [Arg::Network(network), Arg::DatadirPath(datadir_path)] => {
Config::new(datadir_path.clone(), *network)
}
_ => {
return Err("Unknown args combination".into());
}
}?;
let level = if let Config::Run(cfg) = &config {
log_level_from_config(cfg)?
} else {
log::LevelFilter::Info
};
setup_logger(level)?;
let mut settings = Settings::with_flags(config);
settings.exit_on_close_request = false;
if let Err(e) = GUI::run(settings) {
return Err(format!("Failed to launch UI: {}", e).into());
};
Ok(())
}
// This creates the log file automagically if it doesn't exist, and logs on stdout
// if None is given
pub fn setup_logger(log_level: log::LevelFilter) -> Result<(), fern::InitError> {
let dispatcher = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{}][{}][{}] {}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_else(|e| {
println!("Can't get time since epoch: '{}'. Using a dummy value.", e);
std::time::Duration::from_secs(0)
})
.as_secs(),
record.target(),
record.level(),
message
))
})
.level(log_level)
.level_for("iced_wgpu", log::LevelFilter::Off)
.level_for("wgpu_core", log::LevelFilter::Off)
.level_for("wgpu_hal", log::LevelFilter::Off)
.level_for("gfx_backend_vulkan", log::LevelFilter::Off)
.level_for("naga", log::LevelFilter::Off)
.level_for("mio", log::LevelFilter::Off);
dispatcher.chain(std::io::stdout()).apply()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_args() {
assert_eq!(true, parse_args(vec!["--meth".into()]).is_err());
assert_eq!(true, parse_args(vec!["--datadir".into()]).is_err());
assert_eq!(true, parse_args(vec!["--conf".into()]).is_err());
assert_eq!(
Some(vec![
Arg::DatadirPath(PathBuf::from(".")),
Arg::ConfigPath(PathBuf::from("hello.toml")),
]),
parse_args(
"--datadir . --conf hello.toml"
.split(" ")
.map(|a| a.to_string())
.collect()
)
.ok()
);
assert_eq!(
Some(vec![Arg::Network(bitcoin::Network::Regtest)]),
parse_args(vec!["--regtest".into()]).ok()
);
assert_eq!(
Some(vec![
Arg::DatadirPath(PathBuf::from("hello")),
Arg::Network(bitcoin::Network::Testnet)
]),
parse_args(
"--datadir hello --testnet"
.split(" ")
.map(|a| a.to_string())
.collect()
)
.ok()
);
assert_eq!(
Some(vec![
Arg::Network(bitcoin::Network::Testnet),
Arg::DatadirPath(PathBuf::from("hello"))
]),
parse_args(
"--testnet --datadir hello"
.split(" ")
.map(|a| a.to_string())
.collect()
)
.ok()
);
}
}

75
gui/src/ui/color.rs Normal file
View File

@ -0,0 +1,75 @@
use iced::Color;
pub const BACKGROUND: Color = Color::from_rgb(
0xF6 as f32 / 255.0,
0xF7 as f32 / 255.0,
0xF8 as f32 / 255.0,
);
pub const FOREGROUND: Color = Color::WHITE;
pub const PRIMARY: Color = Color::BLACK;
pub const SECONDARY: Color = DARK_GREY;
pub const SUCCESS: Color = Color::from_rgb(
0x29 as f32 / 255.0,
0xBC as f32 / 255.0,
0x97 as f32 / 255.0,
);
#[allow(dead_code)]
pub const SUCCESS_LIGHT: Color = Color::from_rgba(
0x29 as f32 / 255.0,
0xBC as f32 / 255.0,
0x97 as f32 / 255.0,
0.5f32,
);
pub const ALERT: Color = Color::from_rgb(
0xF0 as f32 / 255.0,
0x43 as f32 / 255.0,
0x59 as f32 / 255.0,
);
pub const ALERT_LIGHT: Color = Color::from_rgba(
0xF0 as f32 / 255.0,
0x43 as f32 / 255.0,
0x59 as f32 / 255.0,
0.5f32,
);
pub const WARNING: Color =
Color::from_rgb(0xFF as f32 / 255.0, 0xa7 as f32 / 255.0, 0x0 as f32 / 255.0);
pub const WARNING_LIGHT: Color = Color::from_rgba(
0xFF as f32 / 255.0,
0xa7 as f32 / 255.0,
0x0 as f32 / 255.0,
0.5f32,
);
pub const CANCEL: Color = Color::from_rgb(
0x34 as f32 / 255.0,
0x37 as f32 / 255.0,
0x3D as f32 / 255.0,
);
pub const INFO: Color = Color::from_rgb(
0x2A as f32 / 255.0,
0x98 as f32 / 255.0,
0xBD as f32 / 255.0,
);
pub const INFO_LIGHT: Color = Color::from_rgba(
0x2A as f32 / 255.0,
0x98 as f32 / 255.0,
0xBD as f32 / 255.0,
0.5f32,
);
pub const DARK_GREY: Color = Color::from_rgb(
0x8c as f32 / 255.0,
0x97 as f32 / 255.0,
0xa6 as f32 / 255.0,
);

View File

@ -0,0 +1,62 @@
use iced::pure::{
container, row,
widget::{button, Container},
};
use iced::{Alignment, Color, Length, Vector};
use super::text::text;
use crate::ui::color;
pub fn primary<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
button::Button::new(content(icon, t)).style(Style::Primary)
}
pub fn transparent<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> button::Button<'a, T> {
button::Button::new(content(icon, t)).style(Style::Transparent)
}
fn content<'a, T: 'a>(icon: Option<iced::Text>, t: &str) -> Container<'a, T> {
match icon {
None => container(text(t)).width(Length::Fill).center_x().padding(5),
Some(i) => container(
row()
.push(i)
.push(text(t))
.spacing(10)
.width(iced::Length::Fill)
.align_items(Alignment::Center),
)
.width(iced::Length::Fill)
.center_x()
.padding(5),
}
}
#[derive(Debug, Clone, Copy)]
enum Style {
Primary,
Transparent,
}
impl button::StyleSheet for Style {
fn active(&self) -> button::Style {
match self {
Style::Primary => button::Style {
shadow_offset: Vector::default(),
background: color::PRIMARY.into(),
border_radius: 10.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
text_color: color::FOREGROUND,
},
Style::Transparent => button::Style {
shadow_offset: Vector::default(),
background: Color::TRANSPARENT.into(),
border_radius: 10.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
text_color: color::DARK_GREY,
},
}
}
}

View File

@ -0,0 +1,120 @@
use iced::pure::{
column, container,
widget::text_input::{Style, StyleSheet, TextInput},
Element,
};
use iced::Length;
use crate::ui::{color, component::text::text};
#[derive(Debug, Clone)]
pub struct Value<T> {
pub value: T,
pub valid: bool,
}
impl std::default::Default for Value<String> {
fn default() -> Self {
Self {
value: "".to_string(),
valid: true,
}
}
}
pub struct Form<'a, Message> {
input: TextInput<'a, Message>,
warning: Option<&'a str>,
valid: bool,
}
impl<'a, Message: 'a> Form<'a, Message>
where
Message: Clone,
{
/// Creates a new [`Form`].
///
/// It expects:
/// - a placeholder
/// - the current value
/// - a function that produces a message when the [`Form`] changes
pub fn new<F>(placeholder: &str, value: &Value<String>, on_change: F) -> Self
where
F: 'static + Fn(String) -> Message,
{
Self {
input: TextInput::new(placeholder, &value.value, on_change),
warning: None,
valid: value.valid,
}
}
/// Sets the [`Form`] with a warning message
pub fn warning(mut self, warning: &'a str) -> Self {
self.warning = Some(warning);
self
}
/// Sets the padding of the [`Form`].
pub fn padding(mut self, units: u16) -> Self {
self.input = self.input.padding(units);
self
}
/// Sets the [`Form`] with a text size
pub fn size(mut self, size: u16) -> Self {
self.input = self.input.size(size);
self
}
}
impl<'a, Message: 'a + Clone> From<Form<'a, Message>> for Element<'a, Message> {
fn from(form: Form<'a, Message>) -> Element<'a, Message> {
if !form.valid {
if let Some(message) = form.warning {
return container(
column()
.push(form.input.style(InvalidFormStyle))
.push(text(message).color(color::ALERT))
.width(Length::Fill)
.spacing(5),
)
.width(Length::Fill)
.into();
}
}
container(form.input).width(Length::Fill).into()
}
}
struct InvalidFormStyle;
impl StyleSheet for InvalidFormStyle {
fn active(&self) -> Style {
Style {
background: iced::Background::Color(color::FOREGROUND),
border_radius: 5.0,
border_width: 1.0,
border_color: color::ALERT,
}
}
fn focused(&self) -> Style {
Style {
border_color: color::ALERT,
..self.active()
}
}
fn placeholder_color(&self) -> iced::Color {
iced::Color::from_rgb(0.7, 0.7, 0.7)
}
fn value_color(&self) -> iced::Color {
iced::Color::from_rgb(0.3, 0.3, 0.3)
}
fn selection_color(&self) -> iced::Color {
iced::Color::from_rgb(0.8, 0.8, 1.0)
}
}

View File

@ -0,0 +1,24 @@
pub mod button;
pub mod form;
pub mod text;
use iced::pure::widget::{container, Column, Container};
use iced::Length;
use crate::ui::color;
pub fn separation<'a, T: 'a>() -> Container<'a, T> {
Container::new(Column::new().push(iced::Text::new(" ")))
.style(SepStyle)
.height(Length::Units(1))
}
pub struct SepStyle;
impl container::StyleSheet for SepStyle {
fn style(&self) -> container::Style {
container::Style {
background: color::SECONDARY.into(),
..container::Style::default()
}
}
}

View File

@ -0,0 +1,46 @@
use crate::{color, icon};
use iced::{container, tooltip, Container, Length, Row, Text, Tooltip};
pub fn warning<'a, T: 'a>(message: &str, error: &str) -> Container<'a, T> {
Container::new(Container::new(
Tooltip::new(
Row::new()
.push(icon::warning_icon())
.push(Text::new(message))
.spacing(20),
error,
tooltip::Position::Bottom,
)
.style(TooltipWarningStyle),
))
.padding(15)
.center_x()
.style(WarningStyle)
.width(Length::Fill)
}
struct WarningStyle;
impl container::StyleSheet for WarningStyle {
fn style(&self) -> container::Style {
container::Style {
border_radius: 0.0,
text_color: iced::Color::BLACK.into(),
background: color::WARNING.into(),
border_color: color::WARNING,
..container::Style::default()
}
}
}
struct TooltipWarningStyle;
impl container::StyleSheet for TooltipWarningStyle {
fn style(&self) -> container::Style {
container::Style {
border_radius: 0.0,
border_width: 1.0,
text_color: color::WARNING.into(),
background: color::FOREGROUND.into(),
border_color: color::WARNING,
}
}
}

View File

@ -0,0 +1,17 @@
use crate::ui::font;
pub fn text(content: &str) -> iced::pure::widget::Text {
iced::pure::widget::Text::new(content)
.font(font::REGULAR)
.size(25)
}
pub trait Text {
fn bold(self) -> Self;
}
impl Text for iced::pure::widget::Text {
fn bold(self) -> Self {
self.font(font::BOLD)
}
}

11
gui/src/ui/font.rs Normal file
View File

@ -0,0 +1,11 @@
use iced::Font;
pub const BOLD: Font = Font::External {
name: "Bold",
bytes: include_bytes!("../../static/fonts/OpenSans-Bold.ttf"),
};
pub const REGULAR: Font = Font::External {
name: "Regular",
bytes: include_bytes!("../../static/fonts/OpenSans-Regular.ttf"),
};

165
gui/src/ui/icon.rs Normal file
View File

@ -0,0 +1,165 @@
use iced::pure::text;
use iced::{alignment, Font, Length, Text};
const ICONS: Font = Font::External {
name: "Icons",
bytes: include_bytes!("../../static/icons/bootstrap-icons.ttf"),
};
fn icon(unicode: char) -> Text {
text(&unicode.to_string())
.font(ICONS)
.width(Length::Units(20))
.horizontal_alignment(alignment::Horizontal::Center)
.size(20)
}
pub fn vault_icon() -> Text {
icon('\u{F65A}')
}
pub fn bitcoin_icon() -> Text {
icon('\u{F635}')
}
pub fn history_icon() -> Text {
icon('\u{F292}')
}
pub fn home_icon() -> Text {
icon('\u{F3FC}')
}
pub fn unlock_icon() -> Text {
icon('\u{F600}')
}
pub fn warning_octagon_icon() -> Text {
icon('\u{F337}')
}
pub fn send_icon() -> Text {
icon('\u{F144}')
}
pub fn connect_device_icon() -> Text {
icon('\u{F348}')
}
pub fn connected_device_icon() -> Text {
icon('\u{F350}')
}
pub fn deposit_icon() -> Text {
icon('\u{F123}')
}
pub fn calendar_icon() -> Text {
icon('\u{F1E8}')
}
pub fn turnback_icon() -> Text {
icon('\u{F131}')
}
pub fn vaults_icon() -> Text {
icon('\u{F1C7}')
}
pub fn settings_icon() -> Text {
icon('\u{F3E5}')
}
pub fn block_icon() -> Text {
icon('\u{F1C8}')
}
pub fn square_icon() -> Text {
icon('\u{F584}')
}
pub fn square_check_icon() -> Text {
icon('\u{F26D}')
}
pub fn circle_check_icon() -> Text {
icon('\u{F26B}')
}
pub fn network_icon() -> Text {
icon('\u{F40D}')
}
pub fn dot_icon() -> Text {
icon('\u{F287}')
}
pub fn clipboard_icon() -> Text {
icon('\u{F28E}')
}
pub fn shield_icon() -> Text {
icon('\u{F53F}')
}
pub fn shield_notif_icon() -> Text {
icon('\u{F530}')
}
pub fn shield_check_icon() -> Text {
icon('\u{F52F}')
}
pub fn person_check_icon() -> Text {
icon('\u{F4D6}')
}
pub fn person_icon() -> Text {
icon('\u{F4DA}')
}
pub fn tooltip_icon() -> Text {
icon('\u{F431}')
}
pub fn plus_icon() -> Text {
icon('\u{F4FE}')
}
pub fn warning_icon() -> Text {
icon('\u{F33B}')
}
pub fn trash_icon() -> Text {
icon('\u{F5DE}')
}
pub fn key_icon() -> Text {
icon('\u{F44F}')
}
pub fn cross_icon() -> Text {
icon('\u{F62A}')
}
pub fn pencil_icon() -> Text {
icon('\u{F4CB}')
}
#[allow(dead_code)]
pub fn stakeholder_icon() -> Text {
icon('\u{F4AE}')
}
#[allow(dead_code)]
pub fn manager_icon() -> Text {
icon('\u{F4B4}')
}
pub fn done_icon() -> Text {
icon('\u{F26B}')
}
pub fn todo_icon() -> Text {
icon('\u{F28A}')
}

6
gui/src/ui/mod.rs Normal file
View File

@ -0,0 +1,6 @@
pub mod color;
/// component are wrappers around iced elements;
pub mod component;
pub mod font;
pub mod icon;
pub mod util;

28
gui/src/ui/util.rs Normal file
View File

@ -0,0 +1,28 @@
/// from hecjr idea on Discord
use iced::pure::{
widget::{Column, Row},
Element,
};
pub trait Collection<'a, Message>: Sized {
fn push(self, element: impl Into<Element<'a, Message>>) -> Self;
fn push_maybe(self, element: Option<impl Into<Element<'a, Message>>>) -> Self {
match element {
Some(element) => self.push(element),
None => self,
}
}
}
impl<'a, Message> Collection<'a, Message> for Column<'a, Message> {
fn push(self, element: impl Into<Element<'a, Message>>) -> Self {
Self::push(self, element)
}
}
impl<'a, Message> Collection<'a, Message> for Row<'a, Message> {
fn push(self, element: impl Into<Element<'a, Message>>) -> Self {
Self::push(self, element)
}
}

View File

@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019-2020 The Bootstrap Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,6 @@
# Icons
From the getbootstrap.com repository.
Converted from .woff to ttf with https://raw.githubusercontent.com/hanikesn/woff2otf/master/woff2otf.py
Use http://mathew-kurian.github.io/CharacterMap/ to check Unicode.

Binary file not shown.

View File

@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1326.6667 370.76001"
height="370.76001"
width="1326.6667"
xml:space="preserve"
id="svg2"
version="1.1"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><clipPath
id="clipPath20"
clipPathUnits="userSpaceOnUse"><path
id="path18"
d="m 824.48,1471.45 c 42.012,37.8 63.149,97.51 63.149,179.56 0,81.86 -21.824,138.05 -65.301,168.61 -43.367,30.43 -119.457,45.71 -228.515,45.71 H 339.441 v -450.37 h 248.11 c 115.918,0 194.828,18.89 236.929,56.49 z m 409.06,180.64 c 0,-243.7 -96.44,-401.12 -289.306,-472.1 L 1329.38,636.121 H 911.52 L 574.434,1121.14 H 339.441 V 636.121 H 0 V 2156.89 h 576.48 c 236.454,0 405.145,-39.85 505.89,-119.71 100.75,-79.71 151.17,-208.16 151.17,-385.09 z" /></clipPath><linearGradient
id="linearGradient28"
spreadMethod="pad"
gradientTransform="matrix(2790.33,2807.08,2807.08,-2790.33,-424.565,300.71)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0"><stop
id="stop22"
offset="0"
style="stop-opacity:1;stop-color:#ec2076" /><stop
id="stop24"
offset="0.995707"
style="stop-opacity:1;stop-color:#f68221" /><stop
id="stop26"
offset="1"
style="stop-opacity:1;stop-color:#f68221" /></linearGradient><clipPath
id="clipPath38"
clipPathUnits="userSpaceOnUse"><path
id="path36"
d="m 2678.28,2156.89 v -302.44 h -757.22 v -313.28 h 681.03 V 1251.75 H 1921.06 V 936.211 h 781.11 V 636.121 H 1581.8 V 2156.89 Z" /></clipPath><linearGradient
id="linearGradient46"
spreadMethod="pad"
gradientTransform="matrix(2790.37,2807.12,2807.12,-2790.37,318.496,-437.91)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0"><stop
id="stop40"
offset="0"
style="stop-opacity:1;stop-color:#ec2076" /><stop
id="stop42"
offset="0.997699"
style="stop-opacity:1;stop-color:#f68221" /><stop
id="stop44"
offset="1"
style="stop-opacity:1;stop-color:#f68221" /></linearGradient><clipPath
id="clipPath66"
clipPathUnits="userSpaceOnUse"><path
id="path64"
d="m 2705.19,2482.64 c 0,-27.6 -22.61,-50.13 -50.11,-50.13 L 53.9492,2432.32 c -27.6015,0 -33.3789,15.07 -12.914,33.59 L 353.547,2747.2 c 20.461,18.4 59.723,33.48 87.332,33.48 H 2655.08 c 27.5,0 50.11,-22.54 50.11,-50.14 z" /></clipPath><linearGradient
id="linearGradient74"
spreadMethod="pad"
gradientTransform="matrix(2732.06,2748.46,2748.46,-2732.06,-555.229,524.15)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0"><stop
id="stop68"
offset="0"
style="stop-opacity:1;stop-color:#ec2076" /><stop
id="stop70"
offset="0.995707"
style="stop-opacity:1;stop-color:#f68221" /><stop
id="stop72"
offset="1"
style="stop-opacity:1;stop-color:#f68221" /></linearGradient><clipPath
id="clipPath84"
clipPathUnits="userSpaceOnUse"><path
id="path82"
d="m 3.82031,298.109 c 0,27.539 22.52739,50.129 50.12889,50.129 L 2655.08,348.34 c 27.5,0 33.38,-15.059 12.82,-33.559 L 2355.57,33.6016 C 2335,15.1016 2295.65,0 2268.13,0 H 53.9492 C 26.3477,0 3.82031,22.6094 3.82031,50.1211 Z" /></clipPath><linearGradient
id="linearGradient92"
spreadMethod="pad"
gradientTransform="matrix(2732.03,2748.43,2748.43,-2732.03,471.929,-552.45)"
gradientUnits="userSpaceOnUse"
y2="0"
x2="1"
y1="0"
x1="0"><stop
id="stop86"
offset="0"
style="stop-opacity:1;stop-color:#ec2076" /><stop
id="stop88"
offset="0.995707"
style="stop-opacity:1;stop-color:#f68221" /><stop
id="stop90"
offset="1"
style="stop-opacity:1;stop-color:#f68221" /></linearGradient></defs><g
transform="matrix(1.3333333,0,0,-1.3333333,0,370.76)"
id="g10"><g
transform="scale(0.1)"
id="g12"><g
id="g14"><g
clip-path="url(#clipPath20)"
id="g16"><path
id="path30"
style="fill:url(#linearGradient28);fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 824.48,1471.45 c 42.012,37.8 63.149,97.51 63.149,179.56 0,81.86 -21.824,138.05 -65.301,168.61 -43.367,30.43 -119.457,45.71 -228.515,45.71 H 339.441 v -450.37 h 248.11 c 115.918,0 194.828,18.89 236.929,56.49 z m 409.06,180.64 c 0,-243.7 -96.44,-401.12 -289.306,-472.1 L 1329.38,636.121 H 911.52 L 574.434,1121.14 H 339.441 V 636.121 H 0 V 2156.89 h 576.48 c 236.454,0 405.145,-39.85 505.89,-119.71 100.75,-79.71 151.17,-208.16 151.17,-385.09" /></g></g><g
id="g32"><g
clip-path="url(#clipPath38)"
id="g34"><path
id="path48"
style="fill:url(#linearGradient46);fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2678.28,2156.89 v -302.44 h -757.22 v -313.28 h 681.03 V 1251.75 H 1921.06 V 936.211 h 781.11 V 636.121 H 1581.8 V 2156.89 h 1096.48" /></g></g><path
id="path50"
style="fill:#181818;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 3849.36,636.121 H 3562.19 L 2950.76,2156.89 h 287.26 l 467.79,-1135.71 467.81,1135.71 h 287.17 L 3849.36,636.121" /><path
id="path52"
style="fill:#181818;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 5463.67,1219.07 -276.21,626.57 -276.4,-626.57 z M 4806.61,982.02 4654.35,636.121 h -274.14 l 670.16,1520.769 h 274.16 L 5994.62,636.121 H 5720.46 L 5568.23,982.02 h -761.62" /><path
id="path54"
style="fill:#181818;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 6510.1,984.07 c 69.62,-81.269 163.97,-121.82 282.96,-121.82 118.77,0 213.24,40.551 282.66,121.82 69.72,81.34 104.56,191.5 104.56,330.84 v 841.98 h 256.81 v -852.86 c 0,-219.14 -60.3,-387.71 -180.65,-505.889 -120.4,-118.379 -274.92,-177.321 -463.38,-177.321 -188.68,0 -343.27,58.942 -463.48,177.321 -120.45,118.179 -180.76,286.749 -180.76,505.889 v 852.86 h 256.81 v -841.98 c 0,-139.34 34.85,-249.5 104.47,-330.84" /><path
id="path56"
style="fill:#181818;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 7839.38,636.121 V 2156.89 h 256.84 V 879.699 h 694.04 V 636.121 h -950.88" /><path
id="path58"
style="fill:#181818;fill-opacity:1;fill-rule:nonzero;stroke:none"
d="M 9488.74,1921.84 V 636.121 H 9232.15 V 1921.84 h -461.38 v 235.05 H 9950 v -235.05 h -461.26" /><g
id="g60"><g
clip-path="url(#clipPath66)"
id="g62"><path
id="path76"
style="fill:url(#linearGradient74);fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 2705.19,2482.64 c 0,-27.6 -22.61,-50.13 -50.11,-50.13 L 53.9492,2432.32 c -27.6015,0 -33.3789,15.07 -12.914,33.59 L 353.547,2747.2 c 20.461,18.4 59.723,33.48 87.332,33.48 H 2655.08 c 27.5,0 50.11,-22.54 50.11,-50.14 v -247.9" /></g></g><g
id="g78"><g
clip-path="url(#clipPath84)"
id="g80"><path
id="path94"
style="fill:url(#linearGradient92);fill-opacity:1;fill-rule:nonzero;stroke:none"
d="m 3.82031,298.109 c 0,27.539 22.52739,50.129 50.12889,50.129 L 2655.08,348.34 c 27.5,0 33.38,-15.059 12.82,-33.559 L 2355.57,33.6016 C 2335,15.1016 2295.65,0 2268.13,0 H 53.9492 C 26.3477,0 3.82031,22.6094 3.82031,50.1211 V 298.109" /></g></g></g></g></svg>

After

Width:  |  Height:  |  Size: 7.9 KiB