liana/gui/src/app/mod.rs

343 lines
12 KiB
Rust

pub mod cache;
pub mod config;
pub mod menu;
pub mod message;
pub mod settings;
pub mod state;
pub mod view;
pub mod wallet;
mod error;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use iced::{clipboard, time, Command, Subscription};
use tokio::runtime::Handle;
use tracing::{error, info, warn};
pub use liana::{commands::CoinStatus, config::Config as DaemonConfig, miniscript::bitcoin};
use liana_ui::{
component::network_banner,
widget::{Column, Element},
};
pub use config::Config;
pub use message::Message;
use state::{
CoinsPanel, CreateSpendPanel, Home, PsbtsPanel, ReceivePanel, RecoveryPanel, State,
TransactionsPanel,
};
use crate::{
app::{cache::Cache, error::Error, menu::Menu, wallet::Wallet},
bitcoind::Bitcoind,
daemon::{embedded::EmbeddedDaemon, Daemon, DaemonBackend},
};
use self::state::SettingsState;
struct Panels {
current: Menu,
home: Home,
coins: CoinsPanel,
transactions: TransactionsPanel,
psbts: PsbtsPanel,
recovery: RecoveryPanel,
receive: ReceivePanel,
create_spend: CreateSpendPanel,
settings: SettingsState,
}
impl Panels {
fn new(
cache: &Cache,
wallet: Arc<Wallet>,
data_dir: PathBuf,
daemon_backend: DaemonBackend,
internal_bitcoind: Option<&Bitcoind>,
) -> Panels {
Self {
current: Menu::Home,
home: Home::new(wallet.clone(), &cache.coins),
coins: CoinsPanel::new(&cache.coins, wallet.main_descriptor.first_timelock_value()),
transactions: TransactionsPanel::new(wallet.clone()),
psbts: PsbtsPanel::new(wallet.clone()),
recovery: RecoveryPanel::new(wallet.clone(), &cache.coins, cache.blockheight),
receive: ReceivePanel::new(data_dir.clone(), wallet.clone()),
create_spend: CreateSpendPanel::new(
wallet.clone(),
&cache.coins,
cache.blockheight as u32,
cache.network,
),
settings: state::SettingsState::new(
data_dir,
wallet.clone(),
daemon_backend,
internal_bitcoind.is_some(),
),
}
}
fn current(&self) -> &dyn State {
match self.current {
Menu::Home => &self.home,
Menu::Receive => &self.receive,
Menu::PSBTs => &self.psbts,
Menu::Transactions => &self.transactions,
Menu::TransactionPreSelected(_) => &self.transactions,
Menu::Settings => &self.settings,
Menu::Coins => &self.coins,
Menu::CreateSpendTx => &self.create_spend,
Menu::Recovery => &self.recovery,
Menu::RefreshCoins(_) => &self.create_spend,
Menu::PsbtPreSelected(_) => &self.psbts,
}
}
fn current_mut(&mut self) -> &mut dyn State {
match self.current {
Menu::Home => &mut self.home,
Menu::Receive => &mut self.receive,
Menu::PSBTs => &mut self.psbts,
Menu::Transactions => &mut self.transactions,
Menu::TransactionPreSelected(_) => &mut self.transactions,
Menu::Settings => &mut self.settings,
Menu::Coins => &mut self.coins,
Menu::CreateSpendTx => &mut self.create_spend,
Menu::Recovery => &mut self.recovery,
Menu::RefreshCoins(_) => &mut self.create_spend,
Menu::PsbtPreSelected(_) => &mut self.psbts,
}
}
}
pub struct App {
cache: Cache,
config: Config,
wallet: Arc<Wallet>,
daemon: Arc<dyn Daemon + Sync + Send>,
internal_bitcoind: Option<Bitcoind>,
panels: Panels,
}
impl App {
pub fn new(
cache: Cache,
wallet: Arc<Wallet>,
config: Config,
daemon: Arc<dyn Daemon + Sync + Send>,
data_dir: PathBuf,
internal_bitcoind: Option<Bitcoind>,
) -> (App, Command<Message>) {
let mut panels = Panels::new(
&cache,
wallet.clone(),
data_dir,
daemon.backend(),
internal_bitcoind.as_ref(),
);
let cmd = panels.home.reload(daemon.clone(), wallet.clone());
(
Self {
panels,
cache,
config,
daemon,
wallet,
internal_bitcoind,
},
cmd,
)
}
fn set_current_panel(&mut self, menu: Menu) -> Command<Message> {
self.panels.current_mut().interrupt();
match &menu {
menu::Menu::TransactionPreSelected(txid) => {
if let Ok(Some(tx)) = Handle::current().block_on(async {
self.daemon
.get_history_txs(&[*txid])
.await
.map(|txs| txs.first().cloned())
}) {
self.panels.transactions.preselect(tx);
self.panels.current = menu;
return Command::none();
};
}
menu::Menu::PsbtPreSelected(txid) => {
// Get preselected spend from DB in case it's not yet in the cache.
// We only need this single spend as we will go straight to its view and not show the PSBTs list.
// In case of any error loading the spend or if it doesn't exist, load PSBTs list in usual way.
if let Ok(Some(spend_tx)) = Handle::current().block_on(async {
self.daemon
.list_spend_transactions(Some(&[*txid]))
.await
.map(|txs| txs.first().cloned())
}) {
self.panels.psbts.preselect(spend_tx);
self.panels.current = menu;
return Command::none();
};
}
menu::Menu::RefreshCoins(preselected) => {
self.panels.create_spend = CreateSpendPanel::new_self_send(
self.wallet.clone(),
&self.cache.coins,
self.cache.blockheight as u32,
preselected,
self.cache.network,
);
}
menu::Menu::CreateSpendTx => {
// redo the process of spending only if user want to start a new one.
if !self.panels.create_spend.is_first_step() {
self.panels.create_spend = CreateSpendPanel::new(
self.wallet.clone(),
&self.cache.coins,
self.cache.blockheight as u32,
self.cache.network,
);
}
}
_ => {}
};
self.panels.current = menu;
self.panels
.current_mut()
.reload(self.daemon.clone(), self.wallet.clone())
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch(vec![
time::every(Duration::from_secs(
// LianaLite has no rescan feature, the cache refresh loop is only
// to fetch the new block height tip which is only used to warn user
// about recovery availability.
if self.daemon.backend() == DaemonBackend::RemoteBackend {
120
// For the rescan feature, we set a higher frequency of cache refresh
// to give to user an up-to-date view of the rescan progress.
} else {
10
},
))
.map(|_| Message::Tick),
self.panels.current().subscription(),
])
}
pub fn stop(&mut self) {
info!("Close requested");
if self.daemon.backend() == DaemonBackend::EmbeddedLianad {
if let Err(e) = Handle::current().block_on(async { self.daemon.stop().await }) {
error!("{}", e);
} else {
info!("Internal daemon stopped");
}
if let Some(bitcoind) = &self.internal_bitcoind {
bitcoind.stop();
}
}
}
pub fn update(&mut self, message: Message) -> Command<Message> {
match message {
Message::Tick => {
let daemon = self.daemon.clone();
let datadir_path = self.cache.datadir_path.clone();
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?;
let info = daemon.get_info().await?;
let coins = daemon
.list_coins(&[CoinStatus::Unconfirmed, CoinStatus::Confirmed], &[])
.await?;
Ok(Cache {
datadir_path,
coins: coins.coins,
network: info.network,
blockheight: info.block_height,
rescan_progress: info.rescan_progress,
})
},
Message::UpdateCache,
)
}
Message::UpdateCache(res) => {
match res {
Ok(cache) => self.cache = cache,
Err(e) => tracing::error!("Failed to update cache: {}", e),
}
Command::none()
}
Message::LoadDaemonConfig(cfg) => {
let path = self.config.daemon_config_path.clone().expect(
"Application config must have a daemon configuration file path at this point.",
);
let res = self.load_daemon_config(&path, *cfg);
self.update(Message::DaemonConfigLoaded(res))
}
Message::WalletUpdated(Ok(wallet)) => {
self.wallet = wallet.clone();
self.panels.current_mut().update(
self.daemon.clone(),
&self.cache,
Message::WalletUpdated(Ok(wallet)),
)
}
Message::View(view::Message::Menu(menu)) => self.set_current_panel(menu),
Message::View(view::Message::Clipboard(text)) => clipboard::write(text),
_ => self
.panels
.current_mut()
.update(self.daemon.clone(), &self.cache, message),
}
}
pub fn load_daemon_config(
&mut self,
daemon_config_path: &PathBuf,
cfg: DaemonConfig,
) -> Result<(), Error> {
Handle::current().block_on(async { self.daemon.stop().await })?;
let daemon = EmbeddedDaemon::start(cfg)?;
self.daemon = Arc::new(daemon);
let content =
toml::to_string(&self.daemon.config()).map_err(|e| Error::Config(e.to_string()))?;
OpenOptions::new()
.write(true)
.truncate(true)
.open(daemon_config_path)
.map_err(|e| Error::Config(e.to_string()))?
.write_all(content.as_bytes())
.map_err(|e| {
warn!("failed to write to file: {:?}", e);
Error::Config(e.to_string())
})
}
pub fn view(&self) -> Element<Message> {
let content = self.panels.current().view(&self.cache).map(Message::View);
if self.cache.network != bitcoin::Network::Bitcoin {
Column::with_children(vec![network_banner(self.cache.network).into(), content]).into()
} else {
content
}
}
}