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 DiagnosticsView from "./components/DiagnosticsView"; function App() { const [filter, setFilter] = useState('all'); 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 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').length; const doneCount = downloads.filter(download => download.status === 'completed').length; const schedulerRunning = useSettingsStore(state => state.schedulerRunning); const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const previousSpeedLimit = useRef(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); 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]); useEffect(() => { useDownloadStore.getState().initDB(); // Hydrate the browser-extension pairing token from the OS keychain before // the reactive push to the backend. If no token exists (fresh install or // upgrade from a plaintext-persisting version) a new one is minted and // stored, effectively rotating it away from any leaked plaintext. useSettingsStore.getState().hydratePairingToken().catch(error => { console.error('Failed to hydrate extension pairing token:', error); }); }, []); useEffect(() => { window.document.documentElement.setAttribute('data-font-size', appFontSize); }, [appFontSize]); useEffect(() => { invoke('set_concurrent_limit', { limit: maxConcurrentDownloads }).catch(console.error); }, [maxConcurrentDownloads]); useEffect(() => { invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {}); }, [showDockBadge, activeDownloadCount]); useEffect(() => { invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error); }, [showMenuBarIcon]); useEffect(() => { 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(() => { const unlisten = listen('schedule-trigger', async (event) => { const state = useSettingsStore.getState(); if (event.payload === 'start') { const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID); state.setSchedulerRunning(started > 0); } else if (event.payload === 'stop') { await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID); state.setSchedulerRunning(false); } }); return () => { unlisten.then(f => f()).catch(console.error); }; }, []); useEffect(() => { if (!schedulerRunning) return; const hasPendingScheduledWork = downloads.some(download => isActiveDownloadStatus(download.status) ); 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 () => { let permissionGranted = await isPermissionGranted(); if (!permissionGranted) { await requestPermission(); } }; initNotifications(); }, []); 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(() => { 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'; if (event.payload.status === 'completed') { sendNotification({ title: 'Download Complete', body: `${fileName} has finished downloading.`, sound: settings.playCompletionSound ? 'default' : undefined }); } else { sendNotification({ title: 'Download Failed', body: `${fileName} failed to download.`, }); } }); const unlistenExtension = listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload); }); 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()); }; }, []); return (
{ setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />
{activeView === 'downloads' && } {activeView === 'settings' && } {activeView === 'scheduler' && } {activeView === 'speedLimiter' && } {activeView === 'diagnostics' && }
{/* Status Bar */}
Ready
{activeDownloadCount} active {queuedCount} queued {doneCount} done
); } export default App;