mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
+30
-3
@@ -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
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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])];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user