diff --git a/Cargo.lock b/Cargo.lock index d468a08b..44b58e48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2384,6 +2384,23 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "iced_aw" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "582c517a94ce3205da98e9c10b26bb71aa36b7d7d084441d826dc912711d1bac" +dependencies = [ + "cfg-if", + "chrono", + "getrandom 0.3.1", + "iced", + "iced_fonts", + "itertools 0.14.0", + "num-format", + "num-traits", + "web-time", +] + [[package]] name = "iced_core" version = "0.13.2" @@ -2403,6 +2420,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "iced_fonts" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df7deb0800a850ee25c8a42559f72c0f249e577feb3aad37b9b65dc1e517e52a" +dependencies = [ + "iced_core", +] + [[package]] name = "iced_futures" version = "0.13.2" @@ -2781,6 +2807,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.14" @@ -3020,6 +3055,7 @@ dependencies = [ "flate2", "hex", "iced", + "iced_aw", "iced_runtime", "jsonrpc 0.12.1", "liana", @@ -3047,6 +3083,7 @@ dependencies = [ "bitcoin", "chrono", "iced", + "iced_aw", "iced_core", "iced_runtime", "unicode-segmentation", @@ -3512,6 +3549,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index 1b0ec2e2..fe969d87 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -23,6 +23,7 @@ backtrace = "0.3" hex = "0.4.3" iced = { version = "0.13.1", default-features = false, features = ["tokio", "svg", "qr_code", "image", "lazy", "wgpu", "advanced", "tiny-skia"] } +iced_aw = { version = "0.12.2", features = ["context_menu"] } iced_runtime = "0.13.1" # Used to verify RFC-compliance of an email diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index 0b21daac..03c06431 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -34,7 +34,7 @@ use state::{ use wallet::{sync_status, SyncStatus}; use crate::{ - app::{cache::Cache, error::Error, menu::Menu, wallet::Wallet}, + app::{cache::Cache, error::Error, menu::Menu, settings::WalletId, wallet::Wallet}, daemon::{embedded::EmbeddedDaemon, Daemon, DaemonBackend}, dir::LianaDirectory, node::{bitcoind::Bitcoind, NodeType}, @@ -182,13 +182,17 @@ impl App { ) } - pub fn title(&self) -> String { + pub fn wallet_id(&self) -> WalletId { + self.wallet.id() + } + + pub fn title(&self) -> &str { if let Some(alias) = &self.wallet.alias { if !alias.is_empty() { - return format!("- {}", alias); + return alias; } } - String::new() + "Liana wallet" } fn set_current_panel(&mut self, menu: Menu) -> Task { diff --git a/liana-gui/src/app/view/mod.rs b/liana-gui/src/app/view/mod.rs index 7e83849e..35db36d0 100644 --- a/liana-gui/src/app/view/mod.rs +++ b/liana-gui/src/app/view/mod.rs @@ -18,7 +18,7 @@ pub use message::*; use warning::warn; use iced::{ - widget::{column, row, scrollable, Space}, + widget::{column, responsive, row, scrollable, Space}, Length, }; @@ -186,6 +186,152 @@ pub fn sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { .style(theme::container::foreground) } +pub fn small_sidebar<'a>(menu: &Menu, cache: &'a Cache) -> Container<'a, Message> { + let home_button = if *menu == Menu::Home { + row!( + button::menu_active_small(home_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar(), + ) + } else { + row!(button::menu_small(home_icon()) + .on_press(Message::Menu(Menu::Home)) + .width(iced::Length::Fill),) + }; + + let transactions_button = if *menu == Menu::Transactions { + row!( + button::menu_active_small(history_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(history_icon()) + .on_press(Message::Menu(Menu::Transactions)) + .width(iced::Length::Fill)) + }; + + let coins_button = if *menu == Menu::Coins { + row!( + button::menu_active_small(coins_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(coins_icon()) + .style(theme::button::menu) + .on_press(Message::Menu(Menu::Coins)) + .width(iced::Length::Fill)) + }; + + let psbt_button = if *menu == Menu::PSBTs { + row!( + button::menu_active_small(history_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(history_icon()) + .on_press(Message::Menu(Menu::PSBTs)) + .width(iced::Length::Fill)) + }; + + let spend_button = if *menu == Menu::CreateSpendTx { + row!( + button::menu_active_small(send_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(send_icon()) + .on_press(Message::Menu(Menu::CreateSpendTx)) + .width(iced::Length::Fill)) + }; + + let receive_button = if *menu == Menu::Receive { + row!( + button::menu_active_small(receive_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(receive_icon()) + .on_press(Message::Menu(Menu::Receive)) + .width(iced::Length::Fill)) + }; + + let recovery_button = if *menu == Menu::Recovery { + row!( + button::menu_active_small(recovery_icon()) + .on_press(Message::Reload) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(recovery_icon()) + .on_press(Message::Menu(Menu::Recovery)) + .width(iced::Length::Fill)) + }; + + let settings_button = if *menu == Menu::Settings { + row!( + button::menu_active_small(settings_icon()) + .on_press(Message::Menu(Menu::Settings)) + .width(iced::Length::Fill), + menu_green_bar() + ) + } else { + row!(button::menu_small(settings_icon()) + .on_press(Message::Menu(Menu::Settings)) + .width(iced::Length::Fill)) + }; + + Container::new( + Column::new() + .push( + Column::new() + .push( + Container::new( + liana_grey_logo() + .height(Length::Fixed(120.0)) + .width(Length::Fixed(60.0)), + ) + .padding(10), + ) + .push(home_button) + .push(spend_button) + .push(receive_button) + .push(coins_button) + .push(transactions_button) + .push(psbt_button) + .align_x(iced::Alignment::Center) + .height(Length::Fill), + ) + .push( + Container::new( + Column::new() + .spacing(10) + .push_maybe(cache.rescan_progress.map(|p| { + Container::new(text(format!("{:.2}% ", p * 100.0))) + .padding(5) + .style(theme::pill::simple) + })) + .push(recovery_button) + .push(settings_button), + ) + .height(Length::Shrink), + ) + .align_x(iced::Alignment::Center), + ) + .style(theme::container::foreground) +} + pub fn dashboard<'a, T: Into>>( menu: &'a Menu, cache: &'a Cache, @@ -194,9 +340,14 @@ pub fn dashboard<'a, T: Into>>( ) -> Element<'a, Message> { Row::new() .push( - sidebar(menu, cache) - .width(Length::FillPortion(2)) - .height(Length::Fill), + Container::new(responsive(move |size| { + if size.width > 150.0 { + sidebar(menu, cache).height(Length::Fill).into() + } else { + small_sidebar(menu, cache).height(Length::Fill).into() + } + })) + .width(Length::FillPortion(2)), ) .push( Column::new() diff --git a/liana-gui/src/gui/mod.rs b/liana-gui/src/gui/mod.rs new file mode 100644 index 00000000..d3b4f623 --- /dev/null +++ b/liana-gui/src/gui/mod.rs @@ -0,0 +1,317 @@ +use iced::{ + event::{self, Event}, + keyboard, + widget::{focus_next, focus_previous, pane_grid}, + Length, Subscription, Task, +}; +use tracing::{error, info}; +use tracing_subscriber::filter::LevelFilter; +extern crate serde; +extern crate serde_json; + +use liana::miniscript::bitcoin; +use liana_ui::widget::{Column, Container, Element}; + +pub mod pane; +pub mod tab; + +use crate::{dir::LianaDirectory, launcher, logger::setup_logger, VERSION}; + +pub struct GUI { + panes: pane_grid::State, + focus: Option, + config: Config, +} + +#[derive(Debug)] +pub enum Key { + Tab(bool), +} + +#[derive(Debug)] +pub enum Message { + CtrlC, + FontLoaded(Result<(), iced::font::Error>), + Pane(pane_grid::Pane, pane::Message), + KeyPressed(Key), + Event(iced::Event), + + Clicked(pane_grid::Pane), + Dragged(pane_grid::DragEvent), + Resized(pane_grid::ResizeEvent), +} + +impl From> for Message { + fn from(value: Result<(), iced::font::Error>) -> Self { + Self::FontLoaded(value) + } +} + +async fn ctrl_c() -> Result<(), ()> { + if let Err(e) = tokio::signal::ctrl_c().await { + error!("{}", e); + }; + info!("Signal received, exiting"); + Ok(()) +} + +impl GUI { + pub fn title(&self) -> String { + format!("Liana v{}", VERSION) + } + + pub fn new((config, log_level): (Config, Option)) -> (GUI, Task) { + let log_level = log_level.unwrap_or(LevelFilter::INFO); + if let Err(e) = setup_logger(log_level, config.liana_directory.clone()) { + tracing::warn!("Error while setting error: {}", e); + } + let mut cmds = vec![Task::perform(ctrl_c(), |_| Message::CtrlC)]; + let (pane, cmd) = pane::Pane::new(&config); + let (panes, focused_pane) = pane_grid::State::new(pane); + cmds.push(cmd.map(move |msg| Message::Pane(focused_pane, msg))); + ( + Self { + panes, + focus: Some(focused_pane), + config, + }, + Task::batch(cmds), + ) + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::CtrlC + | Message::Event(iced::Event::Window(iced::window::Event::CloseRequested)) => { + for (_, pane) in self.panes.iter_mut() { + pane.stop(); + } + iced::window::get_latest().and_then(iced::window::close) + } + Message::KeyPressed(Key::Tab(shift)) => { + log::debug!("Tab pressed!"); + if shift { + focus_previous() + } else { + focus_next() + } + } + Message::Pane(pane_id, pane::Message::View(pane::ViewMessage::SplitTab(i))) => { + if let Some(p) = self.panes.get_mut(pane_id) { + if let Some(tab) = p.remove_tab(i) { + let result = self.panes.split( + pane_grid::Axis::Vertical, + pane_id, + pane::Pane::new_with_tab(tab.state), + ); + + if let Some((pane, _)) = result { + self.focus = Some(pane); + } + } + } + Task::none() + } + Message::Pane(pane_id, pane::Message::View(pane::ViewMessage::CloseTab(i))) => { + if let Some(pane) = self.panes.get_mut(pane_id) { + let _ = pane + .update( + pane::Message::View(pane::ViewMessage::CloseTab(i)), + &self.config, + ) + .map(move |msg| Message::Pane(pane_id, msg)); + if pane.tabs.is_empty() { + self.panes.close(pane_id); + if self.focus == Some(pane_id) { + self.focus = None; + } + } + } + if !self.panes.iter().any(|(_, p)| !p.tabs.is_empty()) { + return iced::window::get_latest().and_then(iced::window::close); + } + Task::none() + } + // In case of wallet deletion, remove any tab where the wallet id is currently running. + Message::Pane(p, pane::Message::Tab(t, tab::Message::Launch(msg))) => { + let mut tasks = Vec::new(); + if let launcher::Message::View(launcher::ViewMessage::DeleteWallet( + launcher::DeleteWalletMessage::Confirm(wallet_id), + )) = msg.as_ref() + { + let mut panes_to_close = Vec::::new(); + for (id, pane) in self.panes.iter_mut() { + let tabs_to_close: Vec = pane + .tabs + .iter() + .enumerate() + .filter_map(|(i, tab)| { + if let tab::State::App(a) = &tab.state { + if a.wallet_id() == *wallet_id { + Some(i) + } else { + None + } + } else { + None + } + }) + .collect(); + for i in tabs_to_close { + pane.close_tab(i); + } + if pane.tabs.is_empty() { + panes_to_close.push(*id); + } + } + for id in panes_to_close { + self.panes.close(id); + } + for (&id, pane) in self.panes.iter() { + for tab in &pane.tabs { + if let tab::State::Launcher(l) = &tab.state { + let tab_id = tab.id; + tasks.push(l.reload().map(move |msg| { + Message::Pane( + id, + pane::Message::Tab( + tab_id, + tab::Message::Launch(Box::new(msg)), + ), + ) + })); + } + } + } + } + if let Some(pane) = self.panes.get_mut(p) { + tasks.push( + pane.update( + pane::Message::Tab(t, tab::Message::Launch(msg)), + &self.config, + ) + .map(move |msg| Message::Pane(p, msg)), + ); + } + Task::batch(tasks) + } + Message::Pane(i, msg) => { + if let Some(pane) = self.panes.get_mut(i) { + return pane + .update(msg, &self.config) + .map(move |msg| Message::Pane(i, msg)); + } + Task::none() + } + Message::Clicked(pane) => { + self.focus = Some(pane); + Task::none() + } + Message::Resized(pane_grid::ResizeEvent { split, ratio }) => { + self.panes.resize(split, ratio); + Task::none() + } + Message::Dragged(pane_grid::DragEvent::Dropped { pane, target }) => { + if let pane_grid::Target::Pane(p, pane_grid::Region::Center) = target { + let (tabs, focused_tab) = if let Some(origin) = self.panes.get_mut(pane) { + (std::mem::take(&mut origin.tabs), origin.focused_tab) + } else { + (Vec::new(), 0) + }; + + if let Some(dest) = self.panes.get_mut(p) { + if !tabs.is_empty() { + dest.add_tabs(tabs, focused_tab); + } + } + self.panes.close(pane); + self.focus = Some(p); + } else { + self.panes.drop(pane, target); + } + Task::none() + } + _ => Task::none(), + } + } + + pub fn subscription(&self) -> Subscription { + let mut vec = vec![iced::event::listen_with(|event, status, _| { + match (&event, status) { + ( + Event::Keyboard(keyboard::Event::KeyPressed { + key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab), + modifiers, + .. + }), + event::Status::Ignored, + ) => Some(Message::KeyPressed(Key::Tab(modifiers.shift()))), + ( + iced::Event::Window(iced::window::Event::CloseRequested), + event::Status::Ignored, + ) => Some(Message::Event(event)), + _ => None, + } + })]; + for (id, pane) in self.panes.iter() { + vec.push( + pane.subscription() + .with(*id) + .map(|(id, msg)| Message::Pane(id, msg)), + ); + } + Subscription::batch(vec) + } + + pub fn view(&self) -> Element { + if self.panes.len() == 1 { + if let Some((&id, pane)) = self.panes.iter().nth(0) { + return Column::new() + .push(pane.tabs_menu_view().map(move |msg| Message::Pane(id, msg))) + .push(pane.view().map(move |msg| Message::Pane(id, msg))) + .into(); + } + } + + let focus = self.focus; + let pane_grid = pane_grid::PaneGrid::new(&self.panes, |id, pane, _| { + let _is_focused = focus == Some(id); + + pane_grid::Content::new(pane.view().map(move |msg| Message::Pane(id, msg))).title_bar( + pane_grid::TitleBar::new( + pane.tabs_menu_view().map(move |msg| Message::Pane(id, msg)), + ), + ) + }) + .spacing(10) + .width(Length::Fill) + .height(Length::Fill) + .on_click(Message::Clicked) + .on_drag(Message::Dragged) + .on_resize(10, Message::Resized); + + Container::new(pane_grid) + .style(liana_ui::theme::pane_grid::pane_grid_background) + .width(Length::Fill) + .height(Length::Fill) + .into() + } + + pub fn scale_factor(&self) -> f64 { + 1.0 + } +} + +pub struct Config { + pub liana_directory: LianaDirectory, + network: Option, +} + +impl Config { + pub fn new(liana_directory: LianaDirectory, network: Option) -> Self { + Self { + liana_directory, + network, + } + } +} diff --git a/liana-gui/src/gui/pane.rs b/liana-gui/src/gui/pane.rs new file mode 100644 index 00000000..52b6d61b --- /dev/null +++ b/liana-gui/src/gui/pane.rs @@ -0,0 +1,201 @@ +use iced::{Length, Subscription, Task}; +use iced_aw::ContextMenu; +use liana_ui::{component::text::*, icon::plus_icon, theme, widget::*}; + +use crate::gui::Config; + +use super::tab; + +#[derive(Debug)] +pub enum Message { + Tab(usize, tab::Message), + View(ViewMessage), +} + +#[derive(Debug, Clone)] +pub enum ViewMessage { + FocusTab(usize), + CloseTab(usize), + SplitTab(usize), + AddTab, +} + +pub struct Pane { + pub tabs: Vec, + + // this is an index in the tabs array + pub focused_tab: usize, + + // used to generate tabs ids. + tabs_created: usize, +} + +impl Pane { + pub fn new(cfg: &Config) -> (Self, Task) { + let (state, task) = tab::State::new(cfg.liana_directory.clone(), cfg.network); + ( + Self { + tabs: vec![tab::Tab::new(1, state)], + focused_tab: 0, + tabs_created: 1, + }, + task.map(|msg| Message::Tab(1, msg)), + ) + } + + pub fn new_with_tab(s: tab::State) -> Self { + Self { + tabs: vec![tab::Tab::new(1, s)], + focused_tab: 0, + tabs_created: 1, + } + } + + fn add_tab(&mut self, cfg: &Config) -> Task { + let (state, task) = tab::State::new(cfg.liana_directory.clone(), cfg.network); + self.tabs_created += 1; + let id = self.tabs_created; + self.tabs.push(tab::Tab::new(id, state)); + self.focused_tab = self.tabs.len() - 1; + task.map(move |msg| Message::Tab(id, msg)) + } + + pub fn close_tab(&mut self, i: usize) { + if let Some(mut tab) = self.remove_tab(i) { + tab.stop(); + } + } + + pub fn remove_tab(&mut self, i: usize) -> Option { + if i >= self.tabs.len() { + return None; + } + let tab = self.tabs.remove(i); + self.focused_tab = if self.tabs.is_empty() { + 0 + } else if i < self.tabs.len() - 1 { + i + } else { + self.tabs.len() - 1 + }; + Some(tab) + } + + pub fn add_tabs(&mut self, tabs: Vec, focused_tab: usize) { + for tab in tabs { + self.tabs_created += 1; + let id = self.tabs_created; + self.tabs.push(tab::Tab::new(id, tab.state)); + } + if self.focused_tab + focused_tab + 1 < self.tabs.len() { + self.focused_tab += focused_tab + 1; + } + } + + pub fn update(&mut self, message: Message, cfg: &Config) -> Task { + match message { + Message::Tab(id, msg) => self + .tabs + .iter_mut() + .find(|t| t.id == id) + .map(|t| t.update(msg).map(move |msg| Message::Tab(id, msg))) + .unwrap_or(Task::none()), + Message::View(ViewMessage::FocusTab(i)) => { + if i < self.tabs.len() { + self.focused_tab = i; + } + Task::none() + } + Message::View(ViewMessage::AddTab) => self.add_tab(cfg), + Message::View(ViewMessage::CloseTab(i)) => { + self.close_tab(i); + Task::none() + } + // handle by the pane grid update. + Message::View(ViewMessage::SplitTab(_)) => Task::none(), + } + } + + pub fn subscription(&self) -> Subscription { + let subs: Vec> = self + .tabs + .iter() + .map(|t| { + t.subscription() + .with(t.id) + .map(|(id, msg)| Message::Tab(id, msg)) + }) + .collect(); + Subscription::batch(subs) + } + + pub fn stop(&mut self) { + self.tabs.iter_mut().for_each(|t| t.stop()); + } + + pub fn tabs_menu_view(&self) -> Element { + let mut menu = Row::new().spacing(3); + let tabs_len = self.tabs.len(); + for (i, tab) in self.tabs.iter().enumerate() { + let title = tab.title(); + menu = menu.push(ContextMenu::new( + Into::>::into( + Button::new(if title.len() < 20 { + Row::new().push(p1_regular(title)).push(p1_regular( + &" ".to_string()[..21 - title.len()], + )) + } else { + Row::new() + .push(p1_regular(&title[..17])) + .push(p1_regular("...")) + }) + .style(if i == self.focused_tab { + theme::button::tab_active + } else { + theme::button::tab + }) + .on_press(ViewMessage::FocusTab(i)), + ), + move || { + Column::new() + .push( + Button::new(p1_regular("Close")) + .style(theme::button::secondary) + .on_press(ViewMessage::CloseTab(i)) + .width(100), + ) + .push_maybe(if tabs_len > 1 { + Some( + Button::new(p1_regular("Split")) + .style(theme::button::secondary) + .on_press(ViewMessage::SplitTab(i)) + .width(100), + ) + } else { + None + }) + .into() + }, + )); + } + menu = menu.push( + Button::new(plus_icon()) + .style(theme::button::tab) + .on_press(ViewMessage::AddTab), + ); + Into::>::into(menu.wrap()).map(Message::View) + } + + pub fn view(&self) -> Element { + Container::new(if let Some(t) = self.tabs.get(self.focused_tab) { + let id = t.id; + t.view().map(move |msg| Message::Tab(id, msg)) + } else { + Row::new().into() + }) + .style(theme::container::background) + .width(Length::Fill) + .height(Length::Fill) + .into() + } +} diff --git a/liana-gui/src/gui/tab.rs b/liana-gui/src/gui/tab.rs new file mode 100644 index 00000000..34197484 --- /dev/null +++ b/liana-gui/src/gui/tab.rs @@ -0,0 +1,380 @@ +use std::{collections::HashMap, sync::Arc}; + +use iced::{Subscription, Task}; +use tracing::{error, info}; +extern crate serde; +extern crate serde_json; + +use liana::miniscript::bitcoin; +use liana_ui::widget::Element; +use lianad::commands::ListCoinsResult; + +use crate::{ + app::{ + self, + cache::Cache, + settings::{update_settings_file, WalletSettings}, + wallet::Wallet, + App, + }, + dir::LianaDirectory, + export::import_backup_at_launch, + hw::HardwareWalletConfig, + installer::{self, Installer}, + launcher::{self, Launcher}, + loader::{self, Loader}, + services::connect::{ + client::backend::{api, BackendWalletClient}, + login, + }, +}; + +pub enum State { + Launcher(Box), + Installer(Box), + Loader(Box), + Login(Box), + App(App), +} + +impl State { + pub fn new( + directory: LianaDirectory, + network: Option, + ) -> (Self, Task) { + let (launcher, command) = Launcher::new(directory, network); + ( + State::Launcher(Box::new(launcher)), + command.map(|msg| Message::Launch(Box::new(msg))), + ) + } +} + +#[derive(Debug)] +pub enum Message { + Launch(Box), + Install(Box), + Load(Box), + Run(Box), + Login(Box), +} + +pub struct Tab { + pub id: usize, + pub state: State, +} + +impl Tab { + pub fn new(id: usize, state: State) -> Self { + Tab { id, state } + } + + pub fn title(&self) -> &str { + match &self.state { + State::Installer(_) => "Installer", + State::Loader(_) => "Loading...", + State::Launcher(_) => "Launcher", + State::Login(_) => "Login", + State::App(a) => a.title(), + } + } + + pub fn update(&mut self, message: Message) -> Task { + match (&mut self.state, message) { + (State::Launcher(l), Message::Launch(msg)) => match *msg { + launcher::Message::Install(datadir, network, init) => { + if !datadir.exists() { + // datadir is created right before launching the installer + // so logs can go in /installer.log + if let Err(e) = datadir.init() { + error!("Failed to create datadir: {}", e); + } else { + info!( + "Created a fresh data directory at {}", + &datadir.path().to_string_lossy() + ); + } + } + let (install, command) = Installer::new(datadir, network, None, init); + self.state = State::Installer(Box::new(install)); + command.map(|msg| Message::Install(Box::new(msg))) + } + launcher::Message::Run(datadir_path, cfg, network, settings) => { + if settings.remote_backend_auth.is_some() { + 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, None, settings); + 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(network)) => { + let (launcher, command) = Launcher::new(l.datadir.clone(), Some(network)); + self.state = State::Launcher(Box::new(launcher)); + command.map(|msg| Message::Launch(Box::new(msg))) + } + login::Message::Install(remote_backend) => { + let (install, command) = Installer::new( + l.datadir.clone(), + l.network, + remote_backend, + installer::UserFlow::CreateWallet, + ); + self.state = State::Installer(Box::new(install)); + command.map(|msg| Message::Install(Box::new(msg))) + } + login::Message::Run(Ok((backend_client, wallet, coins))) => { + let config = app::Config::from_file( + &l.datadir + .network_directory(l.network) + .path() + .join(app::config::DEFAULT_FILE_NAME), + ) + .expect("A gui configuration file must be present"); + let (app, command) = create_app_with_remote_backend( + l.settings.clone(), + backend_client, + wallet, + coins, + 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(settings, internal_bitcoind) = *msg { + if settings.remote_backend_auth.is_some() { + 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( + &i.datadir + .network_directory(i.network) + .path() + .join(app::config::DEFAULT_FILE_NAME), + ) + .expect("A gui configuration file must be present"); + + let (loader, command) = Loader::new( + i.datadir.clone(), + cfg, + i.network, + internal_bitcoind, + i.context.backup.take(), + *settings, + ); + self.state = State::Loader(Box::new(loader)); + command.map(|msg| Message::Load(Box::new(msg))) + } + } else if let installer::Message::BackToLauncher(network) = *msg { + let (launcher, command) = Launcher::new(i.destination_path(), Some(network)); + self.state = State::Launcher(Box::new(launcher)); + command.map(|msg| Message::Launch(Box::new(msg))) + } else { + i.update(*msg).map(|msg| Message::Install(Box::new(msg))) + } + } + (State::Loader(loader), Message::Load(msg)) => match *msg { + loader::Message::View(loader::ViewMessage::SwitchNetwork) => { + let (launcher, command) = + Launcher::new(loader.datadir_path.clone(), Some(loader.network)); + self.state = State::Launcher(Box::new(launcher)); + command.map(|msg| Message::Launch(Box::new(msg))) + } + loader::Message::Synced(Ok((wallet, cache, daemon, bitcoind, backup))) => { + if let Some(backup) = backup { + let config = loader.gui_config.clone(); + let datadir = loader.datadir_path.clone(); + Task::perform( + async move { + import_backup_at_launch( + cache, wallet, config, daemon, datadir, bitcoind, backup, + ) + .await + }, + |r| { + let r = r.map_err(loader::Error::RestoreBackup); + Message::Load(Box::new(loader::Message::App( + r, /* restored_from_backup */ true, + ))) + }, + ) + } else { + let (app, command) = App::new( + cache, + wallet, + loader.gui_config.clone(), + daemon, + loader.datadir_path.clone(), + bitcoind, + false, + ); + self.state = State::App(app); + command.map(|msg| Message::Run(Box::new(msg))) + } + } + loader::Message::App( + Ok((cache, wallet, config, daemon, datadir, bitcoind)), + restored_from_backup, + ) => { + let (app, command) = App::new( + cache, + wallet, + config, + daemon, + datadir, + bitcoind, + restored_from_backup, + ); + self.state = State::App(app); + command.map(|msg| Message::Run(Box::new(msg))) + } + loader::Message::App(Err(e), _) => { + tracing::error!("Failed to import backup: {e}"); + Task::none() + } + + _ => loader.update(*msg).map(|msg| Message::Load(Box::new(msg))), + }, + (State::App(i), Message::Run(msg)) => { + i.update(*msg).map(|msg| Message::Run(Box::new(msg))) + } + _ => Task::none(), + } + } + + pub fn subscription(&self) -> Subscription { + Subscription::batch(vec![match &self.state { + State::Installer(v) => v.subscription().map(|msg| Message::Install(Box::new(msg))), + State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))), + State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))), + State::Launcher(v) => v.subscription().map(|msg| Message::Launch(Box::new(msg))), + State::Login(_) => Subscription::none(), + }]) + } + + pub fn view(&self) -> Element { + match &self.state { + State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))), + State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))), + State::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))), + } + } + + pub fn stop(&mut self) { + match &mut self.state { + State::Loader(s) => s.stop(), + State::Launcher(s) => s.stop(), + State::Installer(s) => s.stop(), + State::App(s) => s.stop(), + State::Login(_) => {} + } + } +} + +pub fn create_app_with_remote_backend( + wallet_settings: WalletSettings, + remote_backend: BackendWalletClient, + wallet: api::Wallet, + coins: ListCoinsResult, + liana_dir: LianaDirectory, + network: bitcoin::Network, + config: app::Config, +) -> (app::App, iced::Task) { + // If someone modified the wallet_alias on Liana-Connect, + // then the new alias is imported and stored in the settings file. + if wallet.metadata.wallet_alias != wallet_settings.alias { + let network_directory = liana_dir.network_directory(network); + if let Err(e) = tokio::runtime::Handle::current().block_on(async { + update_settings_file(&network_directory, |mut settings| { + if let Some(w) = settings + .wallets + .iter_mut() + .find(|w| w.wallet_id() == wallet_settings.wallet_id()) + { + w.alias = wallet.metadata.wallet_alias.clone(); + tracing::info!("Wallet alias was changed. Settings updated."); + } + settings + }) + .await + }) { + tracing::error!("Failed to update wallet settings with remote alias: {}", e); + } + } + + let hws: Vec = 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 = 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(); + let provider_keys: HashMap<_, _> = wallet + .metadata + .provider_keys + .into_iter() + .map(|pk| (pk.fingerprint, pk.into())) + .collect(); + + App::new( + Cache { + network, + coins: coins.coins, + rescan_progress: None, + sync_progress: 1.0, // Remote backend is always synced + datadir_path: liana_dir.clone(), + blockheight: wallet.tip_height.unwrap_or(0), + // We ignore last poll fields for remote backend. + last_poll_timestamp: None, + last_poll_at_startup: None, + }, + Arc::new( + Wallet::new(wallet.descriptor) + .with_name(wallet.name) + .with_alias(wallet.metadata.wallet_alias) + .with_pinned_at(wallet_settings.pinned_at) + .with_key_aliases(aliases) + .with_provider_keys(provider_keys) + .with_hardware_wallets(hws) + .load_hotsigners(&liana_dir, network) + .expect("Datadir should be conform"), + ), + config, + Arc::new(remote_backend), + liana_dir, + None, + false, + ) +} diff --git a/liana-gui/src/installer/message.rs b/liana-gui/src/installer/message.rs index c5453259..0cbee993 100644 --- a/liana-gui/src/installer/message.rs +++ b/liana-gui/src/installer/message.rs @@ -31,11 +31,7 @@ use crate::{ #[derive(Debug, Clone)] pub enum Message { UserActionDone(bool), - Exit( - Box, - Option, - /* remove log */ bool, - ), + Exit(Box, Option), Clibpboard(String), Next, Skip, diff --git a/liana-gui/src/installer/step/mod.rs b/liana-gui/src/installer/step/mod.rs index cf3d31bf..2fa2fafd 100644 --- a/liana-gui/src/installer/step/mod.rs +++ b/liana-gui/src/installer/step/mod.rs @@ -129,27 +129,23 @@ impl Step for Final { Message::AllKeysRedeemed => { self.generating = false; // If any errors occurred redeeming tokens, add a warning to the log. - let mut has_error = false; for (pk, res) in &self.key_redemptions { if let Some(res) = res { if let Err(e) = res { warn!("Error redeeming key for token '{}': '{}'.", pk.token, e); - has_error = true; } } else { // We expect to have all redemption results by now. warn!("Missing redemption info for token '{}'.", pk.token); - has_error = true; } } // Now exit the installer whether or not any redemption errors occurred. let internal_bitcoind = self.internal_bitcoind.clone(); let settings = self.wallet_settings.clone().expect("Install is done"); - // If there were any errors, don't remove the installer log. return Task::perform( - async move { (settings, internal_bitcoind, has_error) }, - |(settings, internal_bitcoind, has_error)| { - Message::Exit(Box::new(settings), internal_bitcoind, !has_error) + async move { (settings, internal_bitcoind) }, + |(settings, internal_bitcoind)| { + Message::Exit(Box::new(settings), internal_bitcoind) }, ); } diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index eca793e3..3af47e1b 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -16,7 +16,7 @@ use tokio::runtime::Handle; use crate::{ app::{ self, - settings::{self, WalletSettings}, + settings::{self, WalletId, WalletSettings}, }, delete::{delete_wallet, DeleteError}, dir::{LianaDirectory, NetworkDirectory}, @@ -76,6 +76,13 @@ impl Launcher { ) } + pub fn reload(&self) -> Task { + Task::perform( + check_network_datadir(self.datadir_path.network_directory(self.network)), + Message::Checked, + ) + } + pub fn stop(&mut self) {} pub fn subscription(&self) -> Subscription { @@ -417,7 +424,7 @@ pub enum ViewMessage { pub enum DeleteWalletMessage { ShowModal(usize), CloseModal, - Confirm, + Confirm(WalletId), Deleted, } @@ -446,7 +453,12 @@ impl DeleteWalletModal { } fn update(&mut self, message: Message) -> Task { - if let Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm)) = message { + if let Message::View(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm(wallet_id))) = + message + { + if wallet_id != self.wallet_settings.wallet_id() { + return Task::none(); + } self.warning = None; if let Err(e) = Handle::current().block_on(delete_wallet( &self.network_directory, @@ -468,8 +480,9 @@ impl DeleteWalletModal { .width(Length::Fixed(200.0)) .style(theme::button::destructive); if self.warning.is_none() { - confirm_button = - confirm_button.on_press(ViewMessage::DeleteWallet(DeleteWalletMessage::Confirm)); + confirm_button = confirm_button.on_press(ViewMessage::DeleteWallet( + DeleteWalletMessage::Confirm(self.wallet_settings.wallet_id()), + )); } // Use separate `Row`s for help text in order to have better spacing. let help_text_1 = format!( diff --git a/liana-gui/src/lib.rs b/liana-gui/src/lib.rs index ed545a6b..8af17fff 100644 --- a/liana-gui/src/lib.rs +++ b/liana-gui/src/lib.rs @@ -5,6 +5,7 @@ pub mod delete; pub mod dir; pub mod download; pub mod export; +pub mod gui; pub mod hw; pub mod installer; pub mod launcher; diff --git a/liana-gui/src/logger.rs b/liana-gui/src/logger.rs index 541234e2..cba93989 100644 --- a/liana-gui/src/logger.rs +++ b/liana-gui/src/logger.rs @@ -1,17 +1,8 @@ -use liana::miniscript::bitcoin::Network; -use std::path::PathBuf; use std::{fs::File, sync::Arc}; -use tracing::error; -use tracing_subscriber::{ - filter, - fmt::{format, writer::BoxMakeWriter, Layer}, - prelude::*, - reload, Registry, -}; +use tracing_subscriber::{filter, fmt::writer::BoxMakeWriter, prelude::*, reload}; use crate::dir::LianaDirectory; -const INSTALLER_LOG_FILE_NAME: &str = "installer.log"; const GUI_LOG_FILE_NAME: &str = "liana-gui.log"; #[derive(Debug)] @@ -32,96 +23,46 @@ impl From for LoggerError { } } -pub struct Logger { - file_handle: reload::Handle< - Layer, - Registry, - >, - level_handle: reload::Handle, -} - -impl Logger { - pub fn setup(log_level: filter::LevelFilter) -> Logger { - let (log_level, level_handle) = reload::Layer::new(log_level); - let writer = BoxMakeWriter::new(std::io::stderr); - let file_log = tracing_subscriber::fmt::layer() - .with_writer(writer) - .with_file(false); - let (file_log, file_handle) = reload::Layer::new(file_log); - let stdout_log = tracing_subscriber::fmt::layer().pretty().with_file(false); - tracing_subscriber::registry() - .with( - stdout_log - .and_then(file_log) - .with_filter(log_level) - // Add a filter to *both* layers that rejects spans and - // events whose targets start with ``. - .with_filter(filter::filter_fn(|metadata| { - !metadata.target().starts_with("iced_wgpu") - && !metadata.target().starts_with("iced_winit") - && !metadata.target().starts_with("wgpu_core") - && !metadata.target().starts_with("wgpu_hal") - && !metadata.target().starts_with("gfx_backend_vulkan") - && !metadata.target().starts_with("iced_glutin") - && !metadata.target().starts_with("iced_glow") - && !metadata.target().starts_with("glow_glyph") - && !metadata.target().starts_with("naga") - && !metadata.target().starts_with("winit") - && !metadata.target().starts_with("mio") - && !metadata.target().starts_with("ledger_transport_hid") - && !metadata.target().starts_with("cosmic_text") - })), - ) - .init(); - Self { - file_handle, - level_handle, - } - } - - pub fn set_installer_mode(&self, datadir: LianaDirectory, log_level: filter::LevelFilter) { - let mut datadir = datadir.path().to_path_buf(); - datadir.push(INSTALLER_LOG_FILE_NAME); - if let Err(e) = self.set_layer(datadir, log_level) { - error!("Failed to change logger settings: {:#?}", e); - } - } - - pub fn set_running_mode( - &self, - datadir: LianaDirectory, - network: Network, - log_level: filter::LevelFilter, - ) { - let mut datadir = datadir.path().to_path_buf(); - datadir.push(network.to_string()); - datadir.push(GUI_LOG_FILE_NAME); - if let Err(e) = self.set_layer(datadir, log_level) { - error!("Failed to change logger settings: {:#?}", e); - } - } - - pub fn remove_install_log_file(&self, datadir: LianaDirectory) { - let mut datadir = datadir.path().to_path_buf(); - datadir.push(INSTALLER_LOG_FILE_NAME); - if let Err(e) = std::fs::remove_file(&datadir) { - error!( - "Failed to remove installer log file {} error:{:#?}", - datadir.to_string_lossy(), - e - ); - } - } - - pub fn set_layer( - &self, - destination_path: PathBuf, - log_level: filter::LevelFilter, - ) -> Result<(), LoggerError> { - let file = File::create(destination_path)?; - self.file_handle - .modify(|layer| *layer.writer_mut() = BoxMakeWriter::new(Arc::new(file)))?; - self.level_handle.modify(|filter| *filter = log_level)?; - Ok(()) - } +pub fn setup_logger( + log_level: filter::LevelFilter, + datadir: LianaDirectory, +) -> Result<(), Box> { + let mut log_path = datadir.path().to_path_buf(); + log_path.push(GUI_LOG_FILE_NAME); + + let file = File::create(log_path)?; + let writer = BoxMakeWriter::new(Arc::new(file)); + + let file_log = tracing_subscriber::fmt::layer() + .with_writer(writer) + .with_file(false); + + let stdout_log = tracing_subscriber::fmt::layer().pretty().with_file(false); + + tracing_subscriber::registry() + .with( + stdout_log + .and_then(file_log) + .with_filter(log_level) + // Add a filter to *both* layers that rejects spans and + // events whose targets start with specific prefixes. + .with_filter(filter::filter_fn(|metadata| { + !metadata.target().starts_with("iced_wgpu") + && !metadata.target().starts_with("iced_winit") + && !metadata.target().starts_with("wgpu_core") + && !metadata.target().starts_with("wgpu_hal") + && !metadata.target().starts_with("gfx_backend_vulkan") + && !metadata.target().starts_with("iced_glutin") + && !metadata.target().starts_with("iced_glow") + && !metadata.target().starts_with("glow_glyph") + && !metadata.target().starts_with("naga") + && !metadata.target().starts_with("winit") + && !metadata.target().starts_with("mio") + && !metadata.target().starts_with("ledger_transport_hid") + && !metadata.target().starts_with("cosmic_text") + })), + ) + .init(); + + Ok(()) } diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index c33da20a..a04a8b15 100644 --- a/liana-gui/src/main.rs +++ b/liana-gui/src/main.rs @@ -1,46 +1,22 @@ #![windows_subsystem = "windows"] -use std::{ - collections::HashMap, error::Error, io::Write, path::PathBuf, process, str::FromStr, sync::Arc, -}; +use std::{error::Error, io::Write, path::PathBuf, process, str::FromStr}; #[cfg(target_os = "linux")] use iced::window::settings::PlatformSpecific; -use iced::{ - event::{self, Event}, - keyboard, - widget::{focus_next, focus_previous}, - Settings, Size, Subscription, Task, -}; -use tracing::{error, info}; +use iced::{Settings, Size}; +use tracing::error; use tracing_subscriber::filter::LevelFilter; extern crate serde; extern crate serde_json; use liana::miniscript::bitcoin; -use liana_ui::{component::text, font, image, theme, widget::Element}; -use lianad::commands::ListCoinsResult; +use liana_ui::{component::text, font, image, theme}; use liana_gui::{ - app::{ - self, - cache::Cache, - settings::{update_settings_file, WalletSettings}, - wallet::Wallet, - App, - }, dir::LianaDirectory, - export::import_backup_at_launch, - hw::HardwareWalletConfig, - installer::{self, Installer}, - launcher::{self, Launcher}, - loader::{self, Loader}, - logger::Logger, + gui::{Config, GUI}, node::bitcoind::delete_all_bitcoind_locks_for_process, - services::connect::{ - client::backend::{api, BackendWalletClient}, - login, - }, VERSION, }; @@ -92,448 +68,6 @@ Options: Ok(res) } -pub struct GUI { - state: State, - logger: Logger, - // if set up, it overrides the level filter of the logger. - log_level: Option, -} - -enum State { - Launcher(Box), - Installer(Box), - Loader(Box), - Login(Box), - App(App), -} - -#[derive(Debug)] -pub enum Key { - Tab(bool), -} - -#[derive(Debug)] -pub enum Message { - CtrlC, - FontLoaded(Result<(), iced::font::Error>), - Launch(Box), - Install(Box), - Load(Box), - Run(Box), - Login(Box), - KeyPressed(Key), - Event(iced::Event), -} - -impl From> for Message { - fn from(value: Result<(), iced::font::Error>) -> Self { - Self::FontLoaded(value) - } -} - -async fn ctrl_c() -> Result<(), ()> { - if let Err(e) = tokio::signal::ctrl_c().await { - error!("{}", e); - }; - info!("Signal received, exiting"); - Ok(()) -} - -impl GUI { - fn title(&self) -> String { - match &self.state { - State::Installer(_) => format!("Liana v{} Installer", VERSION), - State::App(a) => format!("Liana v{} {}", VERSION, a.title()), - _ => format!("Liana v{}", VERSION), - } - } - - fn new((config, log_level): (Config, Option)) -> (GUI, Task) { - let logger = Logger::setup(log_level.unwrap_or(LevelFilter::INFO)); - let mut cmds = vec![Task::perform(ctrl_c(), |_| Message::CtrlC)]; - let (launcher, command) = Launcher::new(config.liana_directory, config.network); - cmds.push(command.map(|msg| Message::Launch(Box::new(msg)))); - ( - Self { - state: State::Launcher(Box::new(launcher)), - logger, - log_level, - }, - Task::batch(cmds), - ) - } - - fn update(&mut self, message: Message) -> Task { - match (&mut self.state, message) { - (_, Message::CtrlC) - | (_, Message::Event(iced::Event::Window(iced::window::Event::CloseRequested))) => { - match &mut self.state { - State::Loader(s) => s.stop(), - State::Launcher(s) => s.stop(), - State::Installer(s) => s.stop(), - State::App(s) => s.stop(), - State::Login(_) => {} - }; - iced::window::get_latest().and_then(iced::window::close) - } - (_, Message::KeyPressed(Key::Tab(shift))) => { - log::debug!("Tab pressed!"); - if shift { - focus_previous() - } else { - focus_next() - } - } - (State::Launcher(l), Message::Launch(msg)) => match *msg { - launcher::Message::Install(datadir, network, init) => { - if !datadir.exists() { - // datadir is created right before launching the installer - // so logs can go in /installer.log - if let Err(e) = datadir.init() { - error!("Failed to create datadir: {}", e); - } else { - info!( - "Created a fresh data directory at {}", - &datadir.path().to_string_lossy() - ); - } - } - self.logger.set_installer_mode( - datadir.clone(), - self.log_level.unwrap_or(LevelFilter::INFO), - ); - - let (install, command) = Installer::new(datadir, network, None, init); - self.state = State::Installer(Box::new(install)); - command.map(|msg| Message::Install(Box::new(msg))) - } - launcher::Message::Run(datadir_path, cfg, network, settings) => { - self.logger.set_running_mode( - datadir_path.clone(), - network, - self.log_level - .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), - ); - if settings.remote_backend_auth.is_some() { - 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, None, settings); - 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(network)) => { - let (launcher, command) = Launcher::new(l.datadir.clone(), Some(network)); - self.state = State::Launcher(Box::new(launcher)); - command.map(|msg| Message::Launch(Box::new(msg))) - } - login::Message::Install(remote_backend) => { - let (install, command) = Installer::new( - l.datadir.clone(), - l.network, - remote_backend, - installer::UserFlow::CreateWallet, - ); - self.state = State::Installer(Box::new(install)); - command.map(|msg| Message::Install(Box::new(msg))) - } - login::Message::Run(Ok((backend_client, wallet, coins))) => { - let config = app::Config::from_file( - &l.datadir - .network_directory(l.network) - .path() - .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( - l.settings.clone(), - backend_client, - wallet, - coins, - 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(settings, internal_bitcoind, remove_log) = *msg { - if settings.remote_backend_auth.is_some() { - 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( - &i.datadir - .network_directory(i.network) - .path() - .join(app::config::DEFAULT_FILE_NAME), - ) - .expect("A gui configuration file must be present"); - - self.logger.set_running_mode( - i.datadir.clone(), - i.network, - self.log_level - .unwrap_or_else(|| cfg.log_level().unwrap_or(LevelFilter::INFO)), - ); - if remove_log { - self.logger.remove_install_log_file(i.datadir.clone()); - } - - let (loader, command) = Loader::new( - i.datadir.clone(), - cfg, - i.network, - internal_bitcoind, - i.context.backup.take(), - *settings, - ); - self.state = State::Loader(Box::new(loader)); - command.map(|msg| Message::Load(Box::new(msg))) - } - } else if let installer::Message::BackToLauncher(network) = *msg { - let (launcher, command) = Launcher::new(i.destination_path(), Some(network)); - self.state = State::Launcher(Box::new(launcher)); - command.map(|msg| Message::Launch(Box::new(msg))) - } else { - i.update(*msg).map(|msg| Message::Install(Box::new(msg))) - } - } - (State::Loader(loader), Message::Load(msg)) => match *msg { - loader::Message::View(loader::ViewMessage::SwitchNetwork) => { - let (launcher, command) = - Launcher::new(loader.datadir_path.clone(), Some(loader.network)); - self.state = State::Launcher(Box::new(launcher)); - command.map(|msg| Message::Launch(Box::new(msg))) - } - loader::Message::Synced(Ok((wallet, cache, daemon, bitcoind, backup))) => { - if let Some(backup) = backup { - let config = loader.gui_config.clone(); - let datadir = loader.datadir_path.clone(); - Task::perform( - async move { - import_backup_at_launch( - cache, wallet, config, daemon, datadir, bitcoind, backup, - ) - .await - }, - |r| { - let r = r.map_err(loader::Error::RestoreBackup); - Message::Load(Box::new(loader::Message::App( - r, /* restored_from_backup */ true, - ))) - }, - ) - } else { - let (app, command) = App::new( - cache, - wallet, - loader.gui_config.clone(), - daemon, - loader.datadir_path.clone(), - bitcoind, - false, - ); - self.state = State::App(app); - command.map(|msg| Message::Run(Box::new(msg))) - } - } - loader::Message::App( - Ok((cache, wallet, config, daemon, datadir, bitcoind)), - restored_from_backup, - ) => { - let (app, command) = App::new( - cache, - wallet, - config, - daemon, - datadir, - bitcoind, - restored_from_backup, - ); - self.state = State::App(app); - command.map(|msg| Message::Run(Box::new(msg))) - } - loader::Message::App(Err(e), _) => { - tracing::error!("Failed to import backup: {e}"); - Task::none() - } - - _ => loader.update(*msg).map(|msg| Message::Load(Box::new(msg))), - }, - (State::App(i), Message::Run(msg)) => { - i.update(*msg).map(|msg| Message::Run(Box::new(msg))) - } - _ => Task::none(), - } - } - - fn subscription(&self) -> Subscription { - Subscription::batch(vec![ - match &self.state { - State::Installer(v) => v.subscription().map(|msg| Message::Install(Box::new(msg))), - State::Loader(v) => v.subscription().map(|msg| Message::Load(Box::new(msg))), - State::App(v) => v.subscription().map(|msg| Message::Run(Box::new(msg))), - 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) { - ( - Event::Keyboard(keyboard::Event::KeyPressed { - key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Tab), - modifiers, - .. - }), - event::Status::Ignored, - ) => Some(Message::KeyPressed(Key::Tab(modifiers.shift()))), - ( - iced::Event::Window(iced::window::Event::CloseRequested), - event::Status::Ignored, - ) => Some(Message::Event(event)), - _ => None, - }), - ]) - } - - fn view(&self) -> Element { - match &self.state { - State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))), - State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))), - State::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))), - } - } - - fn scale_factor(&self) -> f64 { - 1.0 - } -} - -pub fn create_app_with_remote_backend( - wallet_settings: WalletSettings, - remote_backend: BackendWalletClient, - wallet: api::Wallet, - coins: ListCoinsResult, - liana_dir: LianaDirectory, - network: bitcoin::Network, - config: app::Config, -) -> (app::App, iced::Task) { - // If someone modified the wallet_alias on Liana-Connect, - // then the new alias is imported and stored in the settings file. - if wallet.metadata.wallet_alias != wallet_settings.alias { - let network_directory = liana_dir.network_directory(network); - if let Err(e) = tokio::runtime::Handle::current().block_on(async { - update_settings_file(&network_directory, |mut settings| { - if let Some(w) = settings - .wallets - .iter_mut() - .find(|w| w.wallet_id() == wallet_settings.wallet_id()) - { - w.alias = wallet.metadata.wallet_alias.clone(); - tracing::info!("Wallet alias was changed. Settings updated."); - } - settings - }) - .await - }) { - tracing::error!("Failed to update wallet settings with remote alias: {}", e); - } - } - - let hws: Vec = 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 = 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(); - let provider_keys: HashMap<_, _> = wallet - .metadata - .provider_keys - .into_iter() - .map(|pk| (pk.fingerprint, pk.into())) - .collect(); - - App::new( - Cache { - network, - coins: coins.coins, - rescan_progress: None, - sync_progress: 1.0, // Remote backend is always synced - datadir_path: liana_dir.clone(), - blockheight: wallet.tip_height.unwrap_or(0), - // We ignore last poll fields for remote backend. - last_poll_timestamp: None, - last_poll_at_startup: None, - }, - Arc::new( - Wallet::new(wallet.descriptor) - .with_name(wallet.name) - .with_alias(wallet.metadata.wallet_alias) - .with_pinned_at(wallet_settings.pinned_at) - .with_key_aliases(aliases) - .with_provider_keys(provider_keys) - .with_hardware_wallets(hws) - .load_hotsigners(&liana_dir, network) - .expect("Datadir should be conform"), - ), - config, - Arc::new(remote_backend), - liana_dir, - None, - false, - ) -} - -pub struct Config { - liana_directory: LianaDirectory, - network: Option, -} - -impl Config { - pub fn new(liana_directory: LianaDirectory, network: Option) -> Self { - Self { - liana_directory, - network, - } - } -} - fn main() -> Result<(), Box> { let args = parse_args(std::env::args().collect())?; let config = match args.as_slice() { diff --git a/liana-ui/Cargo.toml b/liana-ui/Cargo.toml index 42ce2875..a77e41a5 100644 --- a/liana-ui/Cargo.toml +++ b/liana-ui/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" [dependencies] iced = { version = "0.13.1", default-features = false, features = ["svg", "image", "lazy", "qr_code", "advanced"] } +iced_aw = { version = "0.12.2", features = ["context_menu"] } iced_core = { version = "0.13.2" } iced_runtime = { version = "0.13.2" } unicode-segmentation = "1.0" diff --git a/liana-ui/src/color.rs b/liana-ui/src/color.rs index 54b9166e..31af9f64 100644 --- a/liana-ui/src/color.rs +++ b/liana-ui/src/color.rs @@ -47,6 +47,12 @@ pub const GREEN: Color = Color::from_rgb( 0xFF as f32 / 255.0, 0x66 as f32 / 255.0, ); +pub const TRANSPARENT_GREEN: Color = Color::from_rgba( + 0x00 as f32 / 255.0, + 0xFF as f32 / 255.0, + 0x66 as f32 / 255.0, + 0.3, +); pub const RED: Color = Color::from_rgb( 0xE2 as f32 / 255.0, 0x4E as f32 / 255.0, diff --git a/liana-ui/src/component/button.rs b/liana-ui/src/component/button.rs index 0d7769fd..6de615f6 100644 --- a/liana-ui/src/component/button.rs +++ b/liana-ui/src/component/button.rs @@ -14,6 +14,24 @@ pub fn menu_active<'a, T: 'a>(icon: Option>, t: &'static str) -> Button .style(theme::button::menu_pressed) } +pub fn menu_small<'a, T: 'a>(icon: Text<'a>) -> Button<'a, T> { + Button::new( + container(icon.style(theme::text::secondary)) + .padding(10) + .align_x(Horizontal::Center), + ) + .style(theme::button::menu) +} + +pub fn menu_active_small<'a, T: 'a>(icon: Text<'a>) -> Button<'a, T> { + Button::new( + container(icon.style(theme::text::secondary)) + .padding(10) + .align_x(Horizontal::Center), + ) + .style(theme::button::menu_pressed) +} + fn content_menu<'a, T: 'a>(icon: Option>, t: &'static str) -> Container<'a, T> { match icon { None => container(text(t)).padding(5), diff --git a/liana-ui/src/theme/button.rs b/liana-ui/src/theme/button.rs index 63200260..9e7951a2 100644 --- a/liana-ui/src/theme/button.rs +++ b/liana-ui/src/theme/button.rs @@ -145,3 +145,14 @@ fn button(p: &Button, status: Status) -> Style { } } } + +pub fn tab(theme: &Theme, status: Status) -> Style { + let mut style = button(&theme.colors.buttons.tab, status); + style.border.radius = 0.0.into(); + style.border.width = 0.0; + style +} + +pub fn tab_active(theme: &Theme, _status: Status) -> Style { + tab(theme, Status::Pressed) +} diff --git a/liana-ui/src/theme/context_menu.rs b/liana-ui/src/theme/context_menu.rs new file mode 100644 index 00000000..70d02ac2 --- /dev/null +++ b/liana-ui/src/theme/context_menu.rs @@ -0,0 +1,22 @@ +use iced::Background; +use iced_aw::widget::context_menu::{Catalog, Status, Style}; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = Box Style + 'a>; + + fn default<'a>() -> Self::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &Self::Class<'_>, status: Status) -> Style { + class(self, status) + } +} + +pub fn primary(_theme: &Theme, _status: Status) -> Style { + Style { + background: Background::Color(iced::Color::TRANSPARENT), + } +} diff --git a/liana-ui/src/theme/mod.rs b/liana-ui/src/theme/mod.rs index b2c81810..8184191d 100644 --- a/liana-ui/src/theme/mod.rs +++ b/liana-ui/src/theme/mod.rs @@ -4,9 +4,11 @@ pub mod button; pub mod card; pub mod checkbox; pub mod container; +pub mod context_menu; pub mod notification; pub mod overlay; pub mod palette; +pub mod pane_grid; pub mod pick_list; pub mod pill; pub mod progress_bar; diff --git a/liana-ui/src/theme/palette.rs b/liana-ui/src/theme/palette.rs index 1833c36b..6387705a 100644 --- a/liana-ui/src/theme/palette.rs +++ b/liana-ui/src/theme/palette.rs @@ -16,6 +16,7 @@ pub struct Palette { pub sliders: Sliders, pub progress_bars: ProgressBars, pub rule: iced::Color, + pub pane_grid: PaneGrid, } #[derive(Debug, Copy, Clone, PartialEq)] @@ -44,6 +45,7 @@ pub struct Buttons { pub container: Button, pub container_border: Button, pub menu: Button, + pub tab: Button, } #[derive(Debug, Copy, Clone, PartialEq)] @@ -161,6 +163,15 @@ pub struct ProgressBars { pub border: Option, } +#[derive(Debug, Copy, Clone, PartialEq)] +pub struct PaneGrid { + pub background: iced::Color, + pub highlight_border: iced::Color, + pub highlight_background: iced::Color, + pub picked_split: iced::Color, + pub hovered_split: iced::Color, +} + impl std::default::Default for Palette { fn default() -> Self { Self { @@ -353,6 +364,28 @@ impl std::default::Default for Palette { border: color::TRANSPARENT.into(), }), }, + tab: Button { + active: ButtonPalette { + background: color::GREY_6, + text: color::GREY_2, + border: color::GREY_7.into(), + }, + hovered: ButtonPalette { + background: color::GREY_6, + text: color::GREEN, + border: color::GREEN.into(), + }, + pressed: Some(ButtonPalette { + background: color::LIGHT_BLACK, + text: color::GREEN, + border: color::GREEN.into(), + }), + disabled: Some(ButtonPalette { + background: color::GREY_6, + text: color::GREY_2, + border: color::GREY_7.into(), + }), + }, }, cards: Cards { simple: ContainerPalette { @@ -505,6 +538,13 @@ impl std::default::Default for Palette { background: color::GREY_6, }, rule: color::GREY_1, + pane_grid: PaneGrid { + background: color::BLACK, + highlight_border: color::GREEN, + highlight_background: color::TRANSPARENT_GREEN, + picked_split: color::GREEN, + hovered_split: color::GREEN, + }, } } } diff --git a/liana-ui/src/theme/pane_grid.rs b/liana-ui/src/theme/pane_grid.rs new file mode 100644 index 00000000..bbe3bf0d --- /dev/null +++ b/liana-ui/src/theme/pane_grid.rs @@ -0,0 +1,45 @@ +use iced::widget::container; +use iced::widget::pane_grid::{Catalog, Highlight, Line, Style, StyleFn}; +use iced::Border; + +use super::Theme; + +impl Catalog for Theme { + type Class<'a> = StyleFn<'a, Self>; + + fn default<'a>() -> ::Class<'a> { + Box::new(primary) + } + + fn style(&self, class: &::Class<'_>) -> Style { + class(self) + } +} + +pub fn primary(theme: &Theme) -> Style { + Style { + hovered_region: Highlight { + background: theme.colors.pane_grid.highlight_background.into(), + border: Border { + color: theme.colors.pane_grid.highlight_border, + width: 1.0, + radius: 0.0.into(), + }, + }, + picked_split: Line { + color: theme.colors.pane_grid.picked_split, + width: 2.0, + }, + hovered_split: Line { + color: theme.colors.pane_grid.hovered_split, + width: 2.0, + }, + } +} + +pub fn pane_grid_background(theme: &Theme) -> container::Style { + container::Style { + background: Some(theme.colors.pane_grid.background.into()), + ..Default::default() + } +}