refactor(repo): promote tauri app to repository root

This commit is contained in:
NimBold
2026-06-15 10:33:40 +03:30
parent ab7550d39e
commit 6593f9e76a
346 changed files with 1021 additions and 1490 deletions
+113
View File
@@ -0,0 +1,113 @@
.logo.vite:hover {
filter: drop-shadow(0 0 2em #747bff);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafb);
}
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 16px;
line-height: 24px;
font-weight: 400;
color: #0f0f0f;
background-color: #f6f6f6;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
.container {
margin: 0;
padding-top: 10vh;
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: 0.75s;
}
.logo.tauri:hover {
filter: drop-shadow(0 0 2em #24c8db);
}
.row {
display: flex;
justify-content: center;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
h1 {
text-align: center;
}
input,
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
}
button {
cursor: pointer;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: #e8e8e8;
}
input,
button {
outline: none;
}
@media (prefers-color-scheme: dark) {
:root {
color: #f6f6f6;
background-color: #2f2f2f;
}
a:hover {
color: #24c8db;
}
input,
button {
color: #ffffff;
background-color: #0f0f0f98;
}
button:active {
background-color: #0f0f0f69;
}
}
+288
View File
@@ -0,0 +1,288 @@
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 { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
function App() {
const [filter, setFilter] = useState<SidebarFilter>('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 updateDownload = useDownloadStore(state => state.updateDownload);
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(globalSpeedLimit);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
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(() => {
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
}, [sidebarWidth]);
useEffect(() => {
useDownloadStore.getState().initDB();
}, []);
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;
// Convert to aria2 format (e.g. "1M", "500K")
let formattedLimit = null;
if (globalSpeedLimit) {
const match = globalSpeedLimit.trim().match(/^(\d+(?:\.\d+)?)\s*([kmgt]?)b?(?:\/s)?$/i);
if (match) {
const amount = Number(match[1]);
if (Number.isFinite(amount) && amount > 0) {
const multipliers: Record<string, number> = { '': 1, k: 1024, m: 1048576, g: 1073741824 };
const bytes = Math.round(amount * multipliers[match[2].toLowerCase()]);
formattedLimit = `${bytes}`;
}
}
}
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 =>
download.status === 'queued' || download.status === 'downloading'
);
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 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');
} else {
root.classList.add(`theme-${theme}`);
}
};
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(() => {
const unlistenProgress = listen('download-progress', (event) => {
const { id, fraction, speed, eta } = event.payload;
const state = useDownloadStore.getState();
const current = state.downloads.find(d => d.id === id);
if (current && current.status === 'queued') {
updateDownload(id, { status: 'downloading', fraction, speed, eta });
} else {
updateDownload(id, { fraction, speed, eta });
}
});
const unlistenComplete = listen('download-complete', (event) => {
updateDownload(event.payload, { status: 'completed', fraction: 1.0, speed: '-', eta: '-' });
const settings = useSettingsStore.getState();
if (settings.showNotifications) {
const item = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
const fileName = item?.fileName || 'A file';
sendNotification({
title: 'Download Complete',
body: `${fileName} has finished downloading.`,
sound: settings.playCompletionSound ? 'default' : undefined
});
}
});
const unlistenFailed = listen('download-failed', (event) => {
// If it's already paused, don't mark as failed (since we aborted it)
const current = useDownloadStore.getState().downloads.find(d => d.id === event.payload);
if (current && current.status !== 'paused') {
updateDownload(event.payload, { status: 'failed', speed: '-', eta: '-' });
}
});
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(() => {});
unlistenProgress.then(f => f());
unlistenComplete.then(f => f());
unlistenFailed.then(f => f());
unlistenExtension.then(f => f());
unlistenDeepLink.then(f => f());
};
}, []);
return (
<div className="app-shell flex h-screen w-screen overflow-hidden text-text-primary">
<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">
<div className="flex-1 flex flex-col overflow-hidden relative">
{activeView === 'downloads' && <DownloadTable filter={filter} />}
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
</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 />
</div>
);
}
export default App;
+43
View File
@@ -0,0 +1,43 @@
import { Component, ErrorInfo, ReactNode } from "react";
interface Props {
children?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
errorInfo: null
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, errorInfo: null };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
this.setState({ errorInfo });
}
public render() {
if (this.state.hasError) {
return (
<div style={{ padding: '2rem', color: 'red', backgroundColor: '#222', height: '100vh', width: '100vw', whiteSpace: 'pre-wrap', overflow: 'auto' }}>
<h1>Something went wrong.</h1>
<p>{this.state.error?.toString()}</p>
<hr />
<p>{this.state.errorInfo?.componentStack}</p>
</div>
);
}
return this.props.children;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AppFontSize = "small" | "standard" | "large";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AvailableReleaseUpdate = { version: string, tag_name: string, title: string, release_notes: string, release_url: string, published_at: string | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadCategory = "Musics" | "Movies" | "Compressed" | "Documents" | "Pictures" | "Applications" | "Other";
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId: string, _dispatched?: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadStatus = "downloading" | "paused" | "completed" | "failed" | "queued";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ExtensionDownload = { urls: Array<string>, referer: string | null, silent: boolean, filename: string | null, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ListRowDensity = "compact" | "standard" | "relaxed";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MediaCookieSource = "none" | "safari" | "chrome" | "firefox" | "edge" | "brave";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MetadataResponse = { filename: string, size: string, size_bytes: number, };
+11
View File
@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppFontSize } from "./AppFontSize";
import type { ListRowDensity } from "./ListRowDensity";
import type { MediaCookieSource } from "./MediaCookieSource";
import type { ProxyMode } from "./ProxyMode";
import type { SchedulerSettings } from "./SchedulerSettings";
import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, defaultDownloadPath: string, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, downloadDirectories: { [key in string]: string }, siteLogins: Array<SiteLogin>, extensionPairingToken: string, autoCheckUpdates: boolean, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PostQueueAction = "none" | "sleep" | "restart" | "shutdown";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ProxyMode = "none" | "system" | "custom";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Queue = { id: string, name: string, isMain: boolean, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AvailableReleaseUpdate } from "./AvailableReleaseUpdate";
export type ReleaseCheckOutcome = { "type": "UpdateAvailable", update: AvailableReleaseUpdate, } | { "type": "UpToDate", latest_version: string, local_version: string, };
+4
View File
@@ -0,0 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PostQueueAction } from "./PostQueueAction";
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, postQueueAction: PostQueueAction, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SettingsTab = "downloads" | "lookandfeel" | "network" | "locations" | "sitelogins" | "power" | "engine" | "integrations" | "about";
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SiteLogin = { id: string, urlPattern: string, username: string, };
+3
View File
@@ -0,0 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Theme = "dark" | "light" | "system" | "dracula" | "nord";
+951
View File
@@ -0,0 +1,951 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, MAIN_QUEUE_ID, getSiteLogin } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database, Link, ArrowRight, Play, ChevronDown, ChevronRight, Video, Film, Music, type LucideIcon } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
interface RawMediaFormat {
format_id?: string;
ext?: string;
resolution?: string;
format_note?: string;
vcodec?: string;
acodec?: string;
height?: number;
filesize?: number;
filesize_approx?: number;
}
interface MediaFormat {
name: string;
selector: string;
ext: string;
detail: string;
type: string;
bytes: number;
}
interface ParsedDownloadItem {
url: string;
file: string;
size?: string;
sizeBytes?: number;
status?: string;
isMedia?: boolean;
formats?: MediaFormat[];
selectedFormat?: number;
}
const isVideo = (f: RawMediaFormat) => {
const vcodec = f.vcodec?.toLowerCase();
return vcodec && vcodec !== 'none';
};
const isAudio = (f: RawMediaFormat) => {
const acodec = f.acodec?.toLowerCase();
const vcodec = f.vcodec?.toLowerCase();
return acodec && acodec !== 'none' && (!vcodec || vcodec === 'none');
};
const formatSize = (f: RawMediaFormat) => f.filesize ?? f.filesize_approx ?? 0;
const matchesHeight = (f: RawMediaFormat, height: number | null) => {
if (height === null) return true;
const note = f.format_note || "";
if (height === 2160 && (note.includes("2160p") || note.toLowerCase().includes("4k"))) return true;
if (height === 1440 && note.includes("1440p")) return true;
if (height === 1080 && note.includes("1080p")) return true;
if (height === 720 && note.includes("720p")) return true;
if (height === 480 && note.includes("480p")) return true;
if (height === 360 && note.includes("360p")) return true;
if (f.resolution) {
const parts = f.resolution.split('x').map(n => parseInt(n, 10));
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
const maxDim = Math.max(parts[0], parts[1]);
switch (height) {
case 2160: if (maxDim >= 3800) return true; break;
case 1440: if (maxDim >= 2500 && maxDim < 3800) return true; break;
case 1080: if (maxDim >= 1900 && maxDim < 2500) return true; break;
case 720: if (maxDim >= 1200 && maxDim < 1900) return true; break;
case 480: if (maxDim >= 800 && maxDim < 1200) return true; break;
case 360: if (maxDim >= 600 && maxDim < 800) return true; break;
}
}
}
const formatHeight = f.height;
if (!formatHeight) return false;
let tolerance = 100;
if (height >= 2160) tolerance = 600;
else if (height >= 1440) tolerance = 400;
else if (height >= 1080) tolerance = 300;
else if (height >= 720) tolerance = 200;
return formatHeight <= height && formatHeight >= height - tolerance;
};
const hasVideoFormat = (formats: RawMediaFormat[], height: number | null, container: string) => {
return formats.some(f => {
if (!isVideo(f) || !matchesHeight(f, height)) return false;
return container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase();
});
};
const hasAudioFormat = (formats: RawMediaFormat[], ext: string | null) => {
return formats.some(f => {
if (!isAudio(f)) return false;
if (!ext) return true;
return f.ext?.toLowerCase() === ext.toLowerCase();
});
};
const estimatedVideoBytes = (formats: RawMediaFormat[], height: number | null, container: string) => {
let maxVideo = 0;
for (const f of formats) {
if (isVideo(f) && matchesHeight(f, height) && (container === 'mkv' || f.ext?.toLowerCase() === container.toLowerCase())) {
const size = formatSize(f);
if (size > maxVideo) maxVideo = size;
}
}
if (maxVideo === 0) return null;
let maxAudio = estimatedAudioBytes(formats, container === 'webm' ? 'webm' : 'm4a') || estimatedAudioBytes(formats, null) || 0;
return maxVideo + maxAudio;
};
const estimatedAudioBytes = (formats: RawMediaFormat[], ext: string | null): number | null => {
let maxPreferred = 0;
for (const f of formats) {
if (isAudio(f)) {
if (!ext || f.ext?.toLowerCase() === ext.toLowerCase()) {
const size = formatSize(f);
if (size > maxPreferred) maxPreferred = size;
}
}
}
if (maxPreferred > 0 || !ext) return maxPreferred > 0 ? maxPreferred : null;
return estimatedAudioBytes(formats, null);
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return 'Unknown size';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
const parseMediaFormats = (jsonStr: string) => {
try {
const parsed: unknown = JSON.parse(jsonStr);
if (!parsed || typeof parsed !== 'object') return null;
const data = parsed as { title?: unknown; formats?: unknown };
let title = typeof data.title === 'string' ? data.title : 'Media';
title = title.replace(/[\/\\?%*:|"<>]/g, '-');
const rawFormats = Array.isArray(data.formats)
? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object')
: [];
const options = [];
const standardResolutions = [
{ h: 2160, name: "4K" },
{ h: 1440, name: "1440p" },
{ h: 1080, name: "1080p" },
{ h: 720, name: "720p" },
{ h: 480, name: "480p" },
{ h: 360, name: "360p" }
];
const availableResolutions = standardResolutions.filter(res =>
rawFormats.some(f => isVideo(f) && matchesHeight(f, res.h))
);
const videoQualities: { h: number | null, name: string }[] = [{ h: null, name: "Best" }, ...availableResolutions];
const videoContainers = [
{ ext: "mp4", name: "MP4" },
{ ext: "mkv", name: "MKV" },
{ ext: "webm", name: "WebM" }
];
for (const q of videoQualities) {
for (const c of videoContainers) {
if (!hasVideoFormat(rawFormats, q.h, c.ext)) continue;
const est = estimatedVideoBytes(rawFormats, q.h, c.ext);
const filter = q.h ? `[height<=${q.h}]` : '';
let selector = `bestvideo${filter}+bestaudio/best${filter}`;
if (c.ext === 'mp4') {
selector = `bestvideo${filter}[ext=mp4]+bestaudio[ext=m4a]/best${filter}[ext=mp4]/bestvideo${filter}+bestaudio/best${filter}`;
} else if (c.ext === 'webm') {
selector = `bestvideo${filter}[ext=webm]+bestaudio[ext=webm]/best${filter}[ext=webm]/bestvideo${filter}+bestaudio/best${filter}`;
}
options.push({
name: `${q.name} ${c.name}`,
selector,
ext: c.ext,
detail: est ? `~${formatBytes(est)}` : '',
type: 'Video',
bytes: est || 0
});
}
}
if (hasAudioFormat(rawFormats, null)) {
const est = estimatedAudioBytes(rawFormats, null);
options.push({
name: "Audio MP3",
selector: "bestaudio/best",
ext: "mp3",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
if (hasAudioFormat(rawFormats, "m4a")) {
const est = estimatedAudioBytes(rawFormats, "m4a");
options.push({
name: "Audio M4A",
selector: "bestaudio[ext=m4a]/bestaudio/best",
ext: "m4a",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
if (hasAudioFormat(rawFormats, "webm") || hasAudioFormat(rawFormats, "opus")) {
const est = estimatedAudioBytes(rawFormats, "webm") || estimatedAudioBytes(rawFormats, "opus");
options.push({
name: "Audio Opus",
selector: "bestaudio[ext=webm]/bestaudio/best",
ext: "opus",
detail: est ? `~${formatBytes(est)}` : '',
type: 'Audio',
bytes: est || 0
});
}
return { title, formats: options };
} catch (e) {
return null;
}
};
export const AddDownloadsModal = () => {
const {
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
pendingAddFilename,
toggleAddModal,
addDownload,
queues
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [selectedQueueId, setSelectedQueueId] = useState<string>(MAIN_QUEUE_ID);
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
const [parsedItems, setParsedItems] = useState<ParsedDownloadItem[]>([]);
const [conflicts, setConflicts] = useState<DuplicateConflict[]>([]);
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingStartFlag, setPendingStartFlag] = useState(false);
const [resolvedLocation, setResolvedLocation] = useState('');
// Right Form
const [saveLocation, setSaveLocation] = useState(defaultDownloadPath);
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimit, setSpeedLimit] = useState('1024');
const [freeSpace, setFreeSpace] = useState('Unknown');
const [useAuth, setUseAuth] = useState(false);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgo, setChecksumAlgo] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
const [headers, setHeaders] = useState('');
const [cookies, setCookies] = useState('');
const [mirrors, setMirrors] = useState('');
useEffect(() => {
if (isAddModalOpen) {
setSaveLocation(defaultDownloadPath);
setUrls(pendingAddUrls || '');
setParsedItems([]);
setSelectedItemIndex(null);
setSelectedQueueId(queues.find(q => q.isMain)?.id || MAIN_QUEUE_ID);
setUseAuth(false);
setUsername('');
setPassword('');
setAdvancedExpanded(false);
setChecksumEnabled(false);
setChecksumAlgo('SHA-256');
setChecksumValue('');
setHeaders(pendingAddReferer ? `Referer: ${pendingAddReferer}` : '');
setCookies('');
setMirrors('');
}
}, [
isAddModalOpen,
pendingAddUrls,
pendingAddReferer,
defaultDownloadPath,
queues
]);
useEffect(() => {
if (!saveLocation) return;
invoke('get_free_space', { path: saveLocation })
.then(space => setFreeSpace(space))
.catch(() => setFreeSpace('Unknown'));
}, [saveLocation, isAddModalOpen]);
// Metadata parser
useEffect(() => {
const lines = urls.split('\n').map(u => u.trim()).filter(u => u.length > 0);
// Immediately display items in loading state
const initialItems: ParsedDownloadItem[] = lines.map(url => {
const fallbackFile = lines.length === 1 && pendingAddFilename
? pendingAddFilename
: fileNameFromUrl(url);
return { url, file: fallbackFile, size: '-', status: 'Loading', isMedia: isMediaUrl(url) };
});
setParsedItems(initialItems);
if (lines.length === 0) {
setSelectedItemIndex(null);
return;
} else if (selectedItemIndex === null || selectedItemIndex >= lines.length) {
setSelectedItemIndex(0);
}
const timer = setTimeout(async () => {
const updatedItems = [...initialItems];
let firstReadyIndex: number | null = null;
for (let i = 0; i < lines.length; i++) {
const url = lines[i];
try {
new URL(url);
if (isMediaUrl(url)) {
const settingsStore = useSettingsStore.getState();
const { mediaCookieSource } = settingsStore;
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
const login = getSiteLogin(url, settingsStore);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const jsonStr = await invoke('fetch_media_metadata', {
url,
cookieBrowser: browserArg,
username: login?.username || null,
password: keychainPassword
});
const mediaData = parseMediaFormats(jsonStr);
if (mediaData && mediaData.formats.length > 0) {
updatedItems[i] = {
url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
size: mediaData.formats[0].detail || 'Unknown (Media)',
sizeBytes: mediaData.formats[0].bytes,
status: 'Ready',
isMedia: true,
formats: mediaData.formats,
selectedFormat: 0
};
} else {
throw new Error("Invalid media metadata or no formats found");
}
} else {
const settingsStore = useSettingsStore.getState();
const login = getSiteLogin(url, settingsStore);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password:", e);
}
}
const meta = await invoke('fetch_metadata', {
url,
userAgent: settingsStore.customUserAgent || null,
username: login?.username || null,
password: keychainPassword
});
updatedItems[i] = {
url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
};
}
if (firstReadyIndex === null) firstReadyIndex = i;
} catch (e) {
console.error("Meta fetch failed", e);
updatedItems[i] = { ...updatedItems[i], size: 'Unknown', sizeBytes: 0, status: 'Error' };
}
setParsedItems([...updatedItems]);
}
if (firstReadyIndex !== null) {
setSelectedItemIndex(firstReadyIndex);
}
}, 400);
return () => clearTimeout(timer);
}, [urls, pendingAddFilename]);
if (!isAddModalOpen) return null;
const handleBrowse = async () => {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const handleStart = async (startImmediately: boolean) => {
let finalLocation = saveLocation;
const settings = useSettingsStore.getState();
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: finalLocation.startsWith('~') ? undefined : finalLocation
});
if (selected && typeof selected === 'string') {
finalLocation = selected;
} else {
return; // Cancelled
}
} catch (e) {
console.error("Failed to select folder:", e);
}
}
setResolvedLocation(finalLocation);
const store = useDownloadStore.getState();
const newConflicts: DuplicateConflict[] = [];
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'url', msg: 'URL already in queue' }, resolution: 'rename' });
} else {
const fileExistsInStore = store.downloads.some(d => {
const dest = d.destination || settings.defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === finalFile && d.status !== 'failed';
});
let fileExistsOnDisk = false;
try {
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
} catch (e) {}
if (fileExistsInStore || fileExistsOnDisk) {
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
}
}
}
if (newConflicts.length > 0) {
setConflicts(newConflicts);
setPendingStartFlag(startImmediately);
setShowingDuplicates(true);
return;
}
await executeAddDownloads(startImmediately, finalLocation);
};
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) {
for (const res of resolutions) {
const idx = parseInt(res.id);
const item = itemsToAdd[idx];
if (!item) continue;
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
const ext = finalFile.includes('.') ? finalFile.substring(finalFile.lastIndexOf('.')) : '';
let newName = finalFile;
let exists = true;
while (exists) {
newName = `${base} (${count})${ext}`;
const storeHas = useDownloadStore.getState().downloads.some(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
});
let diskHas = false;
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
exists = storeHas || diskHas;
count++;
}
itemsToAdd[idx] = { ...item, file: newName };
} else if (res.resolution === 'replace') {
let finalFile = item.file;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
const fullPath = `${cleanLocation}/${finalFile}`;
const store = useDownloadStore.getState();
const existingItem = store.downloads.find(d => {
const dest = d.destination || useSettingsStore.getState().defaultDownloadPath || '~/Downloads';
return (d.url === item.url || (dest === finalLocation && d.fileName === finalFile)) && d.status !== 'failed';
});
if (existingItem) {
await store.removeDownload(existingItem.id);
}
try { await invoke('delete_file', { path: fullPath }); } catch(e) {}
}
}
}
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
for (const item of resolvedItems) {
try {
const id = crypto.randomUUID();
let finalFile = item.file;
let formatSelector = undefined;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
formatSelector = selectedFormat.selector;
if (!finalFile.endsWith(`.${selectedFormat.ext}`)) {
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
finalFile = `${baseName}.${selectedFormat.ext}`;
}
}
addDownload({
id,
url: item.url,
fileName: finalFile,
status: startImmediately ? 'queued' : 'paused',
category: categoryForFileName(finalFile),
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: finalLocation,
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
queueId: selectedQueueId
});
} catch (e) {
console.error("Invalid URL or failed to add:", e);
}
}
toggleAddModal(false);
};
const SummaryBox = ({ title, value, icon: Icon, color }: {
title: string;
value: string | number;
icon: LucideIcon;
color: string;
}) => (
<div className="flex flex-col bg-bg-input/50 border border-border-modal/40 rounded-lg p-2.5 shadow-sm">
<div className="flex items-center gap-1.5 text-text-muted mb-1">
<Icon size={12} className={color} />
<span className="text-[10px] font-bold uppercase tracking-wider">{title}</span>
</div>
<span className="text-sm font-semibold text-text-primary truncate">{value}</span>
</div>
);
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const requiredStr = requiredBytes > 0
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
: requiredBytes < 1024 * 1024 * 1024 ? `${(requiredBytes / 1024 / 1024).toFixed(1)} MB`
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`)
: 'Unknown';
return (
<>
{showingDuplicates && (
<DuplicateResolutionModal
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
executeAddDownloads(pendingStartFlag, resolvedLocation, resolutions);
}}
onCancel={() => setShowingDuplicates(false)}
/>
)}
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
<div className="app-modal w-[900px] h-[650px] flex flex-col overflow-hidden text-sm">
{/* Main Content Split */}
<div className="flex flex-1 overflow-hidden">
{/* Left Column: URLs and Preview */}
<div className="w-[55%] border-r border-border-modal flex flex-col bg-main-bg/50">
<div className="p-5 flex-1 flex flex-col gap-5">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-text-primary font-semibold">
<Link size={16} className="text-blue-500" />
Download Links
</div>
</div>
<textarea
className="w-full h-32 bg-bg-input/80 border border-border-modal rounded-lg p-3 text-[13px] text-text-primary focus:outline-none focus:border-accent resize-none font-mono shadow-inner transition-colors"
placeholder="Paste HTTP, HTTPS, FTP, or SFTP URLs here..."
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
<div className="flex justify-between items-center px-1">
<span className="text-[11px] text-text-muted font-medium">{parsedItems.length} valid link(s) detected</span>
<button className="flex items-center gap-1.5 text-[11px] text-blue-500 hover:text-blue-400 font-medium">
<RefreshCw size={12} /> Refresh Metadata
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title="Files" value={parsedItems.length} icon={FileText} color="text-blue-500" />
<SummaryBox title="Required" value={requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title="Free" value={freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title="Unknown" value={parsedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 overflow-hidden">
<div className="flex items-center gap-2 text-text-primary font-semibold">
<ArrowRight size={16} className="text-blue-500" />
Preview
</div>
<div className="flex-1 border border-border-modal rounded-lg overflow-hidden bg-bg-input/30 flex flex-col">
<div className="bg-sidebar-bg/50 border-b border-border-modal px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
<div className="flex-[2]">File</div>
<div className="flex-1">Size</div>
<div className="flex-[1.5]">Status</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{parsedItems.length === 0 ? (
<div className="h-full flex items-center justify-center text-text-muted text-xs italic">
No links added yet.
</div>
) : (
parsedItems.map((item, i) => (
<div
key={i}
onClick={() => setSelectedItemIndex(i)}
className={`flex flex-col text-xs px-2 py-2 cursor-pointer rounded-md transition-all group ${
selectedItemIndex === i
? 'bg-blue-500/10 border border-blue-500/30 shadow-sm'
: 'hover:bg-item-hover border border-transparent'
}`}
>
<div className="flex items-center w-full">
<div className="flex-[2] text-text-primary font-medium truncate pr-2" title={item.file}>{item.file}</div>
<div className={`flex-1 font-mono ${item.status === 'Loading' ? 'text-text-muted/50' : 'text-text-muted'}`}>{item.size || 'Unknown'}</div>
<div className={`flex-[1.5] font-medium ${item.status === 'Error' ? 'text-red-500' : item.status === 'Loading' ? 'text-orange-400' : 'text-blue-500'}`}>
{item.status === 'Loading' ? (
<div className="flex items-center gap-1.5">
<RefreshCw size={12} className="animate-spin" /> Fetching...
</div>
) : (
item.status || 'Ready'
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
</div>
{/* Right Column: Settings */}
<div className="w-[45%] flex flex-col overflow-y-auto bg-bg-modal">
<div className="p-6 space-y-7">
{/* Media Format (Dynamic) */}
{selectedItemIndex !== null && parsedItems[selectedItemIndex]?.isMedia && (
<section className="app-card relative overflow-hidden p-4">
<div className="absolute top-0 right-0 p-2 opacity-10">
<Video size={48} />
</div>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3 relative z-10">
<Video size={16} className="text-purple-500" /> Media Format
</div>
{parsedItems[selectedItemIndex].status === 'Loading' ? (
<div className="flex flex-col items-center justify-center py-6 gap-3 relative z-10">
<RefreshCw size={24} className="animate-spin text-purple-500" />
<span className="text-xs text-text-muted font-medium animate-pulse">Fetching media streams...</span>
</div>
) : parsedItems[selectedItemIndex].formats ? (
<div className="space-y-3 relative z-10">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] uppercase font-bold tracking-wider text-text-muted">Available Streams</label>
<div className="flex flex-col gap-1 max-h-48 overflow-y-auto pr-1">
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
const Icon = f.type === 'Audio' ? Music : Film;
return (
<div
key={idx}
onClick={() => {
const newItems = [...parsedItems];
newItems[selectedItemIndex].selectedFormat = idx;
newItems[selectedItemIndex].size = f.detail || 'Unknown';
newItems[selectedItemIndex].sizeBytes = f.bytes || 0;
// Update filename extension
const baseName = newItems[selectedItemIndex].file.substring(0, newItems[selectedItemIndex].file.lastIndexOf('.')) || newItems[selectedItemIndex].file;
newItems[selectedItemIndex].file = `${baseName}.${f.ext}`;
setParsedItems(newItems);
}}
className={`flex items-center justify-between px-3 py-2 rounded-lg cursor-pointer text-xs border transition-all ${
isSelected ? 'bg-purple-500/10 border-purple-500/30 text-purple-600 dark:text-purple-400 font-semibold shadow-sm' : 'bg-bg-input border-border-modal text-text-secondary hover:border-border-modal/80 hover:bg-item-hover/50'
}`}
>
<div className="flex items-center gap-2">
<Icon size={14} className={isSelected ? 'text-purple-500' : 'text-text-muted'} />
<span>{f.name}</span>
</div>
<span className="font-mono text-[11px] opacity-80">{f.detail}</span>
</div>
);
})}
</div>
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center py-4 relative z-10">
<span className="text-xs text-red-400 font-medium">Failed to load media streams.</span>
</div>
)}
</section>
)}
{/* Save Location */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<FolderPlus size={16} className="text-blue-500" /> Save Location
</div>
<div className="flex gap-2">
<input
type="text"
readOnly
value={saveLocation}
className="flex-1 bg-bg-input border border-border-modal rounded-md px-3 py-1.5 text-xs text-text-muted font-mono"
/>
<button
onClick={handleBrowse}
className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded-md text-xs font-medium transition-colors"
>
Browse
</button>
</div>
</section>
{/* Transfer Settings */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<Settings size={16} className="text-blue-500" /> Transfer Settings
</div>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Target Queue</label>
<select value={selectedQueueId} onChange={e=>setSelectedQueueId(e.target.value)} className="w-32 bg-bg-input border border-border-modal rounded-lg px-2 py-1 text-xs text-text-primary focus:border-accent focus:outline-none">
{queues.map(q => (
<option key={q.id} value={q.id}>{q.name}</option>
))}
</select>
</div>
<div className="flex items-center justify-between">
<label className="text-xs text-text-secondary font-medium">Connections per File</label>
<div className="flex items-center gap-2">
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="w-24 accent-blue-500" disabled={parsedItems.some(i => i.isMedia)} />
<span className="text-xs text-text-primary font-mono w-4 text-right">{connections}</span>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={speedLimitEnabled} onChange={e=>setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Limit speed per file
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-1.5">
<input type="number" value={speedLimit} onChange={e=>setSpeedLimit(e.target.value)} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2 py-1 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
<span className="text-[10px] text-text-muted">KiB/s</span>
</div>
)}
</div>
</div>
</section>
{/* Authorization */}
<section>
<div className="flex items-center gap-2 text-sm font-semibold text-text-primary mb-3">
<Shield size={16} className="text-blue-500" /> Authorization
</div>
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer mb-3">
<input type="checkbox" checked={useAuth} onChange={e=>setUseAuth(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Use authorization
</label>
{useAuth && (
<div className="space-y-2.5 pl-5 border-l-2 border-border-modal/50">
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} placeholder="Username" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} placeholder="Password" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs text-text-primary focus:border-accent focus:outline-none" />
</div>
)}
</section>
{/* Advanced */}
<section className="pt-2 border-t border-border-modal/50">
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full hover:text-blue-500 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
Advanced Transfer
</button>
{advancedExpanded && (
<div className="mt-4 space-y-4 pl-6">
<label className="flex items-center gap-2 text-xs text-text-secondary font-medium cursor-pointer">
<input type="checkbox" checked={checksumEnabled} onChange={e=>setChecksumEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20" />
Verify Checksum
</label>
{checksumEnabled && (
<div className="flex gap-2">
<select value={checksumAlgo} onChange={e=>setChecksumAlgo(e.target.value)} className="w-24 bg-bg-input border border-border-modal rounded-lg px-2 text-xs text-text-primary focus:border-accent focus:outline-none">
<option>MD5</option><option>SHA-1</option><option>SHA-256</option>
</select>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} placeholder="Expected digest" className="flex-1 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
</div>
)}
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Headers</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} placeholder="name=value; other=value" className="w-full bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none" />
</div>
<div>
<label className="block text-[10px] uppercase font-bold tracking-wider text-text-muted mb-1">Mirrors</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} className="w-full h-12 bg-bg-input border border-border-modal rounded-lg px-3 py-1.5 text-xs font-mono text-text-primary focus:border-accent focus:outline-none resize-none" />
</div>
</div>
)}
</section>
</div>
</div>
</div>
{/* Footer */}
<div className="p-4 bg-sidebar-bg/50 border-t border-border-modal flex items-center shrink-0">
<div className="text-[11px] text-text-muted font-medium flex-1">
{parsedItems.length === 0 ? "Paste one or more links." : `Ready to add ${parsedItems.length} download(s).`}
</div>
<div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
Cancel
</button>
<button
onClick={() => handleStart(false)}
disabled={parsedItems.length === 0}
className="app-button px-4 text-xs disabled:opacity-50"
>
Add to Queue
</button>
<button
onClick={() => handleStart(true)}
disabled={parsedItems.length === 0}
className="app-button app-button-primary px-5 text-xs disabled:opacity-50"
>
<Play size={12} fill="currentColor" /> Start Downloads
</button>
</div>
</div>
</div>
</div>
</>
);
};
+460
View File
@@ -0,0 +1,460 @@
import React, { useState, useEffect } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { SidebarFilter } from './Sidebar';
import { Play, Pause, Plus, Trash2, FileText, Image as ImageIcon, Music, Film, Box, Archive, FileQuestion, MoreVertical, PanelLeft, ArrowDownCircle, Command } from 'lucide-react';
import { invokeCommand as invoke } from '../ipc';
import { homeDir } from '@tauri-apps/api/path';
interface DownloadTableProps {
filter: SidebarFilter;
}
export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
const { downloads, toggleAddModal, updateDownload, removeDownload, clearFinished, redownload } = useDownloadStore();
const { isSidebarVisible, toggleSidebar } = useSettingsStore();
const isMac = navigator.userAgent.includes('Mac');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const [columnWidths, setColumnWidths] = useState([340, 100, 220, 100, 80, 170]);
const columnMinimums = [200, 80, 170, 80, 70, 120];
const tableGridTemplate = columnWidths.map(width => `${width}px`).join(' ');
const startColumnResize = (index: number, event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = columnWidths[index];
const handlePointerMove = (moveEvent: PointerEvent) => {
const nextWidth = Math.max(columnMinimums[index], startWidth + moveEvent.clientX - startX);
setColumnWidths(widths => widths.map((width, columnIndex) => columnIndex === index ? nextWidth : width));
};
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(() => {
const handleCloseMenu = () => setContextMenu(null);
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
}, []);
const resolvePath = async (dir: string, file: string) => {
let resolvedDir = dir;
if (dir.startsWith('~/')) {
const home = await homeDir();
resolvedDir = home + '/' + dir.slice(2);
} else if (dir === '~') {
resolvedDir = await homeDir();
}
return resolvedDir + '/' + file;
};
const filteredDownloads = downloads.filter((d: DownloadItem) => {
if (filter.startsWith('queue:')) {
return d.queueId === filter.replace('queue:', '');
}
switch (filter) {
case 'all': return true;
case 'active': return d.status === 'downloading';
case 'completed': return d.status === 'completed';
case 'unfinished': return d.status !== 'completed';
default: return d.category === filter;
}
});
const getFilterTitle = () => {
if (filter.startsWith('queue:')) {
const qid = filter.replace('queue:', '');
const queue = useDownloadStore.getState().queues.find(q => q.id === qid);
return queue ? queue.name : 'Unknown Queue';
}
switch (filter) {
case 'all': return 'All Downloads';
case 'active': return 'Active';
case 'completed': return 'Completed';
case 'unfinished': return 'Unfinished';
default: return filter;
}
};
const handlePause = async (id: string) => {
try {
await invoke('pause_download', { id });
updateDownload(id, { status: 'paused', speed: '-', eta: '-' });
} catch (e) {
console.error("Failed to pause:", e);
}
};
const handleResume = (item: DownloadItem) => {
useDownloadStore.setState((state) => ({
downloads: state.downloads.map(d => d.id === item.id ? { ...d, status: 'queued', speed: '-', eta: '-' } : d)
}));
useDownloadStore.getState().processQueue();
};
const handleDelete = async (id: string) => {
try {
await removeDownload(id);
} catch (e) {
console.error("Failed to delete download:", e);
}
};
const contextItem = contextMenu ? downloads.find(d => d.id === contextMenu.id) : null;
const getCategoryIcon = (category: string) => {
switch(category) {
case 'Musics': return <Music size={16} className="text-pink-400" />;
case 'Movies': return <Film size={16} className="text-red-400" />;
case 'Documents': return <FileText size={16} className="text-blue-400" />;
case 'Applications': return <Box size={16} className="text-indigo-400" />;
case 'Pictures': return <ImageIcon size={16} className="text-purple-400" />;
case 'Compressed': return <Archive size={16} className="text-amber-600" />;
case 'Other': return <FileQuestion size={16} className="text-gray-400" />;
default: return <FileQuestion size={16} className="text-gray-400" />;
}
}
return (
<div className="downloads-view flex-1 flex flex-col h-full min-w-0">
<div className={`main-titlebar ${!isSidebarVisible ? 'pl-[80px]' : ''}`} data-tauri-drag-region>
{!isSidebarVisible && (
<button
onClick={toggleSidebar}
className="app-icon-button relative z-50 h-7 w-7 mr-2"
title="Show Sidebar"
>
<PanelLeft size={16} strokeWidth={2} />
</button>
)}
<div className="main-titlebar-title cursor-default" data-tauri-drag-region>Firelink</div>
<div className="main-control-group">
<button className="main-control-button primary" onClick={() => toggleAddModal(true)} title="Add Download">
<Plus size={16} />
</button>
<button
className="main-control-button"
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads.filter(d => d.status === 'paused').forEach(d => handleResume(d));
}}
title="Resume All"
>
<Play size={15} fill="currentColor" />
</button>
<button
className="main-control-button"
disabled={filteredDownloads.length === 0}
onClick={() => {
filteredDownloads.filter(d => d.status === 'downloading').forEach(d => handlePause(d.id));
}}
title="Pause All"
>
<Pause size={15} fill="currentColor" />
</button>
<button
className="main-control-button hover:!text-red-400"
disabled={filteredDownloads.length === 0}
onClick={clearFinished}
title="Clear Finished"
>
<Trash2 size={15} />
</button>
</div>
</div>
<div className="downloads-content-header">
<div className="downloads-title">
{getFilterTitle()}
<span className="downloads-count">{filteredDownloads.length}</span>
</div>
</div>
<div className="downloads-table flex-1 flex flex-col">
{filteredDownloads.length === 0 ? (
<div className="downloads-empty-state">
<ArrowDownCircle aria-hidden="true" />
<div className="downloads-empty-title">No Downloads</div>
<div className="downloads-empty-description flex items-center justify-center mt-2.5 text-[13px] text-text-muted">
Click <Plus size={15} className="text-accent stroke-[3] mx-1.5" /> button or
<span className="flex items-center mx-1.5">
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
{isMac ? <Command size={12} strokeWidth={2.5} className="text-text-primary" /> : <span className="text-[10px] font-bold text-text-primary">Ctrl</span>}
</span>
<span className="text-accent font-bold mx-1.5 text-[14px]">+</span>
<span className="flex items-center justify-center px-1.5 py-0.5 bg-item-hover rounded border border-border-color shadow-sm min-w-[22px] min-h-[22px]">
<span className="text-[11px] font-bold text-text-primary">V</span>
</span>
</span>
to add downloads
</div>
</div>
) : (
<>
<div className="download-table-scroll">
<div className="download-table-header" style={{ gridTemplateColumns: tableGridTemplate }}>
{['File Name', 'Size', 'Status', 'Speed', 'ETA', 'Date Added'].map((label, index) => (
<div key={label} className={index === 5 ? 'download-cell-right' : undefined}>
<span>{label}</span>
<div
className="column-resize-handle"
onPointerDown={(event) => startColumnResize(index, event)}
/>
</div>
))}
</div>
<div className="download-table-body">
<div className="h-full overflow-auto flex flex-col">
{filteredDownloads.map(d => (
<div
key={d.id}
className="download-row group cursor-default relative"
style={{ gridTemplateColumns: tableGridTemplate }}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
}}
>
<div className="download-file-cell">
<span className="shrink-0 text-text-muted">
{getCategoryIcon(d.category)}
</span>
<span className="download-file-name">
{d.fileName}
</span>
</div>
<div>
<span className="tabular-nums">
{d.size && d.size !== '-' ? d.size : 'Unknown'}
</span>
</div>
<div className="download-status-cell">
{d.status === 'completed' ? (
<span className="download-status download-status-completed">Completed</span>
) : (
<>
<div className="download-progress-track">
<div
className={`download-progress-fill ${d.status === 'paused' ? 'paused' : ''}`}
style={{ width: `${(d.fraction || 0) * 100}%` }}
/>
</div>
<span className={`download-status ${d.status === 'paused' ? 'download-status-paused' : d.status === 'failed' ? 'download-status-failed' : d.status === 'downloading' ? 'download-status-downloading' : ''}`}>
{d.status === 'downloading'
? `${((d.fraction || 0) * 100).toFixed(0)}%`
: d.status.charAt(0).toUpperCase() + d.status.slice(1)}
</span>
</>
)}
</div>
<div>
<span className="tabular-nums">{d.status === 'downloading' ? d.speed : '-'}</span>
</div>
<div>
<span className="tabular-nums">{d.status === 'downloading' ? d.eta : '-'}</span>
</div>
<div className="download-cell-right">
<span className="truncate group-hover:hidden tabular-nums ml-auto">
{d.dateAdded ? new Date(d.dateAdded).toLocaleDateString() : '-'}
</span>
<div className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto">
{d.status === 'downloading' && (
<button onClick={() => handlePause(d.id)} className="app-icon-button h-7 w-7" title="Pause">
<Pause size={14} fill="currentColor" />
</button>
)}
{d.status === 'paused' && (
<button onClick={() => handleResume(d)} className="app-icon-button h-7 w-7" title="Resume">
<Play size={14} fill="currentColor" />
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: d.id });
}}
className="app-icon-button h-7 w-7"
title="Options"
>
<MoreVertical size={14} />
</button>
</div>
</div>
</div>
))}
<div className="flex-1 overflow-hidden flex flex-col pointer-events-none">
{Array.from({ length: 50 }).map((_, index) => {
const isEven = (filteredDownloads.length + index) % 2 === 1;
return (
<div
key={`ghost-${index}`}
className={`download-ghost-row ${isEven ? 'striped' : ''}`}
/>
);
})}
</div>
</div>
</div>
</div>
</>
)}
</div>
{/* Floating Context Menu */}
{contextMenu && contextItem && (
<div
className="app-modal fixed z-50 min-w-[180px] overflow-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{
top: Math.min(contextMenu.y, window.innerHeight - 300),
left: Math.min(contextMenu.x, window.innerWidth - 200)
}}
onClick={(e) => e.stopPropagation()}
>
{contextItem.status === 'completed' && (
<button
onClick={async () => {
setContextMenu(null);
try {
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
await invoke('open_file', { path: fullPath });
} catch (e) {
console.error("Failed to open file:", e);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Open File
</button>
)}
<button
onClick={async () => {
setContextMenu(null);
try {
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
await invoke('show_in_folder', { path: fullPath });
} catch (e) {
console.error("Failed to show in folder:", e);
}
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Show in Finder
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
{(contextItem.status === 'downloading' || contextItem.status === 'queued') && (
<button
onClick={() => {
setContextMenu(null);
handlePause(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Pause
</button>
)}
{(contextItem.status === 'paused' || contextItem.status === 'failed') && (
<button
onClick={() => {
setContextMenu(null);
handleResume(contextItem);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Resume
</button>
)}
{['completed', 'failed', 'paused'].includes(contextItem.status) && (
<button
onClick={() => {
setContextMenu(null);
redownload(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Redownload
</button>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy Address
</button>
{contextItem.status === 'completed' && (
<button
onClick={async () => {
setContextMenu(null);
const fullPath = await resolvePath(contextItem.destination || '~/Downloads', contextItem.fileName);
navigator.clipboard.writeText(fullPath);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Copy File Path
</button>
)}
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
handleDelete(contextItem.id);
}}
className="w-full text-left px-3 py-2 text-red-400 hover:bg-red-500/10 transition-colors"
>
Remove from List
</button>
<div className="h-[1px] bg-border-modal/60 my-1.5 mx-2"></div>
<button
onClick={() => {
setContextMenu(null);
useDownloadStore.getState().setSelectedPropertiesDownloadId(contextItem.id);
}}
className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors"
>
Properties
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,68 @@
import { useState } from 'react';
export type DuplicateReason = { type: 'url', msg: string } | { type: 'file', msg: string };
type DuplicateResolution = 'rename' | 'replace' | 'skip';
export interface DuplicateConflict {
id: string; // id of the pending item
fileName: string;
reason: DuplicateReason;
resolution: DuplicateResolution;
}
interface Props {
conflicts: DuplicateConflict[];
onConfirm: (resolutions: { id: string, resolution: DuplicateResolution }[]) => void;
onCancel: () => void;
}
export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfirm, onCancel }: Props) => {
const [conflicts, setConflicts] = useState<DuplicateConflict[]>(initialConflicts);
const updateResolution = (id: string, resolution: DuplicateResolution) => {
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
};
return (
<div className="app-modal-backdrop fixed inset-0 z-[60] flex items-center justify-center">
<div className="app-modal w-[500px] flex flex-col overflow-hidden text-sm">
<div className="p-4 border-b border-border-modal flex flex-col gap-2">
<h2 className="text-lg font-semibold text-text-primary">Duplicate Downloads Detected</h2>
<p className="text-xs text-text-muted">Some of the downloads you are adding already exist in the queue or on disk. Please choose how to resolve these conflicts.</p>
</div>
<div className="max-h-[300px] overflow-y-auto p-4 space-y-3">
{conflicts.map(conflict => (
<div key={conflict.id} className="flex items-center justify-between bg-bg-input/50 p-2.5 rounded-lg border border-border-modal/50 gap-4">
<div className="flex flex-col overflow-hidden min-w-0">
<span className="font-medium text-text-primary truncate" title={conflict.fileName}>{conflict.fileName}</span>
<span className="text-[11px] text-orange-400 mt-0.5">{conflict.reason.msg}</span>
</div>
<select
value={conflict.resolution}
onChange={(e) => updateResolution(conflict.id, e.target.value as DuplicateResolution)}
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
>
<option value="rename">Rename</option>
<option value="replace">Replace</option>
<option value="skip">Skip</option>
</select>
</div>
))}
</div>
<div className="p-4 border-t border-border-modal flex items-center justify-between bg-sidebar-bg/50">
<button onClick={onCancel} className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary">
Cancel
</button>
<button
onClick={() => onConfirm(conflicts.map(c => ({ id: c.id, resolution: c.resolution })))}
className="app-button app-button-primary px-5 text-xs"
>
Continue
</button>
</div>
</div>
</div>
);
};
+355
View File
@@ -0,0 +1,355 @@
import { useState, useEffect } from 'react';
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
import { useSettingsStore } from '../store/useSettingsStore';
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
type LoginMode = 'matching' | 'custom' | 'none';
export const PropertiesModal = () => {
const {
selectedPropertiesDownloadId,
setSelectedPropertiesDownloadId,
downloads,
updateDownload
} = useDownloadStore();
const { defaultDownloadPath } = useSettingsStore();
const [item, setItem] = useState<DownloadItem | null>(null);
// Form states
const [url, setUrl] = useState('');
const [fileName, setFileName] = useState('');
const [saveLocation, setSaveLocation] = useState('');
const [connections, setConnections] = useState(16);
const [speedLimitEnabled, setSpeedLimitEnabled] = useState(false);
const [speedLimitValue, setSpeedLimitValue] = useState('1024'); // KiB/s
const [loginMode, setLoginMode] = useState<LoginMode>('matching');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [advancedExpanded, setAdvancedExpanded] = useState(false);
const [checksumEnabled, setChecksumEnabled] = useState(false);
const [checksumAlgorithm, setChecksumAlgorithm] = useState('SHA-256');
const [checksumValue, setChecksumValue] = useState('');
const [cookies, setCookies] = useState('');
const [headers, setHeaders] = useState('');
const [mirrors, setMirrors] = useState('');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
if (selectedPropertiesDownloadId) {
const activeItem = downloads.find(d => d.id === selectedPropertiesDownloadId);
if (activeItem) {
setItem(activeItem);
setUrl(activeItem.url);
setFileName(activeItem.fileName);
setSaveLocation(activeItem.destination || defaultDownloadPath || '~/Downloads');
setConnections(activeItem.connections || 16);
if (activeItem.speedLimit) {
setSpeedLimitEnabled(true);
setSpeedLimitValue(activeItem.speedLimit.replace(/[^0-9]/g, ''));
} else {
setSpeedLimitEnabled(false);
}
if (activeItem.username || activeItem.password) {
setLoginMode('custom');
setUsername(activeItem.username || '');
setPassword(activeItem.password || '');
} else {
setLoginMode('matching');
setUsername('');
setPassword('');
}
setHeaders(activeItem.headers || '');
setChecksumEnabled(!!activeItem.checksum);
if (activeItem.checksum) {
const [algo, val] = activeItem.checksum.split('=');
if (val) {
setChecksumAlgorithm(algo);
setChecksumValue(val);
}
} else {
setChecksumAlgorithm('SHA-256');
setChecksumValue('');
}
setCookies(activeItem.cookies || '');
setMirrors(activeItem.mirrors || '');
setErrorMessage('');
} else {
setItem(null);
}
} else {
setItem(null);
}
}, [selectedPropertiesDownloadId, downloads, defaultDownloadPath]);
if (!selectedPropertiesDownloadId || !item) return null;
const handleBrowse = async () => {
if (isLocked) return;
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: saveLocation.startsWith('~') ? undefined : saveLocation
});
if (selected && typeof selected === 'string') {
setSaveLocation(selected);
}
} catch (e) {
console.error("Failed to select folder:", e);
}
};
const handleSave = () => {
if (!url.trim()) {
setErrorMessage("Enter a valid URL.");
return;
}
if (!fileName.trim()) {
setErrorMessage("File name cannot be empty.");
return;
}
const updates: Partial<DownloadItem> = {
url,
fileName,
destination: saveLocation,
connections: Number(connections),
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : undefined,
username: loginMode === 'custom' ? username.trim() : undefined,
password: loginMode === 'custom' ? password.trim() : undefined,
headers: headers.trim() || undefined,
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
};
updateDownload(item.id, updates);
setSelectedPropertiesDownloadId(null);
};
const isLocked = ['downloading', 'completed'].includes(item.status);
const isTransferLocked = item.status === 'downloading';
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
if (item.status === 'completed') { statusColor = 'text-green-500'; StatusIcon = CheckCircle; }
else if (item.status === 'downloading') { statusColor = 'text-blue-500'; StatusIcon = Play; }
else if (item.status === 'paused') { statusColor = 'text-orange-500'; StatusIcon = Pause; }
else if (item.status === 'failed') { statusColor = 'text-red-500'; StatusIcon = AlertCircle; }
return (
<div className="app-modal-backdrop fixed inset-0 z-50 flex items-center justify-center">
<div className="app-modal w-[720px] h-[580px] flex flex-col overflow-hidden text-sm">
{/* Header Summary */}
<div className="p-4 px-5 bg-sidebar-bg/50">
<div className="flex items-center justify-between mb-3">
<h2 className="text-base font-semibold truncate text-text-primary pr-4">{item.fileName}</h2>
<span className={`flex items-center gap-1.5 text-xs font-semibold tracking-wide uppercase ${statusColor}`}>
<StatusIcon size={14} />
{item.status}
</span>
</div>
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(item.status === 'completed' ? 1 : item.fraction || 0) * 100}%` }}></div>
</div>
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Progress</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Size</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[40px]">Speed</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.speed || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[30px]">ETA</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.eta || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[90px]">Live connections</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[60px]">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[55px]">Category</span><span className="text-text-secondary truncate">{item.category}</span></div>
<div className="flex gap-1.5"><span className="text-text-muted font-medium w-[50px]">Last try</span><span className="text-text-secondary truncate">-</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[90px]">Date added</span><span className="text-text-secondary truncate">{new Date(item.dateAdded).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}</span></div>
<div className="flex gap-1.5 col-span-2"><span className="text-text-muted font-medium w-[70px]">Destination</span><span className="text-text-secondary truncate" title={item.destination}>{item.destination || defaultDownloadPath}</span></div>
</div>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Scrollable Form Content */}
<div className="flex-1 overflow-y-auto bg-main-bg/30 p-5 space-y-7">
{isLocked && (
<div className="flex gap-2.5 items-center text-xs text-text-secondary bg-border-color/30 p-3 rounded-md border border-border-modal">
{item.status === 'completed' ? <CheckCircle size={16} className="text-green-500" /> : <AlertCircle size={16} className="text-blue-500" />}
<span>
{item.status === 'completed'
? "File identity is read-only. Transfer settings are saved for redownload."
: "Only the speed limit applies to the current transfer. Other settings can be changed after stopping or pausing."}
</span>
</div>
)}
{/* Download Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">Download</h3>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
<label className="text-xs text-text-muted text-right">URL</label>
<input type="text" value={url} onChange={e => setUrl(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">File name</label>
<input type="text" value={fileName} onChange={e => setFileName(e.target.value)} disabled={isLocked} className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Save location</label>
<div className="flex gap-2">
<input type="text" value={saveLocation} readOnly disabled={isLocked} className="flex-1 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<button onClick={handleBrowse} disabled={isLocked} className="bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal px-3 py-1.5 rounded text-xs transition-colors disabled:opacity-40 flex items-center gap-1.5">
<FolderPlus size={14} /> Select
</button>
</div>
<label className="text-xs text-text-muted text-right">Connections</label>
<div className="flex items-center gap-2">
<input type="number" value={connections} min={1} max={16} onChange={e=>setConnections(Number(e.target.value))} disabled={isTransferLocked} className="w-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<span className="text-xs text-text-muted">per file</span>
</div>
<label className="text-xs text-text-muted text-right">Speed</label>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={speedLimitEnabled} onChange={e => setSpeedLimitEnabled(e.target.checked)} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Limit
</label>
{speedLimitEnabled && (
<div className="flex items-center gap-2">
<input type="number" value={speedLimitValue} min={1} step={128} onChange={e=>setSpeedLimitValue(e.target.value)} className="w-20 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent" />
<span className="text-xs text-text-muted">KiB/s</span>
</div>
)}
</div>
</div>
</section>
{/* Site Login Section */}
<section>
<h3 className="text-sm font-semibold text-text-primary mb-4 pb-1 border-b border-border-modal/50">
{item.status === 'completed' ? 'Site Login for Redownload' : 'Site Login'}
</h3>
<div className="flex gap-1 p-1 bg-border-color rounded-lg mb-4 w-fit mx-auto md:mx-0">
{(['matching', 'custom', 'none'] as const).map((mode) => (
<button
key={mode}
onClick={() => !isTransferLocked && setLoginMode(mode)}
disabled={isTransferLocked}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors disabled:opacity-50 ${loginMode === mode ? 'bg-bg-modal text-text-primary shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}
>
{mode === 'matching' ? 'Matching site login' : mode === 'custom' ? 'Custom credentials' : 'No login'}
</button>
))}
</div>
<div className="grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center">
{loginMode === 'matching' && (
<div className="col-start-2 text-xs text-text-secondary italic">
Will use saved login if available.
</div>
)}
{loginMode === 'custom' && (
<>
<label className="text-xs text-text-muted text-right">Username</label>
<input type="text" value={username} onChange={e=>setUsername(e.target.value)} disabled={isTransferLocked} placeholder="Username" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
<label className="text-xs text-text-muted text-right">Password</label>
<input type="password" value={password} onChange={e=>setPassword(e.target.value)} disabled={isTransferLocked} placeholder="Password" className="max-w-[250px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
</div>
</section>
{/* Advanced Transfer Section */}
<section>
<button
onClick={() => setAdvancedExpanded(!advancedExpanded)}
className="flex items-center gap-2 text-sm font-semibold text-text-primary w-full pb-1 border-b border-border-modal/50 hover:text-blue-400 transition-colors"
>
{advancedExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
{item.status === 'completed' ? 'Advanced Transfer for Redownload' : 'Advanced Transfer'}
</button>
{advancedExpanded && (
<div className="mt-4 grid grid-cols-[100px_1fr] gap-y-3.5 gap-x-4 items-center pl-6">
<label className="text-xs text-text-muted text-right">Checksum</label>
<label className="flex items-center gap-2 text-xs text-text-primary">
<input type="checkbox" checked={checksumEnabled} onChange={e => setChecksumEnabled(e.target.checked)} disabled={isTransferLocked} className="rounded border-border-modal text-blue-500 focus:ring-blue-500/20 bg-bg-input" />
Verify
</label>
{checksumEnabled && (
<>
<label className="text-xs text-text-muted text-right">Algorithm</label>
<select value={checksumAlgorithm} onChange={e=>setChecksumAlgorithm(e.target.value)} disabled={isTransferLocked} className="max-w-[150px] bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary focus:outline-none focus:border-accent disabled:opacity-50">
<option value="MD5">MD5</option>
<option value="SHA-1">SHA-1</option>
<option value="SHA-256">SHA-256</option>
<option value="SHA-512">SHA-512</option>
</select>
<label className="text-xs text-text-muted text-right">Digest</label>
<input type="text" value={checksumValue} onChange={e=>setChecksumValue(e.target.value)} disabled={isTransferLocked} placeholder="Expected digest" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
</>
)}
<label className="text-xs text-text-muted text-right">Cookies</label>
<input type="text" value={cookies} onChange={e=>setCookies(e.target.value)} disabled={isTransferLocked} placeholder="Cookies" className="bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50" />
<div className="col-span-2 mt-2">
<label className="block text-xs text-text-muted mb-1.5">Headers</label>
<textarea value={headers} onChange={e=>setHeaders(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
<div className="col-span-2">
<label className="block text-xs text-text-muted mb-1.5">Mirrors</label>
<textarea value={mirrors} onChange={e=>setMirrors(e.target.value)} disabled={isTransferLocked} className="w-full h-16 bg-bg-input border border-border-modal rounded-lg px-2.5 py-1.5 text-xs text-text-primary font-mono focus:outline-none focus:border-accent disabled:opacity-50 resize-none"></textarea>
</div>
</div>
)}
</section>
</div>
<div className="h-[1px] bg-border-modal w-full shrink-0"></div>
{/* Footer */}
<div className="p-3 px-4 bg-sidebar-bg flex items-center justify-between shrink-0">
<div className="text-red-500 text-xs truncate max-w-[400px]">
{errorMessage}
</div>
<div className="flex gap-2">
<button
onClick={() => setSelectedPropertiesDownloadId(null)}
className="app-button border-transparent bg-transparent px-4 text-xs text-text-secondary"
>
Cancel
</button>
<button
onClick={handleSave}
className="app-button app-button-primary px-4 text-xs"
>
<CheckCircle size={14} />
Save
</button>
</div>
</div>
</div>
</div>
);
};
+262
View File
@@ -0,0 +1,262 @@
import { useEffect, useMemo, useState } from 'react';
import { invokeCommand as invoke } from '../ipc';
import {
CheckCircle2, Clock3, List, Moon, LockKeyhole,
Pause, Play, Power, RotateCcw, Save
} from 'lucide-react';
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
import { WindowDragRegion } from './WindowDragRegion';
const days = [
{ value: 0, label: 'Su' },
{ value: 1, label: 'Mo' },
{ value: 2, label: 'Tu' },
{ value: 3, label: 'We' },
{ value: 4, label: 'Th' },
{ value: 5, label: 'Fr' },
{ value: 6, label: 'Sa' },
];
const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[] = [
{ value: 'none', label: 'Do nothing', icon: CheckCircle2 },
{ value: 'sleep', label: 'Sleep', icon: Moon },
{ value: 'restart', label: 'Restart', icon: RotateCcw },
{ value: 'shutdown', label: 'Shut down', icon: Power },
];
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
const [hour, minute] = settings.startTime.split(':').map(Number);
const now = new Date();
for (let offset = 0; offset < 8; offset += 1) {
const candidate = new Date(now);
candidate.setDate(now.getDate() + offset);
candidate.setHours(hour, minute, 0, 0);
const allowedDay = settings.everyday || settings.selectedDays.includes(candidate.getDay());
if (allowedDay && candidate > now) {
return candidate.toLocaleString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
});
}
}
return 'No scheduled day selected';
}
export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const [toast, setToast] = useState('');
const [permissionMessage, setPermissionMessage] = useState('');
const isMac = navigator.userAgent.includes('Mac');
useEffect(() => {
setDraft(savedSettings);
}, [savedSettings]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const nextRun = useMemo(() => nextScheduledRun(draft), [draft]);
const updateDraft = <K extends keyof SchedulerSettings>(key: K, value: SchedulerSettings[K]) => {
setDraft(current => ({ ...current, [key]: value }));
};
const toggleDay = (day: number) => {
setDraft(current => ({
...current,
selectedDays: current.selectedDays.includes(day)
? current.selectedDays.filter(value => value !== day)
: [...current.selectedDays, day].sort()
}));
};
const save = () => {
const normalized = {
...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0
? draft.selectedDays
: savedSettings.selectedDays
};
setScheduler(normalized);
setDraft(normalized);
setToast('Scheduler settings saved');
};
const runNow = async () => {
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
setToast(`Started ${count} download${count === 1 ? '' : 's'}`);
} else {
setToast('No paused or failed downloads to start');
}
};
const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
useSettingsStore.getState().setSchedulerRunning(false);
setToast(count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads');
};
const requestPermission = async () => {
setPermissionMessage('Requesting permission...');
try {
await invoke('request_automation_permission');
setPermissionMessage('Automation permission is available.');
} catch (error) {
setPermissionMessage(String(error));
}
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
<input
type="checkbox"
checked={draft.enabled}
onChange={event => updateDraft('enabled', event.target.checked)}
className="h-4 w-4 accent-accent"
/>
Scheduler
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
schedulerRunning ? 'bg-green-500/15 text-green-500' : 'bg-item-hover text-text-muted'
}`}>
{schedulerRunning ? 'Running' : nextRun}
</span>
<div className="ml-auto flex gap-2">
<button onClick={runNow} className="app-button px-3 text-[11px]">
<Play size={14} /> Run Now
</button>
<button onClick={pauseNow} className="app-button px-3 text-[11px]">
<Pause size={14} /> Pause
</button>
<button onClick={save} className="app-button app-button-primary px-3 text-[11px]">
<Save size={14} /> Save Settings
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-6">
<div className={`max-w-[760px] space-y-4 ${draft.enabled ? '' : 'opacity-50'}`}>
<section className="app-card p-5">
<div className="mb-5 flex items-center gap-2 font-semibold text-text-primary">
<Clock3 size={17} className="text-accent" /> Timing
</div>
<div className="flex flex-wrap items-end gap-8">
<label className="space-y-2 text-[12px] text-text-secondary">
<span className="block">Start Time</span>
<input type="time" value={draft.startTime} onChange={event => updateDraft('startTime', event.target.value)} disabled={!draft.enabled} className="app-control px-3 py-2 text-text-primary" />
</label>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] text-text-secondary">
<input type="checkbox" checked={draft.stopTimeEnabled} onChange={event => updateDraft('stopTimeEnabled', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Stop Time
</label>
<input type="time" value={draft.stopTime} onChange={event => updateDraft('stopTime', event.target.value)} disabled={!draft.enabled || !draft.stopTimeEnabled} className="app-control px-3 py-2 text-text-primary disabled:opacity-50" />
</div>
</div>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
<input type="checkbox" checked={draft.everyday} onChange={event => updateDraft('everyday', event.target.checked)} disabled={!draft.enabled} className="accent-accent" />
Run Every Day
</label>
{!draft.everyday && (
<div className="mt-4 flex gap-2">
{days.map(day => {
const selected = draft.selectedDays.includes(day.value);
return (
<button
key={day.value}
type="button"
disabled={!draft.enabled}
onClick={() => toggleDay(day.value)}
className={`h-8 w-8 rounded-full text-[12px] font-semibold ${
selected ? 'bg-accent text-white' : 'bg-bg-input text-text-primary hover:bg-item-hover'
}`}
>
{day.label}
</button>
);
})}
</div>
)}
</section>
<section className="app-card p-5">
<div className="mb-4 flex items-center gap-2 font-semibold text-text-primary">
<List size={17} className="text-accent" /> Queues to Schedule
</div>
<label className="flex items-center gap-3 text-[13px] text-text-primary">
<input type="checkbox" checked readOnly disabled={!draft.enabled} className="accent-accent" />
Main Queue
<span className="text-[11px] text-text-muted">All paused and failed downloads</span>
</label>
</section>
<section className="app-card p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Power size={17} className="text-accent" /> After Completion
</div>
<p className="mb-4 text-[12px] text-text-muted">Choose what happens after downloads started by the scheduler finish.</p>
<div className="grid grid-cols-2 gap-2">
{postActions.map(action => {
const Icon = action.icon;
return (
<label key={action.value} className={`flex items-center gap-3 rounded-lg border p-3 text-[13px] ${
draft.postQueueAction === action.value ? 'border-accent bg-item-selected text-text-primary' : 'border-border-modal text-text-secondary'
}`}>
<input type="radio" name="post-action" checked={draft.postQueueAction === action.value} onChange={() => updateDraft('postQueueAction', action.value)} disabled={!draft.enabled} className="accent-accent" />
<Icon size={15} />
{action.label}
</label>
);
})}
</div>
{draft.postQueueAction !== 'none' && (
<p className="mt-3 text-[11px] text-orange-400">This action can interrupt other work on the computer. Firelink invokes it immediately after the scheduled queue finishes.</p>
)}
</section>
</div>
{isMac && (
<section className="app-card mt-4 max-w-[760px] p-5">
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<LockKeyhole size={17} className="text-accent" /> System Permissions
</div>
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
<div className="flex gap-2">
<button onClick={requestPermission} className="app-button app-button-primary px-3 text-[11px]">Grant Permission</button>
<button onClick={() => invoke('open_automation_settings')} className="app-button px-3 text-[11px]">Open Settings</button>
</div>
{permissionMessage && <p className="mt-3 text-[11px] text-text-muted">{permissionMessage}</p>}
</section>
)}
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}
+914
View File
@@ -0,0 +1,914 @@
import { useState, useEffect } from 'react';
import {
type AppFontSize,
type ListRowDensity,
SettingsTab,
useSettingsStore
} from '../store/useSettingsStore';
import {
Download, Palette, Globe, Folder, Key,
Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-dialog';
import { getVersion } from '@tauri-apps/api/app';
import { invokeCommand as invoke } from '../ipc';
import { WindowDragRegion } from './WindowDragRegion';
import appIcon from '../assets/app-icon.png';
const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[] = [
{ type: 'downloads', label: 'Downloads', icon: Download },
{ type: 'lookandfeel', label: 'Look and feel', icon: Palette },
{ type: 'network', label: 'Network', icon: Globe },
{ type: 'locations', label: 'Locations', icon: Folder },
{ type: 'sitelogins', label: 'Site Logins', icon: Key },
{ type: 'power', label: 'Power', icon: Moon },
{ type: 'engine', label: 'Engine', icon: Terminal },
{ type: 'integrations', label: 'Integrations', icon: Puzzle },
{ type: 'about', label: 'About', icon: Info },
];
export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
// Local state for versions
const [aria2Version, setAria2Version] = useState<string>('Checking...');
const [ytdlpVersion, setYtdlpVersion] = useState<string>('Checking...');
const [ffmpegVersion, setFfmpegVersion] = useState<string>('Checking...');
const [denoVersion, setDenoVersion] = useState<string>('Checking...');
const [appVersion, setAppVersion] = useState('0.7.3');
const getEngineStatus = (v: string) => {
if (v === 'Checking...') return <span className="text-text-muted font-medium">Checking...</span>;
if (v.startsWith('Error')) return <span className="text-red-500 font-medium">Error / Missing</span>;
return <span className="text-green-500 font-medium">Ready</span>;
};
// Local state for adding site login
const [loginPattern, setLoginPattern] = useState('');
const [loginUser, setLoginUser] = useState('');
const [loginPass, setLoginPass] = useState('');
const [loginError, setLoginError] = useState('');
// Toast notifications
const [toastMessage, setToastMessage] = useState('');
const [isCheckingForUpdates, setIsCheckingForUpdates] = useState(false);
useEffect(() => {
if (toastMessage) {
const t = setTimeout(() => setToastMessage(''), 2000);
return () => clearTimeout(t);
}
}, [toastMessage]);
useEffect(() => {
getVersion().then(setAppVersion).catch(() => undefined);
}, []);
// Fetch engine versions when Engine tab is opened
useEffect(() => {
if (settings.activeView === 'settings' && activeTab === 'engine') {
invoke('test_aria2c')
.then(v => setAria2Version(v))
.catch(e => setAria2Version('Error: ' + e));
invoke('test_ytdlp')
.then(v => setYtdlpVersion(v))
.catch(e => setYtdlpVersion('Error: ' + e));
invoke('test_ffmpeg')
.then(v => setFfmpegVersion(v))
.catch(e => setFfmpegVersion('Error: ' + e));
invoke('test_deno')
.then(v => setDenoVersion(v))
.catch(e => setDenoVersion('Error: ' + e));
}
}, [settings.activeView, activeTab]);
const showToast = (msg: string) => {
setToastMessage(msg);
};
const handleCheckForUpdates = async () => {
if (isCheckingForUpdates) return;
setIsCheckingForUpdates(true);
showToast('Checking for updates...');
try {
const result = await invoke('check_for_updates');
if (result.type === 'UpToDate') {
showToast(`Firelink ${result.latest_version} is up to date`);
} else if (result.type === 'UpdateAvailable') {
showToast(`Firelink ${result.update.version} is available`);
} else {
showToast('The update check returned an unexpected response');
}
} catch (error) {
showToast(`Update check failed: ${String(error)}`);
} finally {
setIsCheckingForUpdates(false);
}
};
const handleBrowseCategory = async (category: string) => {
const currentPath = (settings.downloadDirectories || {})[category] || '';
try {
const selected = await open({
directory: true,
multiple: false,
defaultPath: currentPath.startsWith('~') ? undefined : currentPath
});
if (selected && typeof selected === 'string') {
settings.setCategoryDirectory(category, selected);
}
} catch (e) {
console.error(`Failed to select folder for ${category}:`, e);
}
};
const handleBrowseBulk = async () => {
try {
const base = await open({
directory: true,
multiple: false
});
if (base && typeof base === 'string') {
const cleanBase = base.replace(/\/$/, '');
settings.setCategoryDirectory('Musics', `${cleanBase}/Musics`);
settings.setCategoryDirectory('Movies', `${cleanBase}/Movies`);
settings.setCategoryDirectory('Compressed', `${cleanBase}/Compressed`);
settings.setCategoryDirectory('Documents', `${cleanBase}/Documents`);
settings.setCategoryDirectory('Pictures', `${cleanBase}/Pictures`);
settings.setCategoryDirectory('Applications', `${cleanBase}/Applications`);
settings.setCategoryDirectory('Other', `${cleanBase}/Other`);
showToast("Updated all categories to use base folder");
}
} catch (e) {
console.error("Failed to browse base path:", e);
}
};
const handleAddLogin = async () => {
if (!loginPattern.trim() || !loginUser.trim()) {
setLoginError("Please enter a URL pattern and a username.");
return;
}
const id = crypto.randomUUID();
if (loginPass) {
try {
await invoke('set_keychain_password', { id, password: loginPass });
} catch (e) {
console.error("Failed to save password to keychain:", e);
setLoginError("Failed to save password securely.");
return;
}
}
settings.addSiteLogin({
id,
urlPattern: loginPattern.trim(),
username: loginUser.trim()
});
setLoginPattern('');
setLoginUser('');
setLoginPass('');
setLoginError('');
showToast("Added site credential");
};
const copyToken = () => {
navigator.clipboard.writeText(settings.extensionPairingToken);
showToast("Token copied to clipboard!");
};
const activeTabLabel = settingsTabs.find(tab => tab.type === activeTab)?.label ?? 'Downloads';
const TabButton = ({ type, icon: Icon, label }: { type: SettingsTab; icon: typeof Download; label: string }) => {
const active = activeTab === type;
return (
<button
type="button"
data-active={active}
onClick={() => settings.setActiveSettingsTab(type)}
className={`settings-tab-button flex min-w-0 flex-1 flex-col items-center justify-center px-1 text-center cursor-default ${
active
? 'text-white'
: 'text-text-primary hover:bg-item-hover'
}`}
>
<Icon size={16} strokeWidth={2} />
<span className="settings-tab-label mt-1 w-full whitespace-nowrap font-medium">{label}</span>
</button>
);
};
return (
<div className="settings-view flex-1 flex flex-col relative h-full overflow-hidden">
<WindowDragRegion />
{/* Toast Notification */}
{toastMessage && (
<div className="app-toast absolute top-4 left-1/2 -translate-x-1/2 z-50 px-4 py-2 text-[12px] font-medium">
{toastMessage}
</div>
)}
{/* SwiftUI SettingsPaneContainer-style horizontal tab strip */}
<div className="settings-toolbar">
<div className="settings-tab-strip flex items-stretch gap-1">
{settingsTabs.map(tab => (
<TabButton key={tab.type} {...tab} />
))}
</div>
</div>
{/* Content Area */}
<div className="settings-scroll flex-1 overflow-y-auto">
<div className="settings-content-shell w-full">
<h1 className="settings-title text-text-primary">{activeTabLabel}</h1>
<div className="settings-content max-w-[720px]">
{/* Downloads Pane */}
{activeTab === 'downloads' && (
<div className="settings-pane max-w-[720px]">
<div className="mac-settings-group">
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Default connections:</span>
<small>For new downloads</small>
</div>
<input
type="number" min="1" max="16"
value={settings.perServerConnections}
onChange={(e) => settings.setPerServerConnections(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Parallel downloads:</span>
<small>Max simultaneous active files</small>
</div>
<input
type="number" min="1" max="12"
value={settings.maxConcurrentDownloads}
onChange={(e) => settings.setMaxConcurrentDownloads(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Global speed limit:</span>
<small>0 = unlimited speed</small>
</div>
<div className="relative">
<input
type="text"
value={settings.globalSpeedLimit}
onChange={(e) => settings.setGlobalSpeedLimit(e.target.value)}
placeholder="0"
className="app-control w-24 text-center font-mono pr-9"
/>
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-[10px] text-text-muted pointer-events-none">KiB/s</span>
</div>
</div>
<div className="mac-settings-row">
<div className="settings-row-label">
<span>Automatic retries:</span>
<small>If a connection fails</small>
</div>
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
</div>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show notification when download completes</span>
<small>Alerts you in Notification Center</small>
</div>
<input
type="checkbox"
checked={settings.showNotifications}
onChange={(e) => settings.setShowNotifications(e.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default" style={{ opacity: settings.showNotifications ? 1 : 0.5 }}>
<span className="text-[13px] text-text-primary">Play sound when download completes</span>
<input
type="checkbox"
checked={settings.playCompletionSound}
disabled={!settings.showNotifications}
onChange={(e) => settings.setPlayCompletionSound(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Look & Feel Pane */}
{activeTab === 'lookandfeel' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">App Theme</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Theme</span>
<div className="theme-option-grid" role="radiogroup" aria-label="App theme">
{[
{ value: 'system', label: 'System', colors: ['#f4f4f5', '#252525'] },
{ value: 'light', label: 'Light', colors: ['#ffffff', '#e9e9ec'] },
{ value: 'dark', label: 'Dark', colors: ['#1a1a1a', '#292929'] },
{ value: 'dracula', label: 'Dracula', colors: ['#282a36', '#ff79c6'] },
{ value: 'nord', label: 'Nord', colors: ['#2e3440', '#88c0d0'] },
].map(({ value, label, colors }) => (
<label
key={value}
className="theme-option"
data-active={settings.theme === value}
>
<input
type="radio"
name="app-theme"
checked={settings.theme === value}
onChange={() => settings.setTheme(value as typeof settings.theme)}
/>
<span className="theme-option-preview" aria-hidden="true">
<span style={{ background: colors[0] }} />
<span style={{ background: colors[1] }} />
</span>
<span>{label}</span>
</label>
))}
</div>
</div>
<p className="settings-group-footer">Select a color palette for the app's user interface.</p>
</div>
<h2 className="settings-section-title">Display</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">Font Size</span>
<select
value={settings.appFontSize}
onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
className="app-control w-40"
>
<option value="small">Small</option>
<option value="standard">Standard</option>
<option value="large">Large</option>
</select>
</div>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">List Row Density</span>
<select
value={settings.listRowDensity}
onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
className="app-control w-40"
>
<option value="compact">Compact</option>
<option value="standard">Standard</option>
<option value="relaxed">Relaxed</option>
</select>
</div>
</div>
<h2 className="settings-section-title">macOS Integration</h2>
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show badge on Dock icon</span>
<small>Displays the number of active downloads on the Firelink Dock icon.</small>
</div>
<input
type="checkbox"
checked={settings.showDockBadge}
onChange={(e) => settings.setShowDockBadge(e.target.checked)}
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Show menu bar icon</span>
<small>Provides quick access to downloads and queues from the macOS menu bar.</small>
</div>
<input
type="checkbox"
checked={settings.showMenuBarIcon}
onChange={(e) => settings.setShowMenuBarIcon(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
{/* Network Pane */}
{activeTab === 'network' && (
<div className="settings-pane max-w-[720px]">
<h2 className="settings-section-title">Proxy</h2>
<div className="mac-settings-group">
<div className="mac-settings-row settings-choice-row">
<span className="text-[13px] text-text-primary pt-0.5">Mode</span>
<div className="settings-radio-group">
{[
['none', 'No Proxy'],
['system', 'Use System Proxy'],
['custom', 'Custom Proxy'],
].map(([value, label]) => (
<label key={value}>
<input
type="radio"
name="proxy-mode"
checked={settings.proxyMode === value}
onChange={() => settings.setProxyMode(value as typeof settings.proxyMode)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{settings.proxyMode === 'custom' && (
<>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Host</span>
<input
type="text"
value={settings.proxyHost}
onChange={(e) => settings.setProxyHost(e.target.value)}
placeholder="127.0.0.1"
className="app-control w-40 font-mono"
/>
</div>
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">Proxy Port</span>
<input
type="number"
value={settings.proxyPort}
onChange={(e) => settings.setProxyPort(Number(e.target.value))}
className="app-control w-24 text-center"
/>
</div>
</>
)}
<p className="settings-group-footer">
{settings.proxyMode === 'none' && 'Downloads ignore configured proxies.'}
{settings.proxyMode === 'system' && 'Downloads use the matching macOS system proxy when one is configured.'}
{settings.proxyMode === 'custom' && (settings.proxyHost
? `Downloads use http://${settings.proxyHost}:${settings.proxyPort}.`
: 'Enter a proxy host and port to enable the custom proxy.')}
</p>
</div>
<h2 className="settings-section-title">Identity</h2>
<div className="mac-settings-group">
<div className="mac-settings-row">
<span className="text-[13px] text-text-primary">Custom User Agent</span>
<div className="flex-1 ml-4 relative">
<input
type="text"
list="user-agents"
value={settings.customUserAgent}
onChange={(e) => settings.setCustomUserAgent(e.target.value)}
placeholder="e.g. Mozilla/5.0..."
className="app-control w-full font-mono text-[11px]"
/>
<datalist id="user-agents">
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36">Chrome (macOS)</option>
<option value="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (Windows)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0">Firefox (macOS)</option>
<option value="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15">Safari (macOS)</option>
</datalist>
</div>
</div>
<p className="settings-group-footer">Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.</p>
</div>
</div>
)}
{/* Locations Pane */}
{activeTab === 'locations' && (
<div className="settings-pane max-w-[760px]">
<div className="mac-settings-group">
<label className="mac-settings-row cursor-default">
<span className="text-[13px] text-text-primary">Ask where to save each file</span>
<input
type="checkbox"
checked={settings.askWhereToSaveEachFile}
onChange={(e) => settings.setAskWhereToSaveEachFile(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
<div className="mac-settings-group">
<div className="mac-settings-row bg-item-hover/20">
<span className="text-[13px] font-semibold text-text-primary">All Categories Base</span>
<div className="flex gap-2">
<input
type="text" readOnly placeholder="Choose base folder..."
className="app-control w-64 text-text-muted text-[11px] px-2"
/>
<button
onClick={handleBrowseBulk}
className="app-button px-3 text-xs font-semibold text-accent border border-accent/20 bg-accent/10 hover:bg-accent/20"
>
Browse
</button>
</div>
</div>
{['Musics', 'Movies', 'Compressed', 'Documents', 'Pictures', 'Applications', 'Other'].map((category) => (
<div key={category} className="mac-settings-row">
<span className="text-[13px] text-text-primary pl-4">{category}</span>
<div className="flex gap-2">
<input
type="text"
value={(settings.downloadDirectories || {})[category] || ''}
onChange={(e) => settings.setCategoryDirectory(category, e.target.value)}
className="app-control w-64 text-[11px] px-2"
/>
<button
onClick={() => handleBrowseCategory(category)}
className="app-button px-3 text-xs text-text-secondary hover:bg-item-hover"
>
Browse
</button>
</div>
</div>
))}
<div className="mac-settings-row justify-end border-t-0">
<button
onClick={() => {
settings.resetCategoryDirectories();
showToast("Reset directories to default");
}}
className="app-control hover:bg-item-hover text-text-secondary px-4 py-1"
>
Reset Defaults
</button>
</div>
</div>
</div>
)}
{/* Site Logins Pane */}
{activeTab === 'sitelogins' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Site Credentials</h3>
{/* Site Logins List */}
<div className="space-y-2 max-h-[200px] overflow-y-auto border border-border-modal rounded-lg p-2 bg-item-hover/10">
{(settings.siteLogins || []).length === 0 ? (
<p className="text-center text-text-muted text-[13px] py-6">No saved logins.</p>
) : (
(settings.siteLogins || []).map((login) => (
<div key={login.id} className="flex justify-between items-center p-2 rounded bg-bg-modal border border-border-modal/40">
<div className="text-[13px] space-y-0.5">
<p className="font-bold text-text-primary font-mono text-[11px]">{login.urlPattern}</p>
<p className="text-text-secondary text-xs">User: {login.username}</p>
</div>
<button
onClick={async () => {
try {
await invoke('delete_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not delete password from keychain:", e);
}
settings.removeSiteLogin(login.id);
showToast("Deleted credential");
}}
className="p-1.5 hover:bg-item-hover rounded-md text-text-muted hover:text-red-500"
title="Delete credential"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
{/* Add Site Login Form */}
<div className="border-t border-border-color/30 pt-4 space-y-4">
<h4 className="text-[13px] font-bold text-text-primary">Add Site Credentials</h4>
{loginError && (
<p className="text-red-500 text-xs">{loginError}</p>
)}
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">URL Pattern:</label>
<input
type="text"
value={loginPattern}
onChange={(e) => setLoginPattern(e.target.value)}
placeholder="e.g. *.example.com or example.com/downloads"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Username:</label>
<input
type="text"
value={loginUser}
onChange={(e) => setLoginUser(e.target.value)}
placeholder="Username"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="grid grid-cols-[150px_1fr] items-center gap-4 text-[13px]">
<label className="text-text-secondary">Password:</label>
<input
type="password"
value={loginPass}
onChange={(e) => setLoginPass(e.target.value)}
placeholder="Password"
className="bg-bg-input border border-border-modal rounded-md px-3 py-1.5 w-full text-text-primary focus:outline-none"
/>
</div>
<div className="flex justify-end pt-2">
<button
onClick={handleAddLogin}
className="bg-accent hover:bg-accent text-white px-4 py-1.5 rounded-lg text-xs font-semibold shadow flex items-center gap-1.5"
>
<Plus size={14} /> Add Login
</button>
</div>
</div>
</div>
)}
{/* Power Pane */}
{activeTab === 'power' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Power Management</h3>
<label className="flex items-start gap-3 cursor-default select-none text-[13px] text-text-secondary">
<input
type="checkbox"
checked={settings.preventsSleepWhileDownloading}
onChange={(e) => settings.setPreventsSleepWhileDownloading(e.target.checked)}
className="mt-0.5 rounded accent-accent"
/>
<div>
<p className="font-semibold text-text-primary">Prevent system sleep while downloads are active</p>
<p className="text-text-muted text-xs mt-0.5">The display may still turn off. Firelink only keeps the device awake enough to complete active transfers.</p>
</div>
</label>
</div>
)}
{/* Engine Pane */}
{activeTab === 'engine' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<h3 className="text-base font-bold text-text-primary border-b border-border-color/30 pb-2">Media Downloader & Engines</h3>
<div className="space-y-4">
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-accent" /> Core Downloader (Aria2)
</h4>
<div className="grid grid-cols-[120px_1fr] text-[13px]">
<span className="text-text-secondary">Version:</span>
<span className="font-mono text-xs text-text-muted select-all">{aria2Version}</span>
</div>
<div className="grid grid-cols-[120px_1fr] text-[13px] items-center">
<span className="text-text-secondary">Status:</span>
{getEngineStatus(aria2Version)}
</div>
</div>
<div className="border border-border-modal rounded-lg p-4 space-y-3 bg-item-hover/5">
<h4 className="text-[13px] font-bold text-text-primary flex items-center gap-2 border-b border-border-modal pb-1">
<Terminal size={14} className="text-orange-500" /> Media Extractors
</h4>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">yt-dlp:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ytdlpVersion}</span>
{getEngineStatus(ytdlpVersion)}
</div>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">FFmpeg:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{ffmpegVersion}</span>
{getEngineStatus(ffmpegVersion)}
</div>
<div className="grid grid-cols-[120px_1fr_80px] text-[13px] pb-1 items-center">
<span className="text-text-secondary font-semibold">Deno:</span>
<span className="font-mono text-xs text-text-muted select-all truncate pr-4">{denoVersion}</span>
{getEngineStatus(denoVersion)}
</div>
<div className="grid grid-cols-[180px_1fr] items-center gap-4 text-[13px] border-t border-border-modal/50 pt-3 mt-2">
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
<select
value={settings.mediaCookieSource}
onChange={(e) => settings.setMediaCookieSource(
e.target.value as typeof settings.mediaCookieSource
)}
className="bg-bg-input border border-border-modal rounded-lg p-1.5 text-[13px] text-text-primary focus:outline-none focus:border-accent"
>
<option value="none">None</option>
<option value="safari">Safari</option>
<option value="chrome">Chrome</option>
<option value="firefox">Firefox</option>
<option value="edge">Edge</option>
<option value="brave">Brave</option>
</select>
</div>
<p className="text-text-muted text-xs mt-1">yt-dlp reads browser cookies to bypass video download limits or access restricted media. Firelink does not save browser cookies.</p>
</div>
</div>
</div>
)}
{/* Integrations Pane */}
{activeTab === 'integrations' && (
<div className="settings-pane space-y-6 max-w-[760px]">
<div className="flex items-center gap-3 border-b border-border-color/30 pb-3">
<Puzzle size={28} className="text-orange-500" />
<div>
<h3 className="text-base font-bold text-text-primary">Connect Browser Extension</h3>
<p className="text-text-secondary text-xs">Capture downloads directly from your browser in three easy steps.</p>
</div>
</div>
{/* Step Guide Cards */}
<div className="grid grid-cols-3 gap-4">
{/* Step 1 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-accent/25 text-accent font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">1</span>
<Copy size={16} className="text-accent" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Copy Token</h4>
<p className="text-text-muted text-[11px] leading-relaxed">This secure token authorizes your browser extension.</p>
</div>
<div className="space-y-2">
<button
onClick={copyToken}
className="w-full bg-accent hover:bg-accent text-white font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 shadow transition-colors"
>
<Copy size={11} /> Copy Token
</button>
<button
onClick={() => {
settings.regeneratePairingToken();
showToast("Pairing token regenerated");
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={11} /> Regenerate
</button>
</div>
</div>
{/* Step 2 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col justify-between h-[190px]">
<div>
<div className="flex justify-between items-center mb-2">
<span className="bg-orange-600/25 text-orange-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">2</span>
<Globe size={16} className="text-orange-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Get Extension</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Install the Firelink Companion extension on your browser.</p>
</div>
<div className="space-y-2">
<a
href="https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/"
target="_blank" rel="noreferrer"
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
>
Firefox Add-ons
</a>
<a
href="https://github.com/nimbold/Firelink-Extension/releases"
target="_blank" rel="noreferrer"
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] block text-center transition-colors"
>
GitHub Releases
</a>
</div>
</div>
{/* Step 3 */}
<div className="border border-border-modal rounded-lg p-4 bg-item-hover/5 flex flex-col h-[190px]">
<div className="flex justify-between items-center mb-2">
<span className="bg-green-600/25 text-green-500 font-bold rounded-full w-5 h-5 flex items-center justify-center text-xs">3</span>
<Puzzle size={16} className="text-green-500" />
</div>
<h4 className="text-[13px] font-bold text-text-primary mb-1">Paste & Connect</h4>
<p className="text-text-muted text-[11px] leading-relaxed">Click the Firelink icon in your browser's toolbar and paste the copied token.</p>
</div>
</div>
{/* Status Info */}
<div className="border border-border-modal/70 rounded-lg p-3 bg-item-hover/10 flex justify-between items-center text-[12px]">
<span className="text-text-secondary font-medium">Extension Server Status:</span>
<span className="text-green-500 font-semibold flex items-center gap-1">
Listening on 127.0.0.1:23522 (Active)
</span>
</div>
</div>
)}
{/* About Pane */}
{activeTab === 'about' && (
<div className="settings-pane space-y-6 max-w-[760px]">
{/* Header Box */}
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-6 flex items-center gap-4">
<img src={appIcon} alt="Firelink Icon" className="w-[72px] h-[72px] drop-shadow-md rounded-xl" />
<div className="space-y-1">
<h3 className="text-[17px] font-bold text-text-primary">Firelink</h3>
<p className="text-text-secondary text-[12px] font-medium">Version {appVersion}</p>
<p className="text-text-muted text-[11px]">
A fast desktop download manager powered by Rust and Tauri.
</p>
</div>
</div>
{/* Updates Section */}
<div className="space-y-2">
<h4 className="text-[12px] font-bold text-text-primary px-1">Updates</h4>
<div className="bg-bg-modal border border-border-modal/40 rounded-xl overflow-hidden">
<div className="p-4 flex items-center justify-between border-b border-border-modal/40">
<div>
<p className="text-[13px] font-bold text-text-primary">Check for Updates</p>
<p className="text-text-muted text-[11px] mt-0.5">Firelink checks GitHub Releases for new versions.</p>
</div>
<button
onClick={handleCheckForUpdates}
disabled={isCheckingForUpdates}
className="app-button px-4 text-xs disabled:opacity-50"
>
{isCheckingForUpdates ? (
<>
<RefreshCw size={13} className="animate-spin" />
Checking...
</>
) : 'Check Now'}
</button>
</div>
<label className="p-4 flex items-center justify-between cursor-default">
<span className="text-[13px] font-bold text-text-primary">Automatically check for updates</span>
<button
type="button"
role="switch"
aria-checked={settings.autoCheckUpdates}
onClick={() => settings.setAutoCheckUpdates(!settings.autoCheckUpdates)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-default items-center rounded-full transition-colors duration-200 ease-in-out border border-transparent ${settings.autoCheckUpdates ? 'bg-accent' : 'bg-border-color'}`}
>
<span className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow transition duration-200 ease-in-out ${settings.autoCheckUpdates ? 'translate-x-4' : 'translate-x-0'}`} />
</button>
</label>
</div>
</div>
{/* Credits Footer */}
<div className="bg-bg-modal border border-border-modal/40 rounded-xl p-4 text-[11px] space-y-3">
<div className="flex justify-between items-center">
<span className="text-text-primary font-bold">Created by NimBold</span>
<a href="https://github.com/nimbold/Firelink" target="_blank" rel="noreferrer" className="flex items-center gap-1.5 text-text-secondary hover:text-accent transition-colors font-medium">
<Code size={14} /> Source Code
</a>
</div>
<div className="flex justify-between items-center text-text-muted">
<span>Built with <span className="text-accent">Rust</span> <span className="text-accent">Tauri</span> <span className="text-accent">React</span> <span className="text-accent">TypeScript</span></span>
<a href="https://github.com/nimbold/Firelink/blob/main/LICENSE" target="_blank" rel="noreferrer" className="text-accent hover:underline">MIT License</a>
</div>
<div className="text-text-muted">
Download engines: <span className="text-accent">aria2</span> <span className="text-accent">yt-dlp</span> <span className="text-accent">FFmpeg</span> <span className="text-accent">Deno</span>
</div>
<div className="text-text-muted pt-1 border-t border-border-modal/40">
Copyright © 2026 NimBold. All rights reserved.
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
};
+295
View File
@@ -0,0 +1,295 @@
import React, { useState, useEffect, useRef } from 'react';
import {
Inbox, Zap, CheckCircle2, CircleDashed,
Film, Music, FileText, Box, Image as ImageIcon, Archive, FileQuestion,
List, CalendarClock, Gauge, Settings, Plus, Play, Pause, Edit2, Trash2, PanelLeft,
type LucideIcon
} from 'lucide-react';
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
interface SidebarProps {
selectedFilter: SidebarFilter;
onSelectFilter: (filter: SidebarFilter) => void;
}
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onSelectFilter } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue } = useDownloadStore();
const { activeView, setActiveView, toggleSidebar } = useSettingsStore();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
const [renamingQueueId, setRenamingQueueId] = useState<string | null>(null);
const [editingQueueName, setEditingQueueName] = useState('');
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; id: string } | null>(null);
const addInputRef = useRef<HTMLInputElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
window.addEventListener('click', handleCloseMenu);
return () => window.removeEventListener('click', handleCloseMenu);
}, []);
useEffect(() => {
if (isAddingQueue) addInputRef.current?.focus();
}, [isAddingQueue]);
useEffect(() => {
if (renamingQueueId) renameInputRef.current?.focus();
}, [renamingQueueId]);
const getCount = (filter: SidebarFilter) => {
if (filter.startsWith('queue:')) {
const qid = filter.replace('queue:', '');
return downloads.filter(d => d.queueId === qid).length;
}
switch (filter) {
case 'all': return downloads.length;
case 'active': return downloads.filter(d => d.status === 'downloading').length;
case 'completed': return downloads.filter(d => d.status === 'completed').length;
case 'unfinished': return downloads.filter(d => d.status !== 'completed').length;
default: return downloads.filter(d => d.category === filter as DownloadCategory).length;
}
};
const NavItem = ({ icon: Icon, label, filter }: { icon: LucideIcon, label: string, filter: SidebarFilter }) => {
const isSelected = activeView === 'downloads' && selectedFilter === filter;
return (
<button
type="button"
data-active={isSelected}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
onClick={() => onSelectFilter(filter)}
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{label}</span>
{getCount(filter) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold">
{getCount(filter)}
</span>
)}
</button>
);
};
const handleQueueContextMenu = (e: React.MouseEvent, id: string) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id });
};
const handleAddQueueSubmit = () => {
if (newQueueName.trim()) addQueue(newQueueName.trim());
setNewQueueName('');
setIsAddingQueue(false);
};
const handleRenameQueueSubmit = () => {
if (renamingQueueId && editingQueueName.trim()) {
renameQueue(renamingQueueId, editingQueueName.trim());
}
setRenamingQueueId(null);
};
const QueueItem = ({ queue }: { queue: Queue }) => {
const filterId = `queue:${queue.id}`;
const isSelected = activeView === 'downloads' && selectedFilter === filterId;
const isRenaming = renamingQueueId === queue.id;
if (isRenaming) {
return (
<div className="flex items-center px-2.5 py-1 rounded-lg mb-0.5 bg-item-hover">
<List className="w-4 h-4 mr-2 text-text-secondary" strokeWidth={2} />
<input
ref={renameInputRef}
type="text"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={editingQueueName}
onChange={e => setEditingQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleRenameQueueSubmit();
if (e.key === 'Escape') setRenamingQueueId(null);
}}
onBlur={handleRenameQueueSubmit}
/>
</div>
);
}
return (
<button
type="button"
data-active={isSelected}
onContextMenu={e => handleQueueContextMenu(e, queue.id)}
onClick={() => onSelectFilter(filterId)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
>
<List className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span className="truncate">{queue.name}</span>
{getCount(filterId) > 0 && (
<span className="sidebar-count ml-auto min-w-5 px-1.5 py-0.5 rounded-full text-center text-[10px] leading-none font-bold shrink-0">
{getCount(filterId)}
</span>
)}
</button>
);
};
const ToolItem = ({ icon: Icon, label, view }: { icon: LucideIcon; label: string; view: ActiveView }) => {
const isSelected = activeView === view;
return (
<button
type="button"
data-active={isSelected}
onClick={() => setActiveView(view)}
className="sidebar-nav-item group flex w-full items-center text-[13px] text-left cursor-default font-medium"
>
<Icon className="w-[18px] h-[18px] mr-3 shrink-0" strokeWidth={isSelected ? 2.5 : 2} />
<span>{label}</span>
</button>
);
};
return (
<aside className="sidebar-inner">
<div className="sidebar-top-region">
<WindowDragRegion />
<button
type="button"
onClick={toggleSidebar}
className="sidebar-toggle-button"
title="Hide Sidebar"
>
<PanelLeft size={14} strokeWidth={1.9} />
</button>
</div>
<div className="sidebar-scroll">
<section className="sidebar-section">
<div className="sidebar-section-label">Library</div>
<NavItem icon={Inbox} label="All" filter="all" />
<NavItem icon={Zap} label="Active" filter="active" />
<NavItem icon={CheckCircle2} label="Completed" filter="completed" />
<NavItem icon={CircleDashed} label="Unfinished" filter="unfinished" />
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Folders</div>
<NavItem icon={Music} label="Musics" filter="Musics" />
<NavItem icon={Film} label="Movies" filter="Movies" />
<NavItem icon={Archive} label="Compressed" filter="Compressed" />
<NavItem icon={FileText} label="Documents" filter="Documents" />
<NavItem icon={ImageIcon} label="Pictures" filter="Pictures" />
<NavItem icon={Box} label="Applications" filter="Applications" />
<NavItem icon={FileQuestion} label="Other" filter="Other" />
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Queues</div>
{queues.map(queue => (
<QueueItem key={queue.id} queue={queue} />
))}
{isAddingQueue ? (
<div className="flex items-center px-3.5 py-1.5 rounded-lg bg-item-hover mb-1">
<Plus className="w-4 h-4 mr-2 text-text-secondary shrink-0" strokeWidth={2} />
<input
ref={addInputRef}
type="text"
placeholder="Queue name"
className="flex-1 bg-transparent border border-accent rounded px-1 text-[13px] text-text-primary outline-none min-w-0"
value={newQueueName}
onChange={e => setNewQueueName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') handleAddQueueSubmit();
if (e.key === 'Escape') setIsAddingQueue(false);
}}
onBlur={handleAddQueueSubmit}
/>
</div>
) : (
<button
type="button"
onClick={() => { setIsAddingQueue(true); setNewQueueName(''); }}
className="flex w-full items-center px-3.5 py-1.5 rounded-lg text-[13px] text-text-muted hover:bg-item-hover hover:text-text-secondary cursor-default transition-colors mb-1"
>
<Plus className="w-4 h-4 mr-2 shrink-0" strokeWidth={2} />
<span className="truncate">Add new queue</span>
</button>
)}
</section>
<section className="sidebar-section">
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
</section>
</div>
<div className="sidebar-footer">
<button
type="button"
data-active={activeView === 'settings'}
onClick={() => setActiveView('settings')}
className="sidebar-nav-item sidebar-settings-button group flex w-full items-center text-[13px] text-left cursor-default font-medium transition-colors"
>
<Settings className={`w-[18px] h-[18px] mr-3 shrink-0 ${activeView === 'settings' ? 'text-white' : 'text-text-muted'}`} strokeWidth={activeView === 'settings' ? 2.5 : 2} />
<span>Settings</span>
</button>
</div>
{contextMenu && (
<div
className="fixed z-50 w-48 py-1 rounded-xl shadow-lg border border-border-modal bg-bg-context-menu backdrop-blur-xl animate-fade-in text-[13px] text-text-primary overflow-hidden"
style={{ top: contextMenu.y, left: contextMenu.x }}
onClick={e => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { startQueue(contextMenu.id); setContextMenu(null); }}
>
<Play size={14} className="mr-2 text-text-secondary" />
Start Queue
</button>
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => { pauseQueue(contextMenu.id); setContextMenu(null); }}
>
<Pause size={14} className="mr-2 text-text-secondary" />
Pause Queue
</button>
<div className="h-px bg-border-color my-1 mx-2" />
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-item-hover"
onClick={() => {
const q = queues.find(q => q.id === contextMenu.id);
if (q) {
setEditingQueueName(q.name);
setRenamingQueueId(q.id);
}
setContextMenu(null);
}}
>
<Edit2 size={14} className="mr-2 text-text-secondary" />
Rename Queue
</button>
{!queues.find(q => q.id === contextMenu.id)?.isMain && (
<button
className="w-full text-left px-3 py-1.5 flex items-center hover:bg-red-500/20 text-red-400"
onClick={() => { removeQueue(contextMenu.id); setContextMenu(null); }}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
</button>
)}
</div>
)}
</aside>
);
};
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from 'react';
import { Gauge, Save, Zap } from 'lucide-react';
import { useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
type SpeedUnit = 'KB/s' | 'MB/s';
function parseLimit(limit: string, fallback: number): { value: number; unit: SpeedUnit } {
const match = limit.trim().match(/^(\d+(?:\.\d+)?)\s*([km]?)b?(?:\/s)?$/i);
const valueKiB = match
? Math.max(1, Math.round(Number(match[1]) * (match[2].toLowerCase() === 'm' ? 1024 : 1)))
: fallback;
return valueKiB >= 1024 && valueKiB % 1024 === 0
? { value: valueKiB / 1024, unit: 'MB/s' }
: { value: valueKiB, unit: 'KB/s' };
}
export default function SpeedLimiterView() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const lastCustomSpeedLimitKiB = useSettingsStore(state => state.lastCustomSpeedLimitKiB);
const setGlobalSpeedLimit = useSettingsStore(state => state.setGlobalSpeedLimit);
const setLastCustomSpeedLimitKiB = useSettingsStore(state => state.setLastCustomSpeedLimitKiB);
const initial = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
const [enabled, setEnabled] = useState(Boolean(globalSpeedLimit));
const [value, setValue] = useState(initial.value);
const [unit, setUnit] = useState<SpeedUnit>(initial.unit);
const [toast, setToast] = useState('');
useEffect(() => {
const parsed = parseLimit(globalSpeedLimit, lastCustomSpeedLimitKiB);
setEnabled(Boolean(globalSpeedLimit));
setValue(parsed.value);
setUnit(parsed.unit);
}, [globalSpeedLimit, lastCustomSpeedLimitKiB]);
useEffect(() => {
if (!toast) return;
const timeout = window.setTimeout(() => setToast(''), 2200);
return () => window.clearTimeout(timeout);
}, [toast]);
const save = () => {
const numericValue = Math.max(1, Math.min(Number(value) || 1, unit === 'MB/s' ? 10240 : 10_485_760));
const valueKiB = Math.min(10_485_760, Math.round(unit === 'MB/s' ? numericValue * 1024 : numericValue));
setLastCustomSpeedLimitKiB(valueKiB);
setGlobalSpeedLimit(enabled ? `${valueKiB}K` : '');
setToast(enabled ? `Global limit saved at ${numericValue} ${unit}` : 'Global speed limit disabled');
};
const preset = (presetValue: number) => {
setEnabled(true);
setValue(presetValue);
setUnit('MB/s');
};
return (
<div className="flex-1 flex h-full flex-col overflow-hidden bg-main-bg">
<WindowDragRegion />
<div className="flex items-center gap-3 border-b border-border-color px-6 pb-4">
<label className="flex items-center gap-3 text-[17px] font-semibold tracking-tight text-text-primary">
<input type="checkbox" checked={enabled} onChange={event => setEnabled(event.target.checked)} className="h-4 w-4 accent-accent" />
Speed Limiter
</label>
<span className={`rounded-full px-2.5 py-1 text-[11px] font-semibold ${
enabled ? 'bg-accent/15 text-accent' : 'bg-item-hover text-text-muted'
}`}>
{enabled ? `${value} ${unit}` : 'Unlimited'}
</span>
<button onClick={save} className="app-button app-button-primary ml-auto px-3 text-[11px]">
<Save size={14} /> Save Limit
</button>
</div>
<div className="flex-1 overflow-y-auto p-6">
<section className={`app-card max-w-[720px] p-5 ${enabled ? '' : 'opacity-50'}`}>
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
<Gauge size={18} className="text-accent" /> Global Speed Limit
</div>
<p className="max-w-xl text-[12px] leading-relaxed text-text-muted">
This cap is shared across the configured concurrent download slots. A lower per-download limit still takes precedence.
Saving a new limit gracefully restarts active jobs so the change takes effect immediately.
</p>
<div className="mt-6 flex items-center gap-3">
<input
type="number"
min="1"
value={value}
disabled={!enabled}
onChange={event => setValue(Math.max(1, Number(event.target.value) || 1))}
className="app-control w-28 px-3 py-2 text-right font-mono"
/>
<div className="flex rounded-md border border-border-modal bg-bg-input p-1">
{(['KB/s', 'MB/s'] as SpeedUnit[]).map(option => (
<button
key={option}
type="button"
disabled={!enabled}
onClick={() => setUnit(option)}
className={`rounded px-3 py-1.5 text-[12px] font-medium ${
unit === option ? 'bg-accent text-white' : 'text-text-secondary hover:bg-item-hover'
}`}
>
{option}
</button>
))}
</div>
</div>
<div className="my-6 border-t border-border-color" />
<div className="mb-3 flex items-center gap-2 text-[12px] font-medium text-text-secondary">
<Zap size={14} /> Quick Presets
</div>
<div className="flex flex-wrap gap-2">
{[1, 5, 10].map(presetValue => (
<button
key={presetValue}
type="button"
disabled={!enabled}
onClick={() => preset(presetValue)}
className="app-button px-4 text-[12px] disabled:opacity-50"
>
{presetValue} MB/s
</button>
))}
</div>
</section>
</div>
{toast && (
<div className="app-toast pointer-events-none absolute bottom-7 left-1/2 -translate-x-1/2 px-4 py-2 text-[12px] font-medium">
{toast}
</div>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
interface WindowDragRegionProps {
className?: string;
}
export function WindowDragRegion({ className = '' }: WindowDragRegionProps) {
return (
<div
className={`h-10 shrink-0 cursor-default ${className}`}
data-tauri-drag-region
onPointerDown={(event) => {
if (event.button === 0) {
void getCurrentWindow().startDragging();
}
}}
/>
);
}
+1155
View File
File diff suppressed because it is too large Load Diff
+125
View File
@@ -0,0 +1,125 @@
import { invoke as tauriInvoke } from '@tauri-apps/api/core';
import { listen as tauriListen, type Event, type EventCallback, type UnlistenFn } from '@tauri-apps/api/event';
import type { DownloadCategory } from './bindings/DownloadCategory';
import type { DownloadProgressEvent } from './bindings/DownloadProgressEvent';
import type { DownloadStatus } from './bindings/DownloadStatus';
import type { ExtensionDownload } from './bindings/ExtensionDownload';
import type { MediaCookieSource } from './bindings/MediaCookieSource';
import type { MetadataResponse } from './bindings/MetadataResponse';
import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
type StartDownloadArgs = {
id: string;
url: string;
destination: string;
filename: string;
connections: number | null;
speedLimit: string | null;
username: string | null;
password: string | null;
headers: string | null;
checksum: string | null;
cookies: string | null;
mirrors: string | null;
userAgent: string | null;
maxTries: number | null;
proxy: string | null;
};
type StartMediaDownloadArgs = {
id: string;
url: string;
destination: string;
filename: string;
formatSelector: string | null;
cookieSource: Exclude<MediaCookieSource, 'none'> | null;
speedLimit: string | null;
username: string | null;
password: string | null;
headers: string | null;
proxy: string | null;
userAgent: string | null;
maxTries: number | null;
};
type CommandMap = {
fetch_metadata: {
args: { url: string; userAgent: string | null; username: string | null; password: string | null };
result: MetadataResponse;
};
fetch_media_metadata: {
args: { url: string; cookieBrowser: string | null; username: string | null; password: string | null };
result: string;
};
test_ytdlp: { args: undefined; result: string };
test_aria2c: { args: undefined; result: string };
test_ffmpeg: { args: undefined; result: string };
test_deno: { args: undefined; result: string };
open_file: { args: { path: string }; result: void };
show_in_folder: { args: { path: string }; result: void };
start_download: { args: StartDownloadArgs; result: void };
start_media_download: { args: StartMediaDownloadArgs; result: void };
pause_download: { args: { id: string }; result: void };
remove_download: { args: { id: string; filepath: string | null }; result: void };
update_dock_badge: { args: { count: number }; result: void };
set_prevent_sleep: { args: { prevent: boolean }; result: void };
perform_system_action: { args: { action: PostQueueAction }; result: void };
set_concurrent_limit: { args: { limit: number }; result: void };
set_global_speed_limit: { args: { limit: string | null }; result: void };
request_automation_permission: { args: undefined; result: void };
open_automation_settings: { args: undefined; result: void };
get_free_space: { args: { path: string }; result: string };
set_keychain_password: { args: { id: string; password: string }; result: void };
get_keychain_password: { args: { id: string }; result: string };
delete_keychain_password: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
delete_file: { args: { path: string }; result: void };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
get_system_proxy: { args: undefined; result: string | null };
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
is_supported_media: { args: { url: string }; result: boolean };
db_save_settings: { args: { data: string }; result: void };
db_load_settings: { args: undefined; result: string | null };
db_get_all_downloads: { args: undefined; result: string[] };
db_save_download: {
args: { id: string; status: DownloadStatus; queueId: string; data: string };
result: void;
};
db_delete_download: { args: { id: string }; result: void };
db_get_all_queues: { args: undefined; result: string[] };
db_save_queue: { args: { id: string; data: string }; result: void };
db_delete_queue: { args: { id: string }; result: void };
};
type CommandName = keyof CommandMap;
type CommandArgs<K extends CommandName> = CommandMap[K]['args'];
type CommandResult<K extends CommandName> = CommandMap[K]['result'];
export function invokeCommand<K extends CommandName>(
command: K,
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
): Promise<CommandResult<K>> {
return tauriInvoke<CommandResult<K>>(command, args[0]);
}
type EventMap = {
'schedule-trigger': 'start' | 'stop';
'download-progress': DownloadProgressEvent;
'download-complete': string;
'download-failed': string;
'extension-add-download': ExtensionDownload;
'deep-link-add-download': string;
};
export function listenEvent<K extends keyof EventMap>(
event: K,
handler: EventCallback<EventMap[K]>,
): Promise<UnlistenFn> {
return tauriListen<EventMap[K]>(event, handler);
}
export type IpcEvent<K extends keyof EventMap> = Event<EventMap[K]>;
+16
View File
@@ -0,0 +1,16 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
const rootElement = document.getElementById("root");
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);
}
+441
View File
@@ -0,0 +1,441 @@
import { create } from 'zustand';
import { invokeCommand as invoke } from '../ipc';
import type { DownloadItem } from '../bindings/DownloadItem';
import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import {
categoryForFileName,
fileNameFromUrl,
isMediaUrl
} from '../utils/downloads';
export type { DownloadCategory } from '../utils/downloads';
const getProxyArgs = (settings: ReturnType<typeof useSettingsStore.getState>) => {
if (settings.proxyMode === 'custom' && settings.proxyHost) {
return `http://${settings.proxyHost}:${settings.proxyPort}`;
}
return null;
};
export const getSiteLogin = (url: string, settings: ReturnType<typeof useSettingsStore.getState>) => {
try {
const urlObj = new URL(url);
const host = urlObj.hostname.toLowerCase();
for (const login of settings.siteLogins) {
let pattern = login.urlPattern.toLowerCase().trim();
if (pattern.startsWith('*.')) {
const suffix = pattern.substring(2);
if (host === suffix || host.endsWith('.' + suffix)) return login;
} else if (pattern.includes('*')) {
const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
if (regex.test(host)) return login;
} else if (host === pattern) {
return login;
}
}
} catch (e) {}
return null;
};
const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
if (settings.preventsSleepWhileDownloading) {
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
} else {
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
}
};
// Legacy manual speed limit math removed
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
export type { DownloadItem, Queue };
export type ExtensionDownloadRequest = ExtensionDownload;
interface DownloadState {
downloads: DownloadItem[];
queues: Queue[];
isAddModalOpen: boolean;
pendingAddUrls: string;
pendingAddReferer: string;
pendingAddFilename: string;
selectedPropertiesDownloadId: string | null;
toggleAddModal: (isOpen: boolean) => void;
openAddModalWithUrls: (urls: string, referer?: string | null, filename?: string | null) => void;
handleExtensionDownload: (request: ExtensionDownloadRequest) => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadItem) => void;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string) => Promise<void>;
clearFinished: () => void;
redownload: (id: string) => void;
processQueue: () => Promise<void>;
startQueue: (queueId: string) => Promise<number>;
pauseQueue: (queueId: string) => Promise<number>;
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void;
initDB: () => Promise<void>;
}
export const useDownloadStore = create<DownloadState>((set, get) => ({
downloads: [],
queues: [{ id: MAIN_QUEUE_ID, name: 'Main Queue', isMain: true }],
isAddModalOpen: false,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: '',
selectedPropertiesDownloadId: null,
toggleAddModal: (isOpen) => set({
isAddModalOpen: isOpen,
pendingAddUrls: '',
pendingAddReferer: '',
pendingAddFilename: ''
}),
openAddModalWithUrls: (urls, referer, filename) => set({
isAddModalOpen: true,
pendingAddUrls: urls,
pendingAddReferer: referer?.trim() || '',
pendingAddFilename: filename?.trim() || ''
}),
handleExtensionDownload: (request) => {
const urls = [...new Set(request.urls.map(url => url.trim()).filter(Boolean))];
if (urls.length === 0) return;
const settings = useSettingsStore.getState();
if (!request.silent || settings.askWhereToSaveEachFile) {
get().openAddModalWithUrls(
urls.join('\n'),
request.referer,
urls.length === 1 ? request.filename : null
);
return;
}
const referer = request.referer?.trim();
const headers = referer ? `Referer: ${referer}` : undefined;
const dateAdded = new Date().toISOString();
const downloads = urls.map((url, index): DownloadItem => {
const fileName = index === 0 && urls.length === 1 && request.filename?.trim()
? request.filename.trim()
: fileNameFromUrl(url);
return {
id: crypto.randomUUID(),
url,
fileName,
status: 'queued',
category: categoryForFileName(fileName),
dateAdded,
connections: settings.perServerConnections,
headers,
isMedia: isMediaUrl(url),
queueId: MAIN_QUEUE_ID
};
});
set(state => ({ downloads: [...state.downloads, ...downloads] }));
downloads.forEach(item => {
const toSave = { ...item };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
});
void get().processQueue();
},
setSelectedPropertiesDownloadId: (id) => set({ selectedPropertiesDownloadId: id }),
addDownload: (item) => {
set((state) => ({ downloads: [...state.downloads, item] }));
const toSave = { ...item };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: item.id, status: item.status, queueId: item.queueId, data: JSON.stringify(toSave) }).catch(console.error);
get().processQueue();
},
updateDownload: (id, updates) => {
let updatedItem: DownloadItem | null = null;
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
let newFraction = updates.fraction;
if (newFraction === 0 && d.fraction && d.fraction > 0) {
newFraction = d.fraction;
}
const updated = {
...d,
...updates,
fraction: newFraction !== undefined ? newFraction : updates.fraction !== undefined ? updates.fraction : d.fraction
};
updatedItem = updated;
return updated;
}
return d;
})
}));
if (updatedItem && Object.keys(updates).some(k => !['fraction', 'speed', 'eta'].includes(k))) {
const toSave = { ...(updatedItem as DownloadItem) };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
}
// If status changed to something that frees up a slot, process queue
if (updates.status && ['completed', 'failed', 'paused'].includes(updates.status)) {
get().processQueue();
syncSystemIntegrations();
} else if (updates.status === 'downloading') {
syncSystemIntegrations();
}
},
removeDownload: async (id) => {
const item = get().downloads.find(d => d.id === id);
if (item && item.status === 'downloading') {
try {
await invoke('pause_download', { id });
} catch (e) {
console.error("Failed to terminate download on deletion:", e);
}
}
set((state) => ({
downloads: state.downloads.filter(d => d.id !== id)
}));
invoke('db_delete_download', { id }).catch(console.error);
get().processQueue();
syncSystemIntegrations();
},
clearFinished: () => {
const downloads = get().downloads;
const toRemove = downloads.filter(d => ['completed', 'failed'].includes(d.status));
set((state) => ({
downloads: state.downloads.filter(d => !['completed', 'failed'].includes(d.status))
}));
toRemove.forEach(d => {
invoke('db_delete_download', { id: d.id }).catch(console.error);
});
},
redownload: (id) => {
let updatedItem: DownloadItem | null = null;
set((state) => ({
downloads: state.downloads.map(d => {
if (d.id === id) {
const updated: DownloadItem = { ...d, status: 'queued', _dispatched: false, fraction: 0, speed: '-', eta: '-' };
updatedItem = updated;
return updated;
}
return d;
})
}));
if (updatedItem) {
const toSave = { ...(updatedItem as DownloadItem) };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: toSave.queueId, data: JSON.stringify(toSave) }).catch(console.error);
}
get().processQueue();
},
startQueue: async (queueId) => {
const runnableIds = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || item.status === 'paused' || item.status === 'failed'))
.map(item => item.id);
if (runnableIds.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
runnableIds.includes(item.id)
? { ...item, status: 'queued', _dispatched: false, speed: '-', eta: '-' }
: item
)
}));
await get().processQueue();
return runnableIds.length;
},
pauseQueue: async (queueId) => {
const activeIds = get().downloads
.filter(item => item.queueId === queueId && item.status === 'downloading')
.map(item => item.id);
if (activeIds.length === 0) return 0;
set((state) => ({
downloads: state.downloads.map(item =>
activeIds.includes(item.id)
? { ...item, status: 'paused', speed: '-', eta: '-' }
: item
)
}));
await Promise.all(activeIds.map(id => invoke('pause_download', { id }).catch(() => {})));
syncSystemIntegrations();
return activeIds.length;
},
addQueue: (name) => {
const id = crypto.randomUUID();
const q = { id, name, isMain: false };
set((state) => ({
queues: [...state.queues, q]
}));
invoke('db_save_queue', { id, data: JSON.stringify(q) }).catch(console.error);
},
renameQueue: (id, name) => {
let updatedQ: Queue | null = null;
set((state) => ({
queues: state.queues.map(q => {
if (q.id === id) {
const newQ = { ...q, name };
updatedQ = newQ;
return newQ;
}
return q;
})
}));
if (updatedQ) {
invoke('db_save_queue', { id, data: JSON.stringify(updatedQ) }).catch(console.error);
}
},
removeQueue: (id) => {
if (id === MAIN_QUEUE_ID) return;
set((state) => ({
queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d =>
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
)
}));
invoke('db_delete_queue', { id }).catch(console.error);
// Also we need to save the updated downloads to DB
const downloads = get().downloads.filter(d => d.queueId === id);
downloads.forEach(d => {
const toSave = { ...d, queueId: MAIN_QUEUE_ID };
delete toSave.fraction; delete toSave.speed; delete toSave.eta;
invoke('db_save_download', { id: toSave.id, status: toSave.status, queueId: MAIN_QUEUE_ID, data: JSON.stringify(toSave) }).catch(console.error);
});
},
initDB: async () => {
try {
const queuesStr = await invoke('db_get_all_queues');
const queues = queuesStr.map(q => JSON.parse(q));
const downloadsStr = await invoke('db_get_all_downloads');
const downloads = downloadsStr.map(d => JSON.parse(d));
set(state => ({
queues: queues.length > 0 ? queues : state.queues,
downloads: downloads.length > 0 ? downloads : state.downloads
}));
// Auto resume downloads that were active
const active = get().downloads.filter(d => d.status === 'downloading');
active.forEach(item => {
if (item.isMedia) {
invoke('start_media_download', {
id: item.id,
url: item.url,
destination: item.destination || '~/Downloads',
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: null,
speedLimit: item.speedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
proxy: null,
userAgent: null,
maxTries: null
}).catch(console.error);
} else {
invoke('start_download', {
id: item.id,
url: item.url,
destination: item.destination || '~/Downloads',
filename: item.fileName,
connections: item.connections ?? null,
speedLimit: item.speedLimit || null,
username: item.username || null,
password: item.password || null,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: null,
maxTries: null,
proxy: null
}).catch(console.error);
}
});
void get().processQueue();
} catch (e) {
console.error("Failed to init DB", e);
}
},
processQueue: async () => {
const { downloads, updateDownload } = get();
// Find all queued items that haven't been dispatched to the backend yet
const itemsToStart = downloads.filter(d => d.status === 'queued' && !d._dispatched);
for (const item of itemsToStart) {
// Mark as dispatched so we don't send it again on the next pass
updateDownload(item.id, { _dispatched: true });
try {
const settings = useSettingsStore.getState();
const login = getSiteLogin(item.url, settings);
let keychainPassword = null;
if (login) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
console.warn("Could not fetch keychain password for login:", e);
}
}
const destPath = item.destination ||
(settings.downloadDirectories && settings.downloadDirectories[item.category]) ||
settings.defaultDownloadPath ||
'~/Downloads';
if (item.isMedia) {
await invoke('start_media_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
formatSelector: item.mediaFormatSelector || null,
cookieSource: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
speedLimit: item.speedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
proxy: getProxyArgs(settings),
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries
});
} else {
await invoke('start_download', {
id: item.id,
url: item.url,
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speedLimit: item.speedLimit || null,
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
checksum: item.checksum || null,
cookies: item.cookies || null,
mirrors: item.mirrors || null,
userAgent: settings.customUserAgent || null,
maxTries: settings.maxAutomaticRetries,
proxy: getProxyArgs(settings)
});
}
} catch (e) {
console.error("Failed to start queued download:", e);
updateDownload(item.id, { status: 'failed' });
}
}
}
}));
+337
View File
@@ -0,0 +1,337 @@
import { create } from 'zustand';
import { persist, createJSONStorage, StateStorage } from 'zustand/middleware';
import { invokeCommand as invoke } from '../ipc';
import type { ActiveView } from '../bindings/ActiveView';
import type { AppFontSize } from '../bindings/AppFontSize';
import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction';
import type { PersistedSettings } from '../bindings/PersistedSettings';
import type { ProxyMode } from '../bindings/ProxyMode';
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
import type { SiteLogin } from '../bindings/SiteLogin';
import type { Theme } from '../bindings/Theme';
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
if (name === 'firelink-settings') {
try {
const data = await invoke('db_load_settings');
return data;
} catch (e) {
console.error("Failed to load settings from DB", e);
return null;
}
}
return null;
},
setItem: async (name: string, value: string): Promise<void> => {
if (name === 'firelink-settings') {
try {
await invoke('db_save_settings', { data: value });
} catch (e) {
console.error("Failed to save settings to DB", e);
}
}
},
removeItem: async (_name: string): Promise<void> => {
// no-op for now
},
};
export type {
ActiveView,
AppFontSize,
ListRowDensity,
MediaCookieSource,
PostQueueAction,
ProxyMode,
SchedulerSettings,
SettingsTab,
SiteLogin,
Theme
};
export interface SettingsState {
theme: Theme;
defaultDownloadPath: string;
maxConcurrentDownloads: number;
globalSpeedLimit: string;
isSidebarVisible: boolean;
activeView: ActiveView;
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
schedulerRunning: boolean;
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
// Replicated SwiftUI App Settings
perServerConnections: number;
maxAutomaticRetries: number;
showNotifications: boolean;
playCompletionSound: boolean;
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
showDockBadge: boolean;
showMenuBarIcon: boolean;
proxyMode: ProxyMode;
proxyHost: string;
proxyPort: number;
customUserAgent: string;
askWhereToSaveEachFile: boolean;
preventsSleepWhileDownloading: boolean;
mediaCookieSource: MediaCookieSource;
downloadDirectories: Record<string, string>;
siteLogins: SiteLogin[];
extensionPairingToken: string;
autoCheckUpdates: boolean;
setTheme: (theme: Theme) => void;
setDefaultDownloadPath: (path: string) => void;
setMaxConcurrentDownloads: (count: number) => void;
setGlobalSpeedLimit: (limit: string) => void;
setActiveView: (view: ActiveView) => void;
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
setSchedulerRunning: (running: boolean) => void;
setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void;
toggleSidebar: () => void;
setPerServerConnections: (count: number) => void;
setMaxAutomaticRetries: (count: number) => void;
setShowNotifications: (show: boolean) => void;
setPlayCompletionSound: (play: boolean) => void;
setAppFontSize: (size: AppFontSize) => void;
setListRowDensity: (density: ListRowDensity) => void;
setShowDockBadge: (show: boolean) => void;
setShowMenuBarIcon: (show: boolean) => void;
setProxyMode: (mode: ProxyMode) => void;
setProxyHost: (host: string) => void;
setProxyPort: (port: number) => void;
setCustomUserAgent: (userAgent: string) => void;
setAskWhereToSaveEachFile: (ask: boolean) => void;
setPreventsSleepWhileDownloading: (prevent: boolean) => void;
setMediaCookieSource: (source: MediaCookieSource) => void;
setCategoryDirectory: (category: string, path: string) => void;
resetCategoryDirectories: () => void;
addSiteLogin: (login: SiteLogin) => void;
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => void;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
}
const defaultDirectories = {
Musics: '~/Downloads/Musics',
Movies: '~/Downloads/Movies',
Compressed: '~/Downloads/Compressed',
Documents: '~/Downloads/Documents',
Pictures: '~/Downloads/Pictures',
Applications: '~/Downloads/Applications',
Other: '~/Downloads/Other'
};
const normalizeDownloadDirectories = (directories: unknown): Record<string, string> => {
if (!directories || typeof directories !== 'object') {
return { ...defaultDirectories };
}
const values = directories as Record<string, unknown>;
const directory = (current: string, legacy?: string) => {
const value = values[current] ?? (legacy ? values[legacy] : undefined);
return typeof value === 'string' && value.length > 0
? value
: defaultDirectories[current as keyof typeof defaultDirectories];
};
return {
Musics: directory('Musics', 'Audio'),
Movies: directory('Movies', 'Video'),
Compressed: directory('Compressed', 'Archives'),
Documents: directory('Documents'),
Pictures: directory('Pictures', 'Images'),
Applications: directory('Applications', 'Apps'),
Other: directory('Other')
};
};
const generateSecureToken = () => {
try {
const cryptoObj = typeof window !== 'undefined'
? (window as Window & { msCrypto?: Crypto }).crypto
|| (window as Window & { msCrypto?: Crypto }).msCrypto
: null;
if (cryptoObj && cryptoObj.getRandomValues) {
const arr = new Uint8Array(24);
cryptoObj.getRandomValues(arr);
let binary = '';
for (let i = 0; i < arr.byteLength; i++) {
binary += String.fromCharCode(arr[i]);
}
return btoa(binary);
}
} catch (e) {
console.warn("Secure token generation failed, falling back to random characters", e);
}
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
let token = '';
for (let i = 0; i < 32; i++) {
token += chars.charAt(Math.floor(Math.random() * chars.length));
}
return token;
};
export const useSettingsStore = create<SettingsState>()(
persist(
(set) => ({
theme: 'system',
defaultDownloadPath: '~/Downloads',
maxConcurrentDownloads: 3,
globalSpeedLimit: '',
activeView: 'downloads',
activeSettingsTab: 'downloads',
isSidebarVisible: true,
scheduler: {
enabled: false,
startTime: '00:00',
stopTimeEnabled: false,
stopTime: '08:00',
everyday: true,
selectedDays: [0, 1, 2, 3, 4, 5, 6],
postQueueAction: 'none'
},
schedulerRunning: false,
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
// Replicated SwiftUI defaults
perServerConnections: 16,
maxAutomaticRetries: 3,
showNotifications: true,
playCompletionSound: true,
appFontSize: 'standard',
listRowDensity: 'standard',
showDockBadge: true,
showMenuBarIcon: true,
proxyMode: 'none',
proxyHost: '',
proxyPort: 8080,
customUserAgent: '',
askWhereToSaveEachFile: false,
preventsSleepWhileDownloading: true,
mediaCookieSource: 'none',
downloadDirectories: { ...defaultDirectories },
siteLogins: [],
extensionPairingToken: generateSecureToken(),
autoCheckUpdates: true,
setTheme: (theme) => set({ theme }),
setDefaultDownloadPath: (path) => set({ defaultDownloadPath: path }),
setMaxConcurrentDownloads: (max) => {
set({ maxConcurrentDownloads: max });
invoke('set_concurrent_limit', { limit: max }).catch(console.error);
},
setGlobalSpeedLimit: (limit) => {
set({ globalSpeedLimit: limit });
invoke('set_global_speed_limit', { limit: limit === '' || limit === '0' ? null : limit }).catch(console.error);
},
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
setPerServerConnections: (perServerConnections) => set({ perServerConnections }),
setMaxAutomaticRetries: (maxAutomaticRetries) => set({ maxAutomaticRetries }),
setShowNotifications: (showNotifications) => set({ showNotifications }),
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
setAppFontSize: (appFontSize) => set({ appFontSize }),
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
setShowDockBadge: (showDockBadge) => {
set({ showDockBadge });
if (!showDockBadge) invoke('update_dock_badge', { count: 0 }).catch(console.error);
},
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
setProxyMode: (proxyMode) => set({ proxyMode }),
setProxyHost: (proxyHost) => set({ proxyHost }),
setProxyPort: (proxyPort) => set({ proxyPort }),
setCustomUserAgent: (customUserAgent) => set({ customUserAgent }),
setAskWhereToSaveEachFile: (askWhereToSaveEachFile) => set({ askWhereToSaveEachFile }),
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
set({ preventsSleepWhileDownloading });
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
},
setMediaCookieSource: (mediaCookieSource) => set({ mediaCookieSource }),
setCategoryDirectory: (category, path) => set((state) => ({
downloadDirectories: { ...state.downloadDirectories, [category]: path }
})),
resetCategoryDirectories: () => set({ downloadDirectories: { ...defaultDirectories } }),
addSiteLogin: (login) => set((state) => ({
siteLogins: [...state.siteLogins, login]
})),
removeSiteLogin: (id) => set((state) => ({
siteLogins: state.siteLogins.filter((login) => login.id !== id)
})),
regeneratePairingToken: () => set({ extensionPairingToken: generateSecureToken() }),
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
}),
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
partialize: (state): PersistedSettings => ({
theme: state.theme,
defaultDownloadPath: state.defaultDownloadPath,
maxConcurrentDownloads: state.maxConcurrentDownloads,
globalSpeedLimit: state.globalSpeedLimit,
isSidebarVisible: state.isSidebarVisible,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
schedulerLastStartKey: state.schedulerLastStartKey,
schedulerLastStopKey: state.schedulerLastStopKey,
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,
perServerConnections: state.perServerConnections,
maxAutomaticRetries: state.maxAutomaticRetries,
showNotifications: state.showNotifications,
playCompletionSound: state.playCompletionSound,
appFontSize: state.appFontSize,
listRowDensity: state.listRowDensity,
showDockBadge: state.showDockBadge,
showMenuBarIcon: state.showMenuBarIcon,
proxyMode: state.proxyMode,
proxyHost: state.proxyHost,
proxyPort: state.proxyPort,
customUserAgent: state.customUserAgent,
askWhereToSaveEachFile: state.askWhereToSaveEachFile,
preventsSleepWhileDownloading: state.preventsSleepWhileDownloading,
mediaCookieSource: state.mediaCookieSource,
downloadDirectories: state.downloadDirectories,
siteLogins: state.siteLogins,
extensionPairingToken: state.extensionPairingToken,
autoCheckUpdates: state.autoCheckUpdates
}),
merge: (persistedState: unknown, currentState) => {
const persisted = persistedState && typeof persistedState === 'object'
? persistedState as Partial<SettingsState>
: {};
return ({
...currentState,
...persisted,
appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
downloadDirectories: normalizeDownloadDirectories(persisted.downloadDirectories),
siteLogins: Array.isArray(persisted.siteLogins)
? persisted.siteLogins
: currentState.siteLogins
});
}
}
)
);
+54
View File
@@ -0,0 +1,54 @@
import type { DownloadCategory } from '../bindings/DownloadCategory';
export type { DownloadCategory } from '../bindings/DownloadCategory';
const MEDIA_DOMAINS = [
'youtube.com',
'youtu.be',
'twitter.com',
'x.com',
'twitch.tv',
'vimeo.com',
'instagram.com',
'tiktok.com',
'reddit.com',
'soundcloud.com',
'facebook.com'
];
export const categoryForFileName = (fileName: string): DownloadCategory => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v'].includes(ext)) return 'Movies';
if (['mp3', 'wav', 'aac', 'flac', 'ogg', 'm4a', 'wma'].includes(ext)) return 'Musics';
if (['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'rtf'].includes(ext)) return 'Documents';
if (['exe', 'dmg', 'apk', 'app', 'pkg', 'deb', 'rpm', 'msi', 'iso', 'bin', 'run'].includes(ext)) return 'Applications';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'tiff', 'svg'].includes(ext)) return 'Pictures';
if (['zip', 'rar', '7z', 'tar', 'gz', 'xz', 'bz2'].includes(ext)) return 'Compressed';
return 'Other';
};
export const fileNameFromUrl = (rawUrl: string): string => {
try {
const url = new URL(rawUrl);
const pathName = url.pathname.split('/').filter(Boolean).pop();
if (pathName) {
const decoded = decodeURIComponent(pathName).trim();
if (decoded && decoded !== '.' && decoded !== '..') {
return decoded.replace(/[\/\\?%*:|"<>]/g, '-');
}
}
} catch {
// Fall through to the stable generic name.
}
return 'download';
};
export const isMediaUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);
return MEDIA_DOMAINS.some(domain =>
url.hostname === domain || url.hostname.endsWith(`.${domain}`)
);
} catch {
return false;
}
};
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />