From 84a4ff4bfc692536d80fedd4772b76fb21d93d4d Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 3 Jul 2026 22:21:16 +0330 Subject: [PATCH] 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 --- src-tauri/src/ipc.rs | 2 + src-tauri/src/lib.rs | 38 ++++++-- src-tauri/src/settings.rs | 24 ++++++ src/App.tsx | 5 ++ src/bindings/PersistedSettings.ts | 2 +- src/components/LogsView.tsx | 81 +++++++++-------- src/components/SchedulerView.tsx | 21 +++-- src/components/SpeedLimiterView.tsx | 129 ++++++++++++++++++++++++---- src/store/useDownloadStore.test.ts | 2 + src/store/useSettingsStore.ts | 33 +++++-- 10 files changed, 264 insertions(+), 73 deletions(-) diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index bfed906..44b8707 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -249,6 +249,8 @@ pub struct PersistedSettings { pub approved_download_roots: Vec, pub max_concurrent_downloads: usize, pub global_speed_limit: String, + pub speed_limit_preset_values: Vec, + pub logs_enabled: bool, pub is_sidebar_visible: bool, pub active_settings_tab: SettingsTab, pub scheduler: SchedulerSettings, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 59b3869..38f9809 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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(); diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b73c4b6..6c167e4 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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!({ diff --git a/src/App.tsx b/src/App.tsx index 3f0499c..647829a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 => { diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 8f1f9f4..a78a410 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -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, maxConcurrentDownloads: number, globalSpeedLimit: string, 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, +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, 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, /** * HMAC shared secret for the browser extension. It is persisted in the * settings database so that startup never needs to touch the OS keychain. diff --git a/src/components/LogsView.tsx b/src/components/LogsView.tsx index bc42915..970e8e8 100644 --- a/src/components/LogsView.tsx +++ b/src/components/LogsView.tsx @@ -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([]); const [levelFilter, setLevelFilter] = useState('All'); - const [isPaused, setIsPaused] = useState(false); const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null); const scrollRef = useRef(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() { Logs ({logs.length} entries) + + {logsEnabled ? 'Collecting' : 'Off'} +
@@ -181,15 +196,11 @@ export default function LogsView() {
@@ -226,7 +237,9 @@ export default function LogsView() { style={{ userSelect: 'text', WebkitUserSelect: 'text' }} > {logs.length === 0 && ( -
No persisted log entries are available yet.
+
+ {logsEnabled ? 'No persisted log entries are available yet.' : 'Diagnostic logging is off. Existing support logs will appear here when available.'} +
)} {logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
diff --git a/src/components/SchedulerView.tsx b/src/components/SchedulerView.tsx index c32b4f3..2019dc6 100644 --- a/src/components/SchedulerView.tsx +++ b/src/components/SchedulerView.tsx @@ -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 = (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() {
-
+
Global Speed Limit
-

- 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. +

+ Applies to new and active aria2 transfers, native fallback transfers, and yt-dlp media downloads. Per-download limits still take precedence.

@@ -121,18 +177,57 @@ export default function SpeedLimiterView() {
Quick Presets
-
- {[1, 5, 10].map(presetValue => ( +
+ {presetValues.map(presetValue => { + const displayValue = displayValueFromPresetBase(presetValue, unit); + return ( +
+ + +
+ ); + })} +
+ 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}`} + /> + {unit} - ))} +
diff --git a/src/store/useDownloadStore.test.ts b/src/store/useDownloadStore.test.ts index 3ae2058..900a102 100644 --- a/src/store/useDownloadStore.test.ts +++ b/src/store/useDownloadStore.test.ts @@ -29,6 +29,8 @@ vi.mock('./useSettingsStore', () => ({ proxyMode: 'none', siteLogins: [], globalSpeedLimit: '', + speedLimitPresetValues: [1, 5, 10], + logsEnabled: false, perServerConnections: 16, customUserAgent: '', maxAutomaticRetries: 3, diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index a6a6ac0..8821133 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -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 => { @@ -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; 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()( 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()( 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()( 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()( 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()( : {}; 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,