mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
962 lines
36 KiB
TypeScript
962 lines
36 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 { readClipboardDownloadUrls } from './utils/clipboard';
|
|
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
|
|
import { useDownloadStore, MAIN_QUEUE_ID, type ExtensionDownloadRequest } from './store/useDownloadStore';
|
|
import { initDownloadListener } from './store/downloadStore';
|
|
import { subscribeToSettingsPersistenceErrors, 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 { getPlatformInfo, usePlatformInfo } from './utils/platform';
|
|
import { getKeychainConsentVersion, getKeychainStartupDecision } from './utils/keychainStartup';
|
|
import { getVersion } from '@tauri-apps/api/app';
|
|
import type { PostQueueAction } from './bindings/PostQueueAction';
|
|
import { PanelLeft } from 'lucide-react';
|
|
import { isTrustedFirelinkReleaseUrl } from './utils/releaseUrls';
|
|
|
|
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 [keychainConsentVersion, setKeychainConsentVersion] = useState('');
|
|
|
|
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 autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
|
|
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 showKeychainModal = useSettingsStore(state => state.showKeychainModal);
|
|
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 startupResumeStarted = useRef(false);
|
|
const startupInputReady = useRef(false);
|
|
const frontendReadyUpdate = useRef<Promise<void>>(Promise.resolve());
|
|
const pendingStartupInputs = useRef<Array<
|
|
| { type: 'extension'; payload: ExtensionDownloadRequest }
|
|
| { type: 'deep-link'; payload: string }
|
|
>>([]);
|
|
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
|
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
|
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
|
|
const { addToast, removeToast } = 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);
|
|
|
|
useEffect(() => subscribeToSettingsPersistenceErrors(() => {
|
|
addToast({
|
|
message: 'Could not save settings. Check storage permissions and try again.',
|
|
variant: 'error',
|
|
isActionable: true
|
|
});
|
|
}), [addToast]);
|
|
|
|
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 queueFrontendReadyUpdate = useCallback((ready: boolean) => {
|
|
const update = frontendReadyUpdate.current
|
|
.catch(() => undefined)
|
|
.then(() => invoke('set_extension_frontend_ready', { ready }));
|
|
frontendReadyUpdate.current = update;
|
|
return update;
|
|
}, []);
|
|
|
|
const schedulePostQueueAction = useCallback((action: Exclude<PostQueueAction, 'none'>) => {
|
|
clearPendingPostActionTimer();
|
|
|
|
const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep';
|
|
let timerId: number | null = null;
|
|
let toastId: string | null = null;
|
|
const cancel = () => {
|
|
clearPendingPostActionTimer();
|
|
timerId = null;
|
|
if (toastId !== null) {
|
|
removeToast(toastId);
|
|
toastId = null;
|
|
}
|
|
};
|
|
|
|
toastId = addToast({
|
|
variant: 'warning',
|
|
isActionable: true,
|
|
onDismiss: clearPendingPostActionTimer,
|
|
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 (toastId !== null) {
|
|
removeToast(toastId);
|
|
toastId = null;
|
|
}
|
|
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, removeToast]);
|
|
|
|
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 = () => {
|
|
void queueFrontendReadyUpdate(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) => {
|
|
if (event.payload.request_id) {
|
|
void invoke('ack_extension_download', { requestId: event.payload.request_id }).catch(error => {
|
|
console.error('Failed to acknowledge browser extension download:', error);
|
|
});
|
|
}
|
|
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
|
pendingStartupInputs.current.push({ type: 'extension', payload: event.payload });
|
|
return;
|
|
}
|
|
useDownloadStore.getState().handleExtensionDownload(event.payload).catch(error => {
|
|
console.error('Failed to handle browser extension download:', error);
|
|
});
|
|
});
|
|
unlistenDeepLink = await listen('deep-link-add-download', (event) => {
|
|
if (!startupInputReady.current || useSettingsStore.getState().showKeychainModal) {
|
|
pendingStartupInputs.current.push({ type: 'deep-link', payload: event.payload });
|
|
return;
|
|
}
|
|
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;
|
|
}
|
|
|
|
const [currentAppVersion, currentPlatform] = await Promise.all([
|
|
getVersion().catch(() => ''),
|
|
getPlatformInfo().catch(() => null)
|
|
]);
|
|
if (!active) return;
|
|
const currentKeychainConsentVersion = getKeychainConsentVersion(currentAppVersion);
|
|
setKeychainConsentVersion(currentKeychainConsentVersion);
|
|
|
|
try {
|
|
const settings = useSettingsStore.getState();
|
|
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
|
|
portable: currentPlatform?.portable === true,
|
|
appVersion: currentKeychainConsentVersion,
|
|
approvedVersion: settings.keychainAccessVersion,
|
|
accessGranted: settings.keychainAccessGranted,
|
|
promptDismissed: settings.keychainPromptDismissed
|
|
});
|
|
|
|
let changed = false;
|
|
if (deferKeychainHydration) {
|
|
settings.setKeychainAccessReady(false);
|
|
// This token is already owned by the backend and does not access
|
|
// the OS credential store. Render our explanation before any native
|
|
// Keychain/Credential Manager prompt can be user-triggered.
|
|
await settings.hydrateSessionPairingToken();
|
|
if (showKeychainPrompt) {
|
|
settings.setShowKeychainModal(true);
|
|
}
|
|
} else {
|
|
changed = await settings.hydratePairingToken();
|
|
settings.setKeychainAccessReady(
|
|
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
|
|
);
|
|
}
|
|
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);
|
|
};
|
|
void initialize();
|
|
return () => {
|
|
active = false;
|
|
startupInputReady.current = false;
|
|
pendingStartupInputs.current = [];
|
|
cleanupListeners?.();
|
|
cleanupListeners = null;
|
|
};
|
|
}, [addToast, queueFrontendReadyUpdate]);
|
|
|
|
useEffect(() => {
|
|
if (!coreReady) return;
|
|
// The backend must not emit extension/deep-link handoffs while the
|
|
// explanatory Keychain dialog is active. Deep links are buffered by the
|
|
// coordinator, and extension callers receive a retryable 503 instead.
|
|
void queueFrontendReadyUpdate(!showKeychainModal).catch(error => {
|
|
console.error('Failed to update browser extension readiness:', error);
|
|
});
|
|
}, [coreReady, queueFrontendReadyUpdate, showKeychainModal]);
|
|
|
|
useEffect(() => {
|
|
if (!coreReady || showKeychainModal) {
|
|
startupInputReady.current = false;
|
|
return;
|
|
}
|
|
if (startupInputReady.current) return;
|
|
startupInputReady.current = true;
|
|
const pendingInputs = pendingStartupInputs.current.splice(0);
|
|
for (const input of pendingInputs) {
|
|
if (input.type === 'extension') {
|
|
useDownloadStore.getState().handleExtensionDownload(input.payload).catch(error => {
|
|
console.error('Failed to handle queued browser extension download:', error);
|
|
});
|
|
} else {
|
|
useDownloadStore.getState().openAddModalWithUrls(input.payload);
|
|
}
|
|
}
|
|
}, [coreReady, showKeychainModal]);
|
|
|
|
useEffect(() => {
|
|
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
|
|
startupResumeStarted.current = true;
|
|
useDownloadStore.getState().resumePendingDownloads().catch(error => {
|
|
console.error('Failed to resume saved downloads after startup:', error);
|
|
addToast({
|
|
message: `Could not resume saved downloads: ${String(error)}`,
|
|
variant: 'error',
|
|
isActionable: true
|
|
});
|
|
});
|
|
}, [addToast, coreReady, showKeychainModal]);
|
|
|
|
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;
|
|
if (!isTrustedFirelinkReleaseUrl(result.update.release_url)) {
|
|
throw new Error('The update check returned an untrusted release URL.');
|
|
}
|
|
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 (!coreReady) return;
|
|
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,
|
|
coreReady,
|
|
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 (!coreReady || useSettingsStore.getState().showKeychainModal) return;
|
|
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);
|
|
}, [coreReady, showKeychainModal]);
|
|
|
|
useEffect(() => {
|
|
if (!coreReady || showKeychainModal || !autoAddClipboardLinks) return;
|
|
|
|
let active = true;
|
|
let wasForeground = false;
|
|
let readInFlight = false;
|
|
let lastClipboardKey: string | null = null;
|
|
|
|
const isForeground = () =>
|
|
document.visibilityState === 'visible' &&
|
|
(typeof document.hasFocus !== 'function' || document.hasFocus());
|
|
|
|
const readClipboardOnForeground = async () => {
|
|
if (!active || readInFlight || !isForeground()) return;
|
|
|
|
const storeBeforeRead = useDownloadStore.getState();
|
|
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
|
|
readInFlight = true;
|
|
try {
|
|
const clipboardUrls = await readClipboardDownloadUrls();
|
|
if (!active) return;
|
|
|
|
const currentSettings = useSettingsStore.getState();
|
|
const currentStore = useDownloadStore.getState();
|
|
// A user action or extension handoff won while the native clipboard
|
|
// read was pending. Let that newer Add-modal request win unchanged.
|
|
if (
|
|
!currentSettings.autoAddClipboardLinks ||
|
|
currentSettings.showKeychainModal ||
|
|
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const clipboardKey = [...clipboardUrls].sort().join('\n');
|
|
if (clipboardKey === lastClipboardKey) return;
|
|
lastClipboardKey = clipboardKey;
|
|
if (clipboardUrls.length === 0) return;
|
|
|
|
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
|
|
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
|
|
if (newUrls.length > 0) {
|
|
currentStore.openAddModalWithUrls(newUrls.join('\n'));
|
|
}
|
|
} catch (error) {
|
|
// Clipboard permissions are optional and this feature is explicitly
|
|
// opt-in, so a read failure should not interrupt normal app use.
|
|
console.warn('Automatic clipboard capture failed:', error);
|
|
} finally {
|
|
readInFlight = false;
|
|
}
|
|
};
|
|
|
|
const handleForegroundChange = () => {
|
|
const foreground = isForeground();
|
|
if (!foreground) {
|
|
wasForeground = false;
|
|
return;
|
|
}
|
|
if (!wasForeground) {
|
|
wasForeground = true;
|
|
void readClipboardOnForeground();
|
|
}
|
|
};
|
|
|
|
window.addEventListener('focus', handleForegroundChange);
|
|
window.addEventListener('blur', handleForegroundChange);
|
|
document.addEventListener('visibilitychange', handleForegroundChange);
|
|
handleForegroundChange();
|
|
|
|
return () => {
|
|
active = false;
|
|
window.removeEventListener('focus', handleForegroundChange);
|
|
window.removeEventListener('blur', handleForegroundChange);
|
|
document.removeEventListener('visibilitychange', handleForegroundChange);
|
|
};
|
|
}, [autoAddClipboardLinks, coreReady, showKeychainModal]);
|
|
|
|
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 consentVersion={keychainConsentVersion} />
|
|
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|