mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
2479ead4ed
Audit Assignment 06 lifecycle paths and preserve truthful state across ambiguous Aria2 resume failures. Clean up partial startup listener registration and prevent stale resume workers from leaking or releasing current permits.
757 lines
28 KiB
TypeScript
757 lines
28 KiB
TypeScript
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<string>();
|
|
|
|
const waitForSettingsHydration = (): Promise<void> => {
|
|
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
|
|
return new Promise(resolve => {
|
|
const unsubscribe = useSettingsStore.persist.onFinishHydration(() => {
|
|
unsubscribe();
|
|
resolve();
|
|
});
|
|
});
|
|
};
|
|
|
|
let downloadStateInitialization: Promise<void> | null = null;
|
|
const initializeDownloadState = (): Promise<void> => {
|
|
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<SidebarFilter>('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<number | null>(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<PostQueueAction, 'none'>) => {
|
|
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: (
|
|
<div className="flex items-center gap-3">
|
|
<span>{actionLabel} in 10 seconds.</span>
|
|
<button
|
|
type="button"
|
|
className="app-button px-2 py-1"
|
|
onClick={cancel}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
)
|
|
});
|
|
|
|
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<HTMLDivElement>) => {
|
|
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: (
|
|
<div className="flex flex-col gap-2">
|
|
<p>Browser extension disconnected because its pairing token changed.</p>
|
|
<div className="flex gap-2 justify-end">
|
|
<button
|
|
type="button"
|
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
|
onClick={async () => {
|
|
const token = useSettingsStore.getState().extensionPairingToken;
|
|
try {
|
|
if (token) {
|
|
await navigator.clipboard.writeText(token);
|
|
}
|
|
acknowledgePairingTokenChange();
|
|
} catch (error) {
|
|
addToast({
|
|
message: `Could not copy pairing token: ${String(error)}`,
|
|
variant: 'error',
|
|
isActionable: true
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
Copy token
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="app-button px-2 py-1 bg-surface-raised border border-border-color rounded"
|
|
onClick={() => {
|
|
const settings = useSettingsStore.getState();
|
|
settings.setActiveSettingsTab('integrations');
|
|
settings.setActiveView('settings');
|
|
acknowledgePairingTokenChange();
|
|
}}
|
|
>
|
|
Integrations
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
});
|
|
}
|
|
} 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: (
|
|
<div className="flex items-center gap-3">
|
|
<span>Firelink {result.update.version} is available.</span>
|
|
<button
|
|
type="button"
|
|
className="app-button px-2 py-1"
|
|
onClick={() => {
|
|
void openUrl(result.update.release_url);
|
|
}}
|
|
>
|
|
View release
|
|
</button>
|
|
</div>
|
|
)
|
|
});
|
|
})
|
|
.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 (
|
|
<div className={`app-shell flex h-screen w-screen overflow-hidden text-text-primary ${
|
|
hasWindowChrome ? 'app-shell--window-chrome' : ''
|
|
}`}>
|
|
{(platform.os === 'windows' || platform.os === 'linux') && <WindowControls />}
|
|
<div
|
|
className={`app-sidebar-shell relative z-20 shrink-0 transition-all duration-300 ease-[cubic-bezier(0.2,0.8,0.2,1)] ${
|
|
isSidebarVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
}`}
|
|
style={{
|
|
width: sidebarWidth,
|
|
marginLeft: isSidebarVisible ? 0 : -sidebarWidth
|
|
}}
|
|
>
|
|
<div className="app-sidebar-panel h-full w-full">
|
|
<Sidebar
|
|
selectedFilter={filter}
|
|
onSelectFilter={(f) => {
|
|
setFilter(f);
|
|
useSettingsStore.getState().setActiveView('downloads');
|
|
}}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="sidebar-resize-handle"
|
|
onPointerDown={startSidebarResize}
|
|
title="Resize Sidebar"
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
className={`app-workspace relative z-0 flex-1 flex flex-col h-full overflow-hidden ${
|
|
!isSidebarVisible ? 'app-workspace--sidebar-collapsed' : ''
|
|
} ${usesCustomWindowControls ? 'app-workspace--custom-window-controls' : ''}`}
|
|
>
|
|
{!isSidebarVisible && (
|
|
<button
|
|
type="button"
|
|
onClick={toggleSidebar}
|
|
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
|
|
title="Show Sidebar"
|
|
aria-label="Show Sidebar"
|
|
>
|
|
<PanelLeft size={16} strokeWidth={2} />
|
|
</button>
|
|
)}
|
|
<div className="flex-1 flex flex-col overflow-hidden relative">
|
|
{activeView === 'downloads' && <DownloadTable filter={filter} />}
|
|
{activeView === 'settings' && <SettingsView />}
|
|
{activeView === 'scheduler' && <SchedulerView />}
|
|
{activeView === 'speedLimiter' && <SpeedLimiterView />}
|
|
{activeView === 'logs' && <LogsView />}
|
|
</div>
|
|
|
|
{/* Status Bar */}
|
|
<div className="app-statusbar px-[14px] flex items-center justify-between text-text-muted shrink-0">
|
|
<span>Ready</span>
|
|
<div className="flex gap-3 tabular-nums">
|
|
<span>{activeDownloadCount} active</span>
|
|
<span>{queuedCount} queued</span>
|
|
<span>{doneCount} done</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<AddDownloadsModal />
|
|
<PropertiesModal />
|
|
<DeleteConfirmationModal />
|
|
<KeychainPermissionModal />
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|