import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads'; import { useEffect, useRef, useState } from "react"; import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { DownloadTable } from "./components/DownloadTable"; import { AddDownloadsModal } from "./components/AddDownloadsModal"; import SettingsView from "./components/SettingsView"; import { PropertiesModal } from "./components/PropertiesModal"; import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal"; import { extractValidDownloadUrls } from './utils/url'; import { listenEvent as listen, invokeCommand as invoke } from "./ipc"; import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore'; import { initDownloadListener } from './store/downloadStore'; import { useSettingsStore } from "./store/useSettingsStore"; import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification'; import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; import LogsView from "./components/LogsView"; import { KeychainPermissionModal } from "./components/KeychainPermissionModal"; import { useToast } from "./contexts/ToastContext"; import { openUrl } from '@tauri-apps/plugin-opener'; import { usePlatformInfo } from './utils/platform'; let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); const waitForSettingsHydration = (): Promise => { if (useSettingsStore.persist.hasHydrated()) return Promise.resolve(); return new Promise(resolve => { const unsubscribe = useSettingsStore.persist.onFinishHydration(() => { unsubscribe(); resolve(); }); }); }; const getScheduledQueueIds = () => { const downloadState = useDownloadStore.getState(); const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id)); const selectedQueueIds = useSettingsStore.getState().scheduler.selectedQueueIds .filter(queueId => availableQueueIds.has(queueId)); return selectedQueueIds; }; function App() { const platform = usePlatformInfo(); const platformOsRef = useRef(platform.os); const [filter, setFilter] = useState('all'); const [coreReady, setCoreReady] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const stored = Number(window.localStorage.getItem('firelink-sidebar-width')); return Number.isFinite(stored) && stored >= 190 && stored <= 260 ? stored : 220; }); const theme = useSettingsStore(state => state.theme); const isSidebarVisible = useSettingsStore(state => state.isSidebarVisible); const activeView = useSettingsStore(state => state.activeView); const appFontSize = useSettingsStore(state => state.appFontSize); const listRowDensity = useSettingsStore(state => state.listRowDensity); const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates); const showNotifications = useSettingsStore(state => state.showNotifications); const showDockBadge = useSettingsStore(state => state.showDockBadge); const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon); const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken); const downloads = useDownloadStore(state => state.downloads); const activeDownloadCount = downloads.filter(download => download.status === 'downloading').length; const queuedCount = downloads.filter(download => download.status === 'queued' || download.status === 'staged' ).length; 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(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const activeTransferCount = downloads.filter(download => download.status === 'downloading' || download.status === 'processing' || download.status === 'retrying' ).length; const acknowledgePairingTokenChange = () => { invoke('acknowledge_pairing_token_change').catch(error => { console.error('Failed to acknowledge pairing token migration notice:', error); }); }; const startSidebarResize = (event: React.PointerEvent) => { event.preventDefault(); const startX = event.clientX; const startWidth = sidebarWidth; const handlePointerMove = (moveEvent: PointerEvent) => { const nextWidth = Math.min(260, Math.max(190, startWidth + moveEvent.clientX - startX)); setSidebarWidth(nextWidth); }; const handlePointerUp = () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); document.body.classList.remove('is-resizing'); }; document.body.classList.add('is-resizing'); window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); }; useEffect(() => { initMediaDomains(); window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); const { addToast } = useToast(); useEffect(() => { platformOsRef.current = platform.os; }, [platform.os]); useEffect(() => { let active = true; const initialize = async () => { try { await waitForSettingsHydration(); await useDownloadStore.getState().initDB(); if (active) setCoreReady(true); } catch (error) { console.error('Failed to initialize Firelink state:', error); addToast({ message: `Could not initialize saved downloads: ${String(error)}`, variant: 'error', isActionable: true }); return; } try { const changed = await useSettingsStore.getState().hydratePairingToken(); if (changed) { addToast({ variant: 'warning', isActionable: true, message: (

