feat(tools): harden scheduler speed limiter logs

Modernize the Tools release surfaces for the cross-platform re-release checklist.

- make Speed Limiter presets unit-aware and persist custom removable quick presets

- keep diagnostic logging opt-in on clean installs and honor the saved log state on startup

- stage Scheduler enablement edits without discarding unsaved draft changes

- extend persisted settings and bindings for log opt-in and speed preset state

Verification:

- npm run build

- npm test -- --run src/store/useDownloadStore.test.ts

- cargo test --lib

- cargo test settings --lib
This commit is contained in:
NimBold
2026-07-03 22:21:16 +03:30
parent ef877463da
commit 84a4ff4bfc
10 changed files with 264 additions and 73 deletions
+2
View File
@@ -249,6 +249,8 @@ pub struct PersistedSettings {
pub approved_download_roots: Vec<String>,
pub max_concurrent_downloads: usize,
pub global_speed_limit: String,
pub speed_limit_preset_values: Vec<f64>,
pub logs_enabled: bool,
pub is_sidebar_visible: bool,
pub active_settings_tab: SettingsTab,
pub scheduler: SchedulerSettings,
+32 -6
View File
@@ -4807,15 +4807,40 @@ pub fn run() {
*pairing_token = initial_pairing_token;
}
app.manage(database);
let persisted_settings = crate::settings::load_settings(app.handle()).ok();
let logs_enabled = persisted_settings
.as_ref()
.is_some_and(|settings| settings.logs_enabled);
LOG_PAUSED.store(!logs_enabled, std::sync::atomic::Ordering::Relaxed);
if logs_enabled {
log::info!("=== System Information ===");
log::info!(
"OS: {} {}",
sysinfo::System::name().unwrap_or_else(|| "Unknown".to_string()),
sysinfo::System::os_version().unwrap_or_else(|| "Unknown".to_string())
);
let arch = sysinfo::System::cpu_arch();
log::info!(
"Architecture: {}",
if arch.is_empty() { "Unknown" } else { &arch }
);
log::info!(
"CPU: {} ({} cores)",
sys.cpus().first().map(|c| c.brand()).unwrap_or("Unknown"),
sys.cpus().len()
);
log::info!("Memory: {} MB total", sys.total_memory() / 1024 / 1024);
log::info!("App Version: {}", env!("CARGO_PKG_VERSION"));
log::info!("==========================");
}
let max_concurrent = {
crate::settings::load_settings(app.handle())
persisted_settings
.as_ref()
.map(|settings| settings.max_concurrent_downloads)
.unwrap_or(crate::queue::DEFAULT_MAX_CONCURRENT)
};
let scheduler_settings = Arc::new(RwLock::new(
crate::settings::load_settings(app.handle()).ok(),
));
let scheduler_settings = Arc::new(RwLock::new(persisted_settings.clone()));
let queue_manager = Arc::new(queue::QueueManager::new(app.handle().clone(), max_concurrent));
let dispatcher_mgr = Arc::clone(&queue_manager);
@@ -4888,8 +4913,9 @@ pub fn run() {
}
crate::scheduler::spawn_scheduler(app.handle().clone(), scheduler_settings);
let global_speed_limit = crate::settings::load_settings(app.handle())
.map(|settings| settings.global_speed_limit)
let global_speed_limit = persisted_settings
.as_ref()
.map(|settings| settings.global_speed_limit.clone())
.unwrap_or_default();
let aria2_secret_clone = aria2_secret.clone();
+24
View File
@@ -267,6 +267,8 @@ fn default_settings() -> PersistedSettings {
approved_download_roots: Vec::new(),
max_concurrent_downloads: 3,
global_speed_limit: String::new(),
speed_limit_preset_values: vec![1.0, 5.0, 10.0],
logs_enabled: false,
is_sidebar_visible: true,
active_settings_tab: SettingsTab::Downloads,
scheduler: SchedulerSettings {
@@ -360,6 +362,8 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 7);
assert_eq!(settings.global_speed_limit, "2M");
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
assert!(!settings.logs_enabled);
assert!(settings.scheduler.enabled);
assert_eq!(settings.scheduler.start_time, "06:30");
assert_eq!(settings.scheduler.selected_days, vec![1, 3, 5]);
@@ -381,9 +385,29 @@ mod tests {
assert_eq!(settings.max_concurrent_downloads, 5);
assert_eq!(settings.global_speed_limit, "512K");
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 5.0, 10.0]);
assert!(!settings.logs_enabled);
assert!(!settings.scheduler.enabled);
}
#[test]
fn decodes_persisted_log_opt_in() {
let stored = json!({
"state": {
"speedLimitPresetValues": [1, 2.5, 8],
"logsEnabled": true
},
"version": 3
});
let settings = decode_stored_settings(&Value::String(stored.to_string())).unwrap();
assert_eq!(settings.speed_limit_preset_values, vec![1.0, 2.5, 8.0]);
assert!(settings.logs_enabled);
assert!(!settings.scheduler.enabled);
assert!(settings.global_speed_limit.is_empty());
}
#[test]
fn migrates_legacy_location_settings_and_preserves_custom_overrides() {
let stored = json!({
+5
View File
@@ -87,6 +87,7 @@ function App() {
const showNotifications = useSettingsStore(state => state.showNotifications);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length;
@@ -295,6 +296,10 @@ function App() {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
}, [showMenuBarIcon]);
useEffect(() => {
invoke('toggle_log_pause', { pause: !logsEnabled }).catch(console.error);
}, [logsEnabled]);
useEffect(() => {
if (!extensionPairingToken) return;
invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => {
+1 -1
View File
@@ -8,7 +8,7 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, 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>,
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, 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>,
/**
* HMAC shared secret for the browser extension. It is persisted in the
* settings database so that startup never needs to touch the OS keychain.
+47 -34
View File
@@ -2,10 +2,11 @@ import { useEffect, useRef, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { writeTextFile } from '@tauri-apps/plugin-fs';
import { attachLogger, setLogPaused, getLogPaused, initLogger } from '../utils/logger';
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { useSettingsStore } from '../store/useSettingsStore';
interface LogEntry {
level: 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error';
@@ -14,9 +15,10 @@ interface LogEntry {
export default function LogsView() {
const { addToast } = useToast();
const logsEnabled = useSettingsStore(state => state.logsEnabled);
const setLogsEnabled = useSettingsStore(state => state.setLogsEnabled);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const [isPaused, setIsPaused] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rawLineCountRef = useRef(0);
@@ -34,8 +36,6 @@ export default function LogsView() {
invoke('read_logs', { limit: MAX_LOG_LINES })
]);
if (!active) return;
setIsPaused(getLogPaused());
if (!active) return;
const initialLogs = lines.map(message => {
const level: LogEntry['level'] = message.includes('[ERROR]') ? 'Error'
: message.includes('[WARN]') ? 'Warn'
@@ -48,28 +48,28 @@ export default function LogsView() {
setLogs(initialLogs);
rawLineCountRef.current = initialLogs.length;
unlisten = await attachLogger((log) => {
if (!active) return;
const levelStr: LogEntry['level'] = log.level === 5 ? 'Error'
: log.level === 4 ? 'Warn'
: log.level === 3 ? 'Info'
: log.level === 1 ? 'Trace'
: 'Debug';
const timeStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
const formattedMsg = `[${timeStr}] [${levelStr.toUpperCase()}] ${log.message}`;
if (logsEnabled) {
unlisten = await attachLogger((log) => {
if (!active) return;
const levelStr: LogEntry['level'] = log.level === 5 ? 'Error'
: log.level === 4 ? 'Warn'
: log.level === 3 ? 'Info'
: log.level === 1 ? 'Trace'
: 'Debug';
setLogs(prev => {
const newLogs = [...prev, { level: levelStr, message: formattedMsg }];
rawLineCountRef.current = newLogs.length;
if (newLogs.length > MAX_LOG_LINES + 500) {
const trimmed = newLogs.slice(newLogs.length - MAX_LOG_LINES);
const timeStr = new Date().toISOString().replace('T', ' ').substring(0, 19);
const formattedMsg = `[${timeStr}] [${levelStr.toUpperCase()}] ${log.message}`;
return trimmed;
}
return newLogs;
setLogs(prev => {
const newLogs = [...prev, { level: levelStr, message: formattedMsg }];
rawLineCountRef.current = newLogs.length;
if (newLogs.length > MAX_LOG_LINES + 500) {
return newLogs.slice(newLogs.length - MAX_LOG_LINES);
}
return newLogs;
});
});
});
}
} catch (e) {
console.error('Failed to init logs:', e);
}
@@ -80,7 +80,7 @@ export default function LogsView() {
active = false;
if (unlisten) unlisten();
};
}, []);
}, [logsEnabled]);
useEffect(() => {
if (scrollRef.current) {
@@ -143,6 +143,16 @@ export default function LogsView() {
await invoke('clear_logs').catch(console.error);
};
const handleToggleLogging = async () => {
const nextEnabled = !logsEnabled;
setLogsEnabled(nextEnabled);
await setLogPaused(!nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
variant: 'success'
});
};
const severityClass = (level: string) => {
switch (level) {
case 'Error': return 'log-error';
@@ -162,6 +172,11 @@ export default function LogsView() {
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
<span className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
logsEnabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{logsEnabled ? 'Collecting' : 'Off'}
</span>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
@@ -181,15 +196,11 @@ export default function LogsView() {
</div>
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
<button
onClick={async () => {
const newState = !isPaused;
setIsPaused(newState);
await setLogPaused(newState);
}}
className={`app-icon-button ${isPaused ? 'text-accent' : ''}`}
title={isPaused ? "Resume logs" : "Pause logs system"}
onClick={handleToggleLogging}
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
>
{isPaused ? <Play size={14} /> : <Pause size={14} />}
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={handleClear}
@@ -214,7 +225,7 @@ export default function LogsView() {
<Info size={12} className="text-text-muted opacity-80 shrink-0" />
<span className="opacity-90 leading-tight">
<strong className="font-medium text-text-primary mr-1">Local diagnostics:</strong>
Firelink keeps bounded rotating logs on this device for troubleshooting. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
Diagnostic collection is opt-in, bounded, and local. Common secrets, URL queries, and home-directory paths are redacted during display and export. Nothing is uploaded automatically.
</span>
</div>
@@ -226,7 +237,9 @@ export default function LogsView() {
style={{ userSelect: 'text', WebkitUserSelect: 'text' }}
>
{logs.length === 0 && (
<div className="text-text-muted italic select-none">No persisted log entries are available yet.</div>
<div className="text-text-muted italic select-none">
{logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'}
</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
+13 -8
View File
@@ -75,6 +75,10 @@ export default function SchedulerView() {
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const hasUnsavedChanges = useMemo(
() => JSON.stringify(draft) !== JSON.stringify(savedSettings),
[draft, savedSettings]
);
const updateDraft = <K extends keyof SchedulerSettings>(key: K, value: SchedulerSettings[K]) => {
setDraft(current => ({ ...current, [key]: value }));
@@ -110,15 +114,15 @@ export default function SchedulerView() {
};
const save = () => {
if (!draft.everyday && draft.selectedDays.length === 0) {
if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
return;
}
if (effectiveSelectedQueueIds.length === 0) {
if (draft.enabled && effectiveSelectedQueueIds.length === 0) {
addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true });
return;
}
if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
return;
}
@@ -237,11 +241,7 @@ export default function SchedulerView() {
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
<div className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
<button
onClick={() => {
const newValue = !draft.enabled;
updateDraft('enabled', newValue);
setScheduler({ ...savedSettings, enabled: newValue });
}}
onClick={() => updateDraft('enabled', !draft.enabled)}
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none ${draft.enabled ? 'bg-accent' : 'bg-item-hover'}`}
aria-checked={draft.enabled}
role="switch"
@@ -257,6 +257,11 @@ export default function SchedulerView() {
}`}>
{schedulerRunning ? 'Running' : nextRun}
</span>
{hasUnsavedChanges && (
<span className="rounded-full bg-orange-500/10 px-2.5 py-1 text-[11px] font-semibold text-orange-300">
Unsaved changes
</span>
)}
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
+112 -17
View File
@@ -1,11 +1,14 @@
import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
type SpeedUnit = 'KB/s' | 'MB/s';
const MAX_LIMIT_KIB = 10_485_760;
const MAX_LIMIT_MB = 10240;
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const valueKiB = match
@@ -17,28 +20,57 @@ function parseLimit(limit: string, fallback: number): { value: number; unit: Spe
: { value: valueKiB, unit: 'KB/s' };
}
function sanitizePresetValues(values: number[]): number[] {
const cleaned = values
.map(value => Number(value))
.filter(value => Number.isFinite(value) && value > 0)
.map(value => Math.min(MAX_LIMIT_MB, Math.round(value * 100) / 100));
return Array.from(new Set(cleaned)).sort((a, b) => a - b);
}
function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : value / 1000;
}
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1000);
}
function formatPresetValue(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const speedLimitPresetValues = useSettingsStore(state => state.speedLimitPresetValues);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
const setSpeedLimitPresetValues = useSettingsStore(state => state.setSpeedLimitPresetValues);
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
const { addToast } = useToast();
const presetValues = useMemo(
() => sanitizePresetValues(speedLimitPresetValues),
[speedLimitPresetValues]
);
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setUnit(parsed.unit);
setCustomPresetValue(parsed.value);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
const save = () => {
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : MAX_LIMIT_KIB));
const valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
addToast({
@@ -49,8 +81,33 @@ export default function SpeedLimiterView() {
const preset = (presetValue: number) => {
setEnabled(true);
setValue(presetValue);
setUnit('MB/s');
setValue(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 presetBaseValue = Math.min(MAX_LIMIT_MB, presetBaseFromDisplayValue(numericValue, unit));
const nextPresets = sanitizePresetValues([...presetValues, presetBaseValue]);
const alreadyExists = nextPresets.length === presetValues.length;
setSpeedLimitPresetValues(nextPresets);
setEnabled(true);
setValue(numericValue);
addToast({
message: alreadyExists
? `${formatPresetValue(numericValue)} ${unit} is already in quick presets`
: `Added ${formatPresetValue(numericValue)} ${unit} quick preset`,
variant: alreadyExists ? 'info' : 'success'
});
};
const removePreset = (presetValue: number) => {
const displayValue = displayValueFromPresetBase(presetValue, unit);
const nextPresets = presetValues.filter(value => value !== presetValue);
setSpeedLimitPresetValues(nextPresets);
addToast({
message: `Removed ${formatPresetValue(displayValue)} ${unit} quick preset`,
variant: 'info'
});
};
return (
@@ -82,13 +139,12 @@ export default function SpeedLimiterView() {
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[720px] p-5 ${enabled ? '' : 'opacity-50'}`}>
<section className={`app-card max-w-[760px] p-5 ${enabled ? '' : 'opacity-55'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
This cap is shared by transfers running through the core downloader. A lower per-download limit still takes precedence.
Saving updates the core downloader immediately; media extraction keeps its existing per-download options.
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers, native fallback transfers, and yt-dlp media downloads. Per-download limits still take precedence.
</p>
<div className="mt-6 flex items-center gap-3">
@@ -121,18 +177,57 @@ export default function SpeedLimiterView() {
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
</div>
<div className="flex flex-wrap gap-2">
{[1, 5, 10].map(presetValue => (
<div className="flex flex-wrap items-center gap-2">
{presetValues.map(presetValue => {
const displayValue = displayValueFromPresetBase(presetValue, unit);
return (
<div
key={presetValue}
className="group flex h-8 min-w-[92px] items-center overflow-hidden rounded-md border border-border-modal bg-bg-input text-[12px] text-text-primary transition-colors hover:bg-item-hover"
>
<button
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="h-full flex-1 px-3 text-left disabled:opacity-50"
>
{formatPresetValue(displayValue)} {unit}
</button>
<button
type="button"
disabled={!enabled}
onClick={() => removePreset(presetValue)}
className="flex h-full w-7 items-center justify-center border-l border-border-modal text-text-muted transition-colors hover:bg-red-500/10 hover:text-red-400 focus-visible:bg-red-500/10 focus-visible:text-red-400 disabled:opacity-50"
title={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
aria-label={`Remove ${formatPresetValue(displayValue)} ${unit} preset`}
>
<X size={12} />
</button>
</div>
);
})}
<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"
value={customPresetValue}
disabled={!enabled}
onChange={event => setCustomPresetValue(Math.max(1, Number(event.target.value) || 1))}
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}`}
/>
<span className="text-[11px] text-text-muted">{unit}</span>
<button
key={presetValue}
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="app-button px-4 text-[12px] disabled:opacity-50"
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"
aria-label="Add quick preset"
>
{presetValue} MB/s
<Plus size={14} />
</button>
))}
</div>
</div>
</section>
</div>
+2
View File
@@ -29,6 +29,8 @@ vi.mock('./useSettingsStore', () => ({
proxyMode: 'none',
siteLogins: [],
globalSpeedLimit: '',
speedLimitPresetValues: [1, 5, 10],
logsEnabled: false,
perServerConnections: 16,
customUserAgent: '',
maxAutomaticRetries: 3,
+26 -7
View File
@@ -20,6 +20,7 @@ import {
let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export const DEFAULT_SPEED_LIMIT_PRESET_VALUES = [1, 5, 10];
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
@@ -77,6 +78,8 @@ export interface SettingsState {
approvedDownloadRoots: string[];
maxConcurrentDownloads: number;
globalSpeedLimit: string;
speedLimitPresetValues: number[];
logsEnabled: boolean;
isSidebarVisible: boolean;
activeView: ActiveView;
activeSettingsTab: SettingsTab;
@@ -115,6 +118,8 @@ export interface SettingsState {
approveDownloadRoot: (path: string) => Promise<string>;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setSpeedLimitPresetValues: (values: number[]) => void;
setLogsEnabled: (enabled: boolean) => void;
setActiveView: (view: ActiveView) => void;
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
@@ -187,6 +192,8 @@ export const useSettingsStore = create<SettingsState>()(
approvedDownloadRoots: [],
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
speedLimitPresetValues: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
logsEnabled: false,
activeView: 'downloads',
activeSettingsTab: 'downloads',
isSidebarVisible: true,
@@ -251,6 +258,8 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: globalSpeedLimit');
set({ globalSpeedLimit: limit });
},
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
@@ -366,7 +375,11 @@ export const useSettingsStore = create<SettingsState>()(
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : [],
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots
: []
: [],
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
? persisted.speedLimitPresetValues
: DEFAULT_SPEED_LIMIT_PRESET_VALUES,
logsEnabled: persisted.logsEnabled === true
} as SettingsState;
},
partialize: (state): PersistedSettings => ({
@@ -377,6 +390,8 @@ export const useSettingsStore = create<SettingsState>()(
approvedDownloadRoots: state.approvedDownloadRoots,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
speedLimitPresetValues: state.speedLimitPresetValues,
logsEnabled: state.logsEnabled,
isSidebarVisible: state.isSidebarVisible,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
@@ -412,12 +427,16 @@ export const useSettingsStore = create<SettingsState>()(
: {};
const locations = normalizeDownloadLocationSettings(persisted);
return ({
...currentState,
...persisted,
...locations,
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots
: currentState.approvedDownloadRoots,
...currentState,
...persisted,
...locations,
speedLimitPresetValues: Array.isArray(persisted.speedLimitPresetValues)
? persisted.speedLimitPresetValues
: currentState.speedLimitPresetValues,
logsEnabled: persisted.logsEnabled === true,
approvedDownloadRoots: Array.isArray(persisted.approvedDownloadRoots)
? persisted.approvedDownloadRoots
: currentState.approvedDownloadRoots,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,