From f378fc0d8d40c2f359858a4de4cf0ad33ef570ee Mon Sep 17 00:00:00 2001 From: NimBold Date: Fri, 12 Jun 2026 21:17:34 +0330 Subject: [PATCH] feat(desktop): implement scheduler and speed limiter --- apps/desktop/src-tauri/src/lib.rs | 117 +++++++- apps/desktop/src/App.tsx | 87 +++++- apps/desktop/src/components/SchedulerView.tsx | 262 ++++++++++++++++++ apps/desktop/src/components/Sidebar.tsx | 26 +- .../src/components/SpeedLimiterView.tsx | 139 ++++++++++ apps/desktop/src/store/useDownloadStore.ts | 120 +++++++- apps/desktop/src/store/useSettingsStore.ts | 48 +++- 7 files changed, 779 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/components/SchedulerView.tsx create mode 100644 apps/desktop/src/components/SpeedLimiterView.tsx diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index eb42e70..32a586f 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -470,6 +470,7 @@ async fn start_media_download( destination: String, filename: String, format_selector: Option, + speed_limit: Option, ) -> 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 { 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"); diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index c2de485..6ef9f79 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -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('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 (
{isSidebarVisible && { setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />} - {activeView === 'downloads' ? ( - - ) : ( - - )} + {activeView === 'downloads' && } + {activeView === 'settings' && } + {activeView === 'scheduler' && } + {activeView === 'speedLimiter' && }
diff --git a/apps/desktop/src/components/SchedulerView.tsx b/apps/desktop/src/components/SchedulerView.tsx new file mode 100644 index 0000000..ea33a96 --- /dev/null +++ b/apps/desktop/src/components/SchedulerView.tsx @@ -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(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 = (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 ( +
+ + +
+ + + {schedulerRunning ? 'Running' : nextRun} + +
+ + + +
+
+ +
+
+
+
+ Timing +
+
+ +
+ + 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" /> +
+
+ +
+ + {!draft.everyday && ( +
+ {days.map(day => { + const selected = draft.selectedDays.includes(day.value); + return ( + + ); + })} +
+ )} +
+ +
+
+ Queues to Schedule +
+ +
+ +
+
+ After Completion +
+

Choose what happens after downloads started by the scheduler finish.

+
+ {postActions.map(action => { + const Icon = action.icon; + return ( + + ); + })} +
+ {draft.postQueueAction !== 'none' && ( +

This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.

+ )} +
+
+ + {isMac && ( +
+
+ System Permissions +
+

Sleep, restart, and shut down require macOS Automation permission for Finder.

+
+ + +
+ {permissionMessage &&

{permissionMessage}

} +
+ )} +
+ + {toast && ( +
+ {toast} +
+ )} +
+ ); +} diff --git a/apps/desktop/src/components/Sidebar.tsx b/apps/desktop/src/components/Sidebar.tsx index f027342..d5c104a 100644 --- a/apps/desktop/src/components/Sidebar.tsx +++ b/apps/desktop/src/components/Sidebar.tsx @@ -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 = (props) => { ); }; + const ToolItem = ({ icon: Icon, label, view }: { icon: any; label: string; view: ActiveView }) => { + const isSelected = activeView === view; + return ( + + ); + }; + return (