From f7bafdeb0e064b0b547896b75ae99926ff8b0d70 Mon Sep 17 00:00:00 2001 From: NimBold Date: Tue, 11 Aug 2026 12:17:11 +0330 Subject: [PATCH] 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. --- src-tauri/src/ipc.rs | 13 ++ src-tauri/src/lib.rs | 28 ++++ src-tauri/src/settings.rs | 67 +++++++++ src-tauri/src/window_geometry.rs | 97 +++++++++++++ src-tauri/tauri.conf.json | 2 +- src-tauri/tauri.linux.conf.json | 2 +- src-tauri/tauri.macos.conf.json | 2 +- src-tauri/tauri.windows.conf.json | 2 +- src/App.tsx | 41 +++++- src/bindings/MainWindowSize.ts | 3 + src/bindings/PersistedSettings.ts | 3 +- src/components/PropertiesWindowApp.tsx | 32 +++-- src/components/Sidebar.tsx | 17 ++- src/i18n/catalogs/en.ts | 3 +- src/i18n/catalogs/fa.ts | 3 +- src/i18n/catalogs/he.ts | 3 +- src/i18n/catalogs/ru.ts | 3 +- src/i18n/catalogs/uk.ts | 3 +- src/i18n/catalogs/zh-CN.ts | 3 +- src/propertiesBridge.test.ts | 34 +++++ src/propertiesBridge.ts | 15 +- src/store/useSettingsStore.test.ts | 60 ++++++++ src/store/useSettingsStore.ts | 55 +++++++ src/utils/mainWindowState.test.ts | 151 +++++++++++++++++++ src/utils/mainWindowState.ts | 176 +++++++++++++++++++++++ src/utils/propertiesPresentation.test.ts | 47 ++++++ src/utils/propertiesPresentation.ts | 42 ++++++ src/utils/windowConfiguration.test.ts | 26 ++++ 28 files changed, 897 insertions(+), 36 deletions(-) create mode 100644 src-tauri/src/window_geometry.rs create mode 100644 src/bindings/MainWindowSize.ts create mode 100644 src/utils/mainWindowState.test.ts create mode 100644 src/utils/mainWindowState.ts create mode 100644 src/utils/propertiesPresentation.test.ts create mode 100644 src/utils/propertiesPresentation.ts create mode 100644 src/utils/windowConfiguration.test.ts diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index fa75c97..c2d6080 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -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, 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, #[serde(default = "default_sidebar_position")] pub sidebar_position: String, pub active_settings_tab: SettingsTab, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33af2cc..387b9f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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}"))?; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 078e847..43a456d 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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::(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!({ diff --git a/src-tauri/src/window_geometry.rs b/src-tauri/src/window_geometry.rs new file mode 100644 index 0000000..6a4de37 --- /dev/null +++ b/src-tauri/src/window_geometry.rs @@ -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 { + 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); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 38853f5..362148d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -16,7 +16,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 760, + "height": 800, "minWidth": 960, "minHeight": 640, "transparent": false diff --git a/src-tauri/tauri.linux.conf.json b/src-tauri/tauri.linux.conf.json index f56a439..6e72015 100644 --- a/src-tauri/tauri.linux.conf.json +++ b/src-tauri/tauri.linux.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 760, + "height": 800, "minWidth": 960, "minHeight": 640, "transparent": false, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 13081c2..08616c6 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 760, + "height": 800, "minWidth": 960, "minHeight": 640, "transparent": true, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index 652302c..ccfdc66 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -5,7 +5,7 @@ "create": false, "title": "Firelink", "width": 1280, - "height": 760, + "height": 800, "minWidth": 960, "minHeight": 640, "transparent": true, diff --git a/src/App.tsx b/src/App.tsx index bdb3981..c61f193 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,7 +13,11 @@ import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { flushDownloadPersistence, initializeDownloadPersistence, useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore'; import { getCurrentWindow } from '@tauri-apps/api/window'; import { initDownloadListener } from './store/downloadStore'; -import { subscribeToSettingsPersistenceErrors, useSettingsStore } from "./store/useSettingsStore"; +import { + subscribeToSettingsPersistenceErrors, + useSettingsStore, + waitForSettingsPersistence +} from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import { WindowControls } from "./components/WindowControls"; import { PropertiesWindowBridgeHost } from "./components/PropertiesWindowBridgeHost"; @@ -36,6 +40,8 @@ import { changeAppLocale, localeDirection, resolveAppLocale, syncDocumentLocale import { useTranslation } from 'react-i18next'; import { formatDownloadBytes } from './utils/downloadProgress'; import { synchronizeDocumentAppearance } from './utils/documentAppearance'; +import { createMainWindowSizePersistence } from './utils/mainWindowState'; +import type { MainWindowSize } from './bindings/MainWindowSize'; const loadSettingsView = () => import('./components/SettingsView'); const loadSchedulerView = () => import('./components/SchedulerView'); @@ -410,14 +416,44 @@ function App() { useEffect(() => { const disposePersistence = initializeDownloadPersistence(getCurrentWindow().label); let active = true; + let exitRequested = false; + let exiting = false; + let settingsHydrated = useSettingsStore.persist.hasHydrated(); + let latestSizeBeforeHydration: MainWindowSize | null = null; + const unlistenSettingsHydration = settingsHydrated + ? null + : useSettingsStore.persist.onFinishHydration(() => { + settingsHydrated = true; + const size = latestSizeBeforeHydration; + latestSizeBeforeHydration = null; + if (size && active && !exitRequested && !exiting) { + useSettingsStore.getState().setMainWindowSize(size); + } + }); + const mainWindowSizePersistence = createMainWindowSizePersistence({ + appWindow: getCurrentWindow(), + onSize: size => { + if (!active || exiting) return; + if (!settingsHydrated) { + latestSizeBeforeHydration = size; + return; + } + useSettingsStore.getState().setMainWindowSize(size); + } + }); let cleanupListeners: (() => void) | null = null; let unlistenExit: (() => void) | null = null; const exitListener = listen('app-exit-requested', async () => { + exitRequested = true; try { + await mainWindowSizePersistence.flush(); + await waitForSettingsPersistence(); await flushDownloadPersistence(); } catch (error) { console.error('Failed to flush download state before exit:', error); } finally { + exiting = true; + latestSizeBeforeHydration = null; await invoke('ack_frontend_exit').catch(error => { console.error('Failed to acknowledge frontend exit flush:', error); }); @@ -436,6 +472,7 @@ function App() { let unlistenDeepLink: (() => void) | null = null; const disposeListeners = () => { void queueFrontendReadyUpdate(false).catch(() => {}); + mainWindowSizePersistence.dispose(); unlistenExit?.(); unlistenExit = null; unlistenTerminalState?.(); @@ -646,6 +683,8 @@ function App() { cleanupListeners = null; unlistenExit?.(); unlistenExit = null; + unlistenSettingsHydration?.(); + mainWindowSizePersistence.dispose(); disposePersistence(); }; }, [addToast, queueFrontendReadyUpdate]); diff --git a/src/bindings/MainWindowSize.ts b/src/bindings/MainWindowSize.ts new file mode 100644 index 0000000..31cc7be --- /dev/null +++ b/src/bindings/MainWindowSize.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MainWindowSize = { width: number, height: number, }; diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 838cd48..838e41c 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -3,6 +3,7 @@ import type { AppFontSize } from "./AppFontSize"; import type { CalendarPreference } from "./CalendarPreference"; import type { FontFamily } from "./FontFamily"; import type { ListRowDensity } from "./ListRowDensity"; +import type { MainWindowSize } from "./MainWindowSize"; import type { MediaCookieSource } from "./MediaCookieSource"; import type { ProxyMode } from "./ProxyMode"; import type { SchedulerSettings } from "./SchedulerSettings"; @@ -11,4 +12,4 @@ import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; import type { WindowControlStyle } from "./WindowControlStyle"; -export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, fontFamily: FontFamily, windowControlStyle: WindowControlStyle, calendarPreference: CalendarPreference, language: string, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, minimumNormalDownloadSpeedKiB: number, retryNotFoundErrors: boolean, adaptiveMirrorSelection: boolean, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, torrentEnableDht: boolean, torrentEnableDht6: boolean, torrentEnablePex: boolean, torrentEnableLpd: boolean, torrentMaxOpenFiles: number, torrentDhtMessageTimeout: number, torrentSeparateSeedSlots: boolean, torrentMaxConcurrentSeeds: number, torrentIpv6Enabled: boolean, torrentListenPort: string, torrentDhtListenPort: string, torrentExternalIp: string, torrentDhtEntryPoint: string, torrentDhtEntryPoint6: string, torrentDhtListenAddr6: string, torrentLpdInterface: string, torrentPeerIdPrefix: string, torrentPeerAgent: string, torrentBindAddress: string, aria2DiskCache: string, customUserAgent: string, askWhereToSaveEachFile: boolean, rememberLastUsedDownloadDirectory: boolean, preventsSleepWhileDownloading: boolean, preventsDisplaySleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/PropertiesWindowApp.tsx b/src/components/PropertiesWindowApp.tsx index 6c5e131..039450f 100644 --- a/src/components/PropertiesWindowApp.tsx +++ b/src/components/PropertiesWindowApp.tsx @@ -49,6 +49,7 @@ import { } from '../utils/propertiesDiagnostics'; import { shouldOfferPropertiesUrlExpansion, shouldResetPropertiesUrlExpansion } from '../utils/propertiesUrl'; import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREAKPOINT, shouldUsePropertiesTabOverflow, type PropertiesTab } from '../utils/propertiesTabs'; +import { getPropertiesConnectionPresentation } from '../utils/propertiesPresentation'; import { WindowControls } from './WindowControls'; import { TORRENT_ENCRYPTION_POLICY_DISABLED, @@ -1096,11 +1097,12 @@ export const PropertiesWindowApp = () => { ? t($ => $.addDownloads.unknownSize) : `${snapshot.totalIsEstimate ? '~' : ''}${formatDownloadBytes(snapshot.totalBytes)}`); const statusLabel = t($ => $.downloads.status[snapshot.status]); - const connectionMetric = isTorrent - ? String(snapshot.connectedPeers ?? '—') - : snapshot.isMedia === true - ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` - : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'} ${t($ => $.properties.connections)}`; + const connectionPresentation = getPropertiesConnectionPresentation(snapshot); + const connectionLabel = connectionPresentation.labelKey === 'fragmentConcurrency' + ? t($ => $.properties.fragmentConcurrency) + : connectionPresentation.labelKey === 'torrentConnectedPeers' + ? t($ => $.properties.torrentConnectedPeers) + : t($ => $.properties.connections); const queuePlacement = formatPropertiesQueuePlacement( snapshot.queueName, snapshot.queuePosition, @@ -1192,7 +1194,7 @@ export const PropertiesWindowApp = () => {
{t($ => $.properties.size)}{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}
{t($ => $.properties.speed)}{snapshot.speed || '—'}
{t($ => $.properties.eta)}{snapshot.eta || '—'}
-
{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}{connectionMetric}
+ {connectionPresentation.showHeaderMetric &&
{connectionLabel}{connectionPresentation.value}
} {isTorrent && <>
{t($ => $.properties.torrentUploaded)}{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}
{t($ => $.properties.torrentRatio)}{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}
@@ -1299,7 +1301,7 @@ export const PropertiesWindowApp = () => { {snapshot.isMedia === true &&
{t($ => $.addDownloads.format)}{snapshot.mediaFormatSelector || '—'} {t($ => $.addDownloads.quality)}{snapshot.mediaQuality || '—'} - {t($ => $.properties.configuredConcurrency)}{snapshot.connections ?? '—'} + {t($ => $.properties.fragmentConcurrency)}{snapshot.connections ?? '—'}
} {isTorrent && details &&
{t($ => $.properties.torrentDetailsDisplayName)}{details.displayName || '—'} @@ -1379,12 +1381,22 @@ export const PropertiesWindowApp = () => { controlId="properties-transfer-speed-cap" hint={t($ => $.properties.speedLimitHint)} meta={downloadLimit.trim() ? t($ => $.properties.customPerDownload) : t($ => $.properties.usingDefault)} - className="max-w-md" + className="max-w-sm" format={t($ => $.properties.inputFormat, { format: t($ => $.properties.inputFormatSpeedLimit) })} > { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} /> - + $.properties.fragmentConcurrencyHint) : undefined} + className="max-w-md" + > +
+ { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} /> + {connections || '1'} +
+

{t($ => $.properties.transferSettings)}

} @@ -1539,7 +1551,7 @@ export const PropertiesWindowApp = () => { {snapshot.credentialsRequired === true &&

{t($ => $.properties.credentialsRequired)}

} {isSftp && }
-
{t($ => $.properties.connections)}

{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}

+
{connectionLabel}

{connectionPresentation.value}

{t($ => $.properties.speedCap)}

{snapshot.speedLimit || '—'}

{t($ => $.properties.username)}

{snapshot.hasUsername ? '✓' : '—'}

{t($ => $.properties.password)}

{snapshot.hasPassword ? '✓' : '—'}

diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 02f8328..5ffb4e8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -25,7 +25,13 @@ interface SidebarProps { export const Sidebar: React.FC = (props) => { const { selectedFilter, onSelectFilter } = props; const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore(); - const { activeView, setActiveView, toggleSidebar } = useSettingsStore(); + const { + activeView, + setActiveView, + toggleSidebar, + isFoldersCollapsed: foldersCollapsed, + toggleFoldersCollapsed + } = useSettingsStore(); const { addToast } = useToast(); const { t, i18n } = useTranslation(); @@ -36,9 +42,6 @@ export const Sidebar: React.FC = (props) => { const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null); const contextMenuRef = useRef(null); const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number } | null>(null); - const [foldersCollapsed, setFoldersCollapsed] = useState(() => - window.localStorage.getItem('firelink-folders-collapsed') === 'true' - ); const foldersToggleRef = useRef(null); const foldersListRef = useRef(null); @@ -114,10 +117,6 @@ export const Sidebar: React.FC = (props) => { if (renamingQueueId) renameInputRef.current?.focus(); }, [renamingQueueId]); - useEffect(() => { - window.localStorage.setItem('firelink-folders-collapsed', String(foldersCollapsed)); - }, [foldersCollapsed]); - useEffect(() => { if (foldersCollapsed && foldersListRef.current?.contains(document.activeElement)) { foldersToggleRef.current?.focus(); @@ -128,7 +127,7 @@ export const Sidebar: React.FC = (props) => { if (foldersListRef.current?.contains(document.activeElement)) { foldersToggleRef.current?.focus(); } - setFoldersCollapsed(collapsed => !collapsed); + toggleFoldersCollapsed(); }; useEffect(() => { diff --git a/src/i18n/catalogs/en.ts b/src/i18n/catalogs/en.ts index ca72067..4e35e59 100644 --- a/src/i18n/catalogs/en.ts +++ b/src/i18n/catalogs/en.ts @@ -228,7 +228,8 @@ const common = { speed: 'Speed', eta: 'ETA', connections: 'Connections', - configuredConcurrency: 'Configured concurrency', + fragmentConcurrency: 'Fragment concurrency', + fragmentConcurrencyHint: 'Maximum number of media fragments yt-dlp may process concurrently. Firelink does not report a live fragment count; this is the configured value used when the transfer starts or resumes.', connectedPeers: 'connected peers', details: 'Details', tabs: { diff --git a/src/i18n/catalogs/fa.ts b/src/i18n/catalogs/fa.ts index f6f2c81..8b1c09a 100644 --- a/src/i18n/catalogs/fa.ts +++ b/src/i18n/catalogs/fa.ts @@ -228,7 +228,8 @@ const fa = { speed: 'سرعت', eta: 'زمان باقیمانده', connections: 'اتصالات', - configuredConcurrency: 'هم‌زمانی پیکربندی‌شده', + fragmentConcurrency: 'هم‌زمانی قطعه‌ها', + fragmentConcurrencyHint: 'حداکثر تعداد قطعه‌های رسانه‌ای که yt-dlp می‌تواند هم‌زمان پردازش کند. Firelink تعداد قطعه‌های فعال را به‌صورت زنده گزارش نمی‌کند؛ این مقدار هنگام شروع یا ازسرگیری انتقال استفاده می‌شود.', connectedPeers: 'همتای متصل', details: 'جزئیات', tabs: { diff --git a/src/i18n/catalogs/he.ts b/src/i18n/catalogs/he.ts index a7584ee..68b5b8a 100644 --- a/src/i18n/catalogs/he.ts +++ b/src/i18n/catalogs/he.ts @@ -228,7 +228,8 @@ const he = { speed: 'מהירות', eta: 'זמן נותר', connections: 'חיבורים', - configuredConcurrency: 'מקביליות מוגדרת', + fragmentConcurrency: 'מקביליות מקטעים', + fragmentConcurrencyHint: 'מספר מקטעי המדיה המרבי ש-yt-dlp יכול לעבד במקביל. Firelink אינו מדווח על מספר המקטעים הפעילים בזמן אמת; זהו הערך המוגדר שבו נעשה שימוש כשההעברה מתחילה או מתחדשת.', connectedPeers: 'עמיתים מחוברים', details: 'פרטים', tabs: { diff --git a/src/i18n/catalogs/ru.ts b/src/i18n/catalogs/ru.ts index f9e6cd3..134683b 100644 --- a/src/i18n/catalogs/ru.ts +++ b/src/i18n/catalogs/ru.ts @@ -228,7 +228,8 @@ const ru = { speed: 'Скорость', eta: 'Осталось', connections: 'Соединения', - configuredConcurrency: 'Настроенная параллельность', + fragmentConcurrency: 'Параллельность фрагментов', + fragmentConcurrencyHint: 'Максимальное число медиафрагментов, которые yt-dlp может обрабатывать одновременно. Firelink не сообщает текущее число активных фрагментов; это настроенное значение используется при запуске или возобновлении передачи.', connectedPeers: 'подключённых пиров', details: 'Подробности', tabs: { diff --git a/src/i18n/catalogs/uk.ts b/src/i18n/catalogs/uk.ts index c6ceee0..9ff1e02 100644 --- a/src/i18n/catalogs/uk.ts +++ b/src/i18n/catalogs/uk.ts @@ -228,7 +228,8 @@ const uk = { speed: 'Швидкість', eta: 'Залишилось', connections: 'З\'єднання', - configuredConcurrency: 'Налаштована паралельність', + fragmentConcurrency: 'Паралельність фрагментів', + fragmentConcurrencyHint: 'Максимальна кількість медіафрагментів, які yt-dlp може обробляти одночасно. Firelink не повідомляє поточну кількість активних фрагментів; це налаштоване значення використовується під час запуску або відновлення передачі.', connectedPeers: 'підключених пірів', details: 'Деталі', tabs: { diff --git a/src/i18n/catalogs/zh-CN.ts b/src/i18n/catalogs/zh-CN.ts index 19daa8b..b0b289c 100644 --- a/src/i18n/catalogs/zh-CN.ts +++ b/src/i18n/catalogs/zh-CN.ts @@ -228,7 +228,8 @@ const zhCN = { speed: '速度', eta: '剩余时间', connections: '连接数', - configuredConcurrency: '已配置并发数', + fragmentConcurrency: '分片并发数', + fragmentConcurrencyHint: 'yt-dlp 可同时处理的媒体分片最大数量。Firelink 不会报告实时活动分片数量;此配置值会在传输开始或恢复时使用。', connectedPeers: '已连接对等端', details: '详细信息', tabs: { diff --git a/src/propertiesBridge.test.ts b/src/propertiesBridge.test.ts index 3268ac4..14ed585 100644 --- a/src/propertiesBridge.test.ts +++ b/src/propertiesBridge.test.ts @@ -241,6 +241,40 @@ describe('Properties window bridge', () => { expect(snapshot.queueId).toBe('internal-queue-id'); }); + it('does not project Aria2 connection telemetry onto media snapshots', () => { + const snapshot = sanitizePropertiesSnapshot({ + id: 'media-1', + fileName: 'video.mp4', + url: 'https://example.test/video', + status: 'downloading', + category: 'Other', + dateAdded: '', + isMedia: true, + connections: 16, + } as DownloadItem, { + theme: 'dark', + fontFamily: 'system', + appFontSize: 'standard', + listRowDensity: 'standard', + locale: 'en', + }, { + progress: { + id: 'media-1', + fraction: 0.5, + speed: '1 MiB/s', + eta: '5s', + size: '4 MiB', + size_is_final: false, + active_connections: 8, + requested_connections: 16, + }, + }); + + expect(snapshot.connections).toBe(16); + expect(snapshot).not.toHaveProperty('activeConnections'); + expect(snapshot).not.toHaveProperty('requestedConnections'); + }); + it('preserves resolved Properties window chrome in the sanitized snapshot', () => { const snapshot = sanitizePropertiesSnapshot({ id: 'chrome-1', diff --git a/src/propertiesBridge.ts b/src/propertiesBridge.ts index d39f57e..1791f23 100644 --- a/src/propertiesBridge.ts +++ b/src/propertiesBridge.ts @@ -412,12 +412,17 @@ const copyWithoutSecrets = ( ...(live.progress.total_is_estimate !== undefined ? { totalIsEstimate: live.progress.total_is_estimate } : {}), - ...(live.progress.active_connections !== undefined - ? item.isTorrent === true - ? { connectedPeers: live.progress.active_connections } - : { activeConnections: live.progress.active_connections } + ...(live.progress.active_connections !== undefined && item.isTorrent === true + ? { connectedPeers: live.progress.active_connections } : {}), - ...(item.isTorrent !== true && live.progress.requested_connections !== undefined + ...(live.progress.active_connections !== undefined + && item.isTorrent !== true + && item.isMedia !== true + ? { activeConnections: live.progress.active_connections } + : {}), + ...(item.isTorrent !== true + && item.isMedia !== true + && live.progress.requested_connections !== undefined ? { requestedConnections: live.progress.requested_connections } : {}), ...(live.progress.uploaded_bytes !== undefined diff --git a/src/store/useSettingsStore.test.ts b/src/store/useSettingsStore.test.ts index 1795620..c518c51 100644 --- a/src/store/useSettingsStore.test.ts +++ b/src/store/useSettingsStore.test.ts @@ -25,6 +25,66 @@ describe('last used download directory preference', () => { }); }); +describe('durable main-window and sidebar preferences', () => { + it('uses safe defaults and persists the current values', async () => { + vi.clearAllMocks(); + useSettingsStore.setState({ isFoldersCollapsed: false, mainWindowSize: null }); + + expect(useSettingsStore.getState()).toMatchObject({ + isFoldersCollapsed: false, + mainWindowSize: null + }); + + useSettingsStore.getState().setFoldersCollapsed(true); + useSettingsStore.getState().setMainWindowSize({ width: 1280, height: 800 }); + + await vi.waitFor(() => { + const save = vi.mocked(ipc.invokeCommand).mock.calls + .filter(([command]) => command === 'db_save_settings') + .slice(-1)[0]; + expect(save).toBeDefined(); + expect(JSON.parse((save?.[1] as { data: string }).data).state).toMatchObject({ + isFoldersCollapsed: true, + mainWindowSize: { width: 1280, height: 800 } + }); + }); + }); + + it('rejects malformed, undersized, and oversized geometry during hydration', () => { + const merge = useSettingsStore.persist.getOptions().merge; + expect(merge).toBeTypeOf('function'); + const current = useSettingsStore.getState(); + + expect(merge?.({ mainWindowSize: { width: 959, height: 800 } }, current).mainWindowSize) + .toBe(current.mainWindowSize); + expect(merge?.({ mainWindowSize: { width: 1280, height: 16_385 } }, current).mainWindowSize) + .toBe(current.mainWindowSize); + expect(merge?.({ mainWindowSize: { width: '1280', height: 800 } }, current).mainWindowSize) + .toBe(current.mainWindowSize); + expect(merge?.({ mainWindowSize: { width: 1440, height: 900 } }, current).mainWindowSize) + .toEqual({ width: 1440, height: 900 }); + }); + + it('uses the legacy localStorage value only when durable state is absent', () => { + const originalWindow = globalThis.window; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { localStorage: { getItem: () => 'true' } } + }); + try { + const merge = useSettingsStore.persist.getOptions().merge; + const current = { ...useSettingsStore.getState(), isFoldersCollapsed: false }; + expect(merge?.({}, current).isFoldersCollapsed).toBe(true); + expect(merge?.({ isFoldersCollapsed: false }, current).isFoldersCollapsed).toBe(false); + } finally { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: originalWindow + }); + } + }); +}); + describe('normal download reliability preferences', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 27426c4..53ac572 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -37,16 +37,32 @@ import { isCalendarPreference, type CalendarPreference } from '../utils/dateTime'; +import type { MainWindowSize } from '../bindings/MainWindowSize'; +import { normalizeMainWindowSize } from '../utils/mainWindowState'; let settingsQueue: Promise = Promise.resolve(); let torrentMaxOpenFilesQueue: Promise = Promise.resolve(); let torrentOverallUploadLimitQueue: Promise = Promise.resolve(); let pairingTokenHydrationRequest: Promise | null = null; +let shouldPersistLegacyFoldersFallback = false; const settingsPersistenceErrorListeners = new Set<() => void>(); let settingsPersistenceFailed = false; const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001'; +const LEGACY_FOLDERS_COLLAPSED_KEY = 'firelink-folders-collapsed'; export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10]; +const readLegacyFoldersCollapsed = (): boolean | undefined => { + if (typeof window === 'undefined') return undefined; + try { + const value = window.localStorage.getItem(LEGACY_FOLDERS_COLLAPSED_KEY); + return value === null ? undefined : value === 'true'; + } catch { + return undefined; + } +}; + +const initialFoldersCollapsed = readLegacyFoldersCollapsed() ?? false; + export const subscribeToSettingsPersistenceErrors = (listener: () => void): (() => void) => { settingsPersistenceErrorListeners.add(listener); if (settingsPersistenceFailed) listener(); @@ -73,6 +89,8 @@ export const runSettingsPersistenceTransaction = ( operation: () => Promise ): Promise => enqueueSettingsTask(operation); +export const waitForSettingsPersistence = (): Promise => settingsQueue; + const notifySettingsPersistenceError = () => { if (settingsPersistenceFailed) return; settingsPersistenceFailed = true; @@ -220,7 +238,9 @@ export interface SettingsState { speedLimitPresetValues: number[]; logsEnabled: boolean; isSidebarVisible: boolean; + isFoldersCollapsed: boolean; sidebarPosition: SidebarPosition; + mainWindowSize: MainWindowSize | null; activeView: ActiveView; activeSettingsTab: SettingsTab; scheduler: SchedulerSettings; @@ -297,6 +317,9 @@ export interface SettingsState { setSpeedLimitPresetValues: (values: number[]) => void; setLogsEnabled: (enabled: boolean) => void; setSidebarPosition: (position: SidebarPosition) => void; + setFoldersCollapsed: (collapsed: boolean) => void; + toggleFoldersCollapsed: () => void; + setMainWindowSize: (size: MainWindowSize) => void; setActiveView: (view: ActiveView) => void; setActiveSettingsTab: (tab: SettingsTab) => void; setScheduler: (settings: SchedulerSettings) => void; @@ -388,6 +411,8 @@ export const useSettingsStore = create()( activeView: 'downloads', activeSettingsTab: 'downloads', isSidebarVisible: true, + isFoldersCollapsed: initialFoldersCollapsed, + mainWindowSize: null, sidebarPosition: 'auto', scheduler: { enabled: false, @@ -518,6 +543,12 @@ export const useSettingsStore = create()( setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }), setLogsEnabled: (logsEnabled) => set({ logsEnabled }), setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }), + setFoldersCollapsed: (isFoldersCollapsed) => set({ isFoldersCollapsed }), + toggleFoldersCollapsed: () => set(state => ({ isFoldersCollapsed: !state.isFoldersCollapsed })), + setMainWindowSize: (size) => { + const normalized = normalizeMainWindowSize(size); + if (normalized) set({ mainWindowSize: normalized }); + }, setActiveView: (view) => set({ activeView: view }), setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }), setScheduler: (scheduler) => set({ scheduler }), @@ -751,6 +782,15 @@ export const useSettingsStore = create()( logsEnabled: persisted.logsEnabled === true } as SettingsState; }, + onRehydrateStorage: () => (state, error) => { + if (error || !state) { + shouldPersistLegacyFoldersFallback = false; + return; + } + if (!shouldPersistLegacyFoldersFallback) return; + shouldPersistLegacyFoldersFallback = false; + state.setFoldersCollapsed(state.isFoldersCollapsed); + }, partialize: (state): PersistedSettingsSnapshot => ({ theme: state.theme, fontFamily: state.fontFamily, @@ -769,6 +809,8 @@ export const useSettingsStore = create()( speedLimitPresetValues: state.speedLimitPresetValues, logsEnabled: state.logsEnabled, isSidebarVisible: state.isSidebarVisible, + isFoldersCollapsed: state.isFoldersCollapsed, + mainWindowSize: state.mainWindowSize ?? undefined, sidebarPosition: state.sidebarPosition, activeSettingsTab: state.activeSettingsTab, scheduler: state.scheduler, @@ -829,6 +871,13 @@ export const useSettingsStore = create()( const persisted = persistedState && typeof persistedState === 'object' ? persistedState as Partial : {}; + shouldPersistLegacyFoldersFallback = false; + const legacyFoldersCollapsed = readLegacyFoldersCollapsed(); + if (typeof persisted.isFoldersCollapsed !== 'boolean' && legacyFoldersCollapsed !== undefined) { + shouldPersistLegacyFoldersFallback = true; + } + const foldersCollapsedFallback = legacyFoldersCollapsed + ?? currentState.isFoldersCollapsed; const locations = normalizeDownloadLocationSettings(persisted); return ({ ...currentState, @@ -853,6 +902,12 @@ export const useSettingsStore = create()( language: isAppLocalePreference(persisted.language) ? persisted.language : currentState.language, + isFoldersCollapsed: persistedBoolean( + persisted.isFoldersCollapsed, + foldersCollapsedFallback + ), + mainWindowSize: normalizeMainWindowSize(persisted.mainWindowSize) + ?? currentState.mainWindowSize, appFontSize: isAllowedSetting(APP_FONT_SIZE_VALUES, persisted.appFontSize) ? persisted.appFontSize : currentState.appFontSize, diff --git a/src/utils/mainWindowState.test.ts b/src/utils/mainWindowState.test.ts new file mode 100644 index 0000000..aae512a --- /dev/null +++ b/src/utils/mainWindowState.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createMainWindowSizePersistence, + normalizeMainWindowSize, + physicalToLogicalSize, + type MainWindowEventSource +} from './mainWindowState'; + +const createWindowMock = () => { + let resized: ((event: { payload: { width: number; height: number } }) => void) | undefined; + let scaleChanged: ((event: { + payload: { scaleFactor: number; size: { width: number; height: number } } + }) => void) | undefined; + const appWindow: MainWindowEventSource = { + onResized: vi.fn(async handler => { + resized = handler; + return vi.fn(); + }), + onScaleChanged: vi.fn(async handler => { + scaleChanged = handler; + return vi.fn(); + }), + scaleFactor: vi.fn(async () => 2) + }; + return { + appWindow, + resize: (width: number, height: number) => resized?.({ payload: { width, height } }), + scale: (scaleFactor: number, width: number, height: number) => + scaleChanged?.({ payload: { scaleFactor, size: { width, height } } }) + }; +}; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('main window geometry', () => { + it('converts physical event sizes to bounded logical pixels', () => { + expect(physicalToLogicalSize(2560, 1600, 2)).toEqual({ width: 1280, height: 800 }); + expect(normalizeMainWindowSize({ width: 959, height: 800 })).toBeNull(); + expect(normalizeMainWindowSize({ width: 1280, height: 16_385 })).toBeNull(); + expect(normalizeMainWindowSize({ width: 1280, height: 800 })).toEqual({ width: 1280, height: 800 }); + }); + + it('debounces resize persistence to the latest logical size', async () => { + vi.useFakeTimers(); + const mock = createWindowMock(); + const onSize = vi.fn(); + const controller = createMainWindowSizePersistence({ appWindow: mock.appWindow, onSize }); + + mock.resize(2560, 1600); + await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(1)); + mock.resize(2800, 1800); + await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(2)); + await vi.runAllTicks(); + expect(onSize).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(249); + expect(onSize).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 }); + controller.dispose(); + }); + + it('flushes a pending scale read and ignores a stale result', async () => { + let resolveScale!: (scale: number) => void; + const mock = createWindowMock(); + vi.mocked(mock.appWindow.scaleFactor).mockImplementationOnce( + () => new Promise(resolve => { resolveScale = resolve; }) + ); + const onSize = vi.fn(); + const controller = createMainWindowSizePersistence({ + appWindow: mock.appWindow, + onSize, + debounceMs: 10_000 + }); + + mock.resize(2560, 1600); + mock.scale(1, 1400, 900); + const flush = controller.flush(); + resolveScale(2); + await flush; + + expect(onSize).toHaveBeenCalledTimes(1); + expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 }); + controller.dispose(); + }); + + it('waits for scale reads started while a flush is already in progress', async () => { + let resolveFirst!: (scale: number) => void; + let resolveSecond!: (scale: number) => void; + const mock = createWindowMock(); + vi.mocked(mock.appWindow.scaleFactor) + .mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; })) + .mockImplementationOnce(() => new Promise(resolve => { resolveSecond = resolve; })); + const onSize = vi.fn(); + const controller = createMainWindowSizePersistence({ + appWindow: mock.appWindow, + onSize, + debounceMs: 10_000 + }); + + mock.resize(2560, 1600); + const flush = controller.flush(); + await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(1)); + + mock.resize(2800, 1800); + await vi.waitFor(() => expect(mock.appWindow.scaleFactor).toHaveBeenCalledTimes(2)); + resolveFirst(2); + await Promise.resolve(); + expect(onSize).not.toHaveBeenCalled(); + + resolveSecond(2); + await flush; + + expect(onSize).toHaveBeenCalledTimes(1); + expect(onSize).toHaveBeenCalledWith({ width: 1400, height: 900 }); + controller.dispose(); + }); + + it('cleans up listeners when one registration fails', async () => { + const unlistenResize = vi.fn(); + const onResized = vi.fn(async () => unlistenResize); + const onScaleChanged = vi.fn(async () => { + throw new Error('scale listener unavailable'); + }); + const appWindow: MainWindowEventSource = { + onResized, + onScaleChanged, + scaleFactor: vi.fn(async () => 2) + }; + const controller = createMainWindowSizePersistence({ appWindow, onSize: vi.fn() }); + + await vi.waitFor(() => expect(unlistenResize).toHaveBeenCalledTimes(1)); + controller.dispose(); + expect(unlistenResize).toHaveBeenCalledTimes(1); + }); + + it('ignores a synchronous scale-factor failure without breaking resize handling', () => { + const mock = createWindowMock(); + vi.mocked(mock.appWindow.scaleFactor).mockImplementationOnce(() => { + throw new Error('scale factor unavailable'); + }); + const onSize = vi.fn(); + const controller = createMainWindowSizePersistence({ appWindow: mock.appWindow, onSize }); + + expect(() => mock.resize(2560, 1600)).not.toThrow(); + expect(onSize).not.toHaveBeenCalled(); + controller.dispose(); + }); +}); diff --git a/src/utils/mainWindowState.ts b/src/utils/mainWindowState.ts new file mode 100644 index 0000000..fff7ddf --- /dev/null +++ b/src/utils/mainWindowState.ts @@ -0,0 +1,176 @@ +import type { MainWindowSize } from '../bindings/MainWindowSize'; + +export type { MainWindowSize } from '../bindings/MainWindowSize'; + +export const MAIN_WINDOW_DEFAULT_WIDTH = 1280; +export const MAIN_WINDOW_DEFAULT_HEIGHT = 800; +export const MAIN_WINDOW_MIN_WIDTH = 960; +export const MAIN_WINDOW_MIN_HEIGHT = 640; +export const MAIN_WINDOW_MAX_WIDTH = 16_384; +export const MAIN_WINDOW_MAX_HEIGHT = 16_384; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +export const normalizeMainWindowSize = (value: unknown): MainWindowSize | null => { + if (!isRecord(value)) return null; + const { width, height } = value; + if ( + typeof width !== 'number' + || typeof height !== 'number' + || !Number.isInteger(width) + || !Number.isInteger(height) + || width < MAIN_WINDOW_MIN_WIDTH + || height < MAIN_WINDOW_MIN_HEIGHT + || width > MAIN_WINDOW_MAX_WIDTH + || height > MAIN_WINDOW_MAX_HEIGHT + ) { + return null; + } + return { width, height }; +}; + +export const physicalToLogicalSize = ( + width: number, + height: number, + scaleFactor: number +): MainWindowSize | null => { + if (!Number.isFinite(scaleFactor) || scaleFactor <= 0) return null; + return normalizeMainWindowSize({ + width: Math.round(width / scaleFactor), + height: Math.round(height / scaleFactor) + }); +}; + +type PhysicalSize = { width: number; height: number }; +type EventPayload = { payload: T }; + +export interface MainWindowEventSource { + onResized: (handler: (event: EventPayload) => void) => Promise<() => void>; + onScaleChanged: ( + handler: (event: EventPayload<{ scaleFactor: number; size: PhysicalSize }>) => void + ) => Promise<() => void>; + scaleFactor: () => Promise; +} + +export interface MainWindowSizePersistence { + flush: () => Promise; + dispose: () => void; +} + +export const createMainWindowSizePersistence = ({ + appWindow, + onSize, + debounceMs = 250 +}: { + appWindow: MainWindowEventSource; + onSize: (size: MainWindowSize) => void; + debounceMs?: number; +}): MainWindowSizePersistence => { + let disposed = false; + let generation = 0; + let pendingSize: MainWindowSize | null = null; + let timer: ReturnType | null = null; + const pendingScaleReads = new Set>(); + + const clearTimer = () => { + if (timer === null) return; + clearTimeout(timer); + timer = null; + }; + + const commit = () => { + if (disposed) return; + clearTimer(); + const size = pendingSize; + pendingSize = null; + if (size) onSize(size); + }; + + const scheduleCommit = () => { + clearTimer(); + timer = setTimeout(commit, debounceMs); + }; + + const record = (size: PhysicalSize, scaleFactor: number, eventGeneration: number) => { + if (disposed || eventGeneration !== generation) return; + const logicalSize = physicalToLogicalSize(size.width, size.height, scaleFactor); + if (!logicalSize) return; + pendingSize = logicalSize; + scheduleCommit(); + }; + + const recordResized = (size: PhysicalSize) => { + const eventGeneration = ++generation; + let scaleFactorRead: Promise; + try { + scaleFactorRead = appWindow.scaleFactor(); + } catch { + return; + } + const read = scaleFactorRead + .then(scaleFactor => record(size, scaleFactor, eventGeneration)) + .catch(() => undefined); + pendingScaleReads.add(read); + void read.then( + () => pendingScaleReads.delete(read), + () => pendingScaleReads.delete(read) + ); + }; + + const recordScaleChanged = ({ scaleFactor, size }: { scaleFactor: number; size: PhysicalSize }) => { + const eventGeneration = ++generation; + record(size, scaleFactor, eventGeneration); + }; + + const registerListener = (register: () => Promise): Promise => { + try { + return Promise.resolve(register()); + } catch (error) { + return Promise.reject(error); + } + }; + const listenersReady = Promise.allSettled([ + registerListener(() => appWindow.onResized(({ payload }) => recordResized(payload))), + registerListener(() => appWindow.onScaleChanged(({ payload }) => recordScaleChanged(payload))) + ]); + let listenerDisposers: (() => void)[] | null = null; + let listenersDisposed = false; + const disposeListeners = (disposers: (() => void)[]) => { + if (listenersDisposed) return; + listenersDisposed = true; + disposers.forEach(dispose => { + try { + dispose(); + } catch { + // Listener teardown is best-effort; continue cleaning up the rest. + } + }); + }; + void listenersReady.then(results => { + const disposers = results + .filter((result): result is PromiseFulfilledResult<() => void> => result.status === 'fulfilled') + .map(result => result.value); + const hasRegistrationFailure = results.some(result => result.status === 'rejected'); + if (disposed || hasRegistrationFailure) disposeListeners(disposers); + else listenerDisposers = disposers; + }); + + return { + flush: async () => { + while (pendingScaleReads.size > 0) { + await Promise.all([...pendingScaleReads]); + } + commit(); + }, + dispose: () => { + if (disposed) return; + disposed = true; + generation += 1; + clearTimer(); + pendingSize = null; + if (listenerDisposers) disposeListeners(listenerDisposers); + listenerDisposers = null; + } + }; +}; diff --git a/src/utils/propertiesPresentation.test.ts b/src/utils/propertiesPresentation.test.ts new file mode 100644 index 0000000..dc105f7 --- /dev/null +++ b/src/utils/propertiesPresentation.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { getPropertiesConnectionPresentation } from './propertiesPresentation'; + +describe('Properties connection presentation', () => { + it('keeps media concurrency out of the live header metrics', () => { + expect(getPropertiesConnectionPresentation({ + isMedia: true, + isTorrent: false, + connections: 16, + activeConnections: 8, + requestedConnections: 16, + })).toEqual({ + kind: 'media', + showHeaderMetric: false, + labelKey: 'fragmentConcurrency', + value: '16', + }); + }); + + it('keeps Aria2 active and requested connections together', () => { + expect(getPropertiesConnectionPresentation({ + isMedia: false, + isTorrent: false, + connections: 8, + activeConnections: 3, + requestedConnections: 8, + })).toEqual({ + kind: 'aria2', + showHeaderMetric: true, + labelKey: 'connections', + value: '3 / 8', + }); + }); + + it('uses connected peers for Torrents', () => { + expect(getPropertiesConnectionPresentation({ + isMedia: false, + isTorrent: true, + connectedPeers: 4, + })).toEqual({ + kind: 'torrent', + showHeaderMetric: true, + labelKey: 'torrentConnectedPeers', + value: '4', + }); + }); +}); diff --git a/src/utils/propertiesPresentation.ts b/src/utils/propertiesPresentation.ts new file mode 100644 index 0000000..7436527 --- /dev/null +++ b/src/utils/propertiesPresentation.ts @@ -0,0 +1,42 @@ +import type { PropertiesSnapshot } from '../propertiesBridge'; + +export type PropertiesConnectionKind = 'media' | 'torrent' | 'aria2'; +export type PropertiesConnectionLabelKey = 'fragmentConcurrency' | 'torrentConnectedPeers' | 'connections'; + +export type PropertiesConnectionPresentation = { + kind: PropertiesConnectionKind; + showHeaderMetric: boolean; + labelKey: PropertiesConnectionLabelKey; + value: string; +}; + +const displayCount = (value: number | undefined): string => value == null ? '—' : String(value); + +export const getPropertiesConnectionPresentation = ( + snapshot: Pick, +): PropertiesConnectionPresentation => { + if (snapshot.isMedia === true) { + return { + kind: 'media', + showHeaderMetric: false, + labelKey: 'fragmentConcurrency', + value: displayCount(snapshot.connections), + }; + } + + if (snapshot.isTorrent === true) { + return { + kind: 'torrent', + showHeaderMetric: true, + labelKey: 'torrentConnectedPeers', + value: displayCount(snapshot.connectedPeers), + }; + } + + return { + kind: 'aria2', + showHeaderMetric: true, + labelKey: 'connections', + value: `${displayCount(snapshot.activeConnections)} / ${displayCount(snapshot.requestedConnections ?? snapshot.connections)}`, + }; +}; diff --git a/src/utils/windowConfiguration.test.ts b/src/utils/windowConfiguration.test.ts new file mode 100644 index 0000000..2a3bce9 --- /dev/null +++ b/src/utils/windowConfiguration.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import baseConfiguration from '../../src-tauri/tauri.conf.json'; +import macosConfiguration from '../../src-tauri/tauri.macos.conf.json'; +import windowsConfiguration from '../../src-tauri/tauri.windows.conf.json'; +import linuxConfiguration from '../../src-tauri/tauri.linux.conf.json'; + +const configurations = [ + ['base', baseConfiguration], + ['macOS', macosConfiguration], + ['Windows', windowsConfiguration], + ['Linux', linuxConfiguration] +] as const; + +describe('main window configuration', () => { + it('uses the content-appropriate first-run size on every platform', () => { + for (const [platform, config] of configurations) { + const mainWindow = config.app.windows[0]; + expect(mainWindow, platform).toMatchObject({ + width: 1280, + height: 800, + minWidth: 960, + minHeight: 640 + }); + } + }); +});