Merge #1740: Smart screen size

b1e06ee10638895e2bcf7ac41bd5933048e2ca34 global_settings: do not create file if not existing & empty (pythcoiner)
a0b74709f33d7eb84c2c9692d2d079096891c283 settings: add a tests for GlobalSettings (pythcoiner)
c53ace8b0bb2e006221ae2faf013fe7ab0b1e16d gui: store the window size in global settings only on shutdown (pythcoiner)
13652e3c33f1844440831761d01faa7bfc499d0e settings: add a lock to read/write global settings (pythcoiner)
b696f50f8c9b4938e9a43e0bcb6b77db82c5b002 gui: apply previous screen size before launch (pythcoiner)
2dc838f85c0de563ee19d28332f7c60f4a9c094c gui: be smart with window size at launch (pythcoiner)
089992b9475d4d9b2868399afeb1ceadf7ee37f5 settings: add window_config to GlobalSettings (pythcoiner)
cb8a421571bebb2fe0ac206175682167b7b1b64c settings: rename global::Settings => global::GlobalSettings (pythcoiner)
bf666539bfed7a75a0dfc2d34ac377b1b094278b gui: implement State::datadir_path() (pythcoiner)

Pull request description:

  closes #1546 following last requirements:

  > Currently the consensus has been on the following solution: resize the window according to a logic similar to https://github.com/wizardsardine/liana/pull/1695 at the first launch and then remember the last size for following launches of the application.

  Summary of the feature introduced in this PR:
  - The first time Liana launch, the window is maximized in order to "guess" the maximum usable size on the monitor, and then we apply [this logic](https://github.com/wizardsardine/liana/issues/1546#issuecomment-2750974470) for deciding the default size.
  - The default size is recorded in `global_settings.json` (so this aplly to ALL wallets)
  - On nexts Liana launch, the window is resized to the value contained in `global_settings.json`

  I added something not clearly defined: if the user resize its screen, we reapply the last screen size at startup.

ACKs for top commit:
  edouardparis:
    ACK b1e06ee10638895e2bcf7ac41bd5933048e2ca34

Tree-SHA512: 1ffb0ef852846d5599a747d619a19a2ee2b47ecbb26172de95ffbf6ad2d0c65851b3236070081e81b2503914e6916abc3ed59899131a8a475f6d07662a951d0e
This commit is contained in:
edouardparis 2025-07-21 16:44:23 +02:00
commit a90d894af9
No known key found for this signature in database
GPG Key ID: E65F7A089C20DC8F
8 changed files with 529 additions and 62 deletions

11
Cargo.lock generated
View File

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

View File

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

View File

@ -441,6 +441,10 @@ impl App {
content
}
}
pub fn datadir_path(&self) -> &LianaDirectory {
&self.cache.datadir_path
}
}
fn new_recovery_panel(wallet: Arc<Wallet>, cache: &Cache) -> CreateSpendPanel {

View File

@ -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<BitboxSettings>,
#[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<BitboxSettings>,
pub window_config: Option<WindowConfig>,
}
impl GlobalSettings {
pub fn path(global_datadir: &LianaDirectory) -> PathBuf {
global_datadir.path().join(DEFAULT_FILE_NAME)
}
pub fn load_window_config(path: &PathBuf) -> Option<WindowConfig> {
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<Option<BitboxSettings>, 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<F>(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::<GlobalSettings>(&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<NoiseConfigData, async_hwi::bitbox::api::ConfigError> {
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::<Settings>(&contents)
.map_err(|e| ConfigError(e.to_string()))?;
Ok(settings
.bitbox
fn read_config(&self) -> Result<NoiseConfigData, ConfigError> {
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::<Settings>(&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::<GlobalSettings>(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()
}
}

View File

@ -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<pane::Pane>,
focus: Option<pane_grid::Pane>,
config: Config,
window_id: Option<Id>,
window_init: Option<bool>,
window_config: Option<WindowConfig>,
}
#[derive(Debug)]
@ -39,6 +51,8 @@ pub enum Message {
Clicked(pane_grid::Pane),
Dragged(pane_grid::DragEvent),
Resized(pane_grid::ResizeEvent),
Window(Option<Id>),
WindowSize(Size),
}
impl From<Result<(), iced::font::Error>> 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<Message> {
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,
}
})];

View File

@ -47,7 +47,7 @@ pub enum State {
pub struct Launcher {
state: State,
network: Network,
datadir_path: LianaDirectory,
pub datadir_path: LianaDirectory,
error: Option<String>,
delete_wallet_modal: Option<DeleteWalletModal>,
}

View File

@ -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<dyn Error>> {
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 {

View File

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