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:
NimBold
2026-06-14 17:38:16 +03:30
parent 77fdc30b80
commit f806fd2cbf
40 changed files with 1360 additions and 425 deletions
@@ -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
+17 -23
View File
@@ -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>
+4 -3
View File
@@ -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