fix(tools): harden scheduler limits and logs

Guard scheduler system actions against pending work, serialize diagnostic log transitions, and keep speed-limit saves truthful after backend failures.\n\nNo linked issue was found for this audit.
This commit is contained in:
NimBold
2026-07-15 03:00:46 +03:30
parent 45bbca0515
commit 80a29356e0
10 changed files with 290 additions and 74 deletions
+30 -3
View File
@@ -5003,23 +5003,39 @@ pub(crate) fn redact_sensitive_text(line: &str) -> String {
static SECRET: OnceLock<regex::Regex> = OnceLock::new();
static HEADER: OnceLock<regex::Regex> = OnceLock::new();
static QUERY: OnceLock<regex::Regex> = OnceLock::new();
static USERINFO: OnceLock<regex::Regex> = OnceLock::new();
static FRAGMENT: OnceLock<regex::Regex> = OnceLock::new();
let secret = SECRET.get_or_init(|| {
regex::Regex::new(
r"(?i)(authorization|cookie|password|token|secret)\s*[:=]\s*([^\r\n,;]+)",
r"(?i)(authorization|proxy-authorization|cookie|set-cookie|password|token|secret|credential|pairing[-_ ]?token|api[-_ ]?key)\s*[:=]\s*([^\r\n,;]+)",
)
.expect("valid secret redaction regex")
});
let header = HEADER.get_or_init(|| {
regex::Regex::new(r"(?i)(authorization|cookie)\s*:\s*[^\r\n]+")
regex::Regex::new(
r"(?i)(authorization|proxy-authorization|cookie|set-cookie)\s*:\s*[^\r\n]+",
)
.expect("valid sensitive header redaction regex")
});
let query = QUERY.get_or_init(|| {
regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?]+)\?[^\s]+")
.expect("valid URL query redaction regex")
});
let userinfo = USERINFO.get_or_init(|| {
regex::Regex::new(r"(?i)([A-Za-z][A-Za-z0-9+.-]*://)[^@\s/?#]+@")
.expect("valid URL userinfo redaction regex")
});
let fragment = FRAGMENT.get_or_init(|| {
regex::Regex::new(r"([A-Za-z][A-Za-z0-9+.-]*://[^\s?#]+)#\S+")
.expect("valid URL fragment redaction regex")
});
let redacted = header.replace_all(line, "$1: [redacted]");
let redacted = secret.replace_all(&redacted, "$1=[redacted]");
query.replace_all(&redacted, "$1?[redacted]").into_owned()
let redacted = userinfo.replace_all(&redacted, "$1[redacted]@");
let redacted = query.replace_all(&redacted, "$1?[redacted]");
fragment
.replace_all(&redacted, "$1#[redacted]")
.into_owned()
}
fn redact_log_line(line: &str) -> String {
@@ -5617,6 +5633,17 @@ mod tests {
assert!(redacted.contains("[redacted]"));
}
#[test]
fn redacts_proxy_credentials_pairing_tokens_and_url_fragments() {
let line = "Proxy-Authorization: Basic abc\npairing token: pair-secret\nhttp://user:pass@example.com/file#signature=secret";
let redacted = redact_log_line(line);
assert!(!redacted.contains("Basic abc"));
assert!(!redacted.contains("pair-secret"));
assert!(!redacted.contains("user:pass"));
assert!(!redacted.contains("signature=secret"));
assert!(redacted.contains("http://[redacted]@example.com/file#[redacted]"));
}
#[test]
fn collects_primary_url_and_unique_mirrors_in_order() {
let uris = collect_download_uris(
+22 -23
View File
@@ -1,4 +1,4 @@
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads';
import { schedulerCompletionState } from './utils/schedulerCompletion';
import { useCallback, useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
@@ -19,6 +19,7 @@ import LogsView from "./components/LogsView";
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
import { WindowControls } from "./components/WindowControls";
import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger';
import { openUrl } from '@tauri-apps/plugin-opener';
import { usePlatformInfo } from './utils/platform';
import type { PostQueueAction } from './bindings/PostQueueAction';
@@ -109,7 +110,6 @@ 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 => isTransferActiveStatus(download.status)).length;
@@ -119,8 +119,6 @@ function App() {
const doneCount = downloads.filter(download => download.status === 'completed').length;
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef<string | null>(null);
const pendingPostActionTimer = useRef<number | null>(null);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
@@ -189,7 +187,7 @@ function App() {
);
if (activeTransfers) {
addToast({
message: 'System action cancelled because another download is active.',
message: 'System action cancelled because another download is active or queued.',
variant: 'warning',
isActionable: true
});
@@ -232,6 +230,12 @@ function App() {
return clearPendingPostActionTimer;
}, [clearPendingPostActionTimer]);
useEffect(() => {
if (activeTransferCount > 0) {
clearPendingPostActionTimer();
}
}, [activeTransferCount, clearPendingPostActionTimer]);
useEffect(() => {
initMediaDomains();
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
@@ -393,13 +397,9 @@ 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 (activeView !== 'logs') {
invoke('set_log_stream_active', { active: false }).catch(console.error);
setLogStreamActive(false).catch(console.error);
}
}, [activeView]);
@@ -410,17 +410,6 @@ function App() {
});
}, [extensionPairingToken]);
useEffect(() => {
if (previousSpeedLimit.current === globalSpeedLimit) return;
previousSpeedLimit.current = globalSpeedLimit;
const formattedLimit = normalizeSpeedLimitForBackend(globalSpeedLimit);
invoke('set_global_speed_limit', { limit: formattedLimit }).catch(error => {
console.error('Failed to apply global speed limit:', error);
});
}, [globalSpeedLimit]);
useEffect(() => {
if (!coreReady) return;
const unlisten = listen('schedule-trigger', async (event) => {
@@ -443,6 +432,7 @@ function App() {
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
return;
}
const previouslyTrackedIds = new Set(state.schedulerActiveDownloadIds);
const startedResults = await Promise.all(
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
@@ -450,6 +440,7 @@ function App() {
const scheduledQueueSet = new Set(scheduledQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
previouslyTrackedIds.has(download.id) &&
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
isActiveDownloadStatus(download.status)
)
@@ -459,9 +450,9 @@ function App() {
state.setSchedulerRunning(activeIds.length > 0);
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
} else if (payload.action === 'stop') {
clearPendingPostActionTimer();
const trackedIds = state.schedulerActiveDownloadIds;
if (trackedIds.length > 0) {
clearPendingPostActionTimer();
const pauseResults = await Promise.allSettled(
trackedIds.map(id => useDownloadStore.getState().pauseDownload(id))
);
@@ -506,7 +497,15 @@ function App() {
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
schedulePostQueueAction(settings.scheduler.postQueueAction);
if (downloads.some(download => isActiveDownloadStatus(download.status))) {
addToast({
message: 'Scheduled system action skipped because another download is active or queued.',
variant: 'warning',
isActionable: true
});
} else {
schedulePostQueueAction(settings.scheduler.postQueueAction);
}
}
}, [
addToast,
+72 -20
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { attachLogger, setLogPaused, initLogger } from '../utils/logger';
import { attachLogger, setLogPaused, initLogger, setLogStreamActive } from '../utils/logger';
import { FileDown, Trash2, Terminal, Filter, Play, Pause, Info, Copy } from 'lucide-react';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
@@ -27,6 +27,11 @@ export default function LogsView() {
const scrollRef = useRef<HTMLDivElement>(null);
const liveBatchRef = useRef<LogEntry[]>([]);
const liveFrameRef = useRef<number | null>(null);
const clearGenerationRef = useRef(0);
const clearInFlightRef = useRef<Promise<void> | null>(null);
const toggleInFlightRef = useRef<Promise<void> | null>(null);
const [isClearing, setIsClearing] = useState(false);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
const handleVisibilityChange = () => setPageVisible(document.visibilityState !== 'hidden');
@@ -36,16 +41,17 @@ export default function LogsView() {
useEffect(() => {
if (!pageVisible) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
return;
}
if (!logsEnabled) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
}
let active = true;
let initialized = false;
const initGeneration = clearGenerationRef.current;
let pendingLiveEntries: LogEntry[] = [];
let unlistenPromise: Promise<() => void> | undefined;
@@ -78,9 +84,9 @@ export default function LogsView() {
});
await unlistenPromise;
if (!active) return;
await invoke('set_log_stream_active', { active: true });
await setLogStreamActive(true);
if (!active) {
await invoke('set_log_stream_active', { active: false }).catch(console.error);
await setLogStreamActive(false).catch(console.error);
return;
}
}
@@ -89,6 +95,10 @@ export default function LogsView() {
if (!active) return;
const snapshot = lines.map(persistedLogEntry);
initialized = true;
if (initGeneration !== clearGenerationRef.current) {
pendingLiveEntries = [];
return;
}
const caughtUpLogs = mergeLogSnapshotAndLiveEntries(snapshot, pendingLiveEntries);
pendingLiveEntries = [];
setLogs(caughtUpLogs);
@@ -106,7 +116,7 @@ export default function LogsView() {
liveFrameRef.current = null;
}
if (logsEnabled) {
void invoke('set_log_stream_active', { active: false }).catch(console.error);
void setLogStreamActive(false).catch(console.error);
}
if (unlistenPromise) {
void unlistenPromise.then(unlisten => unlisten()).catch(console.error);
@@ -170,23 +180,63 @@ export default function LogsView() {
};
const handleClear = async () => {
liveBatchRef.current = [];
if (liveFrameRef.current !== null) {
window.cancelAnimationFrame(liveFrameRef.current);
liveFrameRef.current = null;
if (clearInFlightRef.current) return;
const clearOperation = invoke('clear_logs');
clearInFlightRef.current = clearOperation;
setIsClearing(true);
try {
await clearOperation;
clearGenerationRef.current += 1;
liveBatchRef.current = [];
if (liveFrameRef.current !== null) {
window.cancelAnimationFrame(liveFrameRef.current);
liveFrameRef.current = null;
}
setLogs([]);
addToast({ message: 'Logs cleared', variant: 'info' });
} catch (error) {
addToast({
message: `Could not clear logs: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
if (clearInFlightRef.current === clearOperation) {
clearInFlightRef.current = null;
}
setIsClearing(false);
}
setLogs([]);
await invoke('clear_logs').catch(console.error);
};
const handleToggleLogging = async () => {
if (toggleInFlightRef.current) return;
const nextEnabled = !logsEnabled;
setLogsEnabled(nextEnabled);
await setLogPaused(!nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
variant: 'success'
});
const toggleOperation = (async () => {
await setLogPaused(!nextEnabled);
setLogsEnabled(nextEnabled);
addToast({
message: nextEnabled ? 'Diagnostic logging enabled' : 'Diagnostic logging disabled',
variant: 'success'
});
})();
toggleInFlightRef.current = toggleOperation;
setIsToggling(true);
try {
await toggleOperation;
} catch (error) {
addToast({
message: `Could not update diagnostic logging: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
if (toggleInFlightRef.current === toggleOperation) {
toggleInFlightRef.current = null;
}
setIsToggling(false);
}
};
const severityClass = (level: string) => {
@@ -233,14 +283,16 @@ export default function LogsView() {
<div className="w-[1px] h-4 bg-border-modal mx-0.5" />
<button
onClick={handleToggleLogging}
className={`app-icon-button ${logsEnabled ? 'text-accent' : ''}`}
disabled={isToggling}
className={`app-icon-button disabled:cursor-not-allowed disabled:opacity-50 ${logsEnabled ? 'text-accent' : ''}`}
title={logsEnabled ? "Pause diagnostic logging" : "Enable diagnostic logging"}
>
{logsEnabled ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={handleClear}
className="app-icon-button"
disabled={isClearing}
className="app-icon-button disabled:cursor-not-allowed disabled:opacity-50"
title="Clear displayed logs"
>
<Trash2 size={14} />
+4 -1
View File
@@ -6,6 +6,7 @@ import {
} from 'lucide-react';
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
import { isActiveDownloadStatus } from '../utils/downloads';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
import { usePlatformInfo } from '../utils/platform';
@@ -137,6 +138,7 @@ export default function SchedulerView() {
};
const runNow = async () => {
const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
@@ -144,8 +146,9 @@ export default function SchedulerView() {
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
previouslyTrackedIds.has(download.id) &&
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
isActiveDownloadStatus(download.status)
)
.map(download => download.id);
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { displayValueFromPresetBase, presetBaseFromDisplayValue } from './SpeedLimiterView';
describe('SpeedLimiterView speed conversions', () => {
it('converts KB/s and MB/s using binary units consistently', () => {
expect(presetBaseFromDisplayValue(1024, 'KB/s')).toBe(1);
expect(displayValueFromPresetBase(1, 'KB/s')).toBe(1024);
expect(presetBaseFromDisplayValue(5, 'MB/s')).toBe(5);
expect(displayValueFromPresetBase(5, 'MB/s')).toBe(5);
});
});
+40 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Gauge, Plus, Save, X, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
@@ -9,7 +9,7 @@ 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 } {
export 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
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
@@ -29,12 +29,12 @@ function sanitizePresetValues(values: number[]): number[] {
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;
export function presetBaseFromDisplayValue(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : value / 1024;
}
function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1000);
export function displayValueFromPresetBase(value: number, unit: SpeedUnit): number {
return unit === 'MB/s' ? value : Math.round(value * 1024);
}
function formatPresetValue(value: number): string {
@@ -54,6 +54,7 @@ export default function SpeedLimiterView() {
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [customPresetValue, setCustomPresetValue] = useState(initial.value);
const { addToast } = useToast();
const savingRef = useRef(false);
const presetValues = useMemo(
() => sanitizePresetValues(speedLimitPresetValues),
[speedLimitPresetValues]
@@ -68,15 +69,31 @@ export default function SpeedLimiterView() {
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
const save = () => {
const [isSaving, setIsSaving] = useState(false);
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 valueKiB = Math.min(MAX_LIMIT_KIB, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
});
setIsSaving(true);
try {
await setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setLastCustomSpeedLimitKiB(valueKiB);
addToast({
message: enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled',
variant: 'success'
});
} catch (error) {
addToast({
message: `Could not save global speed limit: ${String(error)}`,
variant: 'error',
isActionable: true
});
} finally {
savingRef.current = false;
setIsSaving(false);
}
};
const preset = (presetValue: number) => {
@@ -118,7 +135,8 @@ export default function SpeedLimiterView() {
<div className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary select-none">
<button
onClick={() => setEnabled(!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 ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
disabled={isSaving}
className={`relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full transition-colors duration-200 ease-in-out focus:outline-none disabled:cursor-not-allowed ${enabled ? 'bg-accent' : 'bg-item-hover'}`}
aria-checked={enabled}
role="switch"
>
@@ -133,7 +151,7 @@ export default function SpeedLimiterView() {
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
</span>
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
<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
</button>
</div>
@@ -144,7 +162,7 @@ export default function SpeedLimiterView() {
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-2xl text-[12px] leading-relaxed text-text-muted">
Applies to new and active aria2 transfers and yt-dlp media downloads. Per-download limits still take precedence.
Applies to new and active aria2 transfers and new yt-dlp media downloads. Explicit per-download limits remain attached to those transfers.
</p>
<div className="mt-6 flex items-center gap-3">
@@ -152,7 +170,7 @@ export default function SpeedLimiterView() {
type="number"
min="1"
value={value}
disabled={!enabled}
disabled={!enabled || isSaving}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
className="app-control w-28 px-3 py-2 text-right font-mono"
/>
@@ -161,7 +179,7 @@ export default function SpeedLimiterView() {
<button
key={option}
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={() => setUnit(option)}
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
@@ -187,7 +205,7 @@ export default function SpeedLimiterView() {
>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={() => preset(presetValue)}
className="h-full flex-1 px-3 text-left disabled:opacity-50"
>
@@ -195,7 +213,7 @@ export default function SpeedLimiterView() {
</button>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
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`}
@@ -211,7 +229,7 @@ export default function SpeedLimiterView() {
type="number"
min="1"
value={customPresetValue}
disabled={!enabled}
disabled={!enabled || isSaving}
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}`}
@@ -219,7 +237,7 @@ export default function SpeedLimiterView() {
<span className="text-[11px] text-text-muted">{unit}</span>
<button
type="button"
disabled={!enabled}
disabled={!enabled || isSaving}
onClick={applyCustomPreset}
className="app-icon-button h-6 w-6 disabled:opacity-50"
title="Add quick preset"
+27
View File
@@ -0,0 +1,27 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
}));
vi.mock('../utils/logger', () => ({
info: vi.fn()
}));
describe('useSettingsStore global speed limit persistence', () => {
beforeEach(() => {
vi.clearAllMocks();
useSettingsStore.setState({ globalSpeedLimit: '2M' });
});
it('keeps the saved value when the backend rejects a limit change', async () => {
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('aria2 unavailable'));
await expect(useSettingsStore.getState().setGlobalSpeedLimit('3M')).rejects.toThrow('aria2 unavailable');
expect(useSettingsStore.getState().globalSpeedLimit).toBe('2M');
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
});
});
+6 -2
View File
@@ -17,6 +17,7 @@ import {
DEFAULT_CATEGORY_SUBFOLDERS,
normalizeDownloadLocationSettings
} from '../utils/downloadLocations';
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -165,7 +166,7 @@ export interface SettingsState {
setBaseDownloadFolder: (path: string) => void;
approveDownloadRoot: (path: string) => Promise<string>;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setGlobalSpeedLimit: (limit: string) => Promise<void>;
setSpeedLimitPresetValues: (values: number[]) => void;
setLogsEnabled: (enabled: boolean) => void;
setActiveView: (view: ActiveView) => void;
@@ -308,7 +309,10 @@ export const useSettingsStore = create<SettingsState>()(
maxConcurrentDownloads: clampSettingInteger(max, 1, 12, 3)
});
},
setGlobalSpeedLimit: (limit) => {
setGlobalSpeedLimit: async (limit) => {
await invoke('set_global_speed_limit', {
limit: normalizeSpeedLimitForBackend(limit)
});
info('Settings updated: globalSpeedLimit');
set({ globalSpeedLimit: limit });
},
+61
View File
@@ -0,0 +1,61 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock('@tauri-apps/api/core', () => ({ invoke }));
vi.mock('@tauri-apps/plugin-log', () => ({
attachLogger: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
trace: vi.fn(),
warn: vi.fn()
}));
import { setLogPaused, setLogStreamActive } from './logger';
describe('logger stream transitions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('serializes enable and disable transitions', async () => {
let releaseFirst!: () => void;
invoke
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
.mockResolvedValueOnce(undefined);
const enabling = setLogStreamActive(true);
const disabling = setLogStreamActive(false);
await Promise.resolve();
expect(invoke).toHaveBeenCalledTimes(1);
releaseFirst();
await Promise.all([enabling, disabling]);
expect(invoke.mock.calls).toEqual([
['set_log_stream_active', { active: true }],
['set_log_stream_active', { active: false }]
]);
});
it('serializes pause transitions so the backend cannot finish out of order', async () => {
let releaseFirst!: () => void;
invoke
.mockImplementationOnce(() => new Promise<void>(resolve => { releaseFirst = resolve; }))
.mockResolvedValueOnce(undefined);
const pausing = setLogPaused(true);
const resuming = setLogPaused(false);
await Promise.resolve();
expect(invoke).toHaveBeenCalledTimes(1);
releaseFirst();
await Promise.all([pausing, resuming]);
expect(invoke.mock.calls).toEqual([
['toggle_log_pause', { pause: true }],
['toggle_log_pause', { pause: false }]
]);
});
});
+17 -3
View File
@@ -5,6 +5,8 @@ import { invoke } from '@tauri-apps/api/core';
let isPaused = true;
let initPromise: Promise<void> | null = null;
let logPauseTransition = Promise.resolve();
let logStreamTransition = Promise.resolve();
export const initLogger = () => {
if (!initPromise) {
@@ -15,9 +17,21 @@ export const initLogger = () => {
return initPromise;
};
export const setLogPaused = async (pause: boolean) => {
isPaused = pause;
await invoke('toggle_log_pause', { pause }).catch(console.error);
export const setLogPaused = (pause: boolean): Promise<void> => {
const transition = logPauseTransition.then(async () => {
await invoke('toggle_log_pause', { pause });
isPaused = pause;
});
logPauseTransition = transition.catch(() => undefined);
return transition;
};
export const setLogStreamActive = (active: boolean): Promise<void> => {
const transition = logStreamTransition.then(async () => {
await invoke('set_log_stream_active', { active });
});
logStreamTransition = transition.catch(() => undefined);
return transition;
};
export const getLogPaused = () => isPaused;