mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(settings): preserve speed limiter units
Persist the selected display unit for disabled limits, preserve in-progress fractional input, and keep exact KiB-backed preset values visible.\n\nNo linked issue.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<String, String> {
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, 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<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, 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<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SpeedUnit>(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 (
|
||||
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
|
||||
<WindowDragRegion />
|
||||
@@ -172,7 +212,7 @@ export default function SpeedLimiterView() {
|
||||
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
|
||||
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
|
||||
}`}>
|
||||
{enabled ? `${value} ${unit}` : 'Unlimited'}
|
||||
{enabled ? `${currentDisplayValue} ${unit}` : 'Unlimited'}
|
||||
</span>
|
||||
<button onClick={() => void save()} disabled={isSaving} className="app-button app-button-primary ml-auto px-3 text-[11px] disabled:opacity-50">
|
||||
<Save size={14} /> Save Limit
|
||||
@@ -191,11 +231,11 @@ export default function SpeedLimiterView() {
|
||||
<div className="mt-6 flex items-center gap-3">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={value}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
onChange={event => setValue(event.target.value)}
|
||||
className="app-control w-28 px-3 py-2 text-right font-mono"
|
||||
/>
|
||||
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
|
||||
@@ -251,11 +291,11 @@ export default function SpeedLimiterView() {
|
||||
<div className="ml-1 flex h-8 items-center gap-1.5 rounded-md border border-border-modal bg-bg-input px-2">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
min={speedValueFromKiB(1, unit)}
|
||||
step="any"
|
||||
value={customPresetValue}
|
||||
disabled={!enabled || isSaving}
|
||||
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
|
||||
onChange={event => setCustomPresetValue(event.target.value)}
|
||||
className="w-12 bg-transparent text-right font-mono text-[12px] text-text-primary outline-none disabled:opacity-50"
|
||||
aria-label={`Custom preset in ${unit}`}
|
||||
/>
|
||||
|
||||
@@ -135,6 +135,7 @@ export interface SettingsState {
|
||||
schedulerLastStartKey: string;
|
||||
schedulerLastStopKey: string;
|
||||
lastCustomSpeedLimitKiB: number;
|
||||
lastCustomSpeedLimitUnit: string;
|
||||
|
||||
// Replicated SwiftUI App Settings
|
||||
perServerConnections: number;
|
||||
@@ -175,6 +176,7 @@ export interface SettingsState {
|
||||
setSchedulerLastStartKey: (key: string) => void;
|
||||
setSchedulerLastStopKey: (key: string) => void;
|
||||
setLastCustomSpeedLimitKiB: (limit: number) => void;
|
||||
setLastCustomSpeedLimitUnit: (unit: string) => void;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
setPerServerConnections: (count: number) => void;
|
||||
@@ -236,6 +238,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: '',
|
||||
schedulerLastStopKey: '',
|
||||
lastCustomSpeedLimitKiB: 1024,
|
||||
lastCustomSpeedLimitUnit: 'MB/s',
|
||||
|
||||
// Replicated SwiftUI defaults
|
||||
perServerConnections: 16,
|
||||
@@ -298,6 +301,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
|
||||
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
|
||||
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
|
||||
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
|
||||
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
|
||||
|
||||
setPerServerConnections: (perServerConnections) => set({
|
||||
@@ -447,6 +451,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
schedulerLastStartKey: state.schedulerLastStartKey,
|
||||
schedulerLastStopKey: state.schedulerLastStopKey,
|
||||
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
|
||||
lastCustomSpeedLimitUnit: state.lastCustomSpeedLimitUnit,
|
||||
|
||||
perServerConnections: state.perServerConnections,
|
||||
maxAutomaticRetries: state.maxAutomaticRetries,
|
||||
@@ -538,6 +543,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
|
||||
? persisted.speedLimitPresetValues
|
||||
: currentState.speedLimitPresetValues,
|
||||
lastCustomSpeedLimitUnit: persisted.lastCustomSpeedLimitUnit === 'KB/s'
|
||||
|| persisted.lastCustomSpeedLimitUnit === 'MB/s'
|
||||
? persisted.lastCustomSpeedLimitUnit
|
||||
: currentState.lastCustomSpeedLimitUnit,
|
||||
logsEnabled: persisted.logsEnabled === true,
|
||||
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
|
||||
? persisted.approvedDownloadRoots
|
||||
|
||||
Reference in New Issue
Block a user