Merge #1099: Lianalite login
80077905a64b2d804c20a49d190f742a244d7816 Revert ctx state for remote backend when user clicks on previous (edouardparis)
cc57f8f7497c0ed763fdd115a1bd7fcbbd6c9043 fix: load hotsigners even for wallet with remote backend (edouardparis)
26d7e317efe19c62ba8baf6ac889c4fd9f2c6aa9 fix: retrieve wallet_id from settings.json (edouardparis)
0fbba9eaa24bc4fbb0966235415eba1459b7d8bd Add backend section to settings to send invitation (edouardparis)
5e4c04fa809aada59ad67f0ef34a3b4d75e00d6b Add invitation process to add wallet flow (edouardparis)
f7dde3d6ecaae5ef7563e21483c7d689cd14e958 Add redirect_to query parameter to otp request url (edouardparis)
92ee94edbc406016db8916d6cf0773de5e2ab250 Add login page to connect to remote backend (edouardparis)
Pull request description:
This PR introduce the new login to the remote backend.
The choice of the used backend is defined per wallet.
The authentication credentials for the remote backend are stored in the settings.json file.
## In the installer
The choice between two different backends is showned to the user that can choose to authenticate to the remote backend or use the local bitcoind.
## Between the launcher and the app a new temporary panel: login.
This intermediary state after the choice of network datadir from the user, is used to connect to the remote backend if the wallet information in the settings.json file contains the credentials for the remote backend authentication otherwise the launcher redirects directly to the App state with the embedded daemon running.
ACKs for top commit:
edouardparis:
Self-ACK 80077905a64b2d804c20a49d190f742a244d7816
Tree-SHA512: c762ff0610cbd5ed6a7b14a3f65395d358bb5299c00e56990b304e0edfeea00465e8ca65d735942e6a53960c220dcbbf65e598cbc738d8c4e7363b18735690b8
This commit is contained in:
commit
9707a58ee2
10
gui/Cargo.lock
generated
10
gui/Cargo.lock
generated
@ -1286,6 +1286,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.33"
|
||||
@ -2609,6 +2618,7 @@ dependencies = [
|
||||
"bitcoin_hashes 0.12.0",
|
||||
"chrono",
|
||||
"dirs 3.0.2",
|
||||
"email_address",
|
||||
"flate2",
|
||||
"hex",
|
||||
"iced",
|
||||
|
||||
@ -24,6 +24,9 @@ hex = "0.4.3"
|
||||
iced = { version = "0.12.1", default-features = false, features = ["tokio", "svg", "qr_code", "image", "lazy", "wgpu", "advanced"] }
|
||||
iced_runtime = "0.12.1"
|
||||
|
||||
# Used to verify RFC-compliance of an email
|
||||
email_address = "0.2.7"
|
||||
|
||||
tokio = {version = "1.21.0", features = ["signal"]}
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing_subscriber::filter;
|
||||
|
||||
@ -47,6 +49,26 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn to_file(&self, path: &Path) -> Result<(), ConfigError> {
|
||||
let content = toml::to_string(&self)
|
||||
.map_err(|e| ConfigError::WritingFile(format!("Failed to serialize config: {}", e)))?;
|
||||
|
||||
let mut config_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| ConfigError::WritingFile(e.to_string()))?;
|
||||
|
||||
config_file.write_all(content.as_bytes()).map_err(|e| {
|
||||
tracing::warn!("failed to write to file: {:?}", e);
|
||||
ConfigError::WritingFile(e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!("Done writing gui configuration file");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TODO: Deserialize directly in the struct.
|
||||
pub fn log_level(&self) -> Result<filter::LevelFilter, ConfigError> {
|
||||
if let Some(level) = &self.log_level {
|
||||
@ -72,6 +94,7 @@ pub enum ConfigError {
|
||||
InvalidField(&'static str, String),
|
||||
NotFound,
|
||||
ReadingFile(String),
|
||||
WritingFile(String),
|
||||
Unexpected(String),
|
||||
}
|
||||
|
||||
@ -83,6 +106,7 @@ impl std::fmt::Display for ConfigError {
|
||||
write!(f, "Config field {} is invalid: {}", field, message)
|
||||
}
|
||||
Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e),
|
||||
Self::WritingFile(e) => write!(f, "Error while writing file: {}", e),
|
||||
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
@ -255,11 +255,12 @@ impl App {
|
||||
Message::Tick => {
|
||||
let daemon = self.daemon.clone();
|
||||
let datadir_path = self.cache.datadir_path.clone();
|
||||
let network = self.cache.network;
|
||||
Command::perform(
|
||||
async move {
|
||||
// we check every 10 second if the daemon poller is alive
|
||||
// or if the access token is not expired.
|
||||
daemon.is_alive().await?;
|
||||
daemon.is_alive(&datadir_path, network).await?;
|
||||
|
||||
let info = daemon.get_info().await?;
|
||||
let coins = daemon
|
||||
|
||||
@ -8,7 +8,7 @@ use std::path::PathBuf;
|
||||
use liana::miniscript::bitcoin::{bip32::Fingerprint, Network};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{app::wallet::Wallet, hw::HardwareWalletConfig};
|
||||
use crate::hw::HardwareWalletConfig;
|
||||
|
||||
pub const DEFAULT_FILE_NAME: &str = "settings.json";
|
||||
|
||||
@ -59,14 +59,26 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct AuthConfig {
|
||||
pub email: String,
|
||||
pub wallet_id: String,
|
||||
pub refresh_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WalletSetting {
|
||||
pub name: String,
|
||||
pub descriptor_checksum: String,
|
||||
// if wallet is using remote backend, then this information is stored on the remote backend
|
||||
// wallet metadata
|
||||
#[serde(default)]
|
||||
pub keys: Vec<KeySetting>,
|
||||
// if wallet is using remote backend, then this information is stored on the remote backend
|
||||
// wallet metadata
|
||||
#[serde(default)]
|
||||
pub hardware_wallets: Vec<HardwareWalletConfig>,
|
||||
pub remote_backend_auth: Option<AuthConfig>,
|
||||
}
|
||||
|
||||
impl WalletSetting {
|
||||
@ -79,25 +91,6 @@ impl WalletSetting {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Wallet> for WalletSetting {
|
||||
fn from(w: &Wallet) -> WalletSetting {
|
||||
Self {
|
||||
name: w.name.clone(),
|
||||
hardware_wallets: w.hardware_wallets.clone(),
|
||||
keys: w
|
||||
.keys_aliases
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(master_fingerprint, name)| KeySetting {
|
||||
name,
|
||||
master_fingerprint,
|
||||
})
|
||||
.collect(),
|
||||
descriptor_checksum: w.descriptor_checksum(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct KeySetting {
|
||||
pub name: String,
|
||||
|
||||
@ -7,13 +7,20 @@ use std::sync::Arc;
|
||||
|
||||
use iced::Command;
|
||||
|
||||
use liana_ui::widget::Element;
|
||||
use liana_ui::{component::form, widget::Element};
|
||||
|
||||
use bitcoind::BitcoindSettingsState;
|
||||
use wallet::WalletSettingsState;
|
||||
|
||||
use crate::{
|
||||
app::{cache::Cache, error::Error, message::Message, state::State, view, wallet::Wallet},
|
||||
app::{
|
||||
cache::Cache,
|
||||
error::Error,
|
||||
message::Message,
|
||||
state::State,
|
||||
view::{self},
|
||||
wallet::Wallet,
|
||||
},
|
||||
daemon::{Daemon, DaemonBackend},
|
||||
};
|
||||
|
||||
@ -66,6 +73,12 @@ impl State for SettingsState {
|
||||
.map(|s| s.reload(daemon, wallet))
|
||||
.unwrap_or_else(Command::none)
|
||||
}
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::EditRemoteBackendSettings,
|
||||
)) => {
|
||||
self.setting = Some(BackendSettingsState::new().into());
|
||||
Command::none()
|
||||
}
|
||||
Message::View(view::Message::Settings(view::SettingsMessage::AboutSection)) => {
|
||||
self.setting = Some(AboutSettingsState::default().into());
|
||||
let wallet = self.wallet.clone();
|
||||
@ -138,19 +151,6 @@ pub struct AboutSettingsState {
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl AboutSettingsState {
|
||||
pub fn new(daemon_is_external: bool) -> Self {
|
||||
AboutSettingsState {
|
||||
daemon_version: if !daemon_is_external {
|
||||
Some(liana::VERSION.to_string())
|
||||
} else {
|
||||
None
|
||||
},
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State for AboutSettingsState {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::settings::about_section(cache, self.warning.as_ref(), self.daemon_version.as_ref())
|
||||
@ -158,13 +158,19 @@ impl State for AboutSettingsState {
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
_daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
if let Message::Info(res) = message {
|
||||
match res {
|
||||
Ok(info) => self.daemon_version = Some(info.version),
|
||||
Ok(info) => {
|
||||
if daemon.backend() == DaemonBackend::RemoteBackend {
|
||||
self.daemon_version = None;
|
||||
} else {
|
||||
self.daemon_version = Some(info.version)
|
||||
}
|
||||
}
|
||||
Err(e) => self.warning = Some(e),
|
||||
}
|
||||
}
|
||||
@ -189,3 +195,94 @@ impl From<AboutSettingsState> for Box<dyn State> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BackendSettingsState {
|
||||
email_form: form::Value<String>,
|
||||
processing: bool,
|
||||
success: bool,
|
||||
warning: Option<Error>,
|
||||
}
|
||||
|
||||
impl BackendSettingsState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
email_form: form::Value::default(),
|
||||
processing: false,
|
||||
success: false,
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State for BackendSettingsState {
|
||||
fn view<'a>(&'a self, cache: &'a Cache) -> Element<'a, view::Message> {
|
||||
view::settings::remote_backend_section(
|
||||
cache,
|
||||
&self.email_form,
|
||||
self.processing,
|
||||
self.success,
|
||||
self.warning.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
fn update(
|
||||
&mut self,
|
||||
daemon: Arc<dyn Daemon + Sync + Send>,
|
||||
_cache: &Cache,
|
||||
message: Message,
|
||||
) -> Command<Message> {
|
||||
match message {
|
||||
Message::View(view::Message::Settings(
|
||||
view::SettingsMessage::RemoteBackendSettings(message),
|
||||
)) => match message {
|
||||
view::RemoteBackendSettingsMessage::SendInvitation => {
|
||||
if !self.email_form.valid {
|
||||
return Command::none();
|
||||
}
|
||||
let email = self.email_form.value.clone();
|
||||
self.processing = true;
|
||||
self.success = false;
|
||||
self.warning = None;
|
||||
Command::perform(
|
||||
async move {
|
||||
daemon.send_wallet_invitation(&email).await?;
|
||||
Ok(())
|
||||
},
|
||||
Message::Updated,
|
||||
)
|
||||
}
|
||||
view::RemoteBackendSettingsMessage::EditInvitationEmail(email) => {
|
||||
if !self.processing {
|
||||
self.email_form.valid = email_address::EmailAddress::parse_with_options(
|
||||
&email,
|
||||
email_address::Options::default().with_required_tld(),
|
||||
)
|
||||
.is_ok();
|
||||
self.email_form.value = email;
|
||||
self.success = false;
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
},
|
||||
Message::Updated(res) => {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok(()) => self.success = true,
|
||||
Err(e) => {
|
||||
self.success = false;
|
||||
self.warning = Some(e);
|
||||
}
|
||||
}
|
||||
Command::none()
|
||||
}
|
||||
_ => Command::none(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<BackendSettingsState> for Box<dyn State> {
|
||||
fn from(s: BackendSettingsState) -> Box<dyn State> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
@ -68,6 +68,8 @@ pub enum SettingsMessage {
|
||||
EditBitcoindSettings,
|
||||
BitcoindSettings(SettingsEditMessage),
|
||||
RescanSettings(SettingsEditMessage),
|
||||
EditRemoteBackendSettings,
|
||||
RemoteBackendSettings(RemoteBackendSettingsMessage),
|
||||
EditWalletSettings,
|
||||
AboutSection,
|
||||
RegisterWallet,
|
||||
@ -75,6 +77,12 @@ pub enum SettingsMessage {
|
||||
Save,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RemoteBackendSettingsMessage {
|
||||
EditInvitationEmail(String),
|
||||
SendInvitation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SettingsEditMessage {
|
||||
Select,
|
||||
|
||||
@ -44,9 +44,9 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<Message> {
|
||||
Button::new(text("Settings").size(30).bold())
|
||||
.style(theme::Button::Transparent)
|
||||
.on_press(Message::Menu(Menu::Settings)))
|
||||
.push_maybe(
|
||||
.push(
|
||||
if !is_remote_backend {
|
||||
Some(Container::new(
|
||||
Container::new(
|
||||
Button::new(
|
||||
Row::new()
|
||||
.push(badge::Badge::new(icon::bitcoin_icon()))
|
||||
@ -61,9 +61,24 @@ pub fn list(cache: &Cache, is_remote_backend: bool) -> Element<Message> {
|
||||
.on_press(Message::Settings(SettingsMessage::EditBitcoindSettings))
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Container::Card(theme::Card::Simple)))
|
||||
.style(theme::Container::Card(theme::Card::Simple))
|
||||
} else {
|
||||
None
|
||||
Container::new(
|
||||
Button::new(
|
||||
Row::new()
|
||||
.push(badge::Badge::new(icon::bitcoin_icon()))
|
||||
.push(text("Backend").bold())
|
||||
.padding(10)
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
.on_press(Message::Settings(SettingsMessage::EditRemoteBackendSettings))
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Container::Card(theme::Card::Simple))
|
||||
}
|
||||
)
|
||||
.push(
|
||||
@ -211,6 +226,76 @@ pub fn about_section<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn remote_backend_section<'a>(
|
||||
cache: &'a Cache,
|
||||
email_form: &form::Value<String>,
|
||||
processing: bool,
|
||||
success: bool,
|
||||
warning: Option<&Error>,
|
||||
) -> Element<'a, Message> {
|
||||
dashboard(
|
||||
&Menu::Settings,
|
||||
cache,
|
||||
warning,
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(10)
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Button::new(text("Settings").size(30).bold())
|
||||
.style(theme::Button::Transparent)
|
||||
.on_press(Message::Menu(Menu::Settings)),
|
||||
)
|
||||
.push(icon::chevron_right().size(30))
|
||||
.push(
|
||||
Button::new(text("Backend").size(30).bold())
|
||||
.style(theme::Button::Transparent)
|
||||
.on_press(Message::Settings(SettingsMessage::AboutSection)),
|
||||
),
|
||||
)
|
||||
.push(
|
||||
card::simple(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(text("Grant access to wallet to another user"))
|
||||
.push(
|
||||
form::Form::new_trimmed("User email", email_form, |email| {
|
||||
Message::Settings(SettingsMessage::RemoteBackendSettings(
|
||||
RemoteBackendSettingsMessage::EditInvitationEmail(email),
|
||||
))
|
||||
})
|
||||
.warning("Email is invalid")
|
||||
.size(P1_SIZE)
|
||||
.padding(10),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.push_maybe(if success {
|
||||
Some(text("Invitation was sent").style(color::GREEN))
|
||||
} else {
|
||||
None
|
||||
})
|
||||
.push(Space::with_width(Length::Fill))
|
||||
.push(button::primary(None, "Send invitation").on_press_maybe(
|
||||
if !processing && email_form.valid {
|
||||
Some(Message::Settings(
|
||||
SettingsMessage::RemoteBackendSettings(
|
||||
RemoteBackendSettingsMessage::SendInvitation,
|
||||
),
|
||||
))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
)),
|
||||
),
|
||||
)
|
||||
.width(Length::Fill),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn bitcoind_edit<'a>(
|
||||
network: Network,
|
||||
blockheight: i32,
|
||||
|
||||
@ -105,7 +105,23 @@ impl Wallet {
|
||||
}
|
||||
Err(settings::SettingsError::NotFound) => {
|
||||
let s = settings::Settings {
|
||||
wallets: vec![settings::WalletSetting::from(&self)],
|
||||
wallets: vec![settings::WalletSetting {
|
||||
name: self.name.clone(),
|
||||
hardware_wallets: self.hardware_wallets.clone(),
|
||||
keys: self
|
||||
.keys_aliases
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|(master_fingerprint, name)| settings::KeySetting {
|
||||
name,
|
||||
master_fingerprint,
|
||||
})
|
||||
.collect(),
|
||||
descriptor_checksum: self.descriptor_checksum(),
|
||||
// Only local wallet from previous version of Liana GUI may not have a
|
||||
// settings.json file
|
||||
remote_backend_auth: None,
|
||||
}],
|
||||
};
|
||||
|
||||
tracing::info!("Settings file not found, creating one");
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fmt::Debug;
|
||||
use std::iter::FromIterator;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use liana::commands::{CoinStatus, CreateRecoveryResult};
|
||||
@ -15,7 +16,7 @@ pub mod jsonrpc;
|
||||
use liana::{
|
||||
commands::LabelItem,
|
||||
config::Config,
|
||||
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
|
||||
miniscript::bitcoin::{address, psbt::Psbt, Address, Network, OutPoint, Txid},
|
||||
};
|
||||
|
||||
use super::{model::*, Daemon, DaemonBackend, DaemonError};
|
||||
@ -63,7 +64,7 @@ impl<C: Client + Send + Sync + Debug> Daemon for Lianad<C> {
|
||||
None
|
||||
}
|
||||
|
||||
async fn is_alive(&self) -> Result<(), DaemonError> {
|
||||
async fn is_alive(&self, _datadir: &Path, _network: Network) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{model::*, Daemon, DaemonBackend, DaemonError};
|
||||
@ -6,7 +7,7 @@ use async_trait::async_trait;
|
||||
use liana::{
|
||||
commands::{CoinStatus, LabelItem},
|
||||
config::Config,
|
||||
miniscript::bitcoin::{address, psbt::Psbt, Address, OutPoint, Txid},
|
||||
miniscript::bitcoin::{address, psbt::Psbt, Address, Network, OutPoint, Txid},
|
||||
DaemonControl, DaemonHandle,
|
||||
};
|
||||
|
||||
@ -57,7 +58,7 @@ impl Daemon for EmbeddedDaemon {
|
||||
Some(&self.config)
|
||||
}
|
||||
|
||||
async fn is_alive(&self) -> Result<(), DaemonError> {
|
||||
async fn is_alive(&self, _datadir: &Path, _network: Network) -> Result<(), DaemonError> {
|
||||
let mut handle = self.handle.lock().await;
|
||||
if let Some(h) = handle.as_ref() {
|
||||
if h.is_alive() {
|
||||
|
||||
@ -7,6 +7,7 @@ use std::convert::TryInto;
|
||||
use std::fmt::Debug;
|
||||
use std::io::ErrorKind;
|
||||
use std::iter::FromIterator;
|
||||
use std::path::Path;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
@ -14,7 +15,7 @@ use liana::{
|
||||
commands::{CoinStatus, LabelItem, TransactionInfo},
|
||||
config::Config,
|
||||
miniscript::bitcoin::{
|
||||
address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, OutPoint, Txid,
|
||||
address, bip32::Fingerprint, psbt::Psbt, secp256k1, Address, Network, OutPoint, Txid,
|
||||
},
|
||||
StartupError,
|
||||
};
|
||||
@ -70,7 +71,7 @@ pub enum DaemonBackend {
|
||||
pub trait Daemon: Debug {
|
||||
fn backend(&self) -> DaemonBackend;
|
||||
fn config(&self) -> Option<&Config>;
|
||||
async fn is_alive(&self) -> Result<(), DaemonError>;
|
||||
async fn is_alive(&self, datadir: &Path, network: Network) -> Result<(), DaemonError>;
|
||||
async fn stop(&self) -> Result<(), DaemonError>;
|
||||
async fn get_info(&self) -> Result<model::GetInfoResult, DaemonError>;
|
||||
async fn get_new_address(&self) -> Result<model::GetAddressResult, DaemonError>;
|
||||
@ -118,6 +119,9 @@ pub trait Daemon: Debug {
|
||||
&self,
|
||||
labels: &HashMap<LabelItem, Option<String>>,
|
||||
) -> Result<(), DaemonError>;
|
||||
async fn send_wallet_invitation(&self, _email: &str) -> Result<(), DaemonError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// List spend transactions, optionally filtered to the specified `txids`.
|
||||
// Set `txids` to `None` for no filter (passing an empty slice returns no transactions).
|
||||
|
||||
18
gui/src/datadir.rs
Normal file
18
gui/src/datadir.rs
Normal file
@ -0,0 +1,18 @@
|
||||
pub fn create_directory(datadir_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(unix)]
|
||||
return {
|
||||
use std::fs::DirBuilder;
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = DirBuilder::new();
|
||||
builder.mode(0o700).recursive(true).create(datadir_path)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// TODO: permissions on Windows..
|
||||
#[cfg(not(unix))]
|
||||
return {
|
||||
std::fs::create_dir_all(datadir_path)?;
|
||||
Ok(())
|
||||
};
|
||||
}
|
||||
@ -3,22 +3,35 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
app::{
|
||||
settings::{KeySetting, Settings, WalletSetting},
|
||||
wallet::wallet_name,
|
||||
},
|
||||
app::settings::KeySetting,
|
||||
bitcoind::{Bitcoind, InternalBitcoindConfig},
|
||||
hw::HardwareWalletConfig,
|
||||
lianalite::client::backend::{BackendClient, BackendWalletClient},
|
||||
signer::Signer,
|
||||
};
|
||||
use async_hwi::DeviceKind;
|
||||
use liana::{
|
||||
config::Config,
|
||||
config::{BitcoinConfig, BitcoindConfig},
|
||||
descriptors::LianaDescriptor,
|
||||
miniscript::bitcoin,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RemoteBackend {
|
||||
// The installer will have to create a wallet from the created descriptor.
|
||||
WithoutWallet(BackendClient),
|
||||
// The installer will have to fetch the wallet and only install the missing configuration files.
|
||||
WithWallet(BackendWalletClient),
|
||||
}
|
||||
|
||||
impl RemoteBackend {
|
||||
pub fn user_email(&self) -> &str {
|
||||
match self {
|
||||
Self::WithWallet(b) => b.user_email(),
|
||||
Self::WithoutWallet(b) => b.user_email(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Context {
|
||||
pub bitcoin_config: BitcoinConfig,
|
||||
@ -27,6 +40,7 @@ pub struct Context {
|
||||
pub keys: Vec<KeySetting>,
|
||||
pub hws: Vec<(DeviceKind, bitcoin::bip32::Fingerprint, Option<[u8; 32]>)>,
|
||||
pub data_dir: PathBuf,
|
||||
pub network: bitcoin::Network,
|
||||
pub hw_is_used: bool,
|
||||
// In case a user entered a mnemonic,
|
||||
// we dont want to override the generated signer with it.
|
||||
@ -34,10 +48,15 @@ pub struct Context {
|
||||
pub bitcoind_is_external: bool,
|
||||
pub internal_bitcoind_config: Option<InternalBitcoindConfig>,
|
||||
pub internal_bitcoind: Option<Bitcoind>,
|
||||
pub remote_backend: Option<RemoteBackend>,
|
||||
}
|
||||
|
||||
impl Context {
|
||||
pub fn new(network: bitcoin::Network, data_dir: PathBuf) -> Self {
|
||||
pub fn new(
|
||||
network: bitcoin::Network,
|
||||
data_dir: PathBuf,
|
||||
remote_backend: Option<RemoteBackend>,
|
||||
) -> Self {
|
||||
Self {
|
||||
bitcoin_config: BitcoinConfig {
|
||||
network,
|
||||
@ -48,52 +67,13 @@ impl Context {
|
||||
bitcoind_config: None,
|
||||
descriptor: None,
|
||||
data_dir,
|
||||
network,
|
||||
hw_is_used: false,
|
||||
recovered_signer: None,
|
||||
bitcoind_is_external: true,
|
||||
internal_bitcoind_config: None,
|
||||
internal_bitcoind: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_gui_settings(&self) -> Settings {
|
||||
let hardware_wallets = self
|
||||
.hws
|
||||
.iter()
|
||||
.filter_map(|(kind, fingerprint, token)| {
|
||||
token
|
||||
.as_ref()
|
||||
.map(|token| HardwareWalletConfig::new(kind, *fingerprint, token))
|
||||
})
|
||||
.collect();
|
||||
let descriptor = self
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.expect("Must be a descriptor at this point");
|
||||
Settings {
|
||||
wallets: vec![WalletSetting {
|
||||
name: wallet_name(descriptor),
|
||||
descriptor_checksum: descriptor
|
||||
.to_string()
|
||||
.split_once('#')
|
||||
.map(|(_, checksum)| checksum)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
keys: self.keys.clone(),
|
||||
hardware_wallets,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_daemon_config(&self) -> Config {
|
||||
Config {
|
||||
#[cfg(unix)]
|
||||
daemon: false,
|
||||
log_level: log::LevelFilter::Info,
|
||||
main_descriptor: self.descriptor.clone().unwrap(),
|
||||
data_dir: Some(self.data_dir.clone()),
|
||||
bitcoin_config: self.bitcoin_config.clone(),
|
||||
bitcoind_config: self.bitcoind_config.clone(),
|
||||
remote_backend,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
use liana::miniscript::{bitcoin::bip32::Fingerprint, DescriptorPublicKey};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::Error;
|
||||
use super::{context, Error};
|
||||
use crate::{
|
||||
bitcoind::{Bitcoind, ConfigField, RpcAuthType},
|
||||
download::Progress,
|
||||
hw::HardwareWalletMessage,
|
||||
lianalite::client::{auth::AuthClient, backend::api},
|
||||
};
|
||||
use async_hwi::{DeviceKind, Version};
|
||||
|
||||
@ -28,6 +29,8 @@ pub enum Message {
|
||||
UseHotSigner,
|
||||
Installed(Result<PathBuf, Error>),
|
||||
CreateTaprootDescriptor(bool),
|
||||
SelectBackend(SelectBackend),
|
||||
ImportRemoteWallet(ImportRemoteWallet),
|
||||
SelectBitcoindType(SelectBitcoindTypeMsg),
|
||||
InternalBitcoind(InternalBitcoindMsg),
|
||||
DefineBitcoind(DefineBitcoind),
|
||||
@ -39,6 +42,33 @@ pub enum Message {
|
||||
ImportMnemonic(bool),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SelectBackend {
|
||||
// view messages
|
||||
RequestOTP,
|
||||
EditEmail,
|
||||
EmailEdited(String),
|
||||
OTPEdited(String),
|
||||
ContinueWithRemoteBackend,
|
||||
ContinueWithLocalWallet,
|
||||
// Commands messages
|
||||
OTPRequested(Result<(AuthClient, String), Error>),
|
||||
OTPResent(Result<(), Error>),
|
||||
Connected(Result<context::RemoteBackend, Error>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ImportRemoteWallet {
|
||||
RemoteWallets(Result<Vec<api::Wallet>, Error>),
|
||||
ImportDescriptor(String),
|
||||
ConfirmDescriptor,
|
||||
ImportInvitationToken(String),
|
||||
FetchInvitation,
|
||||
InvitationFetched(Result<api::WalletInvitation, Error>),
|
||||
AcceptInvitation,
|
||||
InvitationAccepted(Result<api::Wallet, Error>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DefineBitcoind {
|
||||
ConfigFieldEdited(ConfigField, String),
|
||||
|
||||
@ -5,34 +5,49 @@ mod step;
|
||||
mod view;
|
||||
|
||||
use iced::{clipboard, Command, Subscription};
|
||||
use liana::miniscript::bitcoin::{self, Network};
|
||||
use liana::{
|
||||
config::Config,
|
||||
miniscript::bitcoin::{self, Network},
|
||||
};
|
||||
use liana_ui::{
|
||||
component::network_banner,
|
||||
widget::{Column, Element},
|
||||
};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
use context::Context;
|
||||
use context::{Context, RemoteBackend};
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::{
|
||||
app::{config as gui_config, settings as gui_settings},
|
||||
hw::HardwareWallets,
|
||||
app::{
|
||||
config as gui_config, settings as gui_settings,
|
||||
settings::{AuthConfig, Settings, SettingsError, WalletSetting},
|
||||
wallet::wallet_name,
|
||||
},
|
||||
daemon::DaemonError,
|
||||
datadir::create_directory,
|
||||
hw::{HardwareWalletConfig, HardwareWallets},
|
||||
lianalite::client::{
|
||||
auth::AuthError,
|
||||
backend::{BackendClient, BackendWalletClient},
|
||||
},
|
||||
signer::Signer,
|
||||
};
|
||||
|
||||
pub use message::Message;
|
||||
use step::{
|
||||
BackupDescriptor, BackupMnemonic, DefineBitcoind, DefineDescriptor, Final, ImportDescriptor,
|
||||
InternalBitcoindStep, RecoverMnemonic, RegisterDescriptor, SelectBitcoindTypeStep, ShareXpubs,
|
||||
Step, Welcome,
|
||||
BackupDescriptor, BackupMnemonic, ChooseBackend, DefineBitcoind, DefineDescriptor, Final,
|
||||
ImportDescriptor, ImportRemoteWallet, InternalBitcoindStep, RecoverMnemonic,
|
||||
RegisterDescriptor, SelectBitcoindTypeStep, ShareXpubs, Step, Welcome,
|
||||
};
|
||||
|
||||
pub struct Installer {
|
||||
network: bitcoin::Network,
|
||||
pub network: bitcoin::Network,
|
||||
pub datadir: PathBuf,
|
||||
|
||||
current: usize,
|
||||
steps: Vec<Box<dyn Step>>,
|
||||
hws: HardwareWallets,
|
||||
@ -57,19 +72,29 @@ impl Installer {
|
||||
{
|
||||
self.current -= 1;
|
||||
}
|
||||
|
||||
if let Some(step) = self.steps.get(self.current) {
|
||||
step.revert(&mut self.context)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
destination_path: PathBuf,
|
||||
network: bitcoin::Network,
|
||||
remote_backend: Option<BackendClient>,
|
||||
) -> (Installer, Command<Message>) {
|
||||
(
|
||||
Installer {
|
||||
network,
|
||||
datadir: destination_path.clone(),
|
||||
current: 0,
|
||||
hws: HardwareWallets::new(destination_path.clone(), network),
|
||||
steps: vec![Welcome::default().into()],
|
||||
context: Context::new(network, destination_path),
|
||||
context: Context::new(
|
||||
network,
|
||||
destination_path,
|
||||
remote_backend.map(RemoteBackend::WithoutWallet),
|
||||
),
|
||||
signer: Arc::new(Mutex::new(Signer::generate(network).unwrap())),
|
||||
},
|
||||
Command::none(),
|
||||
@ -148,6 +173,7 @@ impl Installer {
|
||||
BackupMnemonic::new(self.signer.clone()).into(),
|
||||
BackupDescriptor::default().into(),
|
||||
RegisterDescriptor::new_create_wallet().into(),
|
||||
ChooseBackend::new(self.network).into(),
|
||||
SelectBitcoindTypeStep::new().into(),
|
||||
InternalBitcoindStep::new(&self.context.data_dir).into(),
|
||||
DefineBitcoind::new().into(),
|
||||
@ -165,6 +191,8 @@ impl Installer {
|
||||
Message::ImportWallet => {
|
||||
self.steps = vec![
|
||||
Welcome::default().into(),
|
||||
ChooseBackend::new(self.network).into(),
|
||||
ImportRemoteWallet::new(self.network).into(),
|
||||
ImportDescriptor::new(self.network).into(),
|
||||
RecoverMnemonic::default().into(),
|
||||
RegisterDescriptor::new_import_wallet().into(),
|
||||
@ -173,6 +201,7 @@ impl Installer {
|
||||
DefineBitcoind::new().into(),
|
||||
Final::new().into(),
|
||||
];
|
||||
|
||||
self.next()
|
||||
}
|
||||
Message::HardwareWallets(msg) => match self.hws.update(msg) {
|
||||
@ -194,10 +223,24 @@ impl Installer {
|
||||
.get_mut(self.current)
|
||||
.expect("There is always a step")
|
||||
.update(&mut self.hws, message);
|
||||
Command::perform(
|
||||
install(self.context.clone(), self.signer.clone()),
|
||||
Message::Installed,
|
||||
)
|
||||
match &self.context.remote_backend {
|
||||
Some(RemoteBackend::WithoutWallet(backend)) => Command::perform(
|
||||
create_remote_wallet(
|
||||
self.context.clone(),
|
||||
self.signer.clone(),
|
||||
backend.clone(),
|
||||
),
|
||||
Message::Installed,
|
||||
),
|
||||
Some(RemoteBackend::WithWallet(backend)) => Command::perform(
|
||||
import_remote_wallet(self.context.clone(), backend.clone()),
|
||||
Message::Installed,
|
||||
),
|
||||
None => Command::perform(
|
||||
install_local_wallet(self.context.clone(), self.signer.clone()),
|
||||
Message::Installed,
|
||||
),
|
||||
}
|
||||
}
|
||||
Message::Installed(Err(e)) => {
|
||||
let mut data_dir = self.context.data_dir.clone();
|
||||
@ -252,7 +295,11 @@ impl Installer {
|
||||
.steps
|
||||
.get(self.current)
|
||||
.expect("There is always a step")
|
||||
.view(&self.hws, self.progress());
|
||||
.view(
|
||||
&self.hws,
|
||||
self.progress(),
|
||||
self.context.remote_backend.as_ref().map(|b| b.user_email()),
|
||||
);
|
||||
|
||||
if self.network != Network::Bitcoin {
|
||||
Column::with_children(vec![network_banner(self.network).into(), content]).into()
|
||||
@ -275,8 +322,11 @@ pub fn daemon_check(cfg: liana::config::Config) -> Result<(), Error> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn install(ctx: Context, signer: Arc<Mutex<Signer>>) -> Result<PathBuf, Error> {
|
||||
let mut cfg: liana::config::Config = ctx.extract_daemon_config();
|
||||
pub async fn install_local_wallet(
|
||||
ctx: Context,
|
||||
signer: Arc<Mutex<Signer>>,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let mut cfg: liana::config::Config = extract_daemon_config(&ctx);
|
||||
let data_dir = cfg.data_dir.unwrap();
|
||||
|
||||
let data_dir = data_dir
|
||||
@ -290,6 +340,8 @@ pub async fn install(ctx: Context, signer: Arc<Mutex<Signer>>) -> Result<PathBuf
|
||||
|
||||
let mut network_datadir_path = data_dir;
|
||||
network_datadir_path.push(cfg.bitcoin_config.network.to_string());
|
||||
create_directory(&network_datadir_path)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?;
|
||||
|
||||
// Step needed because of ValueAfterTable error in the toml serialize implementation.
|
||||
let daemon_config = toml::Value::try_from(&cfg)
|
||||
@ -350,7 +402,7 @@ pub async fn install(ctx: Context, signer: Arc<Mutex<Signer>>) -> Result<PathBuf
|
||||
info!("Gui configuration file created");
|
||||
|
||||
// create liana GUI settings file
|
||||
let settings: gui_settings::Settings = ctx.extract_gui_settings();
|
||||
let settings: gui_settings::Settings = extract_local_gui_settings(&ctx).await;
|
||||
create_and_write_file(
|
||||
network_datadir_path,
|
||||
gui_settings::DEFAULT_FILE_NAME,
|
||||
@ -364,6 +416,171 @@ pub async fn install(ctx: Context, signer: Arc<Mutex<Signer>>) -> Result<PathBuf
|
||||
Ok(gui_config_path)
|
||||
}
|
||||
|
||||
pub async fn create_remote_wallet(
|
||||
ctx: Context,
|
||||
signer: Arc<Mutex<Signer>>,
|
||||
remote_backend: BackendClient,
|
||||
) -> Result<PathBuf, Error> {
|
||||
let data_dir = ctx
|
||||
.data_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to canonicalize datadir path: {}", e)))?;
|
||||
|
||||
let mut network_datadir_path = data_dir.clone();
|
||||
network_datadir_path.push(ctx.network.to_string());
|
||||
create_directory(&network_datadir_path)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?;
|
||||
|
||||
let descriptor = ctx
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.expect("There must be a descriptor at this point");
|
||||
|
||||
if descriptor
|
||||
.to_string()
|
||||
.contains(&signer.lock().unwrap().fingerprint().to_string())
|
||||
{
|
||||
signer
|
||||
.lock()
|
||||
.unwrap()
|
||||
.store(&data_dir, ctx.network)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?;
|
||||
|
||||
info!("Hot signer mnemonic stored");
|
||||
}
|
||||
|
||||
if let Some(signer) = &ctx.recovered_signer {
|
||||
signer
|
||||
.store(&data_dir, ctx.network)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?;
|
||||
|
||||
info!("Recovered signer mnemonic stored");
|
||||
}
|
||||
|
||||
let mut network_datadir_path = data_dir;
|
||||
network_datadir_path.push(ctx.network.to_string());
|
||||
|
||||
// create liana GUI configuration file
|
||||
let gui_config_path = create_and_write_file(
|
||||
network_datadir_path.clone(),
|
||||
gui_config::DEFAULT_FILE_NAME,
|
||||
toml::to_string(&gui_config::Config {
|
||||
daemon_config_path: None,
|
||||
daemon_rpc_path: None,
|
||||
log_level: Some("info".to_string()),
|
||||
debug: Some(false),
|
||||
start_internal_bitcoind: false,
|
||||
})
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
|
||||
info!("Gui configuration file created");
|
||||
|
||||
let wallet = remote_backend
|
||||
.create_wallet(&wallet_name(descriptor), descriptor)
|
||||
.await
|
||||
.map_err(|e| Error::Unexpected(e.to_string()))?;
|
||||
|
||||
let hws: Vec<HardwareWalletConfig> = ctx
|
||||
.hws
|
||||
.iter()
|
||||
.filter_map(|(kind, fingerprint, token)| {
|
||||
token
|
||||
.as_ref()
|
||||
.map(|token| HardwareWalletConfig::new(kind, *fingerprint, token))
|
||||
})
|
||||
.collect();
|
||||
let descriptor_str = descriptor.to_string();
|
||||
let aliases = ctx
|
||||
.keys
|
||||
.iter()
|
||||
.filter_map(|k| {
|
||||
if descriptor_str.contains(&k.master_fingerprint.to_string()) {
|
||||
Some((k.master_fingerprint, k.name.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
remote_backend
|
||||
.update_wallet_metadata(&wallet.id, &aliases, &hws)
|
||||
.await
|
||||
.map_err(|e| Error::Unexpected(e.to_string()))?;
|
||||
|
||||
let remote_backend = remote_backend.connect_wallet(wallet).0;
|
||||
|
||||
// create liana GUI settings file
|
||||
let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &remote_backend).await;
|
||||
create_and_write_file(
|
||||
network_datadir_path.clone(),
|
||||
gui_settings::DEFAULT_FILE_NAME,
|
||||
serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
|
||||
info!("Settings file created");
|
||||
|
||||
Ok(gui_config_path)
|
||||
}
|
||||
|
||||
pub async fn import_remote_wallet(
|
||||
ctx: Context,
|
||||
backend: BackendWalletClient,
|
||||
) -> Result<PathBuf, Error> {
|
||||
tracing::info!("Importing wallet from remote backend");
|
||||
|
||||
let data_dir = ctx
|
||||
.data_dir
|
||||
.canonicalize()
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to canonicalize datadir path: {}", e)))?;
|
||||
|
||||
if let Some(signer) = &ctx.recovered_signer {
|
||||
signer
|
||||
.store(&data_dir, ctx.network)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to store mnemonic: {}", e)))?;
|
||||
|
||||
info!("Recovered signer mnemonic stored");
|
||||
}
|
||||
|
||||
let mut network_datadir_path = data_dir;
|
||||
network_datadir_path.push(ctx.network.to_string());
|
||||
create_directory(&network_datadir_path)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to create datadir path: {}", e)))?;
|
||||
|
||||
// create liana GUI settings file
|
||||
let settings: gui_settings::Settings = extract_remote_gui_settings(&ctx, &backend).await;
|
||||
create_and_write_file(
|
||||
network_datadir_path.clone(),
|
||||
gui_settings::DEFAULT_FILE_NAME,
|
||||
serde_json::to_string_pretty(&settings)
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to serialize settings: {}", e)))?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
|
||||
info!("Settings file created");
|
||||
|
||||
// create liana GUI configuration file
|
||||
let gui_config_path = create_and_write_file(
|
||||
network_datadir_path.clone(),
|
||||
gui_config::DEFAULT_FILE_NAME,
|
||||
toml::to_string(&gui_config::Config {
|
||||
daemon_config_path: None,
|
||||
daemon_rpc_path: None,
|
||||
log_level: Some("info".to_string()),
|
||||
debug: Some(false),
|
||||
start_internal_bitcoind: false,
|
||||
})
|
||||
.map_err(|e| Error::Unexpected(format!("Failed to serialize gui config: {}", e)))?
|
||||
.as_bytes(),
|
||||
)?;
|
||||
|
||||
info!("Gui configuration file created");
|
||||
|
||||
Ok(gui_config_path)
|
||||
}
|
||||
|
||||
pub fn create_and_write_file(
|
||||
mut network_datadir: PathBuf,
|
||||
file_name: &str,
|
||||
@ -378,8 +595,93 @@ pub fn create_and_write_file(
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
// if the wallet is using the remote backend, then the hardware wallet settings and
|
||||
// keys will be store on the remote backend side and not in the settings file.
|
||||
pub async fn extract_remote_gui_settings(ctx: &Context, backend: &BackendWalletClient) -> Settings {
|
||||
let descriptor = ctx
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.expect("Context must have a descriptor at this point");
|
||||
|
||||
let descriptor_checksum = descriptor
|
||||
.to_string()
|
||||
.split_once('#')
|
||||
.map(|(_, checksum)| checksum)
|
||||
.expect("LianaDescriptor.to_string() always include the checksum")
|
||||
.to_string();
|
||||
|
||||
let auth = backend.inner_client().auth.read().await;
|
||||
|
||||
Settings {
|
||||
wallets: vec![WalletSetting {
|
||||
name: wallet_name(descriptor),
|
||||
descriptor_checksum,
|
||||
keys: Vec::new(),
|
||||
hardware_wallets: Vec::new(),
|
||||
remote_backend_auth: Some(AuthConfig {
|
||||
email: backend.user_email().to_string(),
|
||||
wallet_id: backend.wallet_id(),
|
||||
refresh_token: auth.refresh_token.clone(),
|
||||
}),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn extract_local_gui_settings(ctx: &Context) -> Settings {
|
||||
let descriptor = ctx
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.expect("Context must have a descriptor at this point");
|
||||
|
||||
let descriptor_checksum = descriptor
|
||||
.to_string()
|
||||
.split_once('#')
|
||||
.map(|(_, checksum)| checksum)
|
||||
.expect("LianaDescriptor.to_string() always include the checksum")
|
||||
.to_string();
|
||||
|
||||
let hardware_wallets = ctx
|
||||
.hws
|
||||
.iter()
|
||||
.filter_map(|(kind, fingerprint, token)| {
|
||||
token
|
||||
.as_ref()
|
||||
.map(|token| HardwareWalletConfig::new(kind, *fingerprint, token))
|
||||
})
|
||||
.collect();
|
||||
Settings {
|
||||
wallets: vec![WalletSetting {
|
||||
name: wallet_name(descriptor),
|
||||
descriptor_checksum,
|
||||
keys: ctx.keys.clone(),
|
||||
hardware_wallets,
|
||||
remote_backend_auth: None,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extract_daemon_config(ctx: &Context) -> Config {
|
||||
Config {
|
||||
#[cfg(unix)]
|
||||
daemon: false,
|
||||
log_level: log::LevelFilter::Info,
|
||||
main_descriptor: ctx
|
||||
.descriptor
|
||||
.clone()
|
||||
.expect("Context must have a descriptor at this point"),
|
||||
data_dir: Some(ctx.data_dir.clone()),
|
||||
bitcoin_config: ctx.bitcoin_config.clone(),
|
||||
bitcoind_config: ctx.bitcoind_config.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
Auth(AuthError),
|
||||
// DaemonError does not implement Clone.
|
||||
// TODO: maybe Arc is overkill
|
||||
Backend(Arc<DaemonError>),
|
||||
Settings(SettingsError),
|
||||
Bitcoind(String),
|
||||
CannotCreateDatadir(String),
|
||||
CannotCreateFile(String),
|
||||
@ -407,9 +709,30 @@ impl From<async_hwi::Error> for Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DaemonError> for Error {
|
||||
fn from(value: DaemonError) -> Self {
|
||||
Self::Backend(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for Error {
|
||||
fn from(value: AuthError) -> Self {
|
||||
Self::Auth(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SettingsError> for Error {
|
||||
fn from(value: SettingsError) -> Self {
|
||||
Self::Settings(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Auth(e) => write!(f, "Authentification error: {}", e),
|
||||
Self::Backend(e) => write!(f, "Remote backend error: {}", e),
|
||||
Self::Settings(e) => write!(f, "Settings file error: {}", e),
|
||||
Self::Bitcoind(e) => write!(f, "Failed to ping bitcoind: {}", e),
|
||||
Self::CannotCreateDatadir(e) => write!(f, "Failed to create datadir: {}", e),
|
||||
Self::CannotGetAvailablePort(e) => write!(f, "Failed to get available port: {}", e),
|
||||
|
||||
552
gui/src/installer/step/backend.rs
Normal file
552
gui/src/installer/step/backend.rs
Normal file
@ -0,0 +1,552 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use iced::Command;
|
||||
|
||||
use liana::{descriptors::LianaDescriptor, miniscript::bitcoin::Network};
|
||||
use liana_ui::{component::form, widget::Element};
|
||||
|
||||
use crate::{
|
||||
daemon::DaemonError,
|
||||
hw::HardwareWallets,
|
||||
installer::{
|
||||
context::{self, Context, RemoteBackend},
|
||||
message::{self, Message},
|
||||
step::Step,
|
||||
view, Error,
|
||||
},
|
||||
lianalite::client::{
|
||||
self,
|
||||
auth::{AuthClient, AuthError},
|
||||
backend::{api, BackendClient},
|
||||
},
|
||||
};
|
||||
|
||||
pub enum ConnectionStep {
|
||||
EnterEmail {
|
||||
email: form::Value<String>,
|
||||
},
|
||||
EnterOtp {
|
||||
client: AuthClient,
|
||||
backend_api_url: String,
|
||||
email: String,
|
||||
otp: form::Value<String>,
|
||||
},
|
||||
Connected {
|
||||
email: String,
|
||||
remote_backend: context::RemoteBackend,
|
||||
remote_backend_is_selected: bool,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct ChooseBackend {
|
||||
network: Network,
|
||||
processing: bool,
|
||||
step: ConnectionStep,
|
||||
connection_error: Option<Error>,
|
||||
auth_error: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ChooseBackend {
|
||||
pub fn new(network: Network) -> Self {
|
||||
Self {
|
||||
network,
|
||||
step: ConnectionStep::EnterEmail {
|
||||
email: form::Value::default(),
|
||||
},
|
||||
connection_error: None,
|
||||
auth_error: None,
|
||||
processing: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChooseBackend> for Box<dyn Step> {
|
||||
fn from(s: ChooseBackend) -> Box<dyn Step> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for ChooseBackend {
|
||||
fn skip(&self, _ctx: &Context) -> bool {
|
||||
self.network != Network::Bitcoin && self.network != Network::Signet
|
||||
}
|
||||
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
|
||||
if matches!(
|
||||
message,
|
||||
Message::SelectBackend(message::SelectBackend::ContinueWithLocalWallet)
|
||||
) {
|
||||
if let ConnectionStep::Connected {
|
||||
remote_backend_is_selected,
|
||||
..
|
||||
} = &mut self.step
|
||||
{
|
||||
*remote_backend_is_selected = false;
|
||||
}
|
||||
return Command::perform(async move {}, |_| Message::Next);
|
||||
}
|
||||
match &mut self.step {
|
||||
ConnectionStep::EnterEmail { email } => match message {
|
||||
Message::SelectBackend(message::SelectBackend::EmailEdited(value)) => {
|
||||
email.valid = value.is_empty()
|
||||
|| email_address::EmailAddress::parse_with_options(
|
||||
&value,
|
||||
email_address::Options::default().with_required_tld(),
|
||||
)
|
||||
.is_ok();
|
||||
email.value = value;
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::RequestOTP) => {
|
||||
if email.value.is_empty() {
|
||||
email.valid = false;
|
||||
} else if email.valid {
|
||||
let email = email.value.clone();
|
||||
let network = self.network;
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
let config =
|
||||
client::get_service_config(network).await.map_err(|e| {
|
||||
if e.status() == Some(reqwest::StatusCode::NOT_FOUND) {
|
||||
Error::Unexpected(
|
||||
"Remote servers are unresponsive".to_string(),
|
||||
)
|
||||
} else {
|
||||
Error::Unexpected(e.to_string())
|
||||
}
|
||||
})?;
|
||||
let client = AuthClient::new(
|
||||
config.auth_api_url,
|
||||
config.auth_api_public_key,
|
||||
email,
|
||||
);
|
||||
client.sign_in_otp().await?;
|
||||
Ok((client, config.backend_api_url))
|
||||
},
|
||||
|res| Message::SelectBackend(message::SelectBackend::OTPRequested(res)),
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::OTPRequested(res)) => {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok((client, backend_api_url)) => {
|
||||
self.step = ConnectionStep::EnterOtp {
|
||||
email: email.value.to_owned(),
|
||||
otp: form::Value::default(),
|
||||
client,
|
||||
backend_api_url,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ConnectionStep::EnterOtp {
|
||||
client,
|
||||
email,
|
||||
otp,
|
||||
backend_api_url,
|
||||
} => match message {
|
||||
Message::SelectBackend(message::SelectBackend::EditEmail) => {
|
||||
self.step = ConnectionStep::EnterEmail {
|
||||
email: form::Value {
|
||||
value: email.clone(),
|
||||
valid: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::RequestOTP) => {
|
||||
*otp = form::Value::default();
|
||||
let client = client.clone();
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
client.resend_otp().await?;
|
||||
Ok(())
|
||||
},
|
||||
message::SelectBackend::OTPResent,
|
||||
)
|
||||
.map(Message::SelectBackend);
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::OTPResent(res)) => {
|
||||
self.processing = false;
|
||||
if let Err(e) = res {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::OTPEdited(value)) => {
|
||||
otp.value = value.trim().to_string();
|
||||
if otp.value.len() == 6 {
|
||||
let client = client.clone();
|
||||
let otp = otp.value.clone();
|
||||
let backend_api_url = backend_api_url.clone();
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
return Command::perform(
|
||||
async move { connect(client, otp, backend_api_url).await },
|
||||
message::SelectBackend::Connected,
|
||||
)
|
||||
.map(Message::SelectBackend);
|
||||
}
|
||||
}
|
||||
|
||||
Message::SelectBackend(message::SelectBackend::Connected(res)) => {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok(remote_backend) => {
|
||||
self.step = ConnectionStep::Connected {
|
||||
email: email.clone(),
|
||||
remote_backend,
|
||||
remote_backend_is_selected: false,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
if let Error::Auth(AuthError { http_status, .. }) = e {
|
||||
if http_status == Some(403) {
|
||||
self.auth_error = Some("Token has expired or is invalid")
|
||||
} else {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
} else {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ConnectionStep::Connected {
|
||||
remote_backend_is_selected,
|
||||
..
|
||||
} => match message {
|
||||
Message::SelectBackend(message::SelectBackend::EditEmail) => {
|
||||
self.step = ConnectionStep::EnterEmail {
|
||||
email: form::Value::default(),
|
||||
}
|
||||
}
|
||||
Message::SelectBackend(message::SelectBackend::ContinueWithRemoteBackend) => {
|
||||
*remote_backend_is_selected = true;
|
||||
return Command::perform(async move {}, |_| Message::Next);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn apply(&mut self, ctx: &mut Context) -> bool {
|
||||
if let ConnectionStep::Connected {
|
||||
remote_backend,
|
||||
remote_backend_is_selected,
|
||||
..
|
||||
} = &self.step
|
||||
{
|
||||
if *remote_backend_is_selected {
|
||||
ctx.remote_backend = Some(remote_backend.clone());
|
||||
}
|
||||
} else {
|
||||
ctx.remote_backend = None;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// If user clicks on previous to get back to the select backend, we revert the applied remote
|
||||
/// backend on the context.
|
||||
fn revert(&self, ctx: &mut Context) {
|
||||
ctx.remote_backend = None;
|
||||
}
|
||||
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
_email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::choose_backend(
|
||||
progress,
|
||||
match &self.step {
|
||||
ConnectionStep::EnterEmail { email } => view::connection_step_enter_email(
|
||||
email,
|
||||
self.processing,
|
||||
self.connection_error.as_ref(),
|
||||
self.auth_error,
|
||||
),
|
||||
ConnectionStep::EnterOtp { email, otp, .. } => view::connection_step_enter_otp(
|
||||
email,
|
||||
otp,
|
||||
self.processing,
|
||||
self.connection_error.as_ref(),
|
||||
self.auth_error,
|
||||
),
|
||||
ConnectionStep::Connected { email, .. } => view::connection_step_connected(
|
||||
email,
|
||||
self.processing,
|
||||
self.connection_error.as_ref(),
|
||||
self.auth_error,
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
auth: AuthClient,
|
||||
token: String,
|
||||
backend_api_url: String,
|
||||
) -> Result<context::RemoteBackend, Error> {
|
||||
let access = auth.verify_otp(token.trim_end()).await?;
|
||||
let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?;
|
||||
Ok(RemoteBackend::WithoutWallet(client))
|
||||
}
|
||||
|
||||
pub struct ImportRemoteWallet {
|
||||
network: Network,
|
||||
invitation_token: form::Value<String>,
|
||||
invitation: Option<api::WalletInvitation>,
|
||||
imported_descriptor: form::Value<String>,
|
||||
descriptor: Option<LianaDescriptor>,
|
||||
error: Option<String>,
|
||||
backend: Option<context::RemoteBackend>,
|
||||
wallets: Vec<api::Wallet>,
|
||||
}
|
||||
|
||||
impl ImportRemoteWallet {
|
||||
pub fn new(network: Network) -> Self {
|
||||
Self {
|
||||
network,
|
||||
invitation_token: form::Value::default(),
|
||||
invitation: None,
|
||||
imported_descriptor: form::Value::default(),
|
||||
descriptor: None,
|
||||
error: None,
|
||||
backend: None,
|
||||
wallets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Step for ImportRemoteWallet {
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
ctx.remote_backend.is_none()
|
||||
}
|
||||
fn load_context(&mut self, ctx: &Context) {
|
||||
self.backend.clone_from(&ctx.remote_backend);
|
||||
}
|
||||
fn load(&self) -> Command<Message> {
|
||||
let backend = self
|
||||
.backend
|
||||
.clone()
|
||||
.expect("Must be one otherwise the step is skipped");
|
||||
Command::perform(
|
||||
async move {
|
||||
let wallets = match backend {
|
||||
context::RemoteBackend::WithoutWallet(backend) => {
|
||||
backend.list_wallets().await?
|
||||
}
|
||||
context::RemoteBackend::WithWallet(backend) => {
|
||||
backend.inner_client().list_wallets().await?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(wallets)
|
||||
},
|
||||
|res| Message::ImportRemoteWallet(message::ImportRemoteWallet::RemoteWallets(res)),
|
||||
)
|
||||
}
|
||||
// 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, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
|
||||
match message {
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptor(desc)) => {
|
||||
self.imported_descriptor.value = desc;
|
||||
if !self.imported_descriptor.value.is_empty() {
|
||||
if let Ok(desc) = LianaDescriptor::from_str(&self.imported_descriptor.value) {
|
||||
if self.network == Network::Bitcoin {
|
||||
self.imported_descriptor.valid = desc.all_xpubs_net_is(self.network);
|
||||
} else {
|
||||
self.imported_descriptor.valid =
|
||||
desc.all_xpubs_net_is(Network::Testnet);
|
||||
}
|
||||
} else {
|
||||
self.imported_descriptor.valid = false;
|
||||
}
|
||||
} else {
|
||||
self.imported_descriptor.valid = false;
|
||||
}
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::ConfirmDescriptor) => {
|
||||
if let Ok(desc) = LianaDescriptor::from_str(&self.imported_descriptor.value) {
|
||||
if self.network == Network::Bitcoin {
|
||||
self.imported_descriptor.valid = desc.all_xpubs_net_is(self.network);
|
||||
} else {
|
||||
self.imported_descriptor.valid = desc.all_xpubs_net_is(Network::Testnet);
|
||||
}
|
||||
if self.imported_descriptor.valid {
|
||||
let backend = self.backend.take();
|
||||
if let Some(context::RemoteBackend::WithWallet(backend)) = backend {
|
||||
self.backend =
|
||||
Some(context::RemoteBackend::WithoutWallet(backend.into_inner()));
|
||||
} else {
|
||||
self.backend = backend;
|
||||
}
|
||||
self.descriptor = Some(desc);
|
||||
return Command::perform(async {}, |_| Message::Next);
|
||||
}
|
||||
} else {
|
||||
self.imported_descriptor.valid = false;
|
||||
}
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::RemoteWallets(res)) => {
|
||||
match res {
|
||||
Ok(wallets) => self.wallets = wallets,
|
||||
Err(e) => self.error = Some(e.to_string()),
|
||||
}
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportInvitationToken(
|
||||
token,
|
||||
)) => {
|
||||
self.invitation_token.value = token;
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::FetchInvitation) => {
|
||||
let backend = self
|
||||
.backend
|
||||
.clone()
|
||||
.map(|b| match b {
|
||||
context::RemoteBackend::WithoutWallet(b) => b,
|
||||
context::RemoteBackend::WithWallet(b) => b.into_inner(),
|
||||
})
|
||||
.expect("Must be a remote backend at this point");
|
||||
let token = self.invitation_token.value.clone();
|
||||
self.error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
let invitation = backend.get_wallet_invitation(&token).await?;
|
||||
Ok(invitation)
|
||||
},
|
||||
|res| {
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationFetched(
|
||||
res,
|
||||
))
|
||||
},
|
||||
);
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationFetched(res)) => {
|
||||
match res {
|
||||
Err(_) => self.invitation_token.valid = false,
|
||||
Ok(invitation) => self.invitation = Some(invitation),
|
||||
}
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::AcceptInvitation) => {
|
||||
let backend = self
|
||||
.backend
|
||||
.clone()
|
||||
.map(|b| match b {
|
||||
context::RemoteBackend::WithoutWallet(b) => b,
|
||||
context::RemoteBackend::WithWallet(b) => b.into_inner(),
|
||||
})
|
||||
.expect("Must be a remote backend at this point");
|
||||
let invitation = self.invitation.clone().expect("Invitation was fetched");
|
||||
self.error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
backend.accept_wallet_invitation(&invitation.id).await?;
|
||||
let wallets = backend.list_wallets().await?;
|
||||
wallets
|
||||
.into_iter()
|
||||
.find(|w| w.id == invitation.wallet_id)
|
||||
.ok_or(
|
||||
DaemonError::Unexpected(
|
||||
"Wallet of accepted invitation not found".to_string(),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
},
|
||||
|res| {
|
||||
Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::InvitationAccepted(res),
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationAccepted(res)) => {
|
||||
match res {
|
||||
Err(e) => self.error = Some(e.to_string()),
|
||||
Ok(wallet) => {
|
||||
self.invitation = None;
|
||||
self.invitation_token = form::Value::default();
|
||||
self.wallets.push(wallet);
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::Select(i) => {
|
||||
if let Some(wallet) = self.wallets.get(i).cloned() {
|
||||
if let Some(backend) = self.backend.take() {
|
||||
self.backend = Some(match backend {
|
||||
context::RemoteBackend::WithoutWallet(backend) => {
|
||||
context::RemoteBackend::WithWallet(
|
||||
backend.connect_wallet(wallet.clone()).0,
|
||||
)
|
||||
}
|
||||
context::RemoteBackend::WithWallet(backend) => {
|
||||
context::RemoteBackend::WithWallet(
|
||||
backend.into_inner().connect_wallet(wallet.clone()).0,
|
||||
)
|
||||
}
|
||||
});
|
||||
// ensure that no descriptor is imported.
|
||||
self.imported_descriptor = form::Value::default();
|
||||
self.descriptor = Some(wallet.descriptor);
|
||||
return Command::perform(async {}, |_| Message::Next);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn apply(&mut self, ctx: &mut Context) -> bool {
|
||||
// Set to true in order to force the registration process to be shown to user.
|
||||
ctx.hw_is_used = true;
|
||||
ctx.descriptor.clone_from(&self.descriptor);
|
||||
ctx.remote_backend.clone_from(&self.backend);
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::import_wallet_or_descriptor(
|
||||
progress,
|
||||
email,
|
||||
&self.invitation_token,
|
||||
self.invitation
|
||||
.as_ref()
|
||||
.map(|invit| invit.wallet_name.as_str()),
|
||||
&self.imported_descriptor,
|
||||
self.error.as_ref(),
|
||||
self.wallets.iter().map(|w| &w.name).collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ImportRemoteWallet> for Box<dyn Step> {
|
||||
fn from(s: ImportRemoteWallet) -> Box<dyn Step> {
|
||||
Box::new(s)
|
||||
}
|
||||
}
|
||||
@ -302,6 +302,9 @@ impl SelectBitcoindTypeStep {
|
||||
}
|
||||
|
||||
impl Step for SelectBitcoindTypeStep {
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
ctx.remote_backend.is_some()
|
||||
}
|
||||
fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
|
||||
if let Message::SelectBitcoindType(msg) = message {
|
||||
match msg {
|
||||
@ -326,7 +329,12 @@ impl Step for SelectBitcoindTypeStep {
|
||||
true
|
||||
}
|
||||
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view(
|
||||
&self,
|
||||
_hws: &HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
_email: Option<&str>,
|
||||
) -> Element<Message> {
|
||||
view::select_bitcoind_type(progress)
|
||||
}
|
||||
}
|
||||
@ -479,7 +487,12 @@ impl Step for DefineBitcoind {
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view(
|
||||
&self,
|
||||
_hws: &HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
_email: Option<&str>,
|
||||
) -> Element<Message> {
|
||||
view::define_bitcoin(
|
||||
progress,
|
||||
&self.address,
|
||||
@ -494,7 +507,7 @@ impl Step for DefineBitcoind {
|
||||
}
|
||||
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
!ctx.bitcoind_is_external
|
||||
!ctx.bitcoind_is_external || ctx.remote_backend.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
@ -787,7 +800,12 @@ impl Step for InternalBitcoindStep {
|
||||
false
|
||||
}
|
||||
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view(
|
||||
&self,
|
||||
_hws: &HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
_email: Option<&str>,
|
||||
) -> Element<Message> {
|
||||
view::start_internal_bitcoind(
|
||||
progress,
|
||||
self.exe_path.as_ref(),
|
||||
@ -806,7 +824,7 @@ impl Step for InternalBitcoindStep {
|
||||
}
|
||||
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
ctx.bitcoind_is_external
|
||||
ctx.bitcoind_is_external || ctx.remote_backend.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -548,10 +548,12 @@ impl Step for DefineDescriptor {
|
||||
&'a self,
|
||||
hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<'a, Message> {
|
||||
let aliases = self.setup.keys_aliases();
|
||||
let content = view::define_descriptor(
|
||||
progress,
|
||||
email,
|
||||
self.use_taproot,
|
||||
self.setup
|
||||
.spending_keys
|
||||
@ -1141,6 +1143,10 @@ impl ImportDescriptor {
|
||||
}
|
||||
|
||||
impl Step for ImportDescriptor {
|
||||
// ImportRemoteWallet is used instead
|
||||
fn skip(&self, ctx: &Context) -> bool {
|
||||
ctx.remote_backend.is_some()
|
||||
}
|
||||
// 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, _hws: &mut HardwareWallets, message: Message) -> Command<Message> {
|
||||
@ -1166,9 +1172,15 @@ impl Step for ImportDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::import_descriptor(
|
||||
progress,
|
||||
email,
|
||||
&self.imported_descriptor,
|
||||
self.wrong_network,
|
||||
self.error.as_ref(),
|
||||
@ -1310,10 +1322,12 @@ impl Step for RegisterDescriptor {
|
||||
&'a self,
|
||||
hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<'a, Message> {
|
||||
let desc = self.descriptor.as_ref().unwrap();
|
||||
view::register_descriptor(
|
||||
progress,
|
||||
email,
|
||||
desc.to_string(),
|
||||
&hws.list,
|
||||
&self.registered,
|
||||
@ -1364,9 +1378,14 @@ impl Step for BackupDescriptor {
|
||||
self.done = false;
|
||||
}
|
||||
}
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
let desc = self.descriptor.as_ref().unwrap();
|
||||
view::backup_descriptor(progress, desc.to_string(), self.done)
|
||||
view::backup_descriptor(progress, email, desc.to_string(), self.done)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1416,7 +1435,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_define_descriptor_use_hotkey() {
|
||||
let mut ctx = Context::new(Network::Signet, PathBuf::from_str("/").unwrap());
|
||||
let mut ctx = Context::new(Network::Signet, PathBuf::from_str("/").unwrap(), None);
|
||||
let sandbox: Sandbox<DefineDescriptor> = Sandbox::new(DefineDescriptor::new(
|
||||
Network::Bitcoin,
|
||||
Arc::new(Mutex::new(Signer::generate(Network::Bitcoin).unwrap())),
|
||||
@ -1498,7 +1517,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_define_descriptor_stores_if_hw_is_used() {
|
||||
let mut ctx = Context::new(Network::Testnet, PathBuf::from_str("/").unwrap());
|
||||
let mut ctx = Context::new(Network::Testnet, PathBuf::from_str("/").unwrap(), None);
|
||||
let sandbox: Sandbox<DefineDescriptor> = Sandbox::new(DefineDescriptor::new(
|
||||
Network::Testnet,
|
||||
Arc::new(Mutex::new(Signer::generate(Network::Testnet).unwrap())),
|
||||
|
||||
@ -51,8 +51,13 @@ impl Step for BackupMnemonic {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
view::backup_mnemonic(progress, &self.words, self.done)
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::backup_mnemonic(progress, email, &self.words, self.done)
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,9 +168,15 @@ impl Step for RecoverMnemonic {
|
||||
ctx.recovered_signer = Some(Arc::new(signer));
|
||||
true
|
||||
}
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::recover_mnemonic(
|
||||
progress,
|
||||
email,
|
||||
&self.words,
|
||||
self.current,
|
||||
&self.suggestions,
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
mod backend;
|
||||
mod bitcoind;
|
||||
mod descriptor;
|
||||
mod mnemonic;
|
||||
@ -9,6 +10,7 @@ pub use bitcoind::{
|
||||
|
||||
pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor};
|
||||
|
||||
pub use backend::{ChooseBackend, ImportRemoteWallet};
|
||||
pub use mnemonic::{BackupMnemonic, RecoverMnemonic};
|
||||
pub use share_xpubs::ShareXpubs;
|
||||
|
||||
@ -35,6 +37,7 @@ pub trait Step {
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<'a, Message>;
|
||||
|
||||
fn load_context(&mut self, _ctx: &Context) {}
|
||||
@ -47,6 +50,7 @@ pub trait Step {
|
||||
fn apply(&mut self, _ctx: &mut Context) -> bool {
|
||||
true
|
||||
}
|
||||
fn revert(&self, _ctx: &mut Context) {}
|
||||
fn stop(&self) {}
|
||||
}
|
||||
|
||||
@ -54,7 +58,12 @@ pub trait Step {
|
||||
pub struct Welcome {}
|
||||
|
||||
impl Step for Welcome {
|
||||
fn view(&self, _hws: &HardwareWallets, _progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
_progress: (usize, usize),
|
||||
_email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::welcome()
|
||||
}
|
||||
}
|
||||
@ -128,9 +137,15 @@ impl Step for Final {
|
||||
Command::none()
|
||||
}
|
||||
|
||||
fn view(&self, _hws: &HardwareWallets, progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
_hws: &'a HardwareWallets,
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::install(
|
||||
progress,
|
||||
email,
|
||||
self.generating,
|
||||
self.config_path.as_ref(),
|
||||
self.warning.as_ref(),
|
||||
|
||||
@ -160,8 +160,14 @@ impl Step for ShareXpubs {
|
||||
true
|
||||
}
|
||||
|
||||
fn view<'a>(&'a self, hws: &'a HardwareWallets, _progress: (usize, usize)) -> Element<Message> {
|
||||
fn view<'a>(
|
||||
&'a self,
|
||||
hws: &'a HardwareWallets,
|
||||
_progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
) -> Element<Message> {
|
||||
view::share_xpubs(
|
||||
email,
|
||||
hws.list
|
||||
.iter()
|
||||
.enumerate()
|
||||
|
||||
@ -16,7 +16,7 @@ use liana_ui::{
|
||||
color,
|
||||
component::{
|
||||
button, card, collapse, form, hw, separation,
|
||||
text::{h3, p1_regular, text, Text},
|
||||
text::{h3, h4_bold, h5_regular, p1_regular, text, Text},
|
||||
tooltip,
|
||||
},
|
||||
icon, image, theme,
|
||||
@ -187,6 +187,7 @@ pub fn define_descriptor_advanced_settings<'a>(use_taproot: bool) -> Element<'a,
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn define_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
use_taproot: bool,
|
||||
spending_keys: Vec<Element<'a, Message>>,
|
||||
spending_threshold: usize,
|
||||
@ -253,6 +254,7 @@ pub fn define_descriptor<'a>(
|
||||
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Create the wallet",
|
||||
Column::new()
|
||||
.push(collapse::Collapse::new(
|
||||
@ -374,8 +376,223 @@ pub fn recovery_path_view(
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn import_wallet_or_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
invitation: &'a form::Value<String>,
|
||||
invitation_wallet: Option<&'a str>,
|
||||
imported_descriptor: &'a form::Value<String>,
|
||||
error: Option<&'a String>,
|
||||
wallets: Vec<&'a String>,
|
||||
) -> Element<'a, Message> {
|
||||
let mut col_wallets = Column::new()
|
||||
.spacing(20)
|
||||
.push(h4_bold("Choose the wallet to import"));
|
||||
let no_wallets = wallets.is_empty();
|
||||
for (i, wallet) in wallets.into_iter().enumerate() {
|
||||
col_wallets = col_wallets.push(
|
||||
Button::new(h5_regular(wallet).width(Length::Fill))
|
||||
.padding(10)
|
||||
.on_press(Message::Select(i)),
|
||||
);
|
||||
}
|
||||
let card_wallets: Element<'a, Message> = if no_wallets {
|
||||
h4_bold("You have no current wallets").into()
|
||||
} else {
|
||||
card::simple(col_wallets).into()
|
||||
};
|
||||
|
||||
let col_invitation_token = collapse::Collapse::new(
|
||||
|| {
|
||||
Button::new(
|
||||
Column::new()
|
||||
.spacing(5)
|
||||
.push(h4_bold("Join a shared wallet").style(color::WHITE))
|
||||
.push(
|
||||
text("If you received an invitation to join a shared wallet")
|
||||
.style(color::GREY_3),
|
||||
),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
|| {
|
||||
Button::new(
|
||||
Column::new()
|
||||
.spacing(5)
|
||||
.push(h4_bold("Join a shared wallet").style(color::WHITE))
|
||||
.push(
|
||||
text("If you received an invitation to join a shared wallet")
|
||||
.style(color::GREY_3),
|
||||
),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
if let Some(wallet) = invitation_wallet {
|
||||
Element::<'a, Message>::from(
|
||||
Column::new()
|
||||
.push(Space::with_height(0))
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(5)
|
||||
.push(text("Accept invitation for wallet:"))
|
||||
.push(text(wallet).bold()),
|
||||
)
|
||||
.push(
|
||||
Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
button::primary(None, "Accept")
|
||||
.width(Length::Fixed(200.0))
|
||||
.on_press(Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::AcceptInvitation,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.spacing(20),
|
||||
)
|
||||
} else {
|
||||
Element::<'a, Message>::from(
|
||||
Container::new(
|
||||
Column::new()
|
||||
.push(Space::with_height(0))
|
||||
.push(
|
||||
Column::new()
|
||||
.push(text("Paste invitation:").bold())
|
||||
.push(
|
||||
form::Form::new_trimmed("Invitation", invitation, |msg| {
|
||||
Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::ImportInvitationToken(
|
||||
msg,
|
||||
),
|
||||
)
|
||||
})
|
||||
.warning("Invitation token is invalid or expired")
|
||||
.size(text::P1_SIZE)
|
||||
.padding(10),
|
||||
)
|
||||
.spacing(10),
|
||||
)
|
||||
.push(
|
||||
Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
button::primary(None, "Next")
|
||||
.width(Length::Fixed(200.0))
|
||||
.on_press_maybe(if !invitation.value.is_empty() {
|
||||
Some(Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::FetchInvitation,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
),
|
||||
)
|
||||
.spacing(20),
|
||||
)
|
||||
.padding(15),
|
||||
)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let col_descriptor = collapse::Collapse::new(
|
||||
|| {
|
||||
Button::new(
|
||||
Column::new()
|
||||
.spacing(5)
|
||||
.push(h4_bold("Import a wallet from descriptor").style(color::WHITE))
|
||||
.push(
|
||||
text("The remote backend will rescan the blockchain to find your coins")
|
||||
.style(color::GREY_3),
|
||||
),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
|| {
|
||||
Button::new(
|
||||
Column::new()
|
||||
.spacing(5)
|
||||
.push(h4_bold("Import a wallet from descriptor").style(color::WHITE))
|
||||
.push(
|
||||
text("The remote backend will rescan the blockchain to find your coins")
|
||||
.style(color::GREY_3),
|
||||
),
|
||||
)
|
||||
.padding(15)
|
||||
.width(Length::Fill)
|
||||
.style(theme::Button::TransparentBorder)
|
||||
},
|
||||
move || {
|
||||
Element::<'a, Message>::from(
|
||||
Container::new(
|
||||
Column::new()
|
||||
.push(Space::with_height(0))
|
||||
.push(
|
||||
Column::new()
|
||||
.push(text("Descriptor:").bold())
|
||||
.push(
|
||||
form::Form::new_trimmed(
|
||||
"Descriptor",
|
||||
imported_descriptor,
|
||||
|msg| {
|
||||
Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::ImportDescriptor(msg),
|
||||
)
|
||||
},
|
||||
)
|
||||
.warning(
|
||||
"Either descriptor is invalid or incompatible with network",
|
||||
)
|
||||
.size(text::P1_SIZE)
|
||||
.padding(10),
|
||||
)
|
||||
.spacing(10),
|
||||
)
|
||||
.push(
|
||||
Row::new().push(Space::with_width(Length::Fill)).push(
|
||||
button::primary(None, "Next")
|
||||
.width(Length::Fixed(200.0))
|
||||
.on_press_maybe(
|
||||
if imported_descriptor.value.is_empty()
|
||||
|| !imported_descriptor.valid
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(Message::ImportRemoteWallet(
|
||||
message::ImportRemoteWallet::ConfirmDescriptor,
|
||||
))
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
.spacing(20),
|
||||
)
|
||||
.padding(15),
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Add wallet",
|
||||
Column::new()
|
||||
.spacing(50)
|
||||
.push_maybe(error.map(|e| card::error("Something wrong happened", e.to_string())))
|
||||
.push(card_wallets)
|
||||
.push(card::simple(col_invitation_token).padding(0))
|
||||
.push(card::simple(col_descriptor).padding(0)),
|
||||
true,
|
||||
Some(Message::Previous),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn import_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
imported_descriptor: &form::Value<String>,
|
||||
wrong_network: bool,
|
||||
error: Option<&String>,
|
||||
@ -397,6 +614,7 @@ pub fn import_descriptor<'a>(
|
||||
.spacing(10);
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Import the wallet",
|
||||
Column::new()
|
||||
.push(Column::new().spacing(20).push(col_descriptor).push(text(
|
||||
@ -605,11 +823,13 @@ pub fn hardware_wallet_xpubs<'a>(
|
||||
}
|
||||
|
||||
pub fn share_xpubs<'a>(
|
||||
email: Option<&'a str>,
|
||||
hws: Vec<Element<'a, Message>>,
|
||||
signer: Element<'a, Message>,
|
||||
) -> Element<'a, Message> {
|
||||
layout(
|
||||
(0, 0),
|
||||
email,
|
||||
"Share your public keys (Xpubs)",
|
||||
Column::new()
|
||||
.push(
|
||||
@ -637,6 +857,7 @@ pub fn share_xpubs<'a>(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn register_descriptor<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
descriptor: String,
|
||||
hws: &'a [HardwareWallet],
|
||||
registered: &HashSet<bitcoin::bip32::Fingerprint>,
|
||||
@ -723,6 +944,7 @@ pub fn register_descriptor<'a>(
|
||||
};
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Register descriptor",
|
||||
Column::new()
|
||||
.push_maybe((!created_desc).then_some(
|
||||
@ -785,13 +1007,15 @@ pub fn register_descriptor<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn backup_descriptor<'a>(
|
||||
pub fn backup_descriptor(
|
||||
progress: (usize, usize),
|
||||
email: Option<&str>,
|
||||
descriptor: String,
|
||||
done: bool,
|
||||
) -> Element<'a, Message> {
|
||||
) -> Element<'_, Message> {
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Backup your wallet descriptor",
|
||||
Column::new()
|
||||
.push(
|
||||
@ -982,6 +1206,7 @@ pub fn define_bitcoin<'a>(
|
||||
};
|
||||
layout(
|
||||
progress,
|
||||
None,
|
||||
"Set up connection to the Bitcoin full node",
|
||||
Column::new()
|
||||
.push(col_address)
|
||||
@ -1040,6 +1265,7 @@ pub fn define_bitcoin<'a>(
|
||||
pub fn select_bitcoind_type<'a>(progress: (usize, usize)) -> Element<'a, Message> {
|
||||
layout(
|
||||
progress,
|
||||
None,
|
||||
"Bitcoin node management",
|
||||
Column::new().push(
|
||||
Row::new()
|
||||
@ -1146,6 +1372,7 @@ pub fn start_internal_bitcoind<'a>(
|
||||
};
|
||||
layout(
|
||||
progress,
|
||||
None,
|
||||
"Start Bitcoin full node",
|
||||
Column::new()
|
||||
.push_maybe(download_state.map(|s| {
|
||||
@ -1250,6 +1477,7 @@ pub fn start_internal_bitcoind<'a>(
|
||||
|
||||
pub fn install<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
generating: bool,
|
||||
config_path: Option<&std::path::PathBuf>,
|
||||
warning: Option<&'a String>,
|
||||
@ -1261,6 +1489,7 @@ pub fn install<'a>(
|
||||
};
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Finalize installation",
|
||||
Column::new()
|
||||
.push_maybe(warning.map(|e| card::invalid(text(e))))
|
||||
@ -1813,11 +2042,13 @@ pub fn key_list_view<'a>(
|
||||
|
||||
pub fn backup_mnemonic<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
words: &'a [&'static str; 12],
|
||||
done: bool,
|
||||
) -> Element<'a, Message> {
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Backup your mnemonic",
|
||||
Column::new()
|
||||
.push(text(prompt::MNEMONIC_HELP))
|
||||
@ -1853,6 +2084,7 @@ pub fn backup_mnemonic<'a>(
|
||||
|
||||
pub fn recover_mnemonic<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
words: &'a [(String, bool); 12],
|
||||
current: usize,
|
||||
suggestions: &'a [String],
|
||||
@ -1861,6 +2093,7 @@ pub fn recover_mnemonic<'a>(
|
||||
) -> Element<'a, Message> {
|
||||
layout(
|
||||
progress,
|
||||
email,
|
||||
"Import Mnemonic",
|
||||
Column::new()
|
||||
.push(text(prompt::RECOVER_MNEMONIC_HELP))
|
||||
@ -1954,8 +2187,152 @@ pub fn recover_mnemonic<'a>(
|
||||
)
|
||||
}
|
||||
|
||||
pub fn choose_backend(
|
||||
progress: (usize, usize),
|
||||
connection_step: Element<Message>,
|
||||
) -> Element<Message> {
|
||||
layout(
|
||||
progress,
|
||||
None,
|
||||
"Choose backend",
|
||||
Column::new()
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(20)
|
||||
.push(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::FillPortion(1))
|
||||
.push(image::liana_brand_grey().height(Length::Fixed(100.0)))
|
||||
.push(text::p2_medium(LIANA_DESC).style(color::GREY_3))
|
||||
.push(button::primary(None, "Install local wallet").on_press(
|
||||
Message::SelectBackend(
|
||||
message::SelectBackend::ContinueWithLocalWallet,
|
||||
),
|
||||
)),
|
||||
)
|
||||
.push(
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.align_items(Alignment::Center)
|
||||
.width(Length::FillPortion(1))
|
||||
.push(image::wizardsardine().height(Length::Fixed(100.0)))
|
||||
.push(text::p2_medium(LIANALITE_DESC).style(color::GREY_3))
|
||||
.push(connection_step),
|
||||
),
|
||||
)
|
||||
.spacing(50),
|
||||
true,
|
||||
Some(Message::Previous),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn connection_step_enter_email<'a>(
|
||||
email: &form::Value<String>,
|
||||
processing: bool,
|
||||
connection_error: Option<&Error>,
|
||||
auth_error: Option<&'static str>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push(
|
||||
form::Form::new_trimmed("email", email, |msg| {
|
||||
Message::SelectBackend(message::SelectBackend::EmailEdited(msg))
|
||||
})
|
||||
.size(text::P1_SIZE)
|
||||
.padding(10)
|
||||
.warning("Email is not valid"),
|
||||
)
|
||||
.push(
|
||||
button::primary(None, "Next").on_press_maybe(if processing || !email.valid {
|
||||
None
|
||||
} else {
|
||||
Some(Message::SelectBackend(message::SelectBackend::RequestOTP))
|
||||
}),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn connection_step_enter_otp<'a>(
|
||||
email: &'a str,
|
||||
otp: &form::Value<String>,
|
||||
processing: bool,
|
||||
connection_error: Option<&Error>,
|
||||
auth_error: Option<&'static str>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(text(email).style(color::GREEN))
|
||||
.push(text("An authentication token has been emailed to you"))
|
||||
.push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push(
|
||||
form::Form::new_trimmed("Token", otp, |msg| {
|
||||
Message::SelectBackend(message::SelectBackend::OTPEdited(msg))
|
||||
})
|
||||
.size(text::P1_SIZE)
|
||||
.padding(10)
|
||||
.warning("Token is not valid"),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(10)
|
||||
.push(
|
||||
button::primary(Some(icon::previous_icon()), "Change Email")
|
||||
.on_press(Message::SelectBackend(message::SelectBackend::EditEmail)),
|
||||
)
|
||||
.push(
|
||||
button::primary(None, "Resend token").on_press_maybe(if processing {
|
||||
None
|
||||
} else {
|
||||
Some(Message::SelectBackend(message::SelectBackend::RequestOTP))
|
||||
}),
|
||||
),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn connection_step_connected<'a>(
|
||||
email: &'a str,
|
||||
processing: bool,
|
||||
connection_error: Option<&Error>,
|
||||
auth_error: Option<&'static str>,
|
||||
) -> Element<'a, Message> {
|
||||
Column::new()
|
||||
.spacing(20)
|
||||
.push(text(email).style(color::GREEN))
|
||||
.push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE)))
|
||||
.push(Container::new(
|
||||
Row::new()
|
||||
.spacing(10)
|
||||
.push(
|
||||
button::primary(Some(icon::previous_icon()), "Change Email")
|
||||
.on_press(Message::SelectBackend(message::SelectBackend::EditEmail)),
|
||||
)
|
||||
.push(
|
||||
button::primary(None, "Continue").on_press_maybe(if processing {
|
||||
None
|
||||
} else {
|
||||
Some(Message::SelectBackend(
|
||||
message::SelectBackend::ContinueWithRemoteBackend,
|
||||
))
|
||||
}),
|
||||
),
|
||||
))
|
||||
.into()
|
||||
}
|
||||
|
||||
pub const LIANALITE_DESC: &str = "Use the connection to the Bitcoin network provided by Wizardsardine. This removes the need for running a Bitcoin full node on your machine. It also provides synchronisation of your wallet data (labels, transactions, etc..) across your machines and participants in your wallet. We will also keep a backup of your wallet descriptor for you. This is the most convenient option but has privacy implications: your data would be stored on our servers (but never shared with a third party).";
|
||||
|
||||
pub const LIANA_DESC: &str = "This option creates a wallet on your machine. The wallet will never access any of our servers, we would not even be able to know you use our wallet. This option requires a local Bitcoin full node. A full node is necessary to use Bitcoin in a sovereign way, but it is more accessible than it sounds. The Liana wallet can download and run one for you so you don't have to manage it yourself. It will never use more than a couple GB of disk space. The initial synchronisation of the node takes time and is computationally intensive, but past this point running a Bitcoin full node on your machine is seamless.";
|
||||
|
||||
fn layout<'a>(
|
||||
progress: (usize, usize),
|
||||
email: Option<&'a str>,
|
||||
title: &'static str,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
padding_left: bool,
|
||||
@ -1968,6 +2345,9 @@ fn layout<'a>(
|
||||
Container::new(scrollable(
|
||||
Column::new()
|
||||
.width(Length::Fill)
|
||||
.push(Row::new().push(Space::with_width(Length::Fill)).push_maybe(
|
||||
email.map(|e| Container::new(p1_regular(e).style(color::GREEN)).padding(20)),
|
||||
))
|
||||
.push(Space::with_height(Length::Fixed(100.0)))
|
||||
.push(
|
||||
Row::new()
|
||||
|
||||
@ -39,6 +39,7 @@ pub struct AuthClient {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
api_public_key: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -67,11 +68,12 @@ impl From<Error> for AuthError {
|
||||
}
|
||||
|
||||
impl AuthClient {
|
||||
pub fn new(url: String, api_public_key: String) -> Self {
|
||||
pub fn new(url: String, api_public_key: String, email: String) -> Self {
|
||||
AuthClient {
|
||||
http: reqwest::Client::new(),
|
||||
url,
|
||||
api_public_key,
|
||||
email,
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,11 +87,20 @@ impl AuthClient {
|
||||
req
|
||||
}
|
||||
|
||||
pub async fn sign_in_otp(&self, email: &str) -> Result<(), AuthError> {
|
||||
/// the redirect_to is setup so the supabase html template has the information
|
||||
/// that user is using the desktop to authenticate and will display the token
|
||||
/// instead of the confirmation link button.
|
||||
pub async fn sign_in_otp(&self) -> Result<(), AuthError> {
|
||||
let response: Response = self
|
||||
.request(Method::POST, &format!("{}/auth/v1/otp", self.url))
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"{}/auth/v1/otp?redirect_to=https://desktop.lianalite.com",
|
||||
self.url
|
||||
),
|
||||
)
|
||||
.json(&SignInOtp {
|
||||
email,
|
||||
email: &self.email,
|
||||
create_user: true,
|
||||
})
|
||||
.send()
|
||||
@ -105,12 +116,12 @@ impl AuthClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn resend_otp(&self, email: &str) -> Result<Response, AuthError> {
|
||||
pub async fn resend_otp(&self) -> Result<Response, AuthError> {
|
||||
let response: Response = self
|
||||
.request(Method::POST, &format!("{}/auth/v1/resend", self.url))
|
||||
.json(&ResendOtp {
|
||||
email,
|
||||
kind: "email",
|
||||
email: &self.email,
|
||||
kind: "signup",
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
@ -123,18 +134,14 @@ impl AuthClient {
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn verify_otp(
|
||||
&self,
|
||||
email: &str,
|
||||
token: &str,
|
||||
) -> Result<AccessTokenResponse, AuthError> {
|
||||
pub async fn verify_otp(&self, token: &str) -> Result<AccessTokenResponse, AuthError> {
|
||||
let response: Response = self
|
||||
.http
|
||||
.post(&format!("{}/auth/v1/verify", self.url))
|
||||
.header("apikey", &self.api_public_key)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&VerifyOtp {
|
||||
email,
|
||||
email: &self.email,
|
||||
token,
|
||||
kind: "email",
|
||||
})
|
||||
|
||||
@ -142,6 +142,21 @@ pub struct FingerprintAlias {
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum WalletInvitationStatus {
|
||||
Pending,
|
||||
Accepted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct WalletInvitation {
|
||||
pub id: String,
|
||||
pub wallet_name: String,
|
||||
pub wallet_id: String,
|
||||
pub status: WalletInvitationStatus,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct WalletLabels {
|
||||
pub labels: HashMap<String, String>,
|
||||
@ -303,7 +318,7 @@ pub struct Address {
|
||||
}
|
||||
|
||||
pub mod payload {
|
||||
use liana::miniscript::bitcoin;
|
||||
use liana::{descriptors::LianaDescriptor, miniscript::bitcoin};
|
||||
use serde::{Serialize, Serializer};
|
||||
|
||||
pub fn ser_to_string<T: std::fmt::Display, S: Serializer>(
|
||||
@ -313,6 +328,18 @@ pub mod payload {
|
||||
s.serialize_str(&field.to_string())
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateWallet<'a> {
|
||||
pub name: &'a str,
|
||||
#[serde(serialize_with = "ser_to_string")]
|
||||
pub descriptor: &'a LianaDescriptor,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateWalletInvitation<'a> {
|
||||
pub email: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ImportPsbt {
|
||||
pub psbt: String,
|
||||
|
||||
@ -2,6 +2,7 @@ pub mod api;
|
||||
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
@ -17,6 +18,7 @@ use reqwest::{Error, IntoUrl, Method, RequestBuilder, Response};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::{
|
||||
app::settings::{AuthConfig, Settings},
|
||||
daemon::{model::*, Daemon, DaemonBackend, DaemonError},
|
||||
hw::HardwareWalletConfig,
|
||||
};
|
||||
@ -53,7 +55,7 @@ fn request<U: IntoUrl>(
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackendClient {
|
||||
auth: Arc<RwLock<auth::AccessTokenResponse>>,
|
||||
pub auth: Arc<RwLock<auth::AccessTokenResponse>>,
|
||||
auth_client: auth::AuthClient,
|
||||
|
||||
url: String,
|
||||
@ -94,18 +96,26 @@ impl BackendClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user_email(&self) -> &str {
|
||||
&self.auth_client.email
|
||||
}
|
||||
|
||||
pub async fn connect_first(self) -> Result<(BackendWalletClient, api::Wallet), DaemonError> {
|
||||
let wallets = self.list_wallets().await?;
|
||||
let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?;
|
||||
Ok((
|
||||
Ok(self.connect_wallet(first))
|
||||
}
|
||||
|
||||
pub fn connect_wallet(self, wallet: api::Wallet) -> (BackendWalletClient, api::Wallet) {
|
||||
(
|
||||
BackendWalletClient {
|
||||
inner: self,
|
||||
curve: secp256k1::Secp256k1::verification_only(),
|
||||
wallet_uuid: first.id.clone(),
|
||||
wallet_desc: first.descriptor.to_owned(),
|
||||
wallet_uuid: wallet.id.clone(),
|
||||
wallet_desc: wallet.descriptor.to_owned(),
|
||||
},
|
||||
first,
|
||||
))
|
||||
wallet,
|
||||
)
|
||||
}
|
||||
|
||||
async fn request<U: IntoUrl>(&self, method: Method, url: U) -> RequestBuilder {
|
||||
@ -129,6 +139,160 @@ impl BackendClient {
|
||||
let list: api::ListWallets = response.json().await?;
|
||||
Ok(list.wallets)
|
||||
}
|
||||
|
||||
pub async fn create_wallet(
|
||||
&self,
|
||||
name: &str,
|
||||
descriptor: &LianaDescriptor,
|
||||
) -> Result<api::Wallet, DaemonError> {
|
||||
let response = self
|
||||
.request(Method::POST, &format!("{}/v1/wallets", self.url))
|
||||
.await
|
||||
.json(&api::payload::CreateWallet { name, descriptor })
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
let wallet: api::Wallet = response.json().await?;
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
pub async fn update_wallet_metadata(
|
||||
&self,
|
||||
wallet_uuid: &str,
|
||||
fingerprint_aliases: &HashMap<Fingerprint, String>,
|
||||
hws: &[HardwareWalletConfig],
|
||||
) -> Result<(), DaemonError> {
|
||||
let wallets = self.list_wallets().await?;
|
||||
let wallet = wallets
|
||||
.iter()
|
||||
.find(|w| w.id == wallet_uuid)
|
||||
.ok_or(DaemonError::Http(
|
||||
Some(404),
|
||||
"No wallet exists for this uuid".to_string(),
|
||||
))?;
|
||||
let ledger_kinds = [
|
||||
async_hwi::DeviceKind::Ledger.to_string(),
|
||||
async_hwi::DeviceKind::LedgerSimulator.to_string(),
|
||||
];
|
||||
for cfg in hws {
|
||||
if ledger_kinds.contains(&cfg.kind)
|
||||
&& !wallet.metadata.ledger_hmacs.iter().any(|ledger_hmac| {
|
||||
ledger_hmac.fingerprint == cfg.fingerprint && ledger_hmac.hmac == cfg.token
|
||||
})
|
||||
{
|
||||
let response: Response = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("{}/v1/wallets/{}", self.url, wallet_uuid),
|
||||
)
|
||||
.await
|
||||
.json(&api::payload::UpdateWallet {
|
||||
ledger_hmac: Some(api::payload::UpdateLedgerHmac {
|
||||
fingerprint: cfg.fingerprint.to_string(),
|
||||
hmac: cfg.token.clone(),
|
||||
}),
|
||||
fingerprint_aliases: None,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fingerprint_aliases.iter().any(|(fg, alias)| {
|
||||
!wallet
|
||||
.metadata
|
||||
.fingerprint_aliases
|
||||
.contains(&api::FingerprintAlias {
|
||||
alias: alias.to_string(),
|
||||
user_id: self.user_id.clone(),
|
||||
fingerprint: *fg,
|
||||
})
|
||||
}) {
|
||||
let response: Response = self
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("{}/v1/wallets/{}", self.url, wallet_uuid),
|
||||
)
|
||||
.await
|
||||
.json(&api::payload::UpdateWallet {
|
||||
ledger_hmac: None,
|
||||
fingerprint_aliases: Some(
|
||||
fingerprint_aliases
|
||||
.iter()
|
||||
.map(|(fg, alias)| api::payload::UpdateFingerprintAlias {
|
||||
fingerprint: fg.to_string(),
|
||||
alias: alias.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_wallet_invitation(
|
||||
&self,
|
||||
invitation_id: &str,
|
||||
) -> Result<api::WalletInvitation, DaemonError> {
|
||||
let response = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("{}/v1/invitations/{}", self.url, invitation_id),
|
||||
)
|
||||
.await
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(response.json().await?)
|
||||
}
|
||||
|
||||
pub async fn accept_wallet_invitation(&self, invitation_id: &str) -> Result<(), DaemonError> {
|
||||
let response = self
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!("{}/v1/invitations/{}/accept", self.url, invitation_id),
|
||||
)
|
||||
.await
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -140,10 +304,26 @@ pub struct BackendWalletClient {
|
||||
}
|
||||
|
||||
impl BackendWalletClient {
|
||||
pub fn inner_client(&self) -> &BackendClient {
|
||||
&self.inner
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> BackendClient {
|
||||
self.inner
|
||||
}
|
||||
|
||||
pub fn user_id(&self) -> &str {
|
||||
&self.inner.user_id
|
||||
}
|
||||
|
||||
pub fn wallet_id(&self) -> String {
|
||||
self.wallet_uuid.clone()
|
||||
}
|
||||
|
||||
pub fn user_email(&self) -> &str {
|
||||
self.inner.user_email()
|
||||
}
|
||||
|
||||
async fn get_wallet(&self) -> Result<api::Wallet, DaemonError> {
|
||||
let list = self.inner.list_wallets().await?;
|
||||
let wallet = list
|
||||
@ -313,7 +493,7 @@ impl BackendWalletClient {
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
async fn auth(&self) -> AccessTokenResponse {
|
||||
pub async fn auth(&self) -> AccessTokenResponse {
|
||||
self.inner.auth.read().await.clone()
|
||||
}
|
||||
}
|
||||
@ -329,7 +509,7 @@ impl Daemon for BackendWalletClient {
|
||||
}
|
||||
|
||||
/// refresh the token if close to expiration.
|
||||
async fn is_alive(&self) -> Result<(), DaemonError> {
|
||||
async fn is_alive(&self, datadir: &Path, network: Network) -> Result<(), DaemonError> {
|
||||
let auth = self.auth().await;
|
||||
if auth.expires_at < Utc::now().timestamp() + 60 {
|
||||
match self.inner.auth.try_write() {
|
||||
@ -344,6 +524,39 @@ impl Daemon for BackendWalletClient {
|
||||
.refresh_token(&auth.refresh_token)
|
||||
.await?;
|
||||
|
||||
let mut settings = Settings::from_file(datadir.to_path_buf(), network)
|
||||
.map_err(|e| {
|
||||
DaemonError::Unexpected(format!(
|
||||
"Cannot access to settings.json file: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| {
|
||||
if let Some(auth) = &w.remote_backend_auth {
|
||||
auth.wallet_id == self.wallet_uuid
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
wallet_settings.remote_backend_auth = Some(AuthConfig {
|
||||
email: self.inner.auth_client.email.clone(),
|
||||
wallet_id: self.wallet_id(),
|
||||
refresh_token: new.refresh_token.clone(),
|
||||
});
|
||||
} else {
|
||||
tracing::info!("Wallet id was not found in the settings");
|
||||
}
|
||||
|
||||
settings
|
||||
.to_file(datadir.to_path_buf(), network)
|
||||
.map_err(|e| {
|
||||
DaemonError::Unexpected(format!(
|
||||
"Cannot access to settings.json file: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
*old = new;
|
||||
tracing::info!("Liana backend access was refreshed");
|
||||
}
|
||||
@ -856,81 +1069,30 @@ impl Daemon for BackendWalletClient {
|
||||
fingerprint_aliases: &HashMap<Fingerprint, String>,
|
||||
hws: &[HardwareWalletConfig],
|
||||
) -> Result<(), DaemonError> {
|
||||
let wallet = self.get_wallet().await?;
|
||||
let ledger_kinds = [
|
||||
async_hwi::DeviceKind::Ledger.to_string(),
|
||||
async_hwi::DeviceKind::LedgerSimulator.to_string(),
|
||||
];
|
||||
for cfg in hws {
|
||||
if ledger_kinds.contains(&cfg.kind)
|
||||
&& !wallet.metadata.ledger_hmacs.iter().any(|ledger_hmac| {
|
||||
ledger_hmac.fingerprint == cfg.fingerprint && ledger_hmac.hmac == cfg.token
|
||||
})
|
||||
{
|
||||
let response: Response = self
|
||||
.inner
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("{}/v1/wallets/{}", self.inner.url, self.wallet_uuid),
|
||||
)
|
||||
.await
|
||||
.json(&api::payload::UpdateWallet {
|
||||
ledger_hmac: Some(api::payload::UpdateLedgerHmac {
|
||||
fingerprint: cfg.fingerprint.to_string(),
|
||||
hmac: cfg.token.clone(),
|
||||
}),
|
||||
fingerprint_aliases: None,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
self.inner
|
||||
.update_wallet_metadata(&self.wallet_uuid, fingerprint_aliases, hws)
|
||||
.await
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fingerprint_aliases.iter().any(|(fg, alias)| {
|
||||
!wallet
|
||||
.metadata
|
||||
.fingerprint_aliases
|
||||
.contains(&api::FingerprintAlias {
|
||||
alias: alias.to_string(),
|
||||
user_id: self.inner.user_id.clone(),
|
||||
fingerprint: *fg,
|
||||
})
|
||||
}) {
|
||||
let response: Response = self
|
||||
.inner
|
||||
.request(
|
||||
Method::PATCH,
|
||||
&format!("{}/v1/wallets/{}", self.inner.url, self.wallet_uuid),
|
||||
)
|
||||
.await
|
||||
.json(&api::payload::UpdateWallet {
|
||||
ledger_hmac: None,
|
||||
fingerprint_aliases: Some(
|
||||
fingerprint_aliases
|
||||
.iter()
|
||||
.map(|(fg, alias)| api::payload::UpdateFingerprintAlias {
|
||||
fingerprint: fg.to_string(),
|
||||
alias: alias.to_string(),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
async fn send_wallet_invitation(&self, email: &str) -> Result<(), DaemonError> {
|
||||
let response = self
|
||||
.inner
|
||||
.request(
|
||||
Method::POST,
|
||||
&format!(
|
||||
"{}/v1/wallets/{}/invitations",
|
||||
self.inner.url, self.wallet_uuid
|
||||
),
|
||||
)
|
||||
.await
|
||||
.json(&api::payload::CreateWalletInvitation { email })
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
return Err(DaemonError::Http(
|
||||
Some(response.status().into()),
|
||||
response.text().await?,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
575
gui/src/lianalite/login.rs
Normal file
575
gui/src/lianalite/login.rs
Normal file
@ -0,0 +1,575 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use iced::{Alignment, Command, Length};
|
||||
|
||||
use liana::miniscript::bitcoin::Network;
|
||||
use liana_ui::{
|
||||
color,
|
||||
component::{button, form, network_banner, notification, text::*},
|
||||
icon, image,
|
||||
widget::*,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::settings::{AuthConfig, Settings, SettingsError, WalletSetting},
|
||||
daemon::DaemonError,
|
||||
};
|
||||
|
||||
use super::client::{
|
||||
auth::{AuthClient, AuthError},
|
||||
backend::{api, BackendClient, BackendWalletClient},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Error {
|
||||
Auth(AuthError),
|
||||
// DaemonError does not implement Clone.
|
||||
// TODO: maybe Arc is overkill
|
||||
Backend(Arc<DaemonError>),
|
||||
Settings(SettingsError),
|
||||
Unexpected(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Auth(e) => write!(f, "Authentication error: {}", e),
|
||||
Self::Backend(e) => write!(f, "Remote backend error: {}", e),
|
||||
Self::Settings(e) => write!(f, "Settings file error: {}", e),
|
||||
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DaemonError> for Error {
|
||||
fn from(value: DaemonError) -> Self {
|
||||
Self::Backend(Arc::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AuthError> for Error {
|
||||
fn from(value: AuthError) -> Self {
|
||||
Self::Auth(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SettingsError> for Error {
|
||||
fn from(value: SettingsError) -> Self {
|
||||
Self::Settings(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Message {
|
||||
View(ViewMessage),
|
||||
OTPRequested(Result<(AuthClient, String), Error>),
|
||||
OTPResent(Result<(), Error>),
|
||||
// wallet_id and result of the connect command.
|
||||
Connected(Result<BackendState, Error>),
|
||||
// redirect to the installer with the remote backend connection.
|
||||
Install(Option<BackendClient>),
|
||||
// redirect to the app runner with the remote backend connection.
|
||||
Run(Result<(BackendWalletClient, api::Wallet), Error>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ViewMessage {
|
||||
RequestOTP,
|
||||
EditEmail,
|
||||
EmailEdited(String),
|
||||
OTPEdited(String),
|
||||
BackToLauncher,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendState {
|
||||
NoWallet(BackendClient),
|
||||
WalletExists(BackendWalletClient, api::Wallet),
|
||||
}
|
||||
|
||||
pub struct LianaLiteLogin {
|
||||
pub datadir: PathBuf,
|
||||
pub network: Network,
|
||||
|
||||
wallet_id: String,
|
||||
|
||||
processing: bool,
|
||||
step: ConnectionStep,
|
||||
|
||||
// Error due to connection
|
||||
connection_error: Option<Error>,
|
||||
// Authentification Error
|
||||
auth_error: Option<&'static str>,
|
||||
}
|
||||
|
||||
pub enum ConnectionStep {
|
||||
CheckingAuthFile,
|
||||
EnterEmail {
|
||||
email: form::Value<String>,
|
||||
},
|
||||
EnterOtp {
|
||||
client: AuthClient,
|
||||
backend_api_url: String,
|
||||
email: String,
|
||||
otp: form::Value<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LianaLiteLogin {
|
||||
pub fn new(datadir: PathBuf, network: Network, settings: Settings) -> (Self, Command<Message>) {
|
||||
match settings
|
||||
.wallets
|
||||
.first()
|
||||
.cloned()
|
||||
.and_then(|w| w.remote_backend_auth)
|
||||
.ok_or(Error::Unexpected(
|
||||
"Missing auth configuration in settings.json".to_string(),
|
||||
)) {
|
||||
Err(e) => (
|
||||
Self {
|
||||
network,
|
||||
datadir: datadir.clone(),
|
||||
step: ConnectionStep::EnterEmail {
|
||||
email: form::Value::default(),
|
||||
},
|
||||
wallet_id: String::new(),
|
||||
connection_error: Some(e),
|
||||
auth_error: None,
|
||||
processing: true,
|
||||
},
|
||||
Command::none(),
|
||||
),
|
||||
Ok(auth_config) => (
|
||||
Self {
|
||||
network,
|
||||
datadir: datadir.clone(),
|
||||
step: ConnectionStep::CheckingAuthFile,
|
||||
connection_error: None,
|
||||
wallet_id: auth_config.wallet_id.clone(),
|
||||
auth_error: None,
|
||||
processing: true,
|
||||
},
|
||||
Command::perform(
|
||||
async move {
|
||||
let service_config = super::client::get_service_config(network)
|
||||
.await
|
||||
.map_err(|e| Error::Unexpected(e.to_string()))?;
|
||||
let client = AuthClient::new(
|
||||
service_config.auth_api_url,
|
||||
service_config.auth_api_public_key,
|
||||
auth_config.email,
|
||||
);
|
||||
connect_with_refresh_token(
|
||||
client,
|
||||
auth_config.refresh_token,
|
||||
auth_config.wallet_id,
|
||||
service_config.backend_api_url,
|
||||
)
|
||||
.await
|
||||
},
|
||||
Message::Connected,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update(&mut self, message: Message) -> Command<Message> {
|
||||
match &mut self.step {
|
||||
ConnectionStep::CheckingAuthFile => {
|
||||
if let Message::Connected(res) = message {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok(BackendState::NoWallet(_)) => {
|
||||
self.auth_error = Some("No wallet found for the given email");
|
||||
}
|
||||
Ok(BackendState::WalletExists(client, wallet)) => {
|
||||
return Command::perform(async move { (client, wallet) }, |(c, w)| {
|
||||
Message::Run(Ok((c, w)))
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
self.connection_error = Some(e);
|
||||
self.step = ConnectionStep::EnterEmail {
|
||||
email: form::Value::default(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStep::EnterEmail { email } => match message {
|
||||
Message::View(ViewMessage::EmailEdited(value)) => {
|
||||
email.valid = value.is_empty()
|
||||
|| email_address::EmailAddress::parse_with_options(
|
||||
&value,
|
||||
email_address::Options::default().with_required_tld(),
|
||||
)
|
||||
.is_ok();
|
||||
email.value = value;
|
||||
}
|
||||
Message::View(ViewMessage::RequestOTP) => {
|
||||
if email.value.is_empty() {
|
||||
email.valid = false;
|
||||
} else if email.valid {
|
||||
let email = email.value.clone();
|
||||
let network = self.network;
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
let config = super::client::get_service_config(network)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
if e.status() == Some(reqwest::StatusCode::NOT_FOUND) {
|
||||
Error::Unexpected(
|
||||
"Remote servers are unresponsive".to_string(),
|
||||
)
|
||||
} else {
|
||||
Error::Unexpected(e.to_string())
|
||||
}
|
||||
})?;
|
||||
let client = AuthClient::new(
|
||||
config.auth_api_url,
|
||||
config.auth_api_public_key,
|
||||
email,
|
||||
);
|
||||
client.sign_in_otp().await?;
|
||||
Ok((client, config.backend_api_url))
|
||||
},
|
||||
Message::OTPRequested,
|
||||
);
|
||||
}
|
||||
}
|
||||
Message::OTPRequested(res) => {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok((client, backend_api_url)) => {
|
||||
self.step = ConnectionStep::EnterOtp {
|
||||
email: email.value.to_owned(),
|
||||
otp: form::Value::default(),
|
||||
client,
|
||||
backend_api_url,
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
ConnectionStep::EnterOtp {
|
||||
client,
|
||||
email,
|
||||
otp,
|
||||
backend_api_url,
|
||||
} => match message {
|
||||
Message::View(ViewMessage::EditEmail) => {
|
||||
self.step = ConnectionStep::EnterEmail {
|
||||
email: form::Value {
|
||||
value: email.clone(),
|
||||
valid: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
Message::View(ViewMessage::RequestOTP) => {
|
||||
*otp = form::Value::default();
|
||||
let client = client.clone();
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
return Command::perform(
|
||||
async move {
|
||||
client.resend_otp().await?;
|
||||
Ok(())
|
||||
},
|
||||
Message::OTPResent,
|
||||
);
|
||||
}
|
||||
Message::OTPResent(res) => match res {
|
||||
Ok(()) => {
|
||||
self.processing = false;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}", e);
|
||||
self.processing = false;
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
},
|
||||
Message::View(ViewMessage::OTPEdited(value)) => {
|
||||
otp.value = value.trim().to_string();
|
||||
otp.valid = true;
|
||||
if otp.value.len() == 6 {
|
||||
let client = client.clone();
|
||||
let otp = otp.value.clone();
|
||||
let backend_api_url = backend_api_url.clone();
|
||||
self.processing = true;
|
||||
self.connection_error = None;
|
||||
self.auth_error = None;
|
||||
let wallet_id = self.wallet_id.clone();
|
||||
return Command::perform(
|
||||
async move { connect(client, otp, wallet_id, backend_api_url).await },
|
||||
Message::Connected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Message::Connected(res) => {
|
||||
self.processing = false;
|
||||
match res {
|
||||
Ok(BackendState::NoWallet(client)) => {
|
||||
return Command::perform(async move { Some(client) }, Message::Install);
|
||||
}
|
||||
Ok(BackendState::WalletExists(client, wallet)) => {
|
||||
let datadir = self.datadir.clone();
|
||||
let network = self.network;
|
||||
return Command::perform(
|
||||
async move {
|
||||
update_wallet_auth_settings(
|
||||
datadir,
|
||||
network,
|
||||
wallet.clone(),
|
||||
client.user_email().to_string(),
|
||||
client.auth().await.refresh_token,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((client, wallet))
|
||||
},
|
||||
Message::Run,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}", e);
|
||||
if let Error::Auth(AuthError { http_status, .. }) = e {
|
||||
if http_status == Some(403) {
|
||||
self.auth_error = Some("Token is expired or is invalid")
|
||||
} else {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
} else {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Message::Run::Ok is handled by the upper level wrapping the LianaLiteLogin
|
||||
// state.
|
||||
Message::Run(Err(e)) => {
|
||||
self.connection_error = Some(e);
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
}
|
||||
|
||||
Command::none()
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Element<Message> {
|
||||
let content = Into::<Element<ViewMessage>>::into(
|
||||
Container::new(
|
||||
Column::new()
|
||||
.spacing(100)
|
||||
.align_items(Alignment::Center)
|
||||
.push(
|
||||
Column::new()
|
||||
.align_items(Alignment::Center)
|
||||
.spacing(20)
|
||||
.width(Length::Fill)
|
||||
.push(image::wizardsardine().height(Length::Fixed(100.0)))
|
||||
.push(
|
||||
Column::new()
|
||||
.max_width(500)
|
||||
.spacing(20)
|
||||
.push(match &self.step {
|
||||
ConnectionStep::CheckingAuthFile => Column::new(),
|
||||
ConnectionStep::EnterEmail { email } => Column::new()
|
||||
.spacing(20)
|
||||
.push_maybe(
|
||||
self.auth_error
|
||||
.map(|e| text(e).style(color::ORANGE)),
|
||||
)
|
||||
.push(
|
||||
form::Form::new_trimmed("email", email, |msg| {
|
||||
ViewMessage::EmailEdited(msg)
|
||||
})
|
||||
.size(P1_SIZE)
|
||||
.padding(10)
|
||||
.warning("Email is not valid"),
|
||||
)
|
||||
.push(button::primary(None, "Next").on_press_maybe(
|
||||
if self.processing {
|
||||
None
|
||||
} else {
|
||||
Some(ViewMessage::RequestOTP)
|
||||
},
|
||||
)),
|
||||
ConnectionStep::EnterOtp { otp, .. } => Column::new()
|
||||
.push(text("An authentication was send to your email"))
|
||||
.push_maybe(
|
||||
self.auth_error
|
||||
.map(|e| text(e).style(color::ORANGE)),
|
||||
)
|
||||
.spacing(20)
|
||||
.push(
|
||||
form::Form::new_trimmed("Token", otp, |msg| {
|
||||
ViewMessage::OTPEdited(msg)
|
||||
})
|
||||
.size(P1_SIZE)
|
||||
.padding(10)
|
||||
.warning("Token is not valid"),
|
||||
)
|
||||
.push(
|
||||
Row::new()
|
||||
.spacing(10)
|
||||
.push(
|
||||
button::primary(
|
||||
Some(icon::previous_icon()),
|
||||
"Change email",
|
||||
)
|
||||
.on_press(ViewMessage::EditEmail),
|
||||
)
|
||||
.push(
|
||||
button::primary(None, "Resend token")
|
||||
.on_press_maybe(if self.processing {
|
||||
None
|
||||
} else {
|
||||
Some(ViewMessage::RequestOTP)
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.push_maybe(if !matches!(self.step, ConnectionStep::CheckingAuthFile) {
|
||||
Some(
|
||||
button::secondary(Some(icon::previous_icon()), "Change network")
|
||||
.width(Length::Fixed(200.0))
|
||||
.on_press(ViewMessage::BackToLauncher),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}),
|
||||
)
|
||||
.padding(50)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.center_x()
|
||||
.center_y(),
|
||||
)
|
||||
.map(Message::View);
|
||||
|
||||
let mut col = Column::new();
|
||||
if self.network != Network::Bitcoin {
|
||||
col = col.push(network_banner(self.network));
|
||||
}
|
||||
if let Some(error) = &self.connection_error {
|
||||
col = col.push(
|
||||
notification::warning("Connection failed".to_string(), error.to_string())
|
||||
.width(Length::Fill),
|
||||
);
|
||||
}
|
||||
|
||||
col.push(content).into()
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_wallet_auth_settings(
|
||||
datadir: PathBuf,
|
||||
network: Network,
|
||||
wallet: api::Wallet,
|
||||
email: String,
|
||||
refresh_token: String,
|
||||
) -> Result<(), Error> {
|
||||
let mut settings = Settings::from_file(datadir.clone(), network)?;
|
||||
|
||||
let descriptor_checksum = wallet
|
||||
.descriptor
|
||||
.to_string()
|
||||
.split_once('#')
|
||||
.map(|(_, checksum)| checksum)
|
||||
.expect("Failed to get checksum from a valid LianaDescriptor")
|
||||
.to_string();
|
||||
|
||||
let remote_backend_auth = Some(AuthConfig {
|
||||
email,
|
||||
wallet_id: wallet.id.clone(),
|
||||
refresh_token,
|
||||
});
|
||||
|
||||
if let Some(wallet_settings) = settings.wallets.iter_mut().find(|w| {
|
||||
if let Some(auth) = &w.remote_backend_auth {
|
||||
auth.wallet_id == wallet.id
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}) {
|
||||
wallet_settings.remote_backend_auth = remote_backend_auth;
|
||||
} else {
|
||||
tracing::info!("Wallet id was not found in the settings, adding now the wallet settings to the settings.json file");
|
||||
settings.wallets.insert(
|
||||
0,
|
||||
WalletSetting {
|
||||
name: wallet.name,
|
||||
descriptor_checksum,
|
||||
keys: Vec::new(),
|
||||
hardware_wallets: Vec::new(),
|
||||
remote_backend_auth,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
settings.to_file(datadir, network).map_err(|e| {
|
||||
DaemonError::Unexpected(format!("Cannot access to settings.json file: {}", e))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
auth: AuthClient,
|
||||
token: String,
|
||||
wallet_id: String,
|
||||
backend_api_url: String,
|
||||
) -> Result<BackendState, Error> {
|
||||
let access = auth.verify_otp(token.trim_end()).await?;
|
||||
let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?;
|
||||
|
||||
let wallets = client.list_wallets().await?;
|
||||
if wallets.is_empty() {
|
||||
return Ok(BackendState::NoWallet(client));
|
||||
}
|
||||
|
||||
if wallet_id.is_empty() {
|
||||
let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?;
|
||||
let (wallet_client, wallet) = client.connect_wallet(first);
|
||||
Ok(BackendState::WalletExists(wallet_client, wallet))
|
||||
} else if let Some(wallet) = wallets.into_iter().find(|w| w.id == wallet_id) {
|
||||
let (wallet_client, wallet) = client.connect_wallet(wallet);
|
||||
Ok(BackendState::WalletExists(wallet_client, wallet))
|
||||
} else {
|
||||
Ok(BackendState::NoWallet(client))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect_with_refresh_token(
|
||||
auth: AuthClient,
|
||||
refresh_token: String,
|
||||
wallet_id: String,
|
||||
backend_api_url: String,
|
||||
) -> Result<BackendState, Error> {
|
||||
let access = auth.refresh_token(&refresh_token).await?;
|
||||
let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?;
|
||||
|
||||
if let Some(wallet) = client
|
||||
.list_wallets()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|w| w.id == wallet_id)
|
||||
{
|
||||
let (wallet_client, wallet) = client.connect_wallet(wallet);
|
||||
Ok(BackendState::WalletExists(wallet_client, wallet))
|
||||
} else {
|
||||
Ok(BackendState::NoWallet(client))
|
||||
}
|
||||
}
|
||||
@ -1,86 +1,2 @@
|
||||
pub mod client;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use liana::miniscript::bitcoin::Network;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct Config {
|
||||
auth: HashMap<String, NetworkAuthConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct NetworkAuthConfig {
|
||||
email: String,
|
||||
access_token: String,
|
||||
expires_at: i64,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
pub const DEFAULT_FILE_NAME: &str = "lite.json";
|
||||
|
||||
impl Config {
|
||||
pub fn file_path(datadir: PathBuf, network: Network) -> PathBuf {
|
||||
let mut path = datadir;
|
||||
path.push(network.to_string());
|
||||
path.push(DEFAULT_FILE_NAME);
|
||||
path
|
||||
}
|
||||
pub fn from_file(datadir: PathBuf, network: Network) -> Result<Self, ConfigError> {
|
||||
let path = Self::file_path(datadir, network);
|
||||
|
||||
let config = std::fs::read(path)
|
||||
.map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ConfigError::NotFound,
|
||||
_ => ConfigError::ReadingFile(format!("Reading settings file: {}", e)),
|
||||
})
|
||||
.and_then(|file_content| {
|
||||
serde_json::from_slice::<Config>(&file_content)
|
||||
.map_err(|e| ConfigError::ReadingFile(format!("Parsing settings file: {}", e)))
|
||||
})?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
pub fn to_file(&self, datadir: PathBuf, network: Network) -> Result<(), ConfigError> {
|
||||
let path = Self::file_path(datadir, network);
|
||||
|
||||
let content = serde_json::to_string_pretty(&self).map_err(|e| {
|
||||
ConfigError::WritingFile(format!("Failed to serialize settings: {}", e))
|
||||
})?;
|
||||
|
||||
let mut settings_file = OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(path)
|
||||
.map_err(|e| ConfigError::WritingFile(e.to_string()))?;
|
||||
|
||||
settings_file.write_all(content.as_bytes()).map_err(|e| {
|
||||
tracing::warn!("failed to write to file: {:?}", e);
|
||||
ConfigError::WritingFile(e.to_string())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Debug, Clone)]
|
||||
pub enum ConfigError {
|
||||
NotFound,
|
||||
ReadingFile(String),
|
||||
WritingFile(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, "Settings file not found"),
|
||||
Self::ReadingFile(e) => write!(f, "Error while reading file: {}", e),
|
||||
Self::WritingFile(e) => write!(f, "Error while writing file: {}", e),
|
||||
Self::Unexpected(e) => write!(f, "Unexpected error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub mod login;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
pub mod app;
|
||||
pub mod bitcoind;
|
||||
pub mod daemon;
|
||||
pub mod datadir;
|
||||
pub mod download;
|
||||
pub mod hw;
|
||||
pub mod installer;
|
||||
|
||||
325
gui/src/main.rs
325
gui/src/main.rs
@ -27,10 +27,14 @@ use liana_gui::{
|
||||
wallet::Wallet,
|
||||
App,
|
||||
},
|
||||
datadir::{self, create_directory},
|
||||
hw::HardwareWalletConfig,
|
||||
installer::{self, Installer},
|
||||
launcher::{self, Launcher},
|
||||
lianalite::client::{auth::AuthClient, backend::BackendClient, get_service_config},
|
||||
lianalite::{
|
||||
client::{backend::api, backend::BackendWalletClient},
|
||||
login,
|
||||
},
|
||||
loader::{self, Loader},
|
||||
logger::Logger,
|
||||
VERSION,
|
||||
@ -41,8 +45,6 @@ enum Arg {
|
||||
ConfigPath(PathBuf),
|
||||
DatadirPath(PathBuf),
|
||||
Network(bitcoin::Network),
|
||||
Email(String),
|
||||
RefreshToken(String),
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Vec<Arg>, Box<dyn Error>> {
|
||||
@ -85,18 +87,6 @@ Options:
|
||||
} else {
|
||||
return Err("missing arg to --datadir".into());
|
||||
}
|
||||
} else if arg == "--email" {
|
||||
if let Some(a) = args.get(i + 1) {
|
||||
res.push(Arg::Email(a.to_string()));
|
||||
} else {
|
||||
return Err("missing arg to --email".into());
|
||||
}
|
||||
} else if arg == "--refresh_token" {
|
||||
if let Some(a) = args.get(i + 1) {
|
||||
res.push(Arg::RefreshToken(a.to_string()));
|
||||
} else {
|
||||
return Err("missing arg to --access_token".into());
|
||||
}
|
||||
} else if arg.contains("--") {
|
||||
let network = bitcoin::Network::from_str(args[i].trim_start_matches("--"))?;
|
||||
res.push(Arg::Network(network));
|
||||
@ -117,6 +107,7 @@ enum State {
|
||||
Launcher(Box<Launcher>),
|
||||
Installer(Box<Installer>),
|
||||
Loader(Box<Loader>),
|
||||
Login(Box<login::LianaLiteLogin>),
|
||||
App(App),
|
||||
}
|
||||
|
||||
@ -133,6 +124,7 @@ pub enum Message {
|
||||
Install(Box<installer::Message>),
|
||||
Load(Box<loader::Message>),
|
||||
Run(Box<app::Message>),
|
||||
Login(Box<login::Message>),
|
||||
KeyPressed(Key),
|
||||
Event(iced::Event),
|
||||
}
|
||||
@ -177,7 +169,7 @@ impl Application for GUI {
|
||||
if !datadir_path.exists() {
|
||||
// datadir is created right before launching the installer
|
||||
// so logs can go in <datadir_path>/installer.log
|
||||
if let Err(e) = create_datadir(&datadir_path) {
|
||||
if let Err(e) = create_directory(&datadir_path) {
|
||||
error!("Failed to create datadir: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
@ -190,7 +182,7 @@ impl Application for GUI {
|
||||
datadir_path.clone(),
|
||||
log_level.unwrap_or(LevelFilter::INFO),
|
||||
);
|
||||
let (install, command) = Installer::new(datadir_path, network);
|
||||
let (install, command) = Installer::new(datadir_path, network, None);
|
||||
cmds.push(command.map(|msg| Message::Install(Box::new(msg))));
|
||||
State::Installer(Box::new(install))
|
||||
}
|
||||
@ -204,99 +196,6 @@ impl Application for GUI {
|
||||
cmds.push(command.map(|msg| Message::Load(Box::new(msg))));
|
||||
State::Loader(Box::new(loader))
|
||||
}
|
||||
Config::RunWithRemoteBackend(email, refresh_token) => {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
// Spawn the root task
|
||||
let (wallet, client) = rt.block_on(async {
|
||||
let config = get_service_config(bitcoin::Network::Signet).await.unwrap();
|
||||
let backend_url = config.backend_api_url.to_owned();
|
||||
|
||||
let supabase_client =
|
||||
AuthClient::new(config.auth_api_url, config.auth_api_public_key);
|
||||
let access = match refresh_token {
|
||||
None => {
|
||||
supabase_client.sign_in_otp(&email).await.unwrap();
|
||||
|
||||
eprintln!("Please enter token:");
|
||||
let mut token = String::new();
|
||||
std::io::stdin()
|
||||
.read_line(&mut token)
|
||||
.expect("Failed to read line");
|
||||
|
||||
supabase_client
|
||||
.verify_otp(&email, token.trim_end())
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
Some(token) => supabase_client.refresh_token(&token).await.unwrap(),
|
||||
};
|
||||
|
||||
let client =
|
||||
BackendClient::connect(supabase_client, backend_url, access.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let (client, wallet) = client.connect_first().await.unwrap();
|
||||
eprintln!(
|
||||
"Connected, next time connect directly without otp verification with:"
|
||||
);
|
||||
eprintln!(
|
||||
"cargo run -- --email {} --refresh_token {}",
|
||||
email, access.refresh_token
|
||||
);
|
||||
|
||||
(wallet, client)
|
||||
});
|
||||
let hws: Vec<HardwareWalletConfig> = wallet
|
||||
.metadata
|
||||
.ledger_hmacs
|
||||
.into_iter()
|
||||
.map(|ledger_hmac| HardwareWalletConfig {
|
||||
kind: async_hwi::DeviceKind::Ledger.to_string(),
|
||||
fingerprint: ledger_hmac.fingerprint,
|
||||
token: ledger_hmac.hmac,
|
||||
})
|
||||
.collect();
|
||||
let aliases: HashMap<bitcoin::bip32::Fingerprint, String> = wallet
|
||||
.metadata
|
||||
.fingerprint_aliases
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
if a.user_id == client.user_id() {
|
||||
Some((a.fingerprint, a.alias))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let (app, command) = App::new(
|
||||
Cache {
|
||||
network: bitcoin::Network::Signet,
|
||||
coins: Vec::new(),
|
||||
rescan_progress: None,
|
||||
datadir_path: default_datadir().unwrap(),
|
||||
blockheight: wallet.tip_height.unwrap_or(0),
|
||||
},
|
||||
Arc::new(
|
||||
Wallet::new(wallet.descriptor)
|
||||
.with_name(wallet.name)
|
||||
.with_key_aliases(aliases)
|
||||
.with_hardware_wallets(hws),
|
||||
),
|
||||
app::Config {
|
||||
daemon_config_path: None,
|
||||
daemon_rpc_path: None,
|
||||
log_level: None,
|
||||
debug: None,
|
||||
start_internal_bitcoind: false,
|
||||
},
|
||||
Arc::new(client),
|
||||
default_datadir().unwrap(),
|
||||
None,
|
||||
);
|
||||
cmds.push(command.map(|msg| Message::Run(Box::new(msg))));
|
||||
State::App(app)
|
||||
}
|
||||
};
|
||||
(
|
||||
Self {
|
||||
@ -317,6 +216,7 @@ impl Application for GUI {
|
||||
State::Launcher(s) => s.stop(),
|
||||
State::Installer(s) => s.stop(),
|
||||
State::App(s) => s.stop(),
|
||||
State::Login(_) => {}
|
||||
};
|
||||
iced::window::close(iced::window::Id::MAIN)
|
||||
}
|
||||
@ -333,7 +233,7 @@ impl Application for GUI {
|
||||
if !datadir_path.exists() {
|
||||
// datadir is created right before launching the installer
|
||||
// so logs can go in <datadir_path>/installer.log
|
||||
if let Err(e) = create_datadir(&datadir_path) {
|
||||
if let Err(e) = datadir::create_directory(&datadir_path) {
|
||||
error!("Failed to create datadir: {}", e);
|
||||
} else {
|
||||
info!(
|
||||
@ -346,7 +246,8 @@ impl Application for GUI {
|
||||
datadir_path.clone(),
|
||||
self.log_level.unwrap_or(LevelFilter::INFO),
|
||||
);
|
||||
let (install, command) = Installer::new(datadir_path, network);
|
||||
|
||||
let (install, command) = Installer::new(datadir_path, network, None);
|
||||
self.state = State::Installer(Box::new(install));
|
||||
command.map(|msg| Message::Install(Box::new(msg)))
|
||||
}
|
||||
@ -357,38 +258,110 @@ impl Application for GUI {
|
||||
self.log_level
|
||||
.unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)),
|
||||
);
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
if let Ok(settings) =
|
||||
app::settings::Settings::from_file(datadir_path.clone(), network)
|
||||
{
|
||||
if settings
|
||||
.wallets
|
||||
.first()
|
||||
.map(|w| w.remote_backend_auth.is_some())
|
||||
== Some(true)
|
||||
{
|
||||
let (login, command) =
|
||||
login::LianaLiteLogin::new(datadir_path, network, settings);
|
||||
self.state = State::Login(Box::new(login));
|
||||
command.map(|msg| Message::Login(Box::new(msg)))
|
||||
} else {
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
}
|
||||
} else {
|
||||
let (loader, command) = Loader::new(datadir_path, cfg, network, None);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
}
|
||||
}
|
||||
_ => l.update(*msg).map(|msg| Message::Launch(Box::new(msg))),
|
||||
},
|
||||
(State::Login(l), Message::Login(msg)) => match *msg {
|
||||
login::Message::View(login::ViewMessage::BackToLauncher) => {
|
||||
let launcher = Launcher::new(l.datadir.clone());
|
||||
self.state = State::Launcher(Box::new(launcher));
|
||||
Command::none()
|
||||
}
|
||||
login::Message::Install(remote_backend) => {
|
||||
let (install, command) =
|
||||
Installer::new(l.datadir.clone(), l.network, remote_backend);
|
||||
self.state = State::Installer(Box::new(install));
|
||||
command.map(|msg| Message::Install(Box::new(msg)))
|
||||
}
|
||||
login::Message::Run(Ok((backend_client, wallet))) => {
|
||||
let config = app::Config::from_file(
|
||||
&l.datadir
|
||||
.join(l.network.to_string())
|
||||
.join(app::config::DEFAULT_FILE_NAME),
|
||||
)
|
||||
.expect("A gui configuration file must be present");
|
||||
self.logger.set_running_mode(
|
||||
l.datadir.clone(),
|
||||
l.network,
|
||||
config.log_level().unwrap_or(LevelFilter::INFO),
|
||||
);
|
||||
|
||||
let (app, command) = create_app_with_remote_backend(
|
||||
backend_client,
|
||||
wallet,
|
||||
l.datadir.clone(),
|
||||
l.network,
|
||||
config,
|
||||
);
|
||||
|
||||
self.state = State::App(app);
|
||||
command.map(|msg| Message::Run(Box::new(msg)))
|
||||
}
|
||||
_ => l.update(*msg).map(|msg| Message::Login(Box::new(msg))),
|
||||
},
|
||||
(State::Installer(i), Message::Install(msg)) => {
|
||||
if let installer::Message::Exit(path, internal_bitcoind) = *msg {
|
||||
let cfg = app::Config::from_file(&path).unwrap();
|
||||
let daemon_cfg =
|
||||
DaemonConfig::from_file(cfg.daemon_config_path.clone()).unwrap();
|
||||
let datadir_path = daemon_cfg
|
||||
.data_dir
|
||||
.as_ref()
|
||||
.expect("Installer must have set it")
|
||||
.clone();
|
||||
let settings = app::settings::Settings::from_file(i.datadir.clone(), i.network)
|
||||
.expect("A settings file was created");
|
||||
if settings
|
||||
.wallets
|
||||
.first()
|
||||
.map(|w| w.remote_backend_auth.is_some())
|
||||
== Some(true)
|
||||
{
|
||||
let (login, command) =
|
||||
login::LianaLiteLogin::new(i.datadir.clone(), i.network, settings);
|
||||
self.state = State::Login(Box::new(login));
|
||||
command.map(|msg| Message::Login(Box::new(msg)))
|
||||
} else {
|
||||
let cfg = app::Config::from_file(&path).expect("A config file was created");
|
||||
let daemon_cfg =
|
||||
DaemonConfig::from_file(cfg.daemon_config_path.clone()).unwrap();
|
||||
let datadir_path = daemon_cfg
|
||||
.data_dir
|
||||
.as_ref()
|
||||
.expect("Installer must have set it")
|
||||
.clone();
|
||||
|
||||
self.logger.set_running_mode(
|
||||
datadir_path.clone(),
|
||||
daemon_cfg.bitcoin_config.network,
|
||||
self.log_level
|
||||
.unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)),
|
||||
);
|
||||
self.logger.remove_install_log_file(datadir_path.clone());
|
||||
let (loader, command) = Loader::new(
|
||||
datadir_path,
|
||||
cfg,
|
||||
daemon_cfg.bitcoin_config.network,
|
||||
internal_bitcoind,
|
||||
);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
self.logger.set_running_mode(
|
||||
datadir_path.clone(),
|
||||
daemon_cfg.bitcoin_config.network,
|
||||
self.log_level
|
||||
.unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)),
|
||||
);
|
||||
self.logger.remove_install_log_file(datadir_path.clone());
|
||||
let (loader, command) = Loader::new(
|
||||
datadir_path,
|
||||
cfg,
|
||||
daemon_cfg.bitcoin_config.network,
|
||||
internal_bitcoind,
|
||||
);
|
||||
self.state = State::Loader(Box::new(loader));
|
||||
command.map(|msg| Message::Load(Box::new(msg)))
|
||||
}
|
||||
} else if let installer::Message::BackToLauncher = *msg {
|
||||
let launcher = Launcher::new(i.destination_path());
|
||||
self.state = State::Launcher(Box::new(launcher));
|
||||
@ -431,6 +404,7 @@ impl Application for GUI {
|
||||
State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))),
|
||||
State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))),
|
||||
State::Launcher(v) => v.subscription().map(|msg| Message::Launch(Box::new(msg))),
|
||||
State::Login(_) => Subscription::none(),
|
||||
},
|
||||
iced::event::listen_with(|event, status| match (&event, status) {
|
||||
(
|
||||
@ -463,6 +437,7 @@ impl Application for GUI {
|
||||
State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))),
|
||||
State::Launcher(v) => v.view().map(|msg| Message::Launch(Box::new(msg))),
|
||||
State::Loader(v) => v.view().map(|msg| Message::Load(Box::new(msg))),
|
||||
State::Login(v) => v.view().map(|msg| Message::Login(Box::new(msg))),
|
||||
}
|
||||
}
|
||||
|
||||
@ -471,30 +446,62 @@ impl Application for GUI {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_datadir(datadir_path: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(unix)]
|
||||
return {
|
||||
use std::fs::DirBuilder;
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
|
||||
let mut builder = DirBuilder::new();
|
||||
builder.mode(0o700).recursive(true).create(datadir_path)?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// TODO: permissions on Windows..
|
||||
#[cfg(not(unix))]
|
||||
return {
|
||||
std::fs::create_dir_all(datadir_path)?;
|
||||
Ok(())
|
||||
};
|
||||
pub fn create_app_with_remote_backend(
|
||||
remote_backend: BackendWalletClient,
|
||||
wallet: api::Wallet,
|
||||
datadir: PathBuf,
|
||||
network: bitcoin::Network,
|
||||
config: app::Config,
|
||||
) -> (app::App, iced::Command<app::Message>) {
|
||||
let hws: Vec<HardwareWalletConfig> = wallet
|
||||
.metadata
|
||||
.ledger_hmacs
|
||||
.into_iter()
|
||||
.map(|ledger_hmac| HardwareWalletConfig {
|
||||
kind: async_hwi::DeviceKind::Ledger.to_string(),
|
||||
fingerprint: ledger_hmac.fingerprint,
|
||||
token: ledger_hmac.hmac,
|
||||
})
|
||||
.collect();
|
||||
let aliases: HashMap<bitcoin::bip32::Fingerprint, String> = wallet
|
||||
.metadata
|
||||
.fingerprint_aliases
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
if a.user_id == remote_backend.user_id() {
|
||||
Some((a.fingerprint, a.alias))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
App::new(
|
||||
Cache {
|
||||
network: bitcoin::Network::Signet,
|
||||
coins: Vec::new(),
|
||||
rescan_progress: None,
|
||||
datadir_path: default_datadir().unwrap(),
|
||||
blockheight: wallet.tip_height.unwrap_or(0),
|
||||
},
|
||||
Arc::new(
|
||||
Wallet::new(wallet.descriptor)
|
||||
.with_name(wallet.name)
|
||||
.with_key_aliases(aliases)
|
||||
.with_hardware_wallets(hws)
|
||||
.load_hotsigners(&datadir, network)
|
||||
.expect("Datadir should be conform"),
|
||||
),
|
||||
config,
|
||||
Arc::new(remote_backend),
|
||||
datadir,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub enum Config {
|
||||
Run(PathBuf, app::Config, bitcoin::Network),
|
||||
Launcher(PathBuf),
|
||||
Install(PathBuf, bitcoin::Network),
|
||||
RunWithRemoteBackend(String, Option<String>),
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@ -524,12 +531,6 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
let datadir_path = default_datadir().unwrap();
|
||||
Config::new(datadir_path, None)
|
||||
}
|
||||
[Arg::Email(email)] => Ok(Config::RunWithRemoteBackend(email.to_string(), None)),
|
||||
[Arg::Email(email), Arg::RefreshToken(token)]
|
||||
| [Arg::RefreshToken(token), Arg::Email(email)] => Ok(Config::RunWithRemoteBackend(
|
||||
email.to_string(),
|
||||
Some(token.to_string()),
|
||||
)),
|
||||
[Arg::Network(network)] => {
|
||||
let datadir_path = default_datadir().unwrap();
|
||||
Config::new(datadir_path, Some(*network))
|
||||
|
||||
@ -4,6 +4,7 @@ use iced::{widget::svg::Handle, window::icon};
|
||||
const LIANA_APP_ICON: &[u8] = include_bytes!("../static/logos/liana-app-icon.png");
|
||||
const LIANA_LOGO_GREY: &[u8] = include_bytes!("../static/logos/LIANA_SYMBOL_Gray.svg");
|
||||
const LIANA_BRAND_GREY: &[u8] = include_bytes!("../static/logos/LIANA_BRAND_Gray.svg");
|
||||
const WIZARDSARDINE_LETTERING: &[u8] = include_bytes!("../static/logos/logo-wizardsardine.svg");
|
||||
|
||||
pub fn liana_app_icon() -> icon::Icon {
|
||||
icon::from_file_data(LIANA_APP_ICON, None).unwrap()
|
||||
@ -19,6 +20,11 @@ pub fn liana_brand_grey() -> Svg {
|
||||
Svg::new(h)
|
||||
}
|
||||
|
||||
pub fn wizardsardine() -> Svg {
|
||||
let h = Handle::from_memory(WIZARDSARDINE_LETTERING.to_vec());
|
||||
Svg::new(h)
|
||||
}
|
||||
|
||||
const CREATE_NEW_WALLET_ICON: &[u8] = include_bytes!("../static/icons/blueprint.svg");
|
||||
|
||||
pub fn create_new_wallet_icon() -> Svg {
|
||||
|
||||
1
gui/ui/static/logos/logo-wizardsardine.svg
Normal file
1
gui/ui/static/logos/logo-wizardsardine.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 237 29"><path fill="#fff" d="M7.053 27.696 0 9.407h4.395l4.712 13.408 4.926-13.408h4.003l4.853 13.335L27.6 9.407h4.322l-7.016 18.29h-4.003l-4.926-13.618-4.926 13.617H7.049h.004ZM34.392 6.969V2.298h4.711v4.67h-4.711Zm.461 20.728V9.407h3.861v18.29h-3.86ZM42.495 27.696v-2.83l9.638-11.71h-9.496V9.408h14.952v2.795l-9.6 11.745h9.851v3.75H42.495ZM77.007 24.336v3.36H73.57v-2.228c-1.298 1.767-3.261 2.653-5.882 2.653-1.818 0-3.367-.548-4.643-1.644-1.276-1.096-1.912-2.515-1.912-4.261s.633-3.201 1.894-4.297c1.265-1.096 2.818-1.645 4.66-1.645h5.458v-3.288H62.41V9.415h14.6v14.928l-.004-.007Zm-8.326.744c1.156 0 2.193-.265 3.101-.795.91-.53 1.364-1.314 1.364-2.352v-2.795H68.68c-.967 0-1.803.276-2.497.831-.698.556-1.047 1.281-1.047 2.174s.349 1.612 1.047 2.142c.698.53 1.53.795 2.497.795ZM80.827 27.696V9.407h9.71v3.996h-5.845v14.29H80.83l-.004.003ZM112.285 27.697h-3.861v-3.22c-1.534 2.428-3.803 3.644-6.802 3.644-2.741 0-4.977-.925-6.715-2.776-1.737-1.851-2.603-4.12-2.603-6.81 0-2.689.873-4.928 2.622-6.79 1.748-1.862 3.98-2.795 6.696-2.795 2.999 0 5.268 1.216 6.802 3.644V0h3.861v27.697ZM98.023 22.71c1.145 1.11 2.592 1.662 4.341 1.662 1.748 0 3.195-.555 4.341-1.662 1.145-1.107 1.719-2.501 1.719-4.174 0-1.674-.578-3.067-1.738-4.174-1.156-1.107-2.599-1.662-4.322-1.662-1.723 0-3.196.559-4.341 1.68s-1.72 2.504-1.72 4.156c0 1.651.571 3.067 1.72 4.174ZM124.107 28.121c-2.126 0-3.904-.512-5.333-1.539-1.429-1.027-2.145-2.424-2.145-4.192h3.861c0 .777.331 1.405.993 1.873.661.472 1.559.708 2.693.708 1.04 0 1.891-.19 2.553-.567.661-.377.992-.918.992-1.626 0-.587-.265-1.085-.796-1.484-.531-.4-1.2-.7-2.003-.904-.804-.2-1.676-.446-2.622-.744a25.162 25.162 0 0 1-2.621-.973 4.889 4.889 0 0 1-2.003-1.625c-.531-.73-.796-1.616-.796-2.654 0-1.745.669-3.088 2.003-4.032s3.065-1.415 5.192-1.415c2.126 0 3.81.5 5.191 1.502 1.382 1.002 2.072 2.352 2.072 4.05h-3.861c0-.729-.309-1.313-.919-1.752-.615-.436-1.455-.654-2.516-.654-.993 0-1.785.182-2.374.548-.593.367-.887.868-.887 1.503 0 .755.371 1.343 1.116 1.768.745.424 1.647.762 2.712 1.008 1.062.247 2.127.53 3.188.85a6.14 6.14 0 0 1 2.712 1.644c.746.777 1.116 1.793 1.116 3.041 0 1.793-.698 3.184-2.09 4.174-1.392.991-3.203 1.485-5.42 1.485l-.008.007ZM150.098 24.336v3.36h-3.436v-2.228c-1.298 1.767-3.261 2.653-5.882 2.653-1.818 0-3.366-.548-4.642-1.644-1.276-1.096-1.913-2.515-1.913-4.261s.633-3.201 1.894-4.297c1.266-1.096 2.818-1.645 4.661-1.645h5.457v-3.288h-10.736V9.415h14.6v14.928l-.003-.007Zm-8.325.744c1.156 0 2.192-.265 3.101-.795.908-.53 1.363-1.314 1.363-2.352v-2.795h-4.464c-.967 0-1.804.276-2.498.831-.698.556-1.047 1.281-1.047 2.174s.349 1.612 1.047 2.142c.698.53 1.531.795 2.498.795ZM153.693 27.696V9.407h9.71v3.996h-5.846v14.29h-3.86l-.004.003ZM185.165 27.697h-3.86v-3.22c-1.535 2.428-3.803 3.644-6.802 3.644-2.742 0-4.977-.925-6.715-2.776-1.738-1.851-2.603-4.12-2.603-6.81 0-2.689.872-4.928 2.621-6.79s3.981-2.795 6.697-2.795c2.999 0 5.267 1.216 6.802 3.644V0h3.86v27.697Zm-14.262-4.987c1.146 1.11 2.593 1.662 4.341 1.662 1.749 0 3.196-.555 4.341-1.662 1.145-1.107 1.72-2.501 1.72-4.174 0-1.674-.579-3.067-1.738-4.174-1.156-1.107-2.6-1.662-4.323-1.662-1.723 0-3.195.559-4.341 1.68-1.145 1.121-1.719 2.504-1.719 4.156 0 1.651.571 3.067 1.719 4.174ZM188.535 6.969V2.298h4.712v4.67h-4.712Zm.462 20.728V9.407h3.861v18.29h-3.861ZM206.927 8.947c2.243 0 4.057.667 5.439 2 1.381 1.331 2.072 3.084 2.072 5.251v11.498h-3.861V17.472c0-1.437-.443-2.595-1.33-3.466s-2.073-1.31-3.563-1.31c-1.491 0-2.81.435-3.756 1.31-.945.871-1.417 2.05-1.417 3.539v10.151h-3.861V9.407h3.791v2.795c1.418-2.17 3.578-3.255 6.486-3.255ZM236.996 18.568v1.274h-15.323c.251 1.463.883 2.643 1.898 3.539 1.014.896 2.275 1.343 3.78 1.343.706 0 1.367-.095 1.985-.283.615-.189 1.113-.414 1.487-.672a6.64 6.64 0 0 0 .993-.83c.283-.295.48-.53.581-.709.102-.178.164-.3.19-.37l3.66.85a10.433 10.433 0 0 1-.992 1.593c-.389.519-.931 1.092-1.625 1.717-.694.624-1.589 1.132-2.687 1.52-1.094.389-2.316.585-3.661.585-2.759 0-5.06-.897-6.907-2.69-1.847-1.793-2.77-4.09-2.77-6.9 0-2.809.927-5.044 2.788-6.863 1.858-1.814 4.155-2.725 6.893-2.725 2.738 0 5.068.914 6.926 2.74 1.857 1.829 2.788 4.12 2.788 6.881h-.004Zm-15.323-1.379h11.321c-.204-1.437-.836-2.606-1.898-3.502-1.062-.897-2.33-1.343-3.814-1.343-1.392 0-2.606.432-3.642 1.292-1.036.86-1.695 2.047-1.967 3.553Z"/></svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
Loading…
x
Reference in New Issue
Block a user