diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index f0ade5f..ef562e6 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -2,6 +2,10 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use ts_rs::TS; +fn default_speed_limit_unit() -> String { + "MB/s".to_string() +} + #[derive(Clone, Copy, Debug, Serialize, Deserialize, TS)] #[serde(rename_all = "lowercase")] #[ts(export, export_to = "../../src/bindings/")] @@ -264,6 +268,8 @@ pub struct PersistedSettings { pub scheduler_last_start_key: String, pub scheduler_last_stop_key: String, pub last_custom_speed_limit_ki_b: u32, + #[serde(default = "default_speed_limit_unit")] + pub last_custom_speed_limit_unit: String, pub per_server_connections: i32, pub max_automatic_retries: i32, pub show_notifications: bool, diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 8930826..27fd21f 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -266,6 +266,12 @@ fn validate_settings(settings: &mut PersistedSettings) { settings.max_concurrent_downloads = settings.max_concurrent_downloads.min(12); settings.per_server_connections = settings.per_server_connections.clamp(1, 16); settings.max_automatic_retries = settings.max_automatic_retries.clamp(0, 10); + if !matches!( + settings.last_custom_speed_limit_unit.as_str(), + "KB/s" | "MB/s" + ) { + settings.last_custom_speed_limit_unit = default_settings().last_custom_speed_limit_unit; + } } fn default_category_subfolders() -> HashMap { @@ -430,6 +436,7 @@ fn default_settings() -> PersistedSettings { scheduler_last_start_key: String::new(), scheduler_last_stop_key: String::new(), last_custom_speed_limit_ki_b: 1024, + last_custom_speed_limit_unit: "MB/s".to_string(), per_server_connections: 16, max_automatic_retries: 3, show_notifications: true, @@ -532,6 +539,7 @@ mod tests { assert_eq!(settings.max_concurrent_downloads, 5); assert_eq!(settings.global_speed_limit, "512K"); + assert_eq!(settings.last_custom_speed_limit_unit, "MB/s"); assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]); assert!(!settings.logs_enabled); assert!(!settings.scheduler.enabled); diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 095e12d..5595e67 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/components/SpeedLimiterView.test.ts b/src/components/SpeedLimiterView.test.ts index e4a7cc8..2aac2d9 100644 --- a/src/components/SpeedLimiterView.test.ts +++ b/src/components/SpeedLimiterView.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import { convertSpeedValue, + clampSpeedDisplayValue, displayValueFromPresetBase, + formatSpeedLimitForStorage, + formatPresetValue, + parseLimit, presetBaseFromDisplayValue, } from './SpeedLimiterView'; @@ -20,4 +24,33 @@ describe('SpeedLimiterView speed conversions', () => { expect(convertSpeedValue(1500, 'KB/s', 'MB/s')).toBe(1500 / 1024); expect(convertSpeedValue(convertSpeedValue(1500, 'KB/s', 'MB/s'), 'MB/s', 'KB/s')).toBe(1500); }); + + it('preserves the selected unit when saving a non-MiB-aligned MB/s value', () => { + const storedLimit = formatSpeedLimitForStorage(1.5, 'MB/s'); + + expect(storedLimit).toBe('1.5M'); + expect(parseLimit(storedLimit, 1024)).toEqual({ value: 1.5, unit: 'MB/s' }); + }); + + it('preserves an explicitly selected KB/s unit at MiB boundaries', () => { + const storedLimit = formatSpeedLimitForStorage(1024, 'KB/s'); + + expect(storedLimit).toBe('1024K'); + expect(parseLimit(storedLimit, 1)).toEqual({ value: 1024, unit: 'KB/s' }); + }); + + it('restores the persisted unit while the limiter is disabled', () => { + expect(parseLimit('', 1536, 'MB/s')).toEqual({ value: 1.5, unit: 'MB/s' }); + expect(parseLimit('', 1536, 'KB/s')).toEqual({ value: 1536, unit: 'KB/s' }); + }); + + it('allows sub-one MB/s values while enforcing the 1 KiB backend minimum', () => { + expect(clampSpeedDisplayValue(0.5, 'MB/s')).toBe(0.5); + expect(clampSpeedDisplayValue(0, 'MB/s')).toBe(1 / 1024); + expect(clampSpeedDisplayValue(0, 'KB/s')).toBe(1); + }); + + it('shows the exact stored preset value instead of a rounded label', () => { + expect(formatPresetValue(1.46484375)).toBe('1.46484375'); + }); }); diff --git a/src/components/SpeedLimiterView.tsx b/src/components/SpeedLimiterView.tsx index 9d81b23..70aba90 100644 --- a/src/components/SpeedLimiterView.tsx +++ b/src/components/SpeedLimiterView.tsx @@ -25,17 +25,48 @@ export function convertSpeedValue(value: number, fromUnit: SpeedUnit, toUnit: Sp return speedValueFromKiB(speedValueToKiB(value, fromUnit), toUnit); } -export function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } { +export function parseLimit( + limit: string, + fallback: number, + fallbackUnit: SpeedUnit = 'MB/s' +): { value: number; unit: SpeedUnit } { const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i); + const suffix = match?.[2].toLowerCase(); const valueKiB = match - ? speedValueToKiB(Number(match[1]) * (match[2].toLowerCase() === 'm' ? KIB_PER_MIB : 1), 'KB/s') + ? speedValueToKiB(Number(match[1]) * (suffix === 'm' ? KIB_PER_MIB : 1), 'KB/s') : speedValueToKiB(fallback, 'KB/s'); + if (!match) { + return { value: speedValueFromKiB(valueKiB, fallbackUnit), unit: fallbackUnit }; + } + + if (suffix === 'm') { + return { value: speedValueFromKiB(valueKiB, 'MB/s'), unit: 'MB/s' }; + } + + if (suffix === 'k') { + return { value: valueKiB, unit: 'KB/s' }; + } + return valueKiB >= 1024 && valueKiB % 1024 === 0 ? { value: valueKiB / 1024, unit: 'MB/s' } : { value: valueKiB, unit: 'KB/s' }; } +export function formatSpeedLimitForStorage(value: number, unit: SpeedUnit): string { + const valueKiB = speedValueToKiB(value, unit); + return unit === 'MB/s' + ? `${speedValueFromKiB(valueKiB, 'MB/s')}M` + : `${valueKiB}K`; +} + +export function clampSpeedDisplayValue(value: number, unit: SpeedUnit): number { + const numericValue = Number.isFinite(value) ? value : speedValueFromKiB(1, unit); + const minimum = speedValueFromKiB(1, unit); + const maximum = speedValueFromKiB(MAX_LIMIT_KIB, unit); + return Math.max(minimum, Math.min(maximum, numericValue)); +} + function sanitizePresetValues(values: number[]): number[] { const cleaned = values .map(value => Number(value)) @@ -53,22 +84,25 @@ export function displayValueFromPresetBase(value: number, unit: SpeedUnit): numb return speedValueFromKiB(speedValueToKiB(value, 'MB/s'), unit); } -function formatPresetValue(value: number): string { - return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, ''); +export function formatPresetValue(value: number): string { + return String(value); } export default function SpeedLimiterView() { const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB); + const lastCustomSpeedLimitUnit = useSettingsStore(state => state.lastCustomSpeedLimitUnit); const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues); const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit); const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB); + const setLastCustomSpeedLimitUnit = useSettingsStore(state => state.setLastCustomSpeedLimitUnit); const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues); - const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB); + const fallbackUnit: SpeedUnit = lastCustomSpeedLimitUnit === 'KB/s' ? 'KB/s' : 'MB/s'; + const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit); const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit)); - const [value, setValue] = useState(initial.value); + const [value, setValue] = useState(String(initial.value)); const [unit, setUnit] = useState(initial.unit); - const [customPresetValue, setCustomPresetValue] = useState(initial.value); + const [customPresetValue, setCustomPresetValue] = useState(String(initial.value)); const { addToast } = useToast(); const savingRef = useRef(false); const presetValues = useMemo( @@ -77,12 +111,12 @@ export default function SpeedLimiterView() { ); useEffect(() => { - const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB); + const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit); setEnabled(Boolean(globalSpeedLimit)); - setValue(parsed.value); + setValue(String(parsed.value)); setUnit(parsed.unit); - setCustomPresetValue(parsed.value); - }, [globalSpeedLimit, lastCustomSpeedLimitKiB]); + setCustomPresetValue(String(parsed.value)); + }, [globalSpeedLimit, lastCustomSpeedLimitKiB, fallbackUnit]); const [isSaving, setIsSaving] = useState(false); @@ -90,12 +124,13 @@ export default function SpeedLimiterView() { const save = async () => { if (savingRef.current) return; savingRef.current = true; - const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB)); + const numericValue = clampSpeedDisplayValue(Number(value), unit); const valueKiB = speedValueToKiB(numericValue, unit); setIsSaving(true); try { - await setGlobalSpeedLimit(enabled ? `${valueKiB}K` : ''); + await setGlobalSpeedLimit(enabled ? formatSpeedLimitForStorage(numericValue, unit) : ''); setLastCustomSpeedLimitKiB(valueKiB); + setLastCustomSpeedLimitUnit(unit); addToast({ message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled', variant: 'success' @@ -114,21 +149,22 @@ export default function SpeedLimiterView() { const preset = (presetValue: number) => { setEnabled(true); - setValue(displayValueFromPresetBase(presetValue, unit)); + setValue(String(displayValueFromPresetBase(presetValue, unit))); }; const applyCustomPreset = () => { - const numericValue = Math.max(1, Math.min(Number(customPresetValue) || 1, unit === 'MB/s' ? MAX_LIMIT_MB : MAX_LIMIT_KIB)); + const numericValue = clampSpeedDisplayValue(Number(customPresetValue), unit); const presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit)); const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]); const alreadyExists = nextPresets.length === presetValues.length; + const storedPresetDisplayValue = displayValueFromPresetBase(presetBaseValue, unit); setSpeedLimitPresetValues(nextPresets); setEnabled(true); - setValue(numericValue); + setValue(String(storedPresetDisplayValue)); addToast({ message: alreadyExists - ? `${formatPresetValue(numericValue)} ${unit} is already in quick presets` - : `Added ${formatPresetValue(numericValue)} ${unit} quick preset`, + ? `${formatPresetValue(storedPresetDisplayValue)} ${unit} is already in quick presets` + : `Added ${formatPresetValue(storedPresetDisplayValue)} ${unit} quick preset`, variant: alreadyExists ? 'info' : 'success' }); }; @@ -145,11 +181,15 @@ export default function SpeedLimiterView() { const changeUnit = (nextUnit: SpeedUnit) => { if (nextUnit === unit) return; - setValue(convertSpeedValue(value, unit, nextUnit)); - setCustomPresetValue(convertSpeedValue(customPresetValue, unit, nextUnit)); + setValue(String(convertSpeedValue(Number(value), unit, nextUnit))); + setCustomPresetValue(String(convertSpeedValue(Number(customPresetValue), unit, nextUnit))); setUnit(nextUnit); }; + const currentDisplayValue = Number.isFinite(Number(value)) && Number(value) > 0 + ? value + : String(speedValueFromKiB(1, unit)); + return (
@@ -172,7 +212,7 @@ export default function SpeedLimiterView() { - {enabled ? `${value} ${unit}` : 'Unlimited'} + {enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}