import { initMediaDomains, isActiveDownloadStatus, isTransferActiveStatus } from './utils/downloads'; import { schedulerCompletionState } from './utils/schedulerCompletion'; import { useCallback, 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 { WindowControls } from "./components/WindowControls"; import { useToast } from "./contexts/ToastContext"; import { setLogStreamActive } from './utils/logger'; import { openUrl } from '@tauri-apps/plugin-opener'; import { usePlatformInfo } from './utils/platform'; import type { PostQueueAction } from './bindings/PostQueueAction'; import { PanelLeft } from 'lucide-react'; 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(); }); }); }; let downloadStateInitialization: Promise | null = null; const initializeDownloadState = (): Promise => { if (!downloadStateInitialization) { downloadStateInitialization = (async () => { await waitForSettingsHydration(); await useDownloadStore.getState().initDB(); })().catch(error => { downloadStateInitialization = null; throw error; }); } return downloadStateInitialization; }; 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; }; type AudioContextConstructor = typeof AudioContext; const playCompletionChime = async () => { const AudioCtor = window.AudioContext || (window as Window & { webkitAudioContext?: AudioContextConstructor }).webkitAudioContext; if (!AudioCtor) return; const context = new AudioCtor(); if (context.state === 'suspended') { await context.resume(); } const oscillator = context.createOscillator(); const gain = context.createGain(); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(880, context.currentTime); oscillator.frequency.exponentialRampToValueAtTime(1320, context.currentTime + 0.12); gain.gain.setValueAtTime(0.0001, context.currentTime); gain.gain.exponentialRampToValueAtTime(0.18, context.currentTime + 0.02); gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.24); oscillator.connect(gain); gain.connect(context.destination); oscillator.start(); oscillator.stop(context.currentTime + 0.24); oscillator.onended = () => { void context.close(); }; }; function App() { const platform = usePlatformInfo(); 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 toggleSidebar = useSettingsStore(state => state.toggleSidebar); 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 => isTransferActiveStatus(download.status)).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 pendingPostActionTimer = useRef(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length; const { addToast } = useToast(); const isMacUserAgent = navigator.userAgent.includes('Mac'); const usesCustomWindowControls = !isMacUserAgent && platform.os !== 'macos'; // Keep dialogs out of the titlebar area while platform detection is still // resolving. The conservative fallback prevents a startup handoff from // briefly rendering underneath native or custom window controls. const hasWindowChrome = isMacUserAgent || ['macos', 'windows', 'linux', 'unknown'].includes(platform.os); const acknowledgePairingTokenChange = () => { invoke('acknowledge_pairing_token_change').catch(error => { console.error('Failed to acknowledge pairing token migration notice:', error); }); }; const clearPendingPostActionTimer = useCallback(() => { if (pendingPostActionTimer.current !== null) { window.clearTimeout(pendingPostActionTimer.current); pendingPostActionTimer.current = null; } }, []); const schedulePostQueueAction = useCallback((action: Exclude) => { clearPendingPostActionTimer(); const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'; let timerId: number | null = null; const cancel = () => { if (timerId !== null) { window.clearTimeout(timerId); if (pendingPostActionTimer.current === timerId) { pendingPostActionTimer.current = null; } timerId = null; } }; addToast({ variant: 'warning', isActionable: true, message: (
{actionLabel} in 10 seconds.
) }); timerId = window.setTimeout(() => { if (pendingPostActionTimer.current === timerId) { pendingPostActionTimer.current = null; } timerId = null; const activeTransfers = useDownloadStore.getState().downloads.some(download => isActiveDownloadStatus(download.status) ); if (activeTransfers) { addToast({ message: 'System action cancelled because another download is active or queued.', 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); pendingPostActionTimer.current = timerId; }, [addToast, clearPendingPostActionTimer]); 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(() => { return clearPendingPostActionTimer; }, [clearPendingPostActionTimer]); useEffect(() => { if (activeTransferCount > 0) { clearPendingPostActionTimer(); } }, [activeTransferCount, clearPendingPostActionTimer]); useEffect(() => { initMediaDomains(); window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); useEffect(() => { let active = true; let cleanupListeners: (() => void) | null = null; const initialize = async () => { let unlistenDownload: (() => void) | null = null; let unlistenTerminalState: (() => void) | null = null; let unlistenExtension: (() => void) | null = null; let unlistenDeepLink: (() => void) | null = null; const disposeListeners = () => { invoke('set_extension_frontend_ready', { ready: false }).catch(() => {}); unlistenTerminalState?.(); unlistenTerminalState = null; unlistenExtension?.(); unlistenExtension = null; unlistenDeepLink?.(); unlistenDeepLink = null; unlistenDownload?.(); unlistenDownload = null; }; try { unlistenDownload = await initDownloadListener(); unlistenTerminalState = await listen('download-state', (event) => { if (event.payload.status !== 'completed' && event.payload.status !== 'failed') return; const settings = useSettingsStore.getState(); if (event.payload.status === 'completed' && settings.playCompletionSound) { playCompletionChime().catch(error => { console.error('Completion sound failed:', error); }); } 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') { try { sendNotification({ title: 'Download Complete', body: `${fileName} has finished downloading.` }); } 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); } } }); unlistenExtension = await listen('extension-add-download', (event) => { useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => { console.error('Failed to handle browser extension download:', error); }); }); unlistenDeepLink = await listen('deep-link-add-download', (event) => { useDownloadStore.getState().openAddModalWithUrls(event.payload); }); cleanupListeners = disposeListeners; if (!active) { disposeListeners(); cleanupListeners = null; return; } await initializeDownloadState(); if (!active) return; } catch (error) { disposeListeners(); cleanupListeners = null; if (!active) return; 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 }); } if (!active) return; setCoreReady(true); invoke('set_extension_frontend_ready', { ready: true }).catch(error => { console.error('Failed to activate browser extension integration:', error); }); }; void initialize(); return () => { active = false; cleanupListeners?.(); cleanupListeners = null; }; }, [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 (activeView !== 'logs') { setLogStreamActive(false).catch(console.error); } }, [activeView]); 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 (!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') { clearPendingPostActionTimer(); 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 previouslyTrackedIds = new Set(state.schedulerActiveDownloadIds); 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 => previouslyTrackedIds.has(download.id) && 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) { clearPendingPostActionTimer(); const pauseResults = await Promise.allSettled( trackedIds.map(id => useDownloadStore.getState().pauseDownload(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, clearPendingPostActionTimer, coreReady]); useEffect(() => { if (!schedulerRunning) return; if (schedulerActiveDownloadIds.length === 0) return; clearPendingPostActionTimer(); const settings = useSettingsStore.getState(); const completionState = schedulerCompletionState(downloads, schedulerActiveDownloadIds); if (completionState === 'active') return; settings.setSchedulerActiveDownloadIds([]); settings.setSchedulerRunning(false); if (completionState !== 'completed') { 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') { if (downloads.some(download => isActiveDownloadStatus(download.status))) { addToast({ message: 'Scheduled system action skipped because another download is active or queued.', variant: 'warning', isActionable: true }); } else { schedulePostQueueAction(settings.scheduler.postQueueAction); } } }, [ addToast, clearPendingPostActionTimer, downloads, schedulePostQueueAction, 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'); root.dataset.resolvedTheme = systemDark ? 'dark' : 'light'; root.style.colorScheme = systemDark ? 'dark' : 'light'; if (systemDark) root.classList.add('dark'); } else { root.classList.add(`theme-${theme}`); if (['dark', 'dracula', 'nord'].includes(theme)) { root.classList.add('dark'); } root.dataset.resolvedTheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; root.style.colorScheme = ['dark', 'dracula', 'nord'].includes(theme) ? 'dark' : 'light'; } }; 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]); return (
{(platform.os === 'windows' || platform.os === 'linux') && }
{ setFilter(f); useSettingsStore.getState().setActiveView('downloads'); }} />
{!isSidebarVisible && ( )}
{activeView === 'downloads' && } {activeView === 'settings' && } {activeView === 'scheduler' && } {activeView === 'speedLimiter' && } {activeView === 'logs' && }
{/* Status Bar */}
Ready
{activeDownloadCount} active {queuedCount} queued {doneCount} done
); } export default App;