import { useCallback, useEffect, useMemo, useState } from 'react'; import { invokeCommand as invoke } from '../ipc'; import { AlertCircle, CheckCircle2, Clock3, List, Moon, LockKeyhole, Pause, Play, Power, RotateCcw, Save } 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'; 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 }, ]; const minuteOfDay = (value: string) => { const [hour, minute] = value.split(':').map(Number); return hour * 60 + minute; }; 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 queues = useDownloadStore(state => state.queues); const [draft, setDraft] = useState(savedSettings); const { addToast } = useToast(); const [permissionMessage, setPermissionMessage] = useState(''); const [automationPermissionGranted, setAutomationPermissionGranted] = useState(null); const platform = usePlatformInfo(); const isMac = platform.os === 'macos'; useEffect(() => { setDraft(savedSettings); }, [savedSettings]); const nextRun = useMemo(() => nextScheduledRun(draft), [draft]); const hasUnsavedChanges = useMemo( () => JSON.stringify(draft) !== JSON.stringify(savedSettings), [draft, savedSettings] ); const updateDraft = (key: K, value: SchedulerSettings[K]) => { setDraft(current => ({ ...current, [key]: value })); }; const toggleDay = (day: number) => { setDraft(current => ({ ...current, selectedDays: current.selectedDays.includes(day) ? current.selectedDays.filter(value => value !== day) : [...current.selectedDays, day].sort() })); }; const availableQueueIds = new Set(queues.map(queue => queue.id)); const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId)); const effectiveSelectedQueueIds = selectedQueueIds; const toggleQueue = (queueId: string) => { setDraft(current => { const isSelected = current.selectedQueueIds.includes(queueId); const availableSelectionCount = current.selectedQueueIds .filter(id => availableQueueIds.has(id)) .length; if (isSelected && availableSelectionCount === 1) return current; return { ...current, selectedQueueIds: isSelected ? current.selectedQueueIds.filter(id => id !== queueId) : [...current.selectedQueueIds, queueId] }; }); }; const save = () => { if (draft.enabled && !draft.everyday && draft.selectedDays.length === 0) { addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true }); return; } if (draft.enabled && effectiveSelectedQueueIds.length === 0) { addToast({ message: 'Select at least one queue for the scheduler', variant: 'error', isActionable: true }); return; } if (draft.enabled && draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) { addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true }); return; } const normalized = { ...draft, selectedDays: draft.selectedDays, selectedQueueIds: effectiveSelectedQueueIds }; setScheduler(normalized); setDraft(normalized); addToast({ message: 'Scheduler settings saved', variant: 'success' }); }; const runNow = async () => { const previouslyTrackedIds = new Set(useSettingsStore.getState().schedulerActiveDownloadIds); const results = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = results.flat(); const selectedQueueSet = new Set(effectiveSelectedQueueIds); const trackedIds = useDownloadStore.getState().downloads .filter(download => previouslyTrackedIds.has(download.id) && selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) && isActiveDownloadStatus(download.status) ) .map(download => download.id); const activeIds = [...new Set([...acceptedIds, ...trackedIds])]; if (activeIds.length > 0) { useSettingsStore.getState().setSchedulerRunning(true); useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds); addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' }); } else { addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' }); } }; const pauseNow = async () => { const counts = await Promise.all( effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId)) ); const count = counts.reduce((total, queueCount) => total + queueCount, 0); useSettingsStore.getState().setSchedulerRunning(false); useSettingsStore.getState().setSchedulerActiveDownloadIds([]); addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' }); }; const refreshPermissionStatus = useCallback(async (showMessage = false) => { if (!isMac) return; try { await invoke('check_automation_permission'); setAutomationPermissionGranted(true); if (showMessage) { setPermissionMessage('Automation permission is available.'); } } catch { setAutomationPermissionGranted(false); if (showMessage) { setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.'); } } }, [isMac]); useEffect(() => { if (!isMac) return; void refreshPermissionStatus(); const refreshOnFocus = () => { void refreshPermissionStatus(); }; const refreshOnVisibility = () => { if (document.visibilityState === 'visible') { void refreshPermissionStatus(); } }; window.addEventListener('focus', refreshOnFocus); document.addEventListener('visibilitychange', refreshOnVisibility); return () => { window.removeEventListener('focus', refreshOnFocus); document.removeEventListener('visibilitychange', refreshOnVisibility); }; }, [isMac, refreshPermissionStatus]); const openAutomationSettings = async (message: string) => { setPermissionMessage(message); try { await invoke('open_automation_settings'); } catch (error) { setPermissionMessage(String(error)); } }; const handlePermissionAction = async () => { if (automationPermissionGranted) { await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.'); return; } setPermissionMessage('Requesting Automation permission...'); try { await invoke('request_automation_permission'); setAutomationPermissionGranted(true); setPermissionMessage('Automation permission is available.'); } catch { setAutomationPermissionGranted(false); await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.'); } }; return (
Scheduler
{schedulerRunning ? 'Running' : nextRun} {hasUnsavedChanges && ( Unsaved changes )}
Timing
updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />

If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.

{!draft.everyday && (
{days.map(day => { const selected = draft.selectedDays.includes(day.value); return ( ); })}
)}
Queues to Schedule
{queues.map(queue => { const selected = draft.selectedQueueIds.includes(queue.id); return ( ); })}
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 System Events.

{automationPermissionGranted ? ( <> Automation permission granted ) : ( <> {automationPermissionGranted === null ? 'Checking Automation permission...' : 'Automation permission missing'} )}
{permissionMessage &&

{permissionMessage}

}
) : (
System Actions

Sleep, restart, and shut down use {platform.os === 'windows' ? 'Windows system privileges' : 'your Linux desktop and system policy'}. Firelink reports any rejected action when it runs; no permanent permission is claimed in advance.

)}
); }