Merge #1757: Multiple panes and tabs dashboard

91ed79cb559fbfc0e3fc6befef5690aab85caefd make remove_tab safer (edouardparis)
5587b5cb8dc1d963167f300ccb82451d44db0538 simplify logger_setup (edouardparis)
fd858d2b4ac71efe121b1da96d59dce2b0b1de3c Close tabs of deleted wallet (edouardparis)
8dce52e22de41ab04f387b659e7cca3f9696eba4 Make sidebar responsive (edouardparis)
2866b8ea18e233d76e41b2da75d6f489e7190a34 Add grid with split button (edouardparis)
9f1de060eb942887a9ae1e8d7311263baebe895f Add pane tabs (edouardparis)
e771a42f456fb710306d7a2d265aa58c64e7be36 Change log destination for a file in the liana directory (edouardparis)
cdc2b56a3b8b64be8a12827188244fd987a4b4ea Detach tab from the gui general scope (edouardparis)
ce38ea1881e2675d10199fb0cc89ea5d754fa5da Migrate gui to its own module (edouardparis)

Pull request description:

ACKs for top commit:
  edouardparis:
    Self-ACK 91ed79cb559fbfc0e3fc6befef5690aab85caefd

Tree-SHA512: 473d782667560e919120d9b858f86223b0fcb2e1f26bf32bbaeee2a5edd2ead1bfc078da1808db2218c08dbbe626fac96a2c342f41bcea7a48b420b80d870021
This commit is contained in:
edouardparis 2025-07-04 18:19:42 +02:00
commit a223bd1aba
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
21 changed files with 1325 additions and 598 deletions

47
Cargo.lock generated
View File

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

View File

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

View File

@ -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<Message> {

View File

@ -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<Element<'a, Message>>>(
menu: &'a Menu,
cache: &'a Cache,
@ -194,9 +340,14 @@ pub fn dashboard<'a, T: Into<Element<'a, Message>>>(
) -> 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()

317
liana-gui/src/gui/mod.rs Normal file
View File

@ -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<pane::Pane>,
focus: Option<pane_grid::Pane>,
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<Result<(), iced::font::Error>> 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<LevelFilter>)) -> (GUI, Task<Message>) {
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<Message> {
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::<pane_grid::Pane>::new();
for (id, pane) in self.panes.iter_mut() {
let tabs_to_close: Vec<usize> = 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<Message> {
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<Message> {
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<bitcoin::Network>,
}
impl Config {
pub fn new(liana_directory: LianaDirectory, network: Option<bitcoin::Network>) -> Self {
Self {
liana_directory,
network,
}
}
}

201
liana-gui/src/gui/pane.rs Normal file
View File

@ -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<tab::Tab>,
// 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<Message>) {
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<Message> {
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<tab::Tab> {
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<tab::Tab>, 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<Message> {
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<Message> {
let subs: Vec<Subscription<Message>> = 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<Message> {
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::<Element<ViewMessage>>::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::<Element<ViewMessage>>::into(menu.wrap()).map(Message::View)
}
pub fn view(&self) -> Element<Message> {
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()
}
}

380
liana-gui/src/gui/tab.rs Normal file
View File

@ -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<Launcher>),
Installer(Box<Installer>),
Loader(Box<Loader>),
Login(Box<login::LianaLiteLogin>),
App(App),
}
impl State {
pub fn new(
directory: LianaDirectory,
network: Option<bitcoin::Network>,
) -> (Self, Task<Message>) {
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<launcher::Message>),
Install(Box<installer::Message>),
Load(Box<loader::Message>),
Run(Box<app::Message>),
Login(Box<login::Message>),
}
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<Message> {
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 <datadir_path>/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<Message> {
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<Message> {
match &self.state {
State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))),
State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))),
State::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<app::Message>) {
// 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<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();
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,
)
}

View File

@ -31,11 +31,7 @@ use crate::{
#[derive(Debug, Clone)]
pub enum Message {
UserActionDone(bool),
Exit(
Box<settings::WalletSettings>,
Option<Bitcoind>,
/* remove log */ bool,
),
Exit(Box<settings::WalletSettings>, Option<Bitcoind>),
Clibpboard(String),
Next,
Skip,

View File

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

View File

@ -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<Message> {
Task::perform(
check_network_datadir(self.datadir_path.network_directory(self.network)),
Message::Checked,
)
}
pub fn stop(&mut self) {}
pub fn subscription(&self) -> Subscription<Message> {
@ -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<Message> {
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!(

View File

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

View File

@ -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<reload::Error> for LoggerError {
}
}
pub struct Logger {
file_handle: reload::Handle<
Layer<Registry, format::DefaultFields, format::Format, BoxMakeWriter>,
Registry,
>,
level_handle: reload::Handle<filter::LevelFilter, Registry>,
}
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 `<prefix>`.
.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<dyn std::error::Error>> {
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(())
}

View File

@ -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<LevelFilter>,
}
enum State {
Launcher(Box<Launcher>),
Installer(Box<Installer>),
Loader(Box<Loader>),
Login(Box<login::LianaLiteLogin>),
App(App),
}
#[derive(Debug)]
pub enum Key {
Tab(bool),
}
#[derive(Debug)]
pub enum Message {
CtrlC,
FontLoaded(Result<(), iced::font::Error>),
Launch(Box<launcher::Message>),
Install(Box<installer::Message>),
Load(Box<loader::Message>),
Run(Box<app::Message>),
Login(Box<login::Message>),
KeyPressed(Key),
Event(iced::Event),
}
impl From<Result<(), iced::font::Error>> 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<LevelFilter>)) -> (GUI, Task<Message>) {
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<Message> {
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 <datadir_path>/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<Message> {
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<Message> {
match &self.state {
State::Installer(v) => v.view().map(|msg| Message::Install(Box::new(msg))),
State::App(v) => v.view().map(|msg| Message::Run(Box::new(msg))),
State::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<app::Message>) {
// 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<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();
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<bitcoin::Network>,
}
impl Config {
pub fn new(liana_directory: LianaDirectory, network: Option<bitcoin::Network>) -> Self {
Self {
liana_directory,
network,
}
}
}
fn main() -> Result<(), Box<dyn Error>> {
let args = parse_args(std::env::args().collect())?;
let config = match args.as_slice() {

View File

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

View File

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

View File

@ -14,6 +14,24 @@ pub fn menu_active<'a, T: 'a>(icon: Option<Text<'a>>, 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<Text<'a>>, t: &'static str) -> Container<'a, T> {
match icon {
None => container(text(t)).padding(5),

View File

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

View File

@ -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<dyn Fn(&Theme, Status) -> 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),
}
}

View File

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

View File

@ -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<iced::Color>,
}
#[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,
},
}
}
}

View File

@ -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>() -> <Self as Catalog>::Class<'a> {
Box::new(primary)
}
fn style(&self, class: &<Self as Catalog>::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()
}
}