mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-07 09:53:17 +00:00
feat(desktop): modernize download core and type IPC
Replace shared download locking with a Tokio coordinator actor and async streamed file writes. Generate TypeScript IPC payload types from Rust and route frontend commands and events through typed wrappers.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
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 } from 'lucide-react';
|
||||
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 { invoke } from '@tauri-apps/api/core';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
|
||||
import { categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
|
||||
|
||||
@@ -19,6 +19,15 @@ interface RawMediaFormat {
|
||||
filesize_approx?: number;
|
||||
}
|
||||
|
||||
interface MediaFormat {
|
||||
name: string;
|
||||
selector: string;
|
||||
ext: string;
|
||||
detail: string;
|
||||
type: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface ParsedDownloadItem {
|
||||
url: string;
|
||||
file: string;
|
||||
@@ -26,7 +35,7 @@ interface ParsedDownloadItem {
|
||||
sizeBytes?: number;
|
||||
status?: string;
|
||||
isMedia?: boolean;
|
||||
formats?: { name: string; selector: string; ext: string; detail: string; type: string; bytes: number }[];
|
||||
formats?: MediaFormat[];
|
||||
selectedFormat?: number;
|
||||
}
|
||||
|
||||
@@ -134,10 +143,14 @@ const formatBytes = (bytes: number) => {
|
||||
|
||||
const parseMediaFormats = (jsonStr: string) => {
|
||||
try {
|
||||
const data = JSON.parse(jsonStr);
|
||||
let title = data.title || 'Media';
|
||||
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: RawMediaFormat[] = data.formats || [];
|
||||
const rawFormats = Array.isArray(data.formats)
|
||||
? data.formats.filter((format): format is RawMediaFormat => Boolean(format) && typeof format === 'object')
|
||||
: [];
|
||||
|
||||
const options = [];
|
||||
|
||||
@@ -297,7 +310,7 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!saveLocation) return;
|
||||
invoke<string>('get_free_space', { path: saveLocation })
|
||||
invoke('get_free_space', { path: saveLocation })
|
||||
.then(space => setFreeSpace(space))
|
||||
.catch(() => setFreeSpace('Unknown'));
|
||||
}, [saveLocation, isAddModalOpen]);
|
||||
@@ -338,13 +351,13 @@ export const AddDownloadsModal = () => {
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke<string>('get_keychain_password', { id: login.id });
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password:", e);
|
||||
}
|
||||
}
|
||||
|
||||
const jsonStr = await invoke<string>('fetch_media_metadata', {
|
||||
const jsonStr = await invoke('fetch_media_metadata', {
|
||||
url,
|
||||
cookieBrowser: browserArg,
|
||||
username: login?.username || null,
|
||||
@@ -371,13 +384,14 @@ export const AddDownloadsModal = () => {
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke<string>('get_keychain_password', { id: login.id });
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password:", e);
|
||||
}
|
||||
}
|
||||
const meta = await invoke<{filename: string, size: string, size_bytes: number}>('fetch_metadata', {
|
||||
const meta = await invoke('fetch_metadata', {
|
||||
url,
|
||||
userAgent: settingsStore.customUserAgent || null,
|
||||
username: login?.username || null,
|
||||
password: keychainPassword
|
||||
});
|
||||
@@ -467,7 +481,7 @@ export const AddDownloadsModal = () => {
|
||||
let fileExistsOnDisk = false;
|
||||
try {
|
||||
const cleanLocation = finalLocation.endsWith('/') ? finalLocation.slice(0, -1) : finalLocation;
|
||||
fileExistsOnDisk = await invoke<boolean>('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
|
||||
fileExistsOnDisk = await invoke('check_file_exists', { path: `${cleanLocation}/${finalFile}` });
|
||||
} catch (e) {}
|
||||
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
@@ -487,7 +501,7 @@ export const AddDownloadsModal = () => {
|
||||
};
|
||||
|
||||
const executeAddDownloads = async (startImmediately: boolean, finalLocation: string, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
|
||||
let itemsToAdd = [...parsedItems];
|
||||
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
|
||||
|
||||
if (resolutions) {
|
||||
for (const res of resolutions) {
|
||||
@@ -496,7 +510,7 @@ export const AddDownloadsModal = () => {
|
||||
if (!item) continue;
|
||||
|
||||
if (res.resolution === 'skip') {
|
||||
itemsToAdd[idx] = null as any; // mark for skip
|
||||
itemsToAdd[idx] = null;
|
||||
} else if (res.resolution === 'rename') {
|
||||
let finalFile = item.file;
|
||||
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
|
||||
@@ -519,7 +533,7 @@ export const AddDownloadsModal = () => {
|
||||
return dest === finalLocation && d.fileName === newName && d.status !== 'failed';
|
||||
});
|
||||
let diskHas = false;
|
||||
try { diskHas = await invoke<boolean>('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
|
||||
try { diskHas = await invoke('check_file_exists', { path: `${cleanLocation}/${newName}` }); } catch(e) {}
|
||||
exists = storeHas || diskHas;
|
||||
count++;
|
||||
}
|
||||
@@ -550,9 +564,9 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
}
|
||||
|
||||
itemsToAdd = itemsToAdd.filter(Boolean);
|
||||
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
|
||||
|
||||
for (const item of itemsToAdd) {
|
||||
for (const item of resolvedItems) {
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
let finalFile = item.file;
|
||||
@@ -596,7 +610,12 @@ export const AddDownloadsModal = () => {
|
||||
toggleAddModal(false);
|
||||
};
|
||||
|
||||
const SummaryBox = ({ title, value, icon: Icon, color }: any) => (
|
||||
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} />
|
||||
@@ -737,7 +756,7 @@ export const AddDownloadsModal = () => {
|
||||
<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: any, idx: number) => {
|
||||
{parsedItems[selectedItemIndex].formats!.map((f, idx) => {
|
||||
const isSelected = parsedItems[selectedItemIndex].selectedFormat === idx;
|
||||
const Icon = f.type === 'Audio' ? Music : Film;
|
||||
return (
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { invoke } from '@tauri-apps/api/core';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { homeDir } from '@tauri-apps/api/path';
|
||||
|
||||
interface DownloadTableProps {
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
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: 'rename' | 'replace' | 'skip';
|
||||
resolution: DuplicateResolution;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conflicts: DuplicateConflict[];
|
||||
onConfirm: (resolutions: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => void;
|
||||
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: 'rename' | 'replace' | 'skip') => {
|
||||
const updateResolution = (id: string, resolution: DuplicateResolution) => {
|
||||
setConflicts(conflicts.map(c => c.id === id ? { ...c, resolution } : c));
|
||||
};
|
||||
|
||||
@@ -39,7 +40,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
</div>
|
||||
<select
|
||||
value={conflict.resolution}
|
||||
onChange={(e) => updateResolution(conflict.id, e.target.value as any)}
|
||||
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>
|
||||
|
||||
@@ -124,13 +124,13 @@ export const PropertiesModal = () => {
|
||||
fileName,
|
||||
destination: saveLocation,
|
||||
connections: Number(connections),
|
||||
speedLimit: speedLimitEnabled && speedLimitValue ? `${speedLimitValue}K` : null,
|
||||
username: loginMode === 'custom' ? username.trim() : null,
|
||||
password: loginMode === 'custom' ? password.trim() : null,
|
||||
headers: headers.trim() || null,
|
||||
checksum: checksumEnabled && checksumValue.trim() ? `${checksumAlgorithm}=${checksumValue.trim()}` : null,
|
||||
cookies: cookies.trim() || null,
|
||||
mirrors: mirrors.trim() || null,
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import {
|
||||
CheckCircle2, Clock3, List, Moon, LockKeyhole,
|
||||
Pause, Play, Power, RotateCcw, Save
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { SettingsTab, useSettingsStore } from '../store/useSettingsStore';
|
||||
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 { invoke } from '@tauri-apps/api/core';
|
||||
import { invokeCommand as invoke } from '../ipc';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import appIcon from '../assets/app-icon.png';
|
||||
|
||||
@@ -21,19 +26,6 @@ const settingsTabs: { type: SettingsTab; label: string; icon: typeof Download }[
|
||||
{ type: 'about', label: 'About', icon: Info },
|
||||
];
|
||||
|
||||
interface AvailableReleaseUpdate {
|
||||
version: string;
|
||||
tag_name: string;
|
||||
title: string;
|
||||
release_notes: string;
|
||||
release_url: string;
|
||||
published_at: string | null;
|
||||
}
|
||||
|
||||
type ReleaseCheckOutcome =
|
||||
| { type: 'UpdateAvailable'; update: AvailableReleaseUpdate }
|
||||
| { type: 'UpToDate'; latest_version: string; local_version: string };
|
||||
|
||||
export default function SettingsView() {
|
||||
const settings = useSettingsStore();
|
||||
const activeTab = settings.activeSettingsTab;
|
||||
@@ -70,19 +62,19 @@ export default function SettingsView() {
|
||||
// Fetch engine versions when Engine tab is opened
|
||||
useEffect(() => {
|
||||
if (settings.activeView === 'settings' && activeTab === 'engine') {
|
||||
invoke<string>('test_aria2c')
|
||||
invoke('test_aria2c')
|
||||
.then(v => setAria2Version(v))
|
||||
.catch(e => setAria2Version('Error: ' + e));
|
||||
|
||||
invoke<string>('test_ytdlp')
|
||||
invoke('test_ytdlp')
|
||||
.then(v => setYtdlpVersion(v))
|
||||
.catch(e => setYtdlpVersion('Error: ' + e));
|
||||
|
||||
invoke<string>('test_ffmpeg')
|
||||
invoke('test_ffmpeg')
|
||||
.then(v => setFfmpegVersion(v))
|
||||
.catch(e => setFfmpegVersion('Error: ' + e));
|
||||
|
||||
invoke<string>('test_deno')
|
||||
invoke('test_deno')
|
||||
.then(v => setDenoVersion(v))
|
||||
.catch(e => setDenoVersion('Error: ' + e));
|
||||
}
|
||||
@@ -99,7 +91,7 @@ export default function SettingsView() {
|
||||
showToast('Checking for updates...');
|
||||
|
||||
try {
|
||||
const result = await invoke<ReleaseCheckOutcome>('check_for_updates');
|
||||
const result = await invoke('check_for_updates');
|
||||
|
||||
if (result.type === 'UpToDate') {
|
||||
showToast(`Firelink ${result.latest_version} is up to date`);
|
||||
@@ -363,7 +355,7 @@ export default function SettingsView() {
|
||||
<span className="text-[13px] text-text-primary">Font Size</span>
|
||||
<select
|
||||
value={settings.appFontSize}
|
||||
onChange={(e) => settings.setAppFontSize(e.target.value as any)}
|
||||
onChange={(e) => settings.setAppFontSize(e.target.value as AppFontSize)}
|
||||
className="app-control w-40"
|
||||
>
|
||||
<option value="small">Small</option>
|
||||
@@ -375,7 +367,7 @@ export default function SettingsView() {
|
||||
<span className="text-[13px] text-text-primary">List Row Density</span>
|
||||
<select
|
||||
value={settings.listRowDensity}
|
||||
onChange={(e) => settings.setListRowDensity(e.target.value as any)}
|
||||
onChange={(e) => settings.setListRowDensity(e.target.value as ListRowDensity)}
|
||||
className="app-control w-40"
|
||||
>
|
||||
<option value="compact">Compact</option>
|
||||
@@ -722,7 +714,9 @@ export default function SettingsView() {
|
||||
<label className="text-text-secondary font-semibold">Browser Cookies Source:</label>
|
||||
<select
|
||||
value={settings.mediaCookieSource}
|
||||
onChange={(e) => settings.setMediaCookieSource(e.target.value as any)}
|
||||
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>
|
||||
|
||||
@@ -2,7 +2,8 @@ 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
|
||||
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';
|
||||
@@ -57,7 +58,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const NavItem = ({ icon: Icon, label, filter }: { icon: any, label: string, filter: SidebarFilter }) => {
|
||||
const NavItem = ({ icon: Icon, label, filter }: { icon: LucideIcon, label: string, filter: SidebarFilter }) => {
|
||||
const isSelected = activeView === 'downloads' && selectedFilter === filter;
|
||||
|
||||
return (
|
||||
@@ -141,7 +142,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const ToolItem = ({ icon: Icon, label, view }: { icon: any; label: string; view: ActiveView }) => {
|
||||
const ToolItem = ({ icon: Icon, label, view }: { icon: LucideIcon; label: string; view: ActiveView }) => {
|
||||
const isSelected = activeView === view;
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user