mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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!({
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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)}`}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,6 +29,8 @@ vi.mock('./useSettingsStore', () => ({
|
||||
proxyMode: 'none',
|
||||
siteLogins: [],
|
||||
globalSpeedLimit: '',
|
||||
speedLimitPresetValues: [1, 5, 10],
|
||||
logsEnabled: false,
|
||||
perServerConnections: 16,
|
||||
customUserAgent: '',
|
||||
maxAutomaticRetries: 3,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user