feat(ui): persist window state and clarify transfer telemetry

- Persist bounded logical main-window geometry with work-area-safe startup restoration.
- Persist the Folders collapse preference in SQLite with guarded legacy localStorage migration.
- Keep media transfer telemetry truthful and compact the transfer controls across locales.
- Add bridge, presentation, persistence, geometry, and configuration regression coverage.
This commit is contained in:
NimBold
2026-08-11 12:17:11 +03:30
parent ae6a00304e
commit f7bafdeb0e
28 changed files with 897 additions and 36 deletions
+13
View File
@@ -653,6 +653,14 @@ pub struct SchedulerSettings {
pub post_queue_action: PostQueueAction,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
pub struct MainWindowSize {
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "../../src/bindings/")]
@@ -678,6 +686,11 @@ pub struct PersistedSettings {
pub speed_limit_preset_values: Vec<f64>,
pub logs_enabled: bool,
pub is_sidebar_visible: bool,
#[serde(default)]
pub is_folders_collapsed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub main_window_size: Option<MainWindowSize>,
#[serde(default = "default_sidebar_position")]
pub sidebar_position: String,
pub active_settings_tab: SettingsTab,
+28
View File
@@ -3218,6 +3218,7 @@ mod torrent_probe;
pub mod torrent;
mod settings;
mod storage;
mod window_geometry;
pub use error::AppError;
// Retained only for compatibility with the optional aria2 diagnostic monitor.
@@ -14285,6 +14286,33 @@ pub fn run() {
// Build the window only after all command state is registered. This
// prevents the frontend from racing startup and invoking IPC before
// the database and portable storage layout are available.
let startup_size = persisted_settings
.as_ref()
.and_then(|settings| settings.main_window_size.as_ref())
.and_then(|size| crate::window_geometry::normalize_main_window_size(Some(size)))
.unwrap_or_else(crate::window_geometry::default_main_window_size);
let startup_size = app
.primary_monitor()
.ok()
.flatten()
.and_then(|monitor| {
let scale_factor = monitor.scale_factor();
if !scale_factor.is_finite() || scale_factor <= 0.0 {
return None;
}
let work_area = monitor.work_area().size;
let logical_width = (work_area.width as f64 / scale_factor).round() as u32;
let logical_height = (work_area.height as f64 / scale_factor).round() as u32;
Some(crate::window_geometry::clamp_main_window_size(
startup_size.clone(),
logical_width,
logical_height,
))
})
.unwrap_or(startup_size);
main_window_builder = main_window_builder
.inner_size(startup_size.width as f64, startup_size.height as f64)
.prevent_overflow();
main_window_builder
.build()
.map_err(|error| format!("failed to create main window: {error}"))?;
+67
View File
@@ -386,6 +386,23 @@ fn sanitize_persisted_setting_values(state: &mut Value) {
return;
};
let main_window_size = state
.get("mainWindowSize")
.cloned()
.and_then(|value| serde_json::from_value::<crate::ipc::MainWindowSize>(value).ok())
.and_then(|size| crate::window_geometry::normalize_main_window_size(Some(&size)));
match main_window_size {
Some(size) => {
state.insert(
"mainWindowSize".to_string(),
serde_json::to_value(size).expect("main window size is serializable"),
);
}
None => {
state.remove("mainWindowSize");
}
}
sanitize_integer_setting(state, "maxConcurrentDownloads", |value| value.as_u64().is_some());
sanitize_integer_setting(state, "perServerConnections", |value| value.as_i64().is_some());
sanitize_integer_setting(state, "maxAutomaticRetries", |value| value.as_i64().is_some());
@@ -575,6 +592,9 @@ fn sanitize_allowed_string(
}
fn validate_settings(settings: &mut PersistedSettings) {
settings.main_window_size = crate::window_geometry::normalize_main_window_size(
settings.main_window_size.as_ref(),
);
if settings.max_concurrent_downloads == 0 {
settings.max_concurrent_downloads = default_settings().max_concurrent_downloads;
}
@@ -838,6 +858,8 @@ fn default_settings() -> PersistedSettings {
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
logs_enabled: false,
is_sidebar_visible: true,
is_folders_collapsed: false,
main_window_size: None,
sidebar_position: "auto".to_string(),
active_settings_tab: SettingsTab::Downloads,
scheduler: SchedulerSettings {
@@ -1378,6 +1400,51 @@ mod tests {
assert!(!default_settings().remember_last_used_download_directory);
}
#[test]
fn legacy_settings_without_geometry_use_no_persisted_size() {
let settings = decode_stored_settings(&Value::String(
json!({ "state": { "theme": "system" }, "version": 0 }).to_string(),
))
.unwrap();
assert!(settings.main_window_size.is_none());
}
#[test]
fn valid_main_window_geometry_round_trips() {
let settings = decode_stored_settings(&Value::String(
json!({
"state": { "mainWindowSize": { "width": 1440, "height": 900 } },
"version": 6
})
.to_string(),
))
.unwrap();
assert_eq!(
settings
.main_window_size
.as_ref()
.map(|size| (size.width, size.height)),
Some((1440, 900))
);
}
#[test]
fn malformed_and_out_of_range_geometry_is_dropped() {
for geometry in [
json!({ "width": "1440", "height": 900 }),
json!({ "width": 959, "height": 900 }),
json!({ "width": 1440, "height": 16_385 }),
] {
let settings = decode_stored_settings(&Value::String(
json!({ "state": { "mainWindowSize": geometry }, "version": 6 }).to_string(),
))
.unwrap();
assert!(settings.main_window_size.is_none());
}
}
#[test]
fn decodes_disabled_last_used_download_directory_setting() {
let stored = json!({
+97
View File
@@ -0,0 +1,97 @@
use crate::ipc::MainWindowSize;
pub const MAIN_WINDOW_DEFAULT_WIDTH: u32 = 1280;
pub const MAIN_WINDOW_DEFAULT_HEIGHT: u32 = 800;
pub const MAIN_WINDOW_MIN_WIDTH: u32 = 960;
pub const MAIN_WINDOW_MIN_HEIGHT: u32 = 640;
pub const MAIN_WINDOW_MAX_WIDTH: u32 = 16_384;
pub const MAIN_WINDOW_MAX_HEIGHT: u32 = 16_384;
pub fn default_main_window_size() -> MainWindowSize {
MainWindowSize {
width: MAIN_WINDOW_DEFAULT_WIDTH,
height: MAIN_WINDOW_DEFAULT_HEIGHT,
}
}
pub fn normalize_main_window_size(size: Option<&MainWindowSize>) -> Option<MainWindowSize> {
let size = size?;
if size.width < MAIN_WINDOW_MIN_WIDTH
|| size.height < MAIN_WINDOW_MIN_HEIGHT
|| size.width > MAIN_WINDOW_MAX_WIDTH
|| size.height > MAIN_WINDOW_MAX_HEIGHT
{
return None;
}
Some(size.clone())
}
pub fn clamp_main_window_size(
size: MainWindowSize,
work_area_width: u32,
work_area_height: u32,
) -> MainWindowSize {
let width_limit = work_area_width.max(MAIN_WINDOW_MIN_WIDTH);
let height_limit = work_area_height.max(MAIN_WINDOW_MIN_HEIGHT);
MainWindowSize {
width: size.width.min(width_limit),
height: size.height.min(height_limit),
}
}
#[cfg(test)]
mod tests {
use super::{
clamp_main_window_size, default_main_window_size, normalize_main_window_size,
MAIN_WINDOW_MIN_HEIGHT, MAIN_WINDOW_MIN_WIDTH,
};
use crate::ipc::MainWindowSize;
#[test]
fn default_size_matches_the_main_window_configuration() {
assert_eq!(default_main_window_size().width, 1280);
assert_eq!(default_main_window_size().height, 800);
}
#[test]
fn rejects_sizes_outside_the_persisted_bounds() {
assert!(normalize_main_window_size(Some(&MainWindowSize {
width: MAIN_WINDOW_MIN_WIDTH - 1,
height: 800,
}))
.is_none());
assert!(normalize_main_window_size(Some(&MainWindowSize {
width: 1280,
height: 16_385,
}))
.is_none());
}
#[test]
fn caps_a_valid_size_to_the_available_work_area() {
let clamped = clamp_main_window_size(
MainWindowSize {
width: 1600,
height: 1000,
},
1280,
720,
);
assert_eq!(clamped.width, 1280);
assert_eq!(clamped.height, 720);
}
#[test]
fn keeps_the_minimum_when_the_work_area_is_shorter_than_the_minimum() {
let clamped = clamp_main_window_size(
MainWindowSize {
width: 1280,
height: 800,
},
800,
500,
);
assert_eq!(clamped.width, MAIN_WINDOW_MIN_WIDTH);
assert_eq!(clamped.height, MAIN_WINDOW_MIN_HEIGHT);
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"height": 800,
"minWidth": 960,
"minHeight": 640,
"transparent": false
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"height": 800,
"minWidth": 960,
"minHeight": 640,
"transparent": false,
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"height": 800,
"minWidth": 960,
"minHeight": 640,
"transparent": true,
+1 -1
View File
@@ -5,7 +5,7 @@
"create": false,
"title": "Firelink",
"width": 1280,
"height": 760,
"height": 800,
"minWidth": 960,
"minHeight": 640,
"transparent": true,