mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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}"))?;
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": false,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"create": false,
|
||||
"title": "Firelink",
|
||||
"width": 1280,
|
||||
"height": 760,
|
||||
"height": 800,
|
||||
"minWidth": 960,
|
||||
"minHeight": 640,
|
||||
"transparent": true,
|
||||
|
||||
+40
-1
@@ -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]);
|
||||
|
||||
@@ -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, };
|
||||
@@ -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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, torrentOverallUploadLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, isFoldersCollapsed: boolean, mainWindowSize?: MainWindowSize, sidebarPosition: string, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -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 = () => {
|
||||
<div className="properties-metric-card"><Download size={14} /><div><span>{t($ => $.properties.size)}</span><strong>{formatDownloadBytes(snapshot.downloadedBytes ?? 0)} / {total}</strong></div></div>
|
||||
<div className="properties-metric-card"><Gauge size={14} /><div><span>{t($ => $.properties.speed)}</span><strong>{snapshot.speed || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Timer size={14} /><div><span>{t($ => $.properties.eta)}</span><strong>{snapshot.eta || '—'}</strong></div></div>
|
||||
<div className="properties-metric-card"><Users size={14} /><div><span>{isTorrent ? t($ => $.properties.torrentConnectedPeers) : t($ => $.properties.connections)}</span><strong>{connectionMetric}</strong></div></div>
|
||||
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div><span>{connectionLabel}</span><strong>{connectionPresentation.value}</strong></div></div>}
|
||||
{isTorrent && <>
|
||||
<div className="properties-metric-card"><Upload size={14} /><div><span>{t($ => $.properties.torrentUploaded)}</span><strong>{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
|
||||
<div className="properties-metric-card"><Activity size={14} /><div><span>{t($ => $.properties.torrentRatio)}</span><strong>{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
|
||||
@@ -1299,7 +1301,7 @@ export const PropertiesWindowApp = () => {
|
||||
{snapshot.isMedia === true && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<span className="text-text-muted">{t($ => $.addDownloads.format)}</span><span className="break-all font-mono">{snapshot.mediaFormatSelector || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.addDownloads.quality)}</span><span>{snapshot.mediaQuality || '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.configuredConcurrency)}</span><span>{snapshot.connections ?? '—'}</span>
|
||||
<span className="text-text-muted">{t($ => $.properties.fragmentConcurrency)}</span><span>{snapshot.connections ?? '—'}</span>
|
||||
</div>}
|
||||
{isTorrent && details && <div className="grid gap-2 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<span className="text-text-muted">{t($ => $.properties.torrentDetailsDisplayName)}</span><span>{details.displayName || '—'}</span>
|
||||
@@ -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) })}
|
||||
>
|
||||
<input id="properties-transfer-speed-cap" className="app-control w-full" value={downloadLimit} onChange={event => { setDownloadLimit(event.target.value); setDraftTab('transfer'); }} placeholder={t($ => $.properties.inputExampleSpeedLimit)} disabled={!editingEnabled} />
|
||||
</PropertiesField>
|
||||
<label className="block max-w-2xl text-xs text-text-muted">{snapshot.isMedia === true ? t($ => $.properties.configuredConcurrency) : t($ => $.properties.connections)}<div className="mt-2 flex items-center gap-3"><input type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={t($ => $.properties.connections)} /><span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span></div></label>
|
||||
<PropertiesField
|
||||
label={connectionLabel}
|
||||
controlId="properties-transfer-concurrency"
|
||||
hint={snapshot.isMedia === true ? t($ => $.properties.fragmentConcurrencyHint) : undefined}
|
||||
className="max-w-md"
|
||||
>
|
||||
<div className="mt-2 flex items-center gap-3" dir="ltr">
|
||||
<input id="properties-transfer-concurrency" type="range" min="1" max="16" value={connections || '1'} onChange={event => { setConnections(event.target.value); setDraftTab('transfer'); }} disabled={!editingEnabled} className="min-w-0 flex-1 accent-blue-500" aria-label={connectionLabel} />
|
||||
<span className="w-8 text-center font-mono text-text-primary">{connections || '1'}</span>
|
||||
</div>
|
||||
</PropertiesField>
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.transferSettings)}</p>
|
||||
</div>}
|
||||
|
||||
@@ -1539,7 +1551,7 @@ export const PropertiesWindowApp = () => {
|
||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<div><span className="text-text-muted">{t($ => $.properties.connections)}</span><p className="mt-1">{snapshot.isMedia === true ? `${snapshot.connections ?? '—'} ${t($ => $.properties.configuredConcurrency)}` : `${snapshot.activeConnections ?? '—'} / ${snapshot.requestedConnections ?? snapshot.connections ?? '—'}`}</p></div>
|
||||
<div><span className="text-text-muted">{connectionLabel}</span><p className="mt-1">{connectionPresentation.value}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.speedCap)}</span><p className="mt-1">{snapshot.speedLimit || '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.username)}</span><p className="mt-1">{snapshot.hasUsername ? '✓' : '—'}</p></div>
|
||||
<div><span className="text-text-muted">{t($ => $.properties.password)}</span><p className="mt-1">{snapshot.hasPassword ? '✓' : '—'}</p></div>
|
||||
|
||||
@@ -25,7 +25,13 @@ interface SidebarProps {
|
||||
export const Sidebar: React.FC<SidebarProps> = (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<SidebarProps> = (props) => {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(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<HTMLButtonElement>(null);
|
||||
const foldersListRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -114,10 +117,6 @@ export const Sidebar: React.FC<SidebarProps> = (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<SidebarProps> = (props) => {
|
||||
if (foldersListRef.current?.contains(document.activeElement)) {
|
||||
foldersToggleRef.current?.focus();
|
||||
}
|
||||
setFoldersCollapsed(collapsed => !collapsed);
|
||||
toggleFoldersCollapsed();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -228,7 +228,8 @@ const fa = {
|
||||
speed: 'سرعت',
|
||||
eta: 'زمان باقیمانده',
|
||||
connections: 'اتصالات',
|
||||
configuredConcurrency: 'همزمانی پیکربندیشده',
|
||||
fragmentConcurrency: 'همزمانی قطعهها',
|
||||
fragmentConcurrencyHint: 'حداکثر تعداد قطعههای رسانهای که yt-dlp میتواند همزمان پردازش کند. Firelink تعداد قطعههای فعال را بهصورت زنده گزارش نمیکند؛ این مقدار هنگام شروع یا ازسرگیری انتقال استفاده میشود.',
|
||||
connectedPeers: 'همتای متصل',
|
||||
details: 'جزئیات',
|
||||
tabs: {
|
||||
|
||||
@@ -228,7 +228,8 @@ const he = {
|
||||
speed: 'מהירות',
|
||||
eta: 'זמן נותר',
|
||||
connections: 'חיבורים',
|
||||
configuredConcurrency: 'מקביליות מוגדרת',
|
||||
fragmentConcurrency: 'מקביליות מקטעים',
|
||||
fragmentConcurrencyHint: 'מספר מקטעי המדיה המרבי ש-yt-dlp יכול לעבד במקביל. Firelink אינו מדווח על מספר המקטעים הפעילים בזמן אמת; זהו הערך המוגדר שבו נעשה שימוש כשההעברה מתחילה או מתחדשת.',
|
||||
connectedPeers: 'עמיתים מחוברים',
|
||||
details: 'פרטים',
|
||||
tabs: {
|
||||
|
||||
@@ -228,7 +228,8 @@ const ru = {
|
||||
speed: 'Скорость',
|
||||
eta: 'Осталось',
|
||||
connections: 'Соединения',
|
||||
configuredConcurrency: 'Настроенная параллельность',
|
||||
fragmentConcurrency: 'Параллельность фрагментов',
|
||||
fragmentConcurrencyHint: 'Максимальное число медиафрагментов, которые yt-dlp может обрабатывать одновременно. Firelink не сообщает текущее число активных фрагментов; это настроенное значение используется при запуске или возобновлении передачи.',
|
||||
connectedPeers: 'подключённых пиров',
|
||||
details: 'Подробности',
|
||||
tabs: {
|
||||
|
||||
@@ -228,7 +228,8 @@ const uk = {
|
||||
speed: 'Швидкість',
|
||||
eta: 'Залишилось',
|
||||
connections: 'З\'єднання',
|
||||
configuredConcurrency: 'Налаштована паралельність',
|
||||
fragmentConcurrency: 'Паралельність фрагментів',
|
||||
fragmentConcurrencyHint: 'Максимальна кількість медіафрагментів, які yt-dlp може обробляти одночасно. Firelink не повідомляє поточну кількість активних фрагментів; це налаштоване значення використовується під час запуску або відновлення передачі.',
|
||||
connectedPeers: 'підключених пірів',
|
||||
details: 'Деталі',
|
||||
tabs: {
|
||||
|
||||
@@ -228,7 +228,8 @@ const zhCN = {
|
||||
speed: '速度',
|
||||
eta: '剩余时间',
|
||||
connections: '连接数',
|
||||
configuredConcurrency: '已配置并发数',
|
||||
fragmentConcurrency: '分片并发数',
|
||||
fragmentConcurrencyHint: 'yt-dlp 可同时处理的媒体分片最大数量。Firelink 不会报告实时活动分片数量;此配置值会在传输开始或恢复时使用。',
|
||||
connectedPeers: '已连接对等端',
|
||||
details: '详细信息',
|
||||
tabs: {
|
||||
|
||||
@@ -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',
|
||||
|
||||
+10
-5
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<void> = Promise.resolve();
|
||||
let torrentMaxOpenFilesQueue: Promise<void> = Promise.resolve();
|
||||
let torrentOverallUploadLimitQueue: Promise<void> = Promise.resolve();
|
||||
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | 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 = <T>(
|
||||
operation: () => Promise<T>
|
||||
): Promise<T> => enqueueSettingsTask(operation);
|
||||
|
||||
export const waitForSettingsPersistence = (): Promise<void> => 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<SettingsState>()(
|
||||
activeView: 'downloads',
|
||||
activeSettingsTab: 'downloads',
|
||||
isSidebarVisible: true,
|
||||
isFoldersCollapsed: initialFoldersCollapsed,
|
||||
mainWindowSize: null,
|
||||
sidebarPosition: 'auto',
|
||||
scheduler: {
|
||||
enabled: false,
|
||||
@@ -518,6 +543,12 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
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<SettingsState>()(
|
||||
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<SettingsState>()(
|
||||
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<SettingsState>()(
|
||||
const persisted = persistedState && typeof persistedState === 'object'
|
||||
? persistedState as Partial<SettingsState>
|
||||
: {};
|
||||
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<SettingsState>()(
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> =>
|
||||
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<T> = { payload: T };
|
||||
|
||||
export interface MainWindowEventSource {
|
||||
onResized: (handler: (event: EventPayload<PhysicalSize>) => void) => Promise<() => void>;
|
||||
onScaleChanged: (
|
||||
handler: (event: EventPayload<{ scaleFactor: number; size: PhysicalSize }>) => void
|
||||
) => Promise<() => void>;
|
||||
scaleFactor: () => Promise<number>;
|
||||
}
|
||||
|
||||
export interface MainWindowSizePersistence {
|
||||
flush: () => Promise<void>;
|
||||
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<typeof setTimeout> | null = null;
|
||||
const pendingScaleReads = new Set<Promise<void>>();
|
||||
|
||||
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<number>;
|
||||
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 = <T>(register: () => Promise<T>): Promise<T> => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<PropertiesSnapshot, 'isMedia' | 'isTorrent' | 'connections' | 'activeConnections' | 'requestedConnections' | 'connectedPeers'>,
|
||||
): 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)}`,
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user