feat(desktop): implement scheduler and speed limiter

This commit is contained in:
NimBold
2026-06-12 21:17:34 +03:30
parent 4c21a36083
commit f378fc0d8d
7 changed files with 779 additions and 20 deletions
+116 -1
View File
@@ -470,6 +470,7 @@ async fn start_media_download(
destination: String,
filename: String,
format_selector: Option<String>,
speed_limit: Option<String>,
) -> Result<(), String> {
println!("start_media_download called for id: {}", id);
let resource_dir = app_handle.path().resource_dir().map_err(|e| e.to_string())?;
@@ -509,6 +510,12 @@ async fn start_media_download(
.arg("--extractor-retries").arg("3")
.arg("-o").arg(out_path.to_string_lossy().to_string());
if let Some(limit) = speed_limit {
if !limit.is_empty() {
cmd.arg("--limit-rate").arg(limit);
}
}
if let Some(format) = format_selector {
cmd.arg("-f").arg(format);
// If the filename implies an audio format, use it as audio output
@@ -672,6 +679,112 @@ fn set_prevent_sleep(state: tauri::State<'_, AppState>, prevent: bool) {
}
}
#[tauri::command]
fn perform_system_action(action: String) -> Result<(), String> {
let status = match action.as_str() {
"sleep" => {
#[cfg(target_os = "macos")]
{
Command::new("osascript")
.arg("-e")
.arg("tell application \"Finder\" to sleep")
.status()
}
#[cfg(target_os = "windows")]
{
Command::new("rundll32.exe")
.arg("powrprof.dll,SetSuspendState")
.arg("0,1,0")
.status()
}
#[cfg(target_os = "linux")]
{
Command::new("systemctl").arg("suspend").status()
}
}
"restart" => {
#[cfg(target_os = "macos")]
{
Command::new("osascript")
.arg("-e")
.arg("tell application \"Finder\" to restart")
.status()
}
#[cfg(target_os = "windows")]
{
Command::new("shutdown").args(["/r", "/t", "0"]).status()
}
#[cfg(target_os = "linux")]
{
Command::new("systemctl").arg("reboot").status()
}
}
"shutdown" => {
#[cfg(target_os = "macos")]
{
Command::new("osascript")
.arg("-e")
.arg("tell application \"Finder\" to shut down")
.status()
}
#[cfg(target_os = "windows")]
{
Command::new("shutdown").args(["/s", "/t", "0"]).status()
}
#[cfg(target_os = "linux")]
{
Command::new("systemctl").arg("poweroff").status()
}
}
_ => return Err("Unsupported system action".to_string()),
};
match status {
Ok(result) if result.success() => Ok(()),
Ok(result) => Err(format!("System action exited with status {result}")),
Err(error) => Err(format!("Failed to perform system action: {error}")),
}
}
#[tauri::command]
fn request_automation_permission() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let status = Command::new("osascript")
.arg("-e")
.arg("tell application \"Finder\" to get name")
.status()
.map_err(|error| error.to_string())?;
return if status.success() {
Ok(())
} else {
Err("Automation permission was not granted".to_string())
};
}
#[cfg(not(target_os = "macos"))]
Ok(())
}
#[tauri::command]
fn open_automation_settings() -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let status = Command::new("open")
.arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation")
.status()
.map_err(|error| error.to_string())?;
return if status.success() {
Ok(())
} else {
Err("Failed to open Automation settings".to_string())
};
}
#[cfg(not(target_os = "macos"))]
Err("Automation settings are only available on macOS".to_string())
}
#[tauri::command]
fn get_free_space(app_handle: tauri::AppHandle, path: String) -> Result<String, String> {
use sysinfo::Disks;
@@ -730,7 +843,9 @@ pub fn run() {
.plugin(tauri_plugin_notification::init())
.invoke_handler(tauri::generate_handler![
greet, test_ytdlp, test_aria2c, test_ffmpeg, open_file, show_in_folder,
start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata, update_dock_badge, set_prevent_sleep, get_free_space
start_download, start_media_download, pause_download, fetch_metadata, fetch_media_metadata,
update_dock_badge, set_prevent_sleep, get_free_space, perform_system_action,
request_automation_permission, open_automation_settings
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+81 -6
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Sidebar, SidebarFilter } from "./components/Sidebar";
import { DownloadTable } from "./components/DownloadTable";
import { AddDownloadsModal } from "./components/AddDownloadsModal";
@@ -9,6 +9,15 @@ import { useDownloadStore } from "./store/useDownloadStore";
import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import { invoke } from "@tauri-apps/api/core";
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
const localDateKey = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
@@ -19,6 +28,10 @@ function App() {
const appFontSize = useSettingsStore(state => state.appFontSize);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const activeDownloadCount = useDownloadStore(state => state.downloads.filter(download => download.status === 'downloading').length);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const downloads = useDownloadStore(state => state.downloads);
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef(globalSpeedLimit);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
@@ -28,6 +41,69 @@ function App() {
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
}, [showDockBadge, activeDownloadCount]);
useEffect(() => {
if (previousSpeedLimit.current === globalSpeedLimit) return;
previousSpeedLimit.current = globalSpeedLimit;
const timeout = window.setTimeout(() => {
useDownloadStore.getState().restartActiveDownloads().catch(error => {
console.error('Failed to apply global speed limit:', error);
});
}, 500);
return () => window.clearTimeout(timeout);
}, [globalSpeedLimit]);
useEffect(() => {
const checkSchedule = async () => {
const state = useSettingsStore.getState();
const scheduler = state.scheduler;
if (!scheduler.enabled) return;
const now = new Date();
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const allowedToday = scheduler.everyday || scheduler.selectedDays.includes(now.getDay());
if (!allowedToday) return;
const dateKey = localDateKey(now);
if (scheduler.startTime === currentTime) {
const triggerKey = `${dateKey}-${currentTime}`;
if (state.schedulerLastStartKey !== triggerKey) {
state.setSchedulerLastStartKey(triggerKey);
const started = await useDownloadStore.getState().startMainQueue();
state.setSchedulerRunning(started > 0);
}
}
if (scheduler.stopTimeEnabled && scheduler.stopTime === currentTime) {
const triggerKey = `${dateKey}-${currentTime}`;
if (state.schedulerLastStopKey !== triggerKey) {
state.setSchedulerLastStopKey(triggerKey);
await useDownloadStore.getState().pauseMainQueue();
state.setSchedulerRunning(false);
}
}
};
void checkSchedule();
const interval = window.setInterval(() => void checkSchedule(), 10_000);
return () => window.clearInterval(interval);
}, []);
useEffect(() => {
if (!schedulerRunning) return;
const hasPendingScheduledWork = downloads.some(download =>
download.status === 'queued' || download.status === 'downloading'
);
if (hasPendingScheduledWork) return;
const settings = useSettingsStore.getState();
settings.setSchedulerRunning(false);
if (settings.scheduler.postQueueAction !== 'none') {
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
console.error('Scheduled post action failed:', error);
});
}
}, [downloads, schedulerRunning]);
useEffect(() => {
// Request notification permissions
const initNotifications = async () => {
@@ -104,11 +180,10 @@ function App() {
return (
<div className="flex h-screen w-screen bg-main-bg text-text-primary overflow-hidden">
{isSidebarVisible && <Sidebar selectedFilter={filter} onSelectFilter={(f) => { setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />}
{activeView === 'downloads' ? (
<DownloadTable filter={filter} />
) : (
<SettingsView />
)}
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
<AddDownloadsModal />
<PropertiesModal />
</div>
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import {
CheckCircle2, Clock3, List, LockKeyhole, Moon,
Pause, Play, Power, RotateCcw, Save
} from 'lucide-react';
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
import { useDownloadStore } from '../store/useDownloadStore';
import { WindowDragRegion } from './WindowDragRegion';
const days = [
{ value: 0, label: 'Su' },
{ value: 1, label: 'Mo' },
{ value: 2, label: 'Tu' },
{ value: 3, label: 'We' },
{ value: 4, label: 'Th' },
{ value: 5, label: 'Fr' },
{ value: 6, label: 'Sa' },
];
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
{ value: 'sleep', label: 'Sleep', icon: Moon },
{ value: 'restart', label: 'Restart', icon: RotateCcw },
{ value: 'shutdown', label: 'Shut down', icon: Power },
];
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
for (let offset = 0; offset < 8; offset += 1) {
const candidate = new Date(now);
candidate.setDate(now.getDate() + offset);
candidate.setHours(hour, minute, 0, 0);
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
if (allowedDay && candidate > now) {
return candidate.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
}
return 'No scheduled day selected';
}
export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const [toast, setToast] = useState('');
const [permissionMessage, setPermissionMessage] = useState('');
const isMac = navigator.userAgent.includes('Mac');
useEffect(() => {
setDraft(savedSettings);
}, [savedSettings]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const updateDraft = <K extends keyof SchedulerSettings>(key: K, value: SchedulerSettings[K]) => {
setDraft(current => ({ ...current, [key]: value }));
};
const toggleDay = (day: number) => {
setDraft(current => ({
...current,
selectedDays: current.selectedDays.includes(day)
? current.selectedDays.filter(value => value !== day)
: [...current.selectedDays, day].sort()
}));
};
const save = () => {
const normalized = {
...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0
? draft.selectedDays
: savedSettings.selectedDays
};
setScheduler(normalized);
setDraft(normalized);
setToast('Scheduler settings saved');
};
const runNow = async () => {
const count = await useDownloadStore.getState().startMainQueue();
if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
} else {
setToast('No paused or failed downloads to start');
}
};
const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseMainQueue();
useSettingsStore.getState().setSchedulerRunning(false);
setToast(count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads');
};
const requestPermission = async () => {
setPermissionMessage('Requesting permission...');
try {
await invoke('request_automation_permission');
setPermissionMessage('Automation permission is available.');
} catch (error) {
setPermissionMessage(String(error));
}
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-4 border-b border-border-color px-6 pb-5">
<label className="flex items-center gap-3 text-xl font-bold text-text-primary">
<input
type="checkbox"
checked={draft.enabled}
onChange={event => updateDraft('enabled', event.target.checked)}
className="h-4 w-4 accent-accent"
/>
Scheduler
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
}`}>
{schedulerRunning ? 'Running' : nextRun}
</span>
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="flex items-center gap-2 rounded-md border border-border-modal bg-bg-input px-3 py-2 text-[12px] font-medium text-text-primary hover:bg-item-hover">
<Play size={14} /> Run Now
</button>
<button onClick={pauseNow} className="flex items-center gap-2 rounded-md border border-border-modal bg-bg-input px-3 py-2 text-[12px] font-medium text-text-primary hover:bg-item-hover">
<Pause size={14} /> Pause
</button>
<button onClick={save} className="flex items-center gap-2 rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-white hover:opacity-90">
<Save size={14} /> Save Settings
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className={`max-w-[760px] space-y-5 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="rounded-xl border border-border-modal bg-bg-modal/40 p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
<Clock3 size={17} className="text-accent" /> Timing
</div>
<div className="flex flex-wrap items-end gap-8">
<label className="space-y-2 text-[12px] text-text-secondary">
<span className="block">Start Time</span>
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="rounded-md border border-border-modal bg-bg-input px-3 py-2 text-text-primary" />
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Stop Time
</label>
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="rounded-md border border-border-modal bg-bg-input px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Run Every Day
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
{days.map(day => {
const selected = draft.selectedDays.includes(day.value);
return (
<button
key={day.value}
type="button"
disabled={!draft.enabled}
onClick={() => toggleDay(day.value)}
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
</button>
);
})}
</div>
)}
</section>
<section className="rounded-xl border border-border-modal bg-bg-modal/40 p-5">
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
</div>
<label className="flex items-center gap-3 text-[13px] text-text-primary">
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
Main Queue
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
</label>
</section>
<section className="rounded-xl border border-border-modal bg-bg-modal/40 p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Power size={17} className="text-accent" /> After Completion
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
return (
<label key={action.value} className={`flex items-center gap-3 rounded-lg border p-3 text-[13px] ${
draft.postQueueAction === action.value ? 'border-accent bg-item-selected text-text-primary' : 'border-border-modal text-text-secondary'
}`}>
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
<Icon size={15} />
{action.label}
</label>
);
})}
</div>
{draft.postQueueAction !== 'none' && (
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
)}
</section>
</div>
{isMac && (
<section className="mt-5 max-w-[760px] rounded-xl border border-border-modal bg-bg-modal/40 p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
<div className="flex gap-2">
<button onClick={requestPermission} className="rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-white">Grant Permission</button>
<button onClick={() => invoke('open_automation_settings')} className="rounded-md border border-border-modal bg-bg-input px-3 py-2 text-[12px] text-text-primary">Open Settings</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
</section>
)}
</div>
{toast && (
<div className="pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 rounded-full border border-border-modal bg-bg-modal px-4 py-2 text-[12px] font-medium text-text-primary shadow-xl">
{toast}
</div>
)}
</div>
);
}
+19 -7
View File
@@ -5,7 +5,7 @@ import {
List, CalendarClock, Gauge, Settings, Plus
} from 'lucide-react';
import { useDownloadStore, DownloadCategory } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings';
@@ -57,6 +57,22 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
);
};
const ToolItem = ({ icon: Icon, label, view }: { icon: any; label: string; view: ActiveView }) => {
const isSelected = activeView === view;
return (
<button
type="button"
onClick={() => useSettingsStore.getState().setActiveView(view)}
className={`flex w-full items-center px-2.5 py-1.5 rounded-lg text-[13px] text-left cursor-default transition-colors mb-0.5 ${
isSelected ? 'bg-accent text-white font-medium' : 'text-text-primary hover:bg-item-hover'
}`}
>
<Icon className={`w-4 h-4 mr-2 ${isSelected ? 'text-white' : 'text-text-secondary'}`} strokeWidth={isSelected ? 2.25 : 2} />
<span>{label}</span>
</button>
);
};
return (
<aside className="w-[220px] min-w-[190px] max-w-[260px] bg-sidebar-bg border-r border-border-color flex flex-col relative shrink-0">
<WindowDragRegion />
@@ -94,12 +110,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<section>
<div className="text-[11px] font-semibold text-text-muted px-2.5 mb-1.5">Tools</div>
<div className="flex items-center px-2.5 py-1.5 rounded-lg text-[13px] text-text-primary hover:bg-item-hover cursor-default transition-colors mb-0.5">
<CalendarClock className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} /><span>Scheduler</span>
</div>
<div className="flex items-center px-2.5 py-1.5 rounded-lg text-[13px] text-text-primary hover:bg-item-hover cursor-default transition-colors">
<Gauge className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} /><span>Speed Limiter</span>
</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
</section>
</div>
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
type SpeedUnit = 'KB/s' | 'MB/s';
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)))
: fallback;
return valueKiB >= 1024 && valueKiB % 1024 === 0
? { value: valueKiB / 1024, unit: 'MB/s' }
: { value: valueKiB, unit: 'KB/s' };
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
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 [toast, setToast] = useState('');
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setUnit(parsed.unit);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
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));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setToast(enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled');
};
const preset = (presetValue: number) => {
setEnabled(true);
setValue(presetValue);
setUnit('MB/s');
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-4 border-b border-border-color px-6 pb-5">
<label className="flex items-center gap-3 text-xl font-bold text-text-primary">
<input type="checkbox" checked={enabled} onChange={event => setEnabled(event.target.checked)} className="h-4 w-4 accent-accent" />
Speed Limiter
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
</span>
<button onClick={save} className="ml-auto flex items-center gap-2 rounded-md bg-accent px-3 py-2 text-[12px] font-semibold text-white hover:opacity-90">
<Save size={14} /> Save Limit
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`max-w-[720px] rounded-xl border border-border-modal bg-bg-modal/40 p-5 ${enabled ? '' : 'opacity-50'}`}>
<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 across the configured concurrent download slots. A lower per-download limit still takes precedence.
Saving a new limit gracefully restarts active jobs so the change takes effect immediately.
</p>
<div className="mt-6 flex items-center gap-3">
<input
type="number"
min="1"
value={value}
disabled={!enabled}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
className="w-28 rounded-md border border-border-modal bg-bg-input px-3 py-2 text-right font-mono text-text-primary focus:border-accent focus:outline-none"
/>
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
{(['KB/s', 'MB/s'] as SpeedUnit[]).map(option => (
<button
key={option}
type="button"
disabled={!enabled}
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'
}`}
>
{option}
</button>
))}
</div>
</div>
<div className="my-6 border-t border-border-color" />
<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 => (
<button
key={presetValue}
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="rounded-md border border-border-modal bg-bg-input px-4 py-2 text-[12px] font-medium text-text-primary hover:bg-item-hover disabled:cursor-default"
>
{presetValue} MB/s
</button>
))}
</div>
</section>
</div>
{toast && (
<div className="pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 rounded-full border border-border-modal bg-bg-modal px-4 py-2 text-[12px] font-medium text-text-primary shadow-xl">
{toast}
</div>
)}
</div>
);
}
+116 -4
View File
@@ -40,6 +40,42 @@ const syncSystemIntegrations = () => {
}
};
const speedLimitToKiB = (value?: string | null): number | null => {
if (!value) return null;
const match = value.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?(?:\/s)?$/i);
if (!match) return null;
const amount = Number(match[1]);
if (!Number.isFinite(amount) || amount <= 0) return null;
const multipliers: Record<string, number> = {
'': 1,
k: 1,
m: 1024,
g: 1024 * 1024,
t: 1024 * 1024 * 1024
};
return Math.max(1, Math.round(amount * multipliers[match[2].toLowerCase()]));
};
const effectiveSpeedLimit = (
itemLimit: string | null | undefined,
globalLimit: string,
maxConcurrentDownloads: number
): string | null => {
const itemKiB = speedLimitToKiB(itemLimit);
const globalKiB = speedLimitToKiB(globalLimit);
const perSlotGlobalKiB = globalKiB
? Math.max(1, Math.floor(globalKiB / Math.max(maxConcurrentDownloads, 1)))
: null;
const effectiveKiB = itemKiB && perSlotGlobalKiB
? Math.min(itemKiB, perSlotGlobalKiB)
: itemKiB ?? perSlotGlobalKiB;
return effectiveKiB ? `${effectiveKiB}K` : null;
};
export type DownloadStatus = 'downloading' | 'paused' | 'completed' | 'failed' | 'queued';
export type DownloadCategory = 'Documents' | 'Images' | 'Audio' | 'Video' | 'Apps' | 'Archives' | 'Other';
@@ -76,7 +112,10 @@ interface DownloadState {
removeDownload: (id: string) => Promise<void>;
clearFinished: () => void;
redownload: (id: string) => void;
processQueue: () => void;
processQueue: () => Promise<void>;
startMainQueue: () => Promise<number>;
pauseMainQueue: () => Promise<number>;
restartActiveDownloads: () => Promise<number>;
}
export const useDownloadStore = create<DownloadState>((set, get) => ({
@@ -145,9 +184,71 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}));
get().processQueue();
},
startMainQueue: async () => {
const runnableIds = get().downloads
.filter(item => item.status === 'queued' || item.status === 'paused' || item.status === 'failed')
.map(item => item.id);
if (runnableIds.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
runnableIds.includes(item.id)
? { ...item, status: 'queued', speed: '-', eta: '-' }
: item
)
}));
await get().processQueue();
return runnableIds.length;
},
pauseMainQueue: async () => {
const activeIds = get().downloads
.filter(item => item.status === 'downloading')
.map(item => item.id);
if (activeIds.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
activeIds.includes(item.id)
? { ...item, status: 'paused', speed: '-', eta: '-' }
: item
)
}));
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
syncSystemIntegrations();
return activeIds.length;
},
restartActiveDownloads: async () => {
const activeIds = get().downloads
.filter(item => item.status === 'downloading')
.map(item => item.id);
if (activeIds.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
activeIds.includes(item.id)
? { ...item, status: 'paused', speed: '-', eta: '-' }
: item
)
}));
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
await new Promise(resolve => window.setTimeout(resolve, 350));
set((state) => ({
downloads: state.downloads.map(item =>
activeIds.includes(item.id)
? { ...item, status: 'queued' }
: item
)
}));
await get().processQueue();
return activeIds.length;
},
processQueue: async () => {
const { downloads, updateDownload } = get();
const { maxConcurrentDownloads } = useSettingsStore.getState();
const settingsSnapshot = useSettingsStore.getState();
const { maxConcurrentDownloads } = settingsSnapshot;
const activeCount = downloads.filter(d => d.status === 'downloading').length;
if (activeCount >= maxConcurrentDownloads) return;
@@ -169,21 +270,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
'~/Downloads';
if (item.isMedia) {
const speedLimit = effectiveSpeedLimit(
item.speedLimit,
settings.globalSpeedLimit,
settings.maxConcurrentDownloads
);
await invoke('start_media_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null
formatSelector: item.mediaFormatSelector || null,
speedLimit
});
} else {
const speedLimit = effectiveSpeedLimit(
item.speedLimit,
settings.globalSpeedLimit,
settings.maxConcurrentDownloads
);
await invoke('start_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speedLimit: item.speedLimit || settings.globalSpeedLimit || null,
speedLimit,
username: item.username || (login ? login.username : null),
password: item.password || (login ? login.password : null),
headers: item.headers || null,
+46 -2
View File
@@ -11,6 +11,18 @@ export interface SiteLogin {
export type AppFontSize = 'small' | 'standard' | 'large';
export type ListRowDensity = 'compact' | 'standard' | 'relaxed';
export type SettingsTab = 'downloads' | 'lookandfeel' | 'network' | 'locations' | 'sitelogins' | 'power' | 'engine' | 'integrations' | 'about';
export type ActiveView = 'downloads' | 'settings' | 'scheduler' | 'speedLimiter';
export type PostQueueAction = 'none' | 'sleep' | 'restart' | 'shutdown';
export interface SchedulerSettings {
enabled: boolean;
startTime: string;
stopTimeEnabled: boolean;
stopTime: string;
everyday: boolean;
selectedDays: number[];
postQueueAction: PostQueueAction;
}
export interface SettingsState {
theme: 'dark' | 'light' | 'system' | 'dracula' | 'nord';
@@ -18,8 +30,13 @@ export interface SettingsState {
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSidebarVisible: boolean;
activeView: 'downloads' | 'settings';
activeView: ActiveView;
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
schedulerRunning: boolean;
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
// Replicated SwiftUI App Settings
perServerConnections: number;
@@ -45,8 +62,13 @@ export interface SettingsState {
setDefaultDownloadPath: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setActiveView: (view: 'downloads' | 'settings') => void;
setActiveView: (view: ActiveView) => void;
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
setSchedulerRunning: (running: boolean) => void;
setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void;
toggleSidebar: () => void;
setPerServerConnections: (count: number) => void;
@@ -114,6 +136,19 @@ export const useSettingsStore = create<SettingsState>()(
activeView: 'downloads',
activeSettingsTab: 'downloads',
isSidebarVisible: true,
scheduler: {
enabled: false,
startTime: '00:00',
stopTimeEnabled: false,
stopTime: '08:00',
everyday: true,
selectedDays: [0, 1, 2, 3, 4, 5, 6],
postQueueAction: 'none'
},
schedulerRunning: false,
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
// Replicated SwiftUI defaults
perServerConnections: 16,
@@ -149,6 +184,11 @@ export const useSettingsStore = create<SettingsState>()(
setGlobalSpeedLimit: (limit) => set({ globalSpeedLimit: limit }),
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
@@ -187,6 +227,10 @@ export const useSettingsStore = create<SettingsState>()(
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
schedulerLastStartKey: state.schedulerLastStartKey,
schedulerLastStopKey: state.schedulerLastStopKey,
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
perServerConnections: state.perServerConnections,
maxAutomaticRetries: state.maxAutomaticRetries,