diff --git a/Cargo.lock b/Cargo.lock index 291432d0..15ab47ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1827,6 +1827,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -3072,6 +3082,7 @@ dependencies = [ "dirs 3.0.2", "email_address", "flate2", + "fs2", "hex", "iced", "iced_aw", diff --git a/liana-gui/Cargo.toml b/liana-gui/Cargo.toml index abd16fed..026408d2 100644 --- a/liana-gui/Cargo.toml +++ b/liana-gui/Cargo.toml @@ -54,6 +54,7 @@ bitcoin_hashes = "0.12" reqwest = { version = "0.11", default-features=false, features = ["json", "rustls-tls", "stream"] } rust-ini = "0.19.0" rfd = "0.15.1" +fs2 = "0.4.3" # Used for opening URLs in browser open = "5.3" diff --git a/liana-gui/src/app/mod.rs b/liana-gui/src/app/mod.rs index c0316d8c..4e7f6bc8 100644 --- a/liana-gui/src/app/mod.rs +++ b/liana-gui/src/app/mod.rs @@ -441,6 +441,10 @@ impl App { content } } + + pub fn datadir_path(&self) -> &LianaDirectory { + &self.cache.datadir_path + } } fn new_recovery_panel(wallet: Arc, cache: &Cache) -> CreateSpendPanel { diff --git a/liana-gui/src/app/settings.rs b/liana-gui/src/app/settings.rs index af0ba3fd..9aa269e4 100644 --- a/liana-gui/src/app/settings.rs +++ b/liana-gui/src/app/settings.rs @@ -380,18 +380,141 @@ impl std::fmt::Display for SettingsError { pub mod global { use crate::dir::LianaDirectory; use async_hwi::bitbox::{ConfigError, NoiseConfig, NoiseConfigData}; + use fs2::FileExt; use serde::{Deserialize, Serialize}; - use std::io::{Read, Write}; + use std::fs::OpenOptions; + use std::io::{Read, Seek, SeekFrom, Write}; use std::path::PathBuf; pub const DEFAULT_FILE_NAME: &str = "global_settings.json"; - #[derive(Debug, Deserialize, Serialize)] - pub struct Settings { - pub bitbox: Option, + #[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] + pub struct WindowConfig { + pub width: f32, + pub height: f32, } - #[derive(Debug, Deserialize, Serialize)] + #[derive(Debug, Deserialize, Serialize, Default)] + pub struct GlobalSettings { + pub bitbox: Option, + pub window_config: Option, + } + + impl GlobalSettings { + pub fn path(global_datadir: &LianaDirectory) -> PathBuf { + global_datadir.path().join(DEFAULT_FILE_NAME) + } + + pub fn load_window_config(path: &PathBuf) -> Option { + let mut ret = None; + if let Err(e) = Self::update(path, |s| ret = s.window_config.clone(), false) { + tracing::error!("Failed to load window config: {e}"); + } + ret + } + + pub fn update_window_config( + path: &PathBuf, + window_config: &WindowConfig, + ) -> Result<(), String> { + Self::update( + path, + |s| s.window_config = Some(window_config.clone()), + true, + ) + } + + pub fn load_bitbox_settings(path: &PathBuf) -> Result, String> { + let mut ret = None; + Self::update(path, |s| ret = s.bitbox.clone(), false)?; + Ok(ret) + } + + pub fn update_bitbox_settings( + path: &PathBuf, + bitbox: &BitboxSettings, + ) -> Result<(), String> { + Self::update(path, |s| s.bitbox = Some(bitbox.clone()), true) + } + + pub fn update(path: &PathBuf, mut update: F, mut write: bool) -> Result<(), String> + where + F: FnMut(&mut GlobalSettings), + { + log::info!("GLobalSettings::update() write: {write}"); + let exists = path.is_file(); + + let (mut global_settings, file) = if exists { + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| format!("Opening file: {e}"))?; + + file.lock_exclusive() + .map_err(|e| format!("Locking file: {e}"))?; + + let mut content = String::new(); + file.read_to_string(&mut content) + .map_err(|e| format!("Reading file: {e}"))?; + + if !write { + file.unlock().map_err(|e| format!("Unlocking file: {e}"))?; + } + + ( + serde_json::from_str::(&content).map_err(|e| e.to_string())?, + Some(file), + ) + } else { + (GlobalSettings::default(), None) + }; + + update(&mut global_settings); + + if !exists + && global_settings.bitbox.is_none() + && global_settings.window_config.is_none() + { + write = false; + } + + if write { + let mut file = if let Some(file) = file { + file + } else { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path) + .map_err(|e| format!("Opening file: {e}"))?; + + file.lock_exclusive() + .map_err(|e| format!("Locking file: {e}"))?; + file + }; + let content = serde_json::to_vec_pretty(&global_settings) + .map_err(|e| format!("Failed to serialize GlobalSettings: {e}"))?; + + file.seek(SeekFrom::Start(0)) + .map_err(|e| format!("Failed to seek file: {e}"))?; + + file.write_all(&content) + .map_err(|e| format!("Failed to write file: {e}"))?; + file.set_len(content.len() as u64) + .map_err(|e| format!("Failed to truncate file: {e}"))?; + file.unlock().map_err(|e| format!("Unlocking file: {e}"))?; + } + + Ok(()) + } + } + + #[derive(Debug, Deserialize, Serialize, Clone)] pub struct BitboxSettings { pub noise_config: NoiseConfigData, } @@ -407,68 +530,209 @@ pub mod global { /// in the provided directory. pub fn new(global_datadir: &LianaDirectory) -> PersistedBitboxNoiseConfig { PersistedBitboxNoiseConfig { - file_path: global_datadir.path().join(DEFAULT_FILE_NAME), + file_path: GlobalSettings::path(global_datadir), } } } impl NoiseConfig for PersistedBitboxNoiseConfig { - fn read_config(&self) -> Result { - if !self.file_path.exists() { - return Ok(NoiseConfigData::default()); - } - - let mut file = - std::fs::File::open(&self.file_path).map_err(|e| ConfigError(e.to_string()))?; - - let mut contents = String::new(); - file.read_to_string(&mut contents) - .map_err(|e| ConfigError(e.to_string()))?; - - let settings = serde_json::from_str::(&contents) - .map_err(|e| ConfigError(e.to_string()))?; - - Ok(settings - .bitbox + fn read_config(&self) -> Result { + let res = GlobalSettings::load_bitbox_settings(&self.file_path) + .map_err(ConfigError)? .map(|s| s.noise_config) - .unwrap_or_else(NoiseConfigData::default)) + .unwrap_or_else(NoiseConfigData::default); + Ok(res) } fn store_config(&self, conf: &NoiseConfigData) -> Result<(), ConfigError> { - let data = if self.file_path.exists() { - let mut file = - std::fs::File::open(&self.file_path).map_err(|e| ConfigError(e.to_string()))?; - - let mut contents = String::new(); - file.read_to_string(&mut contents) - .map_err(|e| ConfigError(e.to_string()))?; - - let mut settings = serde_json::from_str::(&contents) - .map_err(|e| ConfigError(e.to_string()))?; - - settings.bitbox = Some(BitboxSettings { - noise_config: conf.clone(), - }); - - serde_json::to_string_pretty(&settings).map_err(|e| ConfigError(e.to_string()))? - } else { - serde_json::to_string_pretty(&Settings { - bitbox: Some(BitboxSettings { - noise_config: conf.clone(), - }), - }) - .map_err(|e| ConfigError(e.to_string()))? - }; - - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .open(&self.file_path) - .map_err(|e| ConfigError(e.to_string()))?; - - file.write_all(data.as_bytes()) - .map_err(|e| ConfigError(e.to_string())) + GlobalSettings::update( + &self.file_path, + |s| { + if let Some(bitbox) = s.bitbox.as_mut() { + bitbox.noise_config = conf.clone(); + } else { + s.bitbox = Some(BitboxSettings { + noise_config: conf.clone(), + }); + } + }, + true, + ) + .map_err(ConfigError) } } } + +#[cfg(test)] +mod test { + use super::global::{GlobalSettings, WindowConfig}; + use std::env; + + const RAW_GLOBAL_SETTINGS: &str = r#"{ + "bitbox": { + "noise_config": { + "app_static_privkey": [ + 84, + 118, + 69, + 7, + 5, + 246, + 50, + 252, + 79, + 62, + 233, + 118, + 54, + 46, + 247, + 143, + 255, + 152, + 11, + 96, + 7, + 213, + 209, + 42, + 219, + 58, + 237, + 22, + 53, + 221, + 227, + 228 + ], + "device_static_pubkeys": [ + [ + 252, + 78, + 254, + 112, + 62, + 72, + 220, + 22, + 23, + 147, + 205, + 166, + 248, + 39, + 97, + 46, + 32, + 255, + 132, + 125, + 97, + 142, + 31, + 146, + 44, + 186, + 231, + 1, + 12, + 190, + 105, + 11 + ] + ] + } + }, + "window_config": { + "width": 1248.0, + "height": 688.0 + } + }"#; + + #[test] + fn test_parse_global_config() { + let _ = serde_json::from_str::(RAW_GLOBAL_SETTINGS).unwrap(); + } + + #[test] + fn test_update_global_config() { + let path = env::current_dir() + .unwrap() + .join("test_assets") + .join("global_settings.json"); + assert!(path.exists()); + + // read global config file + GlobalSettings::update( + &path, + |s| { + assert_eq!( + *s.window_config.as_ref().unwrap(), + WindowConfig { + width: 1248.0, + height: 688.0 + } + ); + assert!(s.bitbox.is_some()); + // this must not be written to the file as write == false + s.window_config.as_mut().unwrap().height = 0.0; + }, + false, + ) + .unwrap(); + + // re-read the global config file + GlobalSettings::update( + &path, + |s| { + // change have not been written + assert_eq!( + *s.window_config.as_ref().unwrap(), + WindowConfig { + width: 1248.0, + height: 688.0 + } + ); + }, + true, + ) + .unwrap(); + + // edit the global config file + GlobalSettings::update( + &path, + |s| { + assert_eq!( + *s.window_config.as_ref().unwrap(), + WindowConfig { + width: 1248.0, + height: 688.0 + } + ); + assert!(s.bitbox.is_some()); + // this must be written to the file as write == true + s.window_config.as_mut().unwrap().height = 0.0; + }, + true, + ) + .unwrap(); + + // re-read the global config file + GlobalSettings::update( + &path, + |s| { + // change have been written + assert_eq!( + *s.window_config.as_ref().unwrap(), + WindowConfig { + width: 1248.0, + height: 0.0 + } + ); + s.window_config.as_mut().unwrap().height = 688.0; + }, + true, + ) + .unwrap() + } +} diff --git a/liana-gui/src/gui/mod.rs b/liana-gui/src/gui/mod.rs index d3b4f623..f64ea2dc 100644 --- a/liana-gui/src/gui/mod.rs +++ b/liana-gui/src/gui/mod.rs @@ -2,8 +2,9 @@ use iced::{ event::{self, Event}, keyboard, widget::{focus_next, focus_previous, pane_grid}, - Length, Subscription, Task, + Length, Size, Subscription, Task, }; +use iced_runtime::window; use tracing::{error, info}; use tracing_subscriber::filter::LevelFilter; extern crate serde; @@ -15,12 +16,23 @@ use liana_ui::widget::{Column, Container, Element}; pub mod pane; pub mod tab; -use crate::{dir::LianaDirectory, launcher, logger::setup_logger, VERSION}; +use crate::{ + app::settings::global::{GlobalSettings, WindowConfig}, + dir::LianaDirectory, + launcher, + logger::setup_logger, + VERSION, +}; + +use iced::window::Id; pub struct GUI { panes: pane_grid::State, focus: Option, config: Config, + window_id: Option, + window_init: Option, + window_config: Option, } #[derive(Debug)] @@ -39,6 +51,8 @@ pub enum Message { Clicked(pane_grid::Pane), Dragged(pane_grid::DragEvent), Resized(pane_grid::ResizeEvent), + Window(Option), + WindowSize(Size), } impl From> for Message { @@ -65,15 +79,24 @@ impl GUI { 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 mut cmds = vec![ + window::get_oldest().map(Message::Window), + 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))); + let window_config = + GlobalSettings::load_window_config(&GlobalSettings::path(&config.liana_directory)); + let window_init = window_config.is_some().then_some(true); ( Self { panes, focus: Some(focused_pane), config, + window_id: None, + window_init, + window_config, }, Task::batch(cmds), ) @@ -81,11 +104,81 @@ impl GUI { pub fn update(&mut self, message: Message) -> Task { match message { + // we get this message only once at startup + Message::Window(id) => { + self.window_id = id; + // Common case: if there is an already saved screen size we reuse it + if let (Some(id), Some(WindowConfig { width, height })) = (id, &self.window_config) + { + window::resize( + id, + Size { + width: *width, + height: *height, + }, + ) + // Initial startup: we maximize the screen in order to know the max usable screen area + } else if let Some(id) = &self.window_id { + window::maximize(*id, true) + } else { + Task::none() + } + } + Message::WindowSize(monitor_size) => { + let cloned_cfg = self.window_config.clone(); + match (cloned_cfg, &self.window_init, &self.window_id) { + // no previous screen size recorded && window maximized + (None, Some(false), Some(id)) => { + self.window_init = Some(true); + let mut batch = vec![window::maximize(*id, false)]; + let new_size = if monitor_size.height >= 1200.0 { + let size = Size { + width: 1200.0, + height: 950.0, + }; + batch.push(window::resize(*id, size)); + size + } else { + batch.push(window::resize(*id, iced::window::Settings::default().size)); + iced::window::Settings::default().size + }; + self.window_config = Some(WindowConfig { + width: new_size.width, + height: new_size.height, + }); + Task::batch(batch) + } + // we already have a record of the last window size and we update it + (Some(WindowConfig { width, height }), _, _) => { + if width != monitor_size.width || height != monitor_size.height { + if let Some(cfg) = &mut self.window_config { + cfg.width = monitor_size.width; + cfg.height = monitor_size.height; + } + } + Task::none() + } + // we ignore the first notification about initial window size it will always be + // the default one + _ => { + if self.window_init.is_none() { + self.window_init = Some(false); + } + Task::none() + } + } + } Message::CtrlC | Message::Event(iced::Event::Window(iced::window::Event::CloseRequested)) => { for (_, pane) in self.panes.iter_mut() { pane.stop(); } + if let Some(window_config) = &self.window_config { + let path = GlobalSettings::path(&self.config.liana_directory); + if let Err(e) = GlobalSettings::update_window_config(&path, window_config) { + tracing::error!("Failed to update the window config: {e}"); + } + } iced::window::get_latest().and_then(iced::window::close) } Message::KeyPressed(Key::Tab(shift)) => { @@ -250,6 +343,9 @@ impl GUI { iced::Event::Window(iced::window::Event::CloseRequested), event::Status::Ignored, ) => Some(Message::Event(event)), + (iced::Event::Window(iced::window::Event::Resized(size)), _) => { + Some(Message::WindowSize(*size)) + } _ => None, } })]; diff --git a/liana-gui/src/launcher.rs b/liana-gui/src/launcher.rs index 9dc10f2c..45f7d86c 100644 --- a/liana-gui/src/launcher.rs +++ b/liana-gui/src/launcher.rs @@ -47,7 +47,7 @@ pub enum State { pub struct Launcher { state: State, network: Network, - datadir_path: LianaDirectory, + pub datadir_path: LianaDirectory, error: Option, delete_wallet_modal: Option, } diff --git a/liana-gui/src/main.rs b/liana-gui/src/main.rs index d6f0093c..08d806c8 100644 --- a/liana-gui/src/main.rs +++ b/liana-gui/src/main.rs @@ -14,6 +14,7 @@ use liana::miniscript::bitcoin; use liana_ui::{component::text, font, image, theme}; use liana_gui::{ + app::settings::global::{GlobalSettings, WindowConfig}, dir::LianaDirectory, gui::{Config, GUI}, node::bitcoind::delete_all_bitcoind_locks_for_process, @@ -106,8 +107,18 @@ fn main() -> Result<(), Box> { fonts: font::load(), }; + let global_config_path = GlobalSettings::path(&config.liana_directory); + let initial_size = if let Some(WindowConfig { width, height }) = + GlobalSettings::load_window_config(&global_config_path) + { + Size { width, height } + } else { + iced::window::Settings::default().size + }; + #[allow(unused_mut)] let mut window_settings = iced::window::Settings { + size: initial_size, icon: Some(image::liana_app_icon()), position: iced::window::Position::Default, min_size: Some(Size { diff --git a/liana-gui/test_assets/global_settings.json b/liana-gui/test_assets/global_settings.json new file mode 100644 index 00000000..012040ea --- /dev/null +++ b/liana-gui/test_assets/global_settings.json @@ -0,0 +1,80 @@ +{ + "bitbox": { + "noise_config": { + "app_static_privkey": [ + 84, + 118, + 69, + 7, + 5, + 246, + 50, + 252, + 79, + 62, + 233, + 118, + 54, + 46, + 247, + 143, + 255, + 152, + 11, + 96, + 7, + 213, + 209, + 42, + 219, + 58, + 237, + 22, + 53, + 221, + 227, + 228 + ], + "device_static_pubkeys": [ + [ + 252, + 78, + 254, + 112, + 62, + 72, + 220, + 22, + 23, + 147, + 205, + 166, + 248, + 39, + 97, + 46, + 32, + 255, + 132, + 125, + 97, + 142, + 31, + 146, + 44, + 186, + 231, + 1, + 12, + 190, + 105, + 11 + ] + ] + } + }, + "window_config": { + "width": 1248.0, + "height": 688.0 + } +}