Browser extension disconnected because its pairing token changed.

) }); } } catch (error) { console.error('Failed to hydrate extension pairing token:', error); addToast({ message: `Secure credential persistence is unavailable. Browser pairing works for this session only: ${String(error)}`, variant: 'error', isActionable: true }); } }; void initialize(); return () => { active = false; }; }, [addToast]); useEffect(() => { window.document.documentElement.setAttribute('data-font-size', appFontSize); }, [appFontSize]); useEffect(() => { window.document.documentElement.setAttribute('data-list-density', listRowDensity); }, [listRowDensity]); useEffect(() => { const checkForUpdate = () => { if (!useSettingsStore.getState().autoCheckUpdates || automaticUpdateCheckStarted) return; automaticUpdateCheckStarted = true; invoke('check_for_updates') .then(result => { if (result.type !== 'UpdateAvailable') return; addToast({ variant: 'info', isActionable: true, message: (
Firelink {result.update.version} is available.
) }); }) .catch(error => { automaticUpdateCheckStarted = false; console.error('Automatic update check failed:', error); }); }; if (useSettingsStore.persist.hasHydrated()) { checkForUpdate(); return; } return useSettingsStore.persist.onFinishHydration(checkForUpdate); }, [addToast, autoCheckUpdates]); useEffect(() => { invoke('set_concurrent_limit', { limit: maxConcurrentDownloads }).catch(console.error); }, [maxConcurrentDownloads]); useEffect(() => { if (platform.os === 'macos') { invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); } }, [platform.os, showDockBadge, activeDownloadCount]); useEffect(() => { invoke('set_prevent_sleep', { prevent: preventsSleepWhileDownloading && activeTransferCount > 0 }).catch(error => { console.error('Failed to update sleep prevention:', error); addToast({ message: `Could not update sleep prevention: ${String(error)}`, variant: 'error', isActionable: true }); }); }, [addToast, preventsSleepWhileDownloading, activeTransferCount]); useEffect(() => { invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); }, [showMenuBarIcon]); useEffect(() => { if (!extensionPairingToken) return; invoke('set_extension_pairing_token', { token: extensionPairingToken }).catch(error => { console.error('Failed to configure browser extension pairing token:', error); }); }, [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) => { const state = useSettingsStore.getState(); const payload = event.payload; if (processingScheduleKeys.has(payload.key)) return; processingScheduleKeys.add(payload.key); try { if (payload.action === 'start') { const scheduledQueueIds = getScheduledQueueIds(); if (scheduledQueueIds.length === 0) { state.setSchedulerActiveDownloadIds([]); state.setSchedulerRunning(false); addToast({ message: 'Scheduler has no valid queues selected. Update Scheduler settings.', variant: 'warning', isActionable: true }); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); return; } const startedResults = await Promise.all( scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId)) ); const acceptedIds = startedResults.flat(); const scheduledQueueSet = new Set(scheduledQueueIds); const trackedIds = useDownloadStore.getState().downloads .filter(download => scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) && isActiveDownloadStatus(download.status) ) .map(download => download.id); const activeIds = [...new Set([...acceptedIds, ...trackedIds])]; state.setSchedulerActiveDownloadIds(activeIds); state.setSchedulerRunning(activeIds.length > 0); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); } else if (payload.action === 'stop') { const trackedIds = state.schedulerActiveDownloadIds; if (trackedIds.length > 0) { const pauseResults = await Promise.allSettled( trackedIds.map(id => invoke('pause_download', { id })) ); const failedPauses = pauseResults.filter(result => result.status === 'rejected').length; if (failedPauses > 0) { addToast({ message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`, variant: 'error', isActionable: true }); } } state.setSchedulerActiveDownloadIds([]); state.setSchedulerRunning(false); await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key }); } } finally { processingScheduleKeys.delete(payload.key); } }); return () => { unlisten.then(f => f()).catch(console.error); }; }, [addToast, coreReady]); useEffect(() => { if (!schedulerRunning) return; if (schedulerActiveDownloadIds.length === 0) return; const settings = useSettingsStore.getState(); const scheduledItems = schedulerActiveDownloadIds.map(id => downloads.find(download => download.id === id) ); if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return; const allCompleted = scheduledItems.every(item => item?.status === 'completed'); settings.setSchedulerActiveDownloadIds([]); settings.setSchedulerRunning(false); let timer: number | undefined; if (!allCompleted) { addToast({ message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', variant: 'warning', isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { const action = settings.scheduler.postQueueAction; let cancelled = false; addToast({ variant: 'warning', isActionable: true, message: (
{action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds.
) }); timer = window.setTimeout(() => { if (cancelled) return; const activeTransfers = useDownloadStore.getState().downloads.some(download => isActiveDownloadStatus(download.status) ); if (activeTransfers) { addToast({ message: 'System action cancelled because another download is active.', variant: 'warning', isActionable: true }); return; } invoke('perform_system_action', { action }).catch(error => { console.error('Scheduled post action failed:', error); addToast({ message: `Scheduled system action failed: ${String(error)}`, variant: 'error', isActionable: true }); }); }, 10_000); } return () => { if (timer !== undefined) { window.clearTimeout(timer); } }; }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]); useEffect(() => { const initNotifications = async () => { if (!useSettingsStore.getState().showNotifications) return; try { const permissionGranted = await isPermissionGranted(); if (!permissionGranted) { const permission = await requestPermission(); if (permission !== 'granted') { addToast({ message: 'System notifications are disabled for Firelink.', variant: 'warning', isActionable: true }); } } } catch (error) { addToast({ message: `Could not configure notifications: ${String(error)}`, variant: 'error', isActionable: true }); } }; if (useSettingsStore.persist.hasHydrated()) { void initNotifications(); return; } return useSettingsStore.persist.onFinishHydration(() => { void initNotifications(); }); }, [addToast, showNotifications]); useEffect(() => { const handlePaste = (e: ClipboardEvent) => { if ( e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement || (e.target as HTMLElement).isContentEditable ) { return; } const text = e.clipboardData?.getData('text/plain'); if (text && text.trim().length > 0) { const urls = extractValidDownloadUrls(text); if (urls.length > 0) { useDownloadStore.getState().openAddModalWithUrls(urls.join('\n')); } } }; window.addEventListener('paste', handlePaste); return () => window.removeEventListener('paste', handlePaste); }, []); useEffect(() => { const root = window.document.documentElement; const applyTheme = () => { // Remove all theme classes first root.classList.remove('theme-dark', 'theme-light', 'theme-dracula', 'theme-nord', 'dark'); if (theme === 'system') { const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches; root.classList.add(systemDark ? 'theme-dark' : 'theme-light'); if (systemDark) root.classList.add('dark'); } else { root.classList.add(`theme-${theme}`); if (['dark', 'dracula', 'nord'].includes(theme)) { root.classList.add('dark'); } } }; applyTheme(); if (theme === 'system') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const listener = () => applyTheme(); mediaQuery.addEventListener('change', listener); return () => mediaQuery.removeEventListener('change', listener); } }, [theme]); useEffect(() => { if (!coreReady) return; const unlistenDownload = initDownloadListener(); const unlistenTerminalState = listen('download-state', (event) => { if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return; const settings = useSettingsStore.getState(); if (!settings.showNotifications) return; const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload.id); const fileName = item?.fileName || 'A file'; const platformOs = platformOsRef.current; const sound = settings.playCompletionSound ? platformOs === 'macos' ? 'Ping' : platformOs === 'linux' ? 'message-new-instant' : undefined : undefined; if (event.payload.status === 'completed') { try { sendNotification({ title: 'Download Complete', body: `${fileName} has finished downloading.`, sound }); } catch (error) { console.error('Completion notification failed:', error); } } else { try { sendNotification({ title: 'Download Failed', body: `${fileName} failed to download.`, }); } catch (error) { console.error('Failure notification failed:', error); } } }); const unlistenExtension = listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { console.error('Failed to handle browser extension download:', error); }); }); const unlistenDeepLink = listen('deep-link-add-download', (event) => { useDownloadStore.getState().openAddModalWithUrls(event.payload); }); Promise.all([unlistenExtension, unlistenDeepLink]) .then(() => invoke('set_extension_frontend_ready', { ready: true })) .catch(error => console.error('Failed to activate browser extension integration:', error)); return () => { invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); unlistenTerminalState.then(f => f()); unlistenExtension.then(f => f()); unlistenDeepLink.then(f => f()); unlistenDownload.then(f => { if (f) f(); }); }; }, [coreReady]); return (
{ setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />
{activeView === 'downloads' && } {activeView === 'settings' && } {activeView === 'scheduler' && } {activeView === 'speedLimiter' && } {activeView === 'logs' && }
{/* Status Bar */}
Ready
{activeDownloadCount} active {queuedCount} queued {doneCount} done
); } export default App;