fix(core): harden download lifecycle and scheduling

This commit is contained in:
NimBold
2026-06-22 11:19:18 +03:30
parent d535bdac8f
commit 06b14df307
38 changed files with 1257 additions and 428 deletions
+53 -15
View File
@@ -14,11 +14,20 @@ import { useSettingsStore } from "./store/useSettingsStore";
import { isPermissionGranted, requestPermission, sendNotification } from '@tauri-apps/plugin-notification';
import SchedulerView from "./components/SchedulerView";
import SpeedLimiterView from "./components/SpeedLimiterView";
import DiagnosticsView from "./components/DiagnosticsView";
import LogsView from "./components/LogsView";
import { useToast } from "./contexts/ToastContext";
import { openUrl } from '@tauri-apps/plugin-opener';
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
const getScheduledQueueIds = () => {
const downloadState = useDownloadStore.getState();
const availableQueueIds = new Set(downloadState.queues.map(queue => queue.id));
const selectedQueueIds = useSettingsStore.getState().scheduler.selectedQueueIds
.filter(queueId => availableQueueIds.has(queueId));
return selectedQueueIds.length > 0 ? selectedQueueIds : [MAIN_QUEUE_ID];
};
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
@@ -40,9 +49,12 @@ function App() {
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 queuedCount = downloads.filter(download =>
download.status === 'queued' || download.status === 'staged'
).length;
const doneCount = downloads.filter(download => download.status === 'completed').length;
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef<string | null>(null);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
@@ -218,15 +230,28 @@ function App() {
useEffect(() => {
const unlisten = listen('schedule-trigger', async (event) => {
const state = useSettingsStore.getState();
const payload = event.payload as any;
if (payload.action === 'start') {
state.setSchedulerLastStartKey(payload.key);
const started = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(started > 0);
} else if (payload.action === 'stop') {
state.setSchedulerLastStopKey(payload.key);
await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
state.setSchedulerRunning(false);
const payload = event.payload;
if (processingScheduleKeys.has(payload.key)) return;
processingScheduleKeys.add(payload.key);
try {
if (payload.action === 'start') {
const startedResults = await Promise.all(
getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const acceptedIds = startedResults.flat();
state.setSchedulerActiveDownloadIds(acceptedIds);
state.setSchedulerRunning(acceptedIds.length > 0);
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
} else if (payload.action === 'stop') {
await Promise.all(
getScheduledQueueIds().map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
}
} finally {
processingScheduleKeys.delete(payload.key);
}
});
@@ -237,19 +262,32 @@ function App() {
useEffect(() => {
if (!schedulerRunning) return;
if (schedulerActiveDownloadIds.length === 0) return;
const scheduledIds = new Set(schedulerActiveDownloadIds);
const hasPendingScheduledWork = downloads.some(download =>
isActiveDownloadStatus(download.status)
scheduledIds.has(download.id) && isActiveDownloadStatus(download.status)
);
if (hasPendingScheduledWork) return;
const settings = useSettingsStore.getState();
const scheduledItems = schedulerActiveDownloadIds.map(id =>
downloads.find(download => download.id === id)
);
const hasFailures = scheduledItems.some(item => !item || item.status === 'failed');
settings.setSchedulerActiveDownloadIds([]);
settings.setSchedulerRunning(false);
if (settings.scheduler.postQueueAction !== 'none') {
if (hasFailures) {
addToast({
message: 'Scheduled downloads finished with failures. The post-queue system action was skipped.',
variant: 'warning',
isActionable: true
});
} else if (settings.scheduler.postQueueAction !== 'none') {
invoke('perform_system_action', { action: settings.scheduler.postQueueAction }).catch(error => {
console.error('Scheduled post action failed:', error);
});
}
}, [downloads, schedulerRunning]);
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
useEffect(() => {
const initNotifications = async () => {
@@ -395,7 +433,7 @@ function App() {
{activeView === 'settings' && <SettingsView />}
{activeView === 'scheduler' && <SchedulerView />}
{activeView === 'speedLimiter' && <SpeedLimiterView />}
{activeView === 'diagnostics' && <DiagnosticsView />}
{activeView === 'logs' && <LogsView />}
</div>
{/* Status Bar */}
+1 -1
View File
@@ -33,7 +33,7 @@ export class ErrorBoundary extends Component<Props, State> {
<div className="app-card max-w-lg space-y-4 p-6 text-center">
<h1 className="text-xl font-semibold">Firelink could not display this window.</h1>
<p className="text-sm text-text-secondary">
The error was written to Diagnostics. Reload the interface to reconnect to the running download service.
The error was written to Logs. Reload the interface to reconnect to the running download service.
</p>
<button
type="button"
+1 -1
View File
@@ -1,3 +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" | "diagnostics";
export type ActiveView = "downloads" | "settings" | "scheduler" | "speedLimiter" | "logs";
+1 -1
View File
@@ -2,4 +2,4 @@
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, hasBeenDispatched?: boolean, };
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, queuePosition?: number, hasBeenDispatched?: boolean, };
+1 -1
View File
@@ -1,3 +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 = "ready" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
export type DownloadStatus = "ready" | "staged" | "downloading" | "processing" | "paused" | "completed" | "failed" | "queued" | "retrying";
+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 EnqueueAccepted = { id: string, filename: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueItem = { id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
export type EnqueueItem = { id: string, queue_id: string, url: string, destination: string, filename: string, connections: number | null, speed_limit: string | null, username: string | null, password: string | null, headers: string | null, checksum: string | null, cookies: string | null, mirrors: string | null, user_agent: string | null, max_tries: number | null, proxy: string | null, format_selector: string | null, cookie_source: string | null, is_media: boolean | null, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type EnqueueResult = { id: string, success: boolean, error?: string, };
export type EnqueueResult = { id: string, success: boolean, filename?: string, error?: string, };
+1 -1
View File
@@ -1,4 +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, };
export type SchedulerSettings = { enabled: boolean, startTime: string, stopTimeEnabled: boolean, stopTime: string, everyday: boolean, selectedDays: Array<number>, selectedQueueIds: Array<string>, postQueueAction: PostQueueAction, };
+108 -44
View File
@@ -9,7 +9,7 @@ import { FolderPlus, Settings, Shield, RefreshCw, FileText, HardDrive, Database,
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';
import { canonicalizeDownloadFileName, categoryForFileName, fileNameFromUrl, isMediaUrl } from '../utils/downloads';
import { fetchMediaMetadataDeduped } from '../utils/mediaMetadata';
import {
resolveCategoryDestination,
@@ -72,8 +72,10 @@ export const AddDownloadsModal = () => {
const [showingDuplicates, setShowingDuplicates] = useState(false);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const actionMenuRef = useRef<HTMLDivElement>(null);
// Right Form
@@ -104,7 +106,10 @@ export const AddDownloadsModal = () => {
setParsedItems([]);
setSelectedItemIndex(null);
setPendingUseSharedDestination(false);
setPendingDestinationOverrides({});
setConnections(perServerConnections);
setSpeedLimitEnabled(false);
setSpeedLimit('1024');
setUseAuth(false);
setUsername('');
setPassword('');
@@ -119,6 +124,7 @@ export const AddDownloadsModal = () => {
setCookies(pendingAddCookies);
setMirrors('');
setIsQueueMenuOpen(false);
setIsSubmitting(false);
} else {
setUrls('');
}
@@ -211,8 +217,8 @@ export const AddDownloadsModal = () => {
const mediaData = await fetchMediaMetadataDeduped({
url,
cookieBrowser: browserArg,
username: login?.username || null,
password: keychainPassword
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword
});
if (mediaData && mediaData.formats.length > 0) {
const mappedFormats = mediaData.formats.map(f => {
@@ -235,7 +241,7 @@ export const AddDownloadsModal = () => {
});
updatedItems[i] = {
url,
file: `${mediaData.title}.${mediaData.formats[0].ext}`,
file: canonicalizeDownloadFileName(`${mediaData.title}.${mediaData.formats[0].ext}`),
size: mappedFormats[0].detail,
sizeBytes: mappedFormats[0].bytes,
status: 'Ready',
@@ -260,12 +266,14 @@ export const AddDownloadsModal = () => {
const meta = await invoke('fetch_metadata', {
url,
userAgent: settingsStore.customUserAgent || null,
username: login?.username || null,
password: keychainPassword
username: useAuth ? username.trim() || null : login?.username || null,
password: useAuth ? password || null : keychainPassword
});
updatedItems[i] = {
url: meta.url || url,
file: lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename,
file: canonicalizeDownloadFileName(
lines.length === 1 && pendingAddFilename ? pendingAddFilename : meta.filename
),
size: meta.size,
sizeBytes: meta.size_bytes,
status: 'Ready'
@@ -301,7 +309,15 @@ export const AddDownloadsModal = () => {
active = false;
clearTimeout(timer);
};
}, [urls, pendingAddFilename, isSaveLocationManual, metadataRefreshNonce]);
}, [
urls,
pendingAddFilename,
isSaveLocationManual,
metadataRefreshNonce,
useAuth,
username,
password
]);
if (!isAddModalOpen) return null;
@@ -327,25 +343,41 @@ export const AddDownloadsModal = () => {
};
const handleAction = async (action: AddDownloadAction) => {
if (isSubmitting || parsedItems.length === 0 || parsedItems.some(item => item.status !== 'Ready')) {
return;
}
if (speedLimitEnabled && (!Number.isFinite(Number(speedLimit)) || Number(speedLimit) <= 0)) {
addToast({ message: 'Speed limit must be greater than zero', variant: 'error', isActionable: true });
return;
}
setIsSubmitting(true);
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
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;
useSharedDestination = true;
setIsSaveLocationManual(true);
} else {
return; // Cancelled
for (const [index, item] of parsedItems.entries()) {
try {
const suggestedLocation = isSaveLocationManual
? finalLocation
: await categoryLocationForFile(item.file);
const selected = await open({
directory: true,
multiple: false,
title: `Choose a folder for ${item.file}`,
defaultPath: suggestedLocation.startsWith('~') ? undefined : suggestedLocation
});
if (selected && typeof selected === 'string') {
destinationOverrides[index] = selected;
} else {
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
setIsSubmitting(false);
return;
}
} catch (e) {
console.error("Failed to select folder:", e);
}
}
@@ -355,7 +387,7 @@ export const AddDownloadsModal = () => {
for (let i = 0; i < parsedItems.length; i++) {
const item = parsedItems[i];
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -363,7 +395,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[i] || await categoryLocationForFile(finalFile);
const isUrlDupe = store.downloads.some(d => d.url === item.url && d.status !== 'failed' && d.status !== 'completed');
if (isUrlDupe) {
@@ -400,14 +432,26 @@ export const AddDownloadsModal = () => {
setConflicts(newConflicts);
setPendingAction(action);
setPendingUseSharedDestination(useSharedDestination);
setPendingDestinationOverrides(destinationOverrides);
setShowingDuplicates(true);
setIsSubmitting(false);
return;
}
await executeAddDownloads(action, finalLocation, useSharedDestination);
try {
await executeAddDownloads(action, finalLocation, useSharedDestination, undefined, destinationOverrides);
} finally {
setIsSubmitting(false);
}
};
const executeAddDownloads = async (action: AddDownloadAction, finalLocation: string, useSharedDestination: boolean, resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[]) => {
const executeAddDownloads = async (
action: AddDownloadAction,
finalLocation: string,
useSharedDestination: boolean,
resolutions?: { id: string, resolution: 'rename' | 'replace' | 'skip' }[],
destinationOverrides: Record<number, string> = {}
) => {
let itemsToAdd: Array<ParsedDownloadItem | null> = [...parsedItems];
if (resolutions) {
@@ -420,7 +464,7 @@ export const AddDownloadsModal = () => {
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
} else if (res.resolution === 'rename') {
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -428,7 +472,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
let count = 1;
const base = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -471,7 +515,7 @@ export const AddDownloadsModal = () => {
itemsToAdd[idx] = null;
continue;
}
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
const selectedFormat = item.formats[item.selectedFormat];
const baseName = finalFile.substring(0, finalFile.lastIndexOf('.')) || finalFile;
@@ -479,7 +523,7 @@ export const AddDownloadsModal = () => {
}
const itemLocation = useSharedDestination
? finalLocation
: await categoryLocationForFile(finalFile);
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
const store = useDownloadStore.getState();
@@ -510,14 +554,14 @@ export const AddDownloadsModal = () => {
}
}
const resolvedItems = itemsToAdd.filter((item): item is ParsedDownloadItem => item !== null);
let addedCount = 0;
const failures: string[] = [];
for (const item of resolvedItems) {
for (const [itemIndex, item] of itemsToAdd.entries()) {
if (!item) continue;
try {
const id = crypto.randomUUID();
let finalFile = item.file;
let finalFile = canonicalizeDownloadFileName(item.file);
let formatSelector = undefined;
if (item.isMedia && item.formats && item.selectedFormat !== undefined) {
@@ -546,7 +590,9 @@ export const AddDownloadsModal = () => {
: undefined,
cookies: cookies.trim() || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination ? finalLocation : undefined,
destination: useSharedDestination
? finalLocation
: destinationOverrides[itemIndex],
isMedia: item.isMedia,
mediaFormatSelector: formatSelector,
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
@@ -598,16 +644,20 @@ export const AddDownloadsModal = () => {
selectedItem.size = format.detail || 'Unknown';
selectedItem.sizeBytes = format.bytes || 0;
const baseName = selectedItem.file.substring(0, selectedItem.file.lastIndexOf('.')) || selectedItem.file;
selectedItem.file = `${baseName}.${format.ext}`;
selectedItem.file = canonicalizeDownloadFileName(`${baseName}.${format.ext}`);
setParsedItems(newItems);
};
const requiredBytes = parsedItems.reduce((acc, item) => acc + (item.sizeBytes || 0), 0);
const hasApproximateSize = parsedItems.some(item =>
item.formats?.[item.selectedFormat ?? -1]?.isApproximate
);
const requiredStr = requiredBytes > 0
? (requiredBytes < 1024 * 1024 ? `${(requiredBytes / 1024).toFixed(1)} KB`
? `${hasApproximateSize ? '~' : ''}${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`)
: `${(requiredBytes / 1024 / 1024 / 1024).toFixed(2)} GB`}`
: 'Unknown';
const canSubmit = parsedItems.length > 0 && parsedItems.every(item => item.status === 'Ready');
return (
<>
@@ -616,14 +666,22 @@ export const AddDownloadsModal = () => {
conflicts={conflicts}
onConfirm={(resolutions) => {
setShowingDuplicates(false);
void executeAddDownloads(pendingAction, resolvedLocation, pendingUseSharedDestination, resolutions)
setIsSubmitting(true);
void executeAddDownloads(
pendingAction,
resolvedLocation,
pendingUseSharedDestination,
resolutions,
pendingDestinationOverrides
)
.catch(error => {
addToast({
message: `Could not resolve duplicate downloads: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
})
.finally(() => setIsSubmitting(false));
}}
onCancel={() => setShowingDuplicates(false)}
/>
@@ -652,7 +710,9 @@ export const AddDownloadsModal = () => {
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>
<span className="text-[11px] text-text-muted font-medium">
{parsedItems.filter(item => item.status === 'Ready').length} ready, {parsedItems.filter(item => item.status === 'Error').length} failed
</span>
<button
type="button"
onClick={() => setMetadataRefreshNonce(value => value + 1)}
@@ -836,7 +896,7 @@ export const AddDownloadsModal = () => {
<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="add-download-range w-24 accent-blue-500 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50" disabled={parsedItems.some(i => i.isMedia)} aria-label="Connections per file" />
<input type="range" min="1" max="16" value={connections} onChange={e=>setConnections(Number(e.target.value))} className="add-download-range w-24 accent-blue-500 cursor-pointer" aria-label="Connections per file" />
<span className="add-download-value text-xs text-text-primary font-mono w-6 text-center">{connections}</span>
</div>
</div>
@@ -924,7 +984,11 @@ export const AddDownloadsModal = () => {
{/* Footer */}
<div className="add-download-footer p-4 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).`}
{parsedItems.length === 0
? 'Paste one or more links.'
: canSubmit
? `Ready to add ${parsedItems.length} download(s).`
: 'Wait for metadata or remove links that failed validation.'}
</div>
<div className="flex gap-2.5">
<button onClick={() => toggleAddModal(false)} className="add-download-button add-download-button-cancel px-4 text-xs">
@@ -933,7 +997,7 @@ export const AddDownloadsModal = () => {
<div ref={actionMenuRef} className="relative flex gap-2.5">
<button
onClick={() => handleAction({ type: 'start-now' })}
disabled={parsedItems.length === 0}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-primary px-5 text-xs"
>
<Play size={12} fill="currentColor" /> Start Downloads
@@ -942,7 +1006,7 @@ export const AddDownloadsModal = () => {
<button
type="button"
onClick={() => setIsQueueMenuOpen(open => !open)}
disabled={parsedItems.length === 0}
disabled={!canSubmit || isSubmitting}
className="add-download-button add-download-button-secondary px-4 text-xs"
aria-label="Add to queue"
aria-haspopup="menu"
+24 -11
View File
@@ -31,9 +31,20 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onClick,
}) => {
const download = useDownloadStore(state => state.downloads.find(d => d.id === downloadId));
const pendingOrder = useDownloadStore(state => state.pendingOrder);
const queueItems = useDownloadStore(state => {
const item = state.downloads.find(candidate => candidate.id === downloadId);
if (!item) return [];
const queueId = item.queueId;
return state.downloads
.filter(candidate =>
candidate.queueId === queueId &&
candidate.status !== 'completed'
)
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0))
.map(candidate => candidate.id);
});
const moveInQueue = useDownloadStore(state => state.moveInQueue);
const queueIndex = pendingOrder.indexOf(downloadId);
const queueIndex = queueItems.indexOf(downloadId);
const progressBarRef = useRef<HTMLDivElement>(null);
const statusTextRef = useRef<HTMLSpanElement>(null);
@@ -106,7 +117,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className={`download-progress-fill ${
download.status === 'paused' ? 'paused' :
download.status === 'processing' ? 'processing' :
download.status === 'queued' ? 'queued' :
download.status === 'queued' || download.status === 'staged' ? 'queued' :
download.status === 'retrying' ? 'retrying' : ''
}`}
style={{ width: `${(download.fraction || 0) * 100}%` }}
@@ -115,8 +126,8 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<span
ref={statusTextRef}
title={
download.status === 'queued' && queueIndex !== -1
? `Queued #${queueIndex + 1}`
(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
? `${download.status === 'staged' ? 'In queue' : 'Queued'} #${queueIndex + 1}`
: download.status === 'downloading'
? `${((download.fraction || 0) * 100).toFixed(0)}%`
: download.status === 'processing'
@@ -128,14 +139,16 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
download.status === 'failed' ? 'download-status-failed' :
download.status === 'processing' ? 'download-status-processing' :
download.status === 'downloading' ? 'download-status-downloading' :
download.status === 'queued' ? 'download-status-queued' :
download.status === 'queued' || download.status === 'staged' ? 'download-status-queued' :
download.status === 'retrying' ? 'download-status-retrying' : ''
}`}
>
{download.status === 'queued' && queueIndex !== -1 ? (
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 ? (
<>
<Clock size={12} className="animate-pulse shrink-0" />
<span className="truncate">Queued #{queueIndex + 1}</span>
<Clock size={12} className={download.status === 'queued' ? 'animate-pulse shrink-0' : 'shrink-0'} />
<span className="truncate">
{download.status === 'staged' ? 'In queue' : 'Queued'} #{queueIndex + 1}
</span>
</>
) : download.status === 'downloading' ? (
`${((download.fraction || 0) * 100).toFixed(0)}%`
@@ -181,7 +194,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
className="hidden group-hover:flex items-center justify-end gap-0.5 w-full ml-auto"
onDoubleClick={(e) => e.stopPropagation()}
>
{download.status === 'queued' && queueIndex !== -1 && (
{(download.status === 'queued' || download.status === 'staged') && queueIndex !== -1 && (
<>
<button
onClick={() => moveInQueue(download.id, 'up')}
@@ -193,7 +206,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</button>
<button
onClick={() => moveInQueue(download.id, 'down')}
disabled={queueIndex === pendingOrder.length - 1}
disabled={queueIndex === queueItems.length - 1}
className="app-icon-button h-7 w-7 disabled:opacity-40"
title="Move Down"
>
+8 -13
View File
@@ -338,7 +338,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
</div>
<div className="download-table-body">
<div className="h-full overflow-auto flex flex-col">
<div className="download-table-list">
{filteredDownloads.map((d, index) => (
<DownloadItemComponent
key={d.id}
@@ -354,16 +354,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
onClick={handleItemClick}
/>
))}
{Array.from({ length: Math.max(0, 50 - filteredDownloads.length) }).map((_, i) => {
const globalIndex = filteredDownloads.length + i;
return (
<div
key={`ghost-${i}`}
className={`download-ghost-row ${globalIndex % 2 !== 0 ? 'striped' : ''}`}
/>
);
})}
<div className="flex-1 bg-transparent pointer-events-none"></div>
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
</div>
</div>
</div>
@@ -411,7 +402,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
assignToQueue(Array.from(selectedIds), q.id);
void assignToQueue(Array.from(selectedIds), q.id).catch(error => {
showInteractionError('Could not move downloads to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
@@ -526,7 +519,9 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter }) => {
{queues.map(q => (
<button key={q.id} onClick={() => {
setContextMenu(null);
assignToQueue([contextItem.id], q.id);
void assignToQueue([contextItem.id], q.id).catch(error => {
showInteractionError('Could not move download to queue', error);
});
}} className="w-full text-left px-3 py-2 hover:bg-item-hover transition-colors text-[12px]">
{q.name}
</button>
@@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { attachLogger } from '@tauri-apps/plugin-log';
import { invokeCommand as invoke } from '../ipc';
import { save } from '@tauri-apps/plugin-dialog';
import { FileDown, Trash2, Terminal, Filter } from 'lucide-react';
@@ -11,39 +10,46 @@ interface LogEntry {
message: string;
}
const getLevelStr = (level: number): LogEntry['level'] => {
switch (level) {
case 1: return 'Trace';
case 2: return 'Debug';
case 3: return 'Info';
case 4: return 'Warn';
case 5: return 'Error';
default: return 'Debug';
}
};
export default function DiagnosticsView() {
export default function LogsView() {
const { addToast } = useToast();
const [logs, setLogs] = useState<LogEntry[]>([]);
const [levelFilter, setLevelFilter] = useState<LogEntry['level'] | 'All'>('All');
const scrollRef = useRef<HTMLDivElement>(null);
const rawLineCountRef = useRef(0);
const clearedThroughRef = useRef(0);
const lastSnapshotRef = useRef('');
const MAX_LOG_LINES = 2000;
useEffect(() => {
let active = true;
const unlistenPromise = attachLogger((logRecord) => {
if (!active) return;
const level = getLevelStr(logRecord.level);
const message = logRecord.message;
if (message.includes('[download]') && message.includes('%')) return;
setLogs(prev => {
const next = [...prev, { level, message }];
return next.length > MAX_LOG_LINES ? next.slice(-MAX_LOG_LINES) : next;
});
});
const refresh = async () => {
try {
const lines = await invoke('read_logs', { limit: MAX_LOG_LINES });
if (!active) return;
if (lines.length < clearedThroughRef.current) {
clearedThroughRef.current = 0;
}
const snapshot = `${lines.length}:${lines[lines.length - 1] || ''}`;
if (snapshot === lastSnapshotRef.current) return;
lastSnapshotRef.current = snapshot;
rawLineCountRef.current = lines.length;
setLogs(lines.slice(clearedThroughRef.current).map(message => {
const level = message.includes('[ERROR]') ? 'Error'
: message.includes('[WARN]') ? 'Warn'
: message.includes('[INFO]') ? 'Info'
: message.includes('[TRACE]') ? 'Trace'
: 'Debug';
return { level, message };
}));
} catch {
if (active) setLogs([]);
}
};
void refresh();
const interval = window.setInterval(refresh, 2000);
return () => {
active = false;
void unlistenPromise.then(unlisten => unlisten()).catch(() => undefined);
window.clearInterval(interval);
};
}, []);
@@ -56,19 +62,22 @@ export default function DiagnosticsView() {
const handleExport = async () => {
try {
const path = await save({
defaultPath: 'Firelink-Diagnostics.log',
defaultPath: 'Firelink-Support-Logs.log',
filters: [{ name: 'Log Files', extensions: ['log'] }],
});
if (!path) return;
await invoke('export_logs', { destPath: path });
addToast({ message: 'Diagnostics exported', variant: 'success' });
addToast({ message: 'Support logs exported', variant: 'success' });
} catch (e) {
console.error('Export failed:', e);
addToast({ message: `Could not export diagnostics: ${String(e)}`, variant: 'error', isActionable: true });
addToast({ message: `Could not export logs: ${String(e)}`, variant: 'error', isActionable: true });
}
};
const handleClear = () => setLogs([]);
const handleClear = () => {
clearedThroughRef.current = rawLineCountRef.current;
setLogs([]);
};
const severityClass = (level: string) => {
switch (level) {
@@ -80,14 +89,14 @@ export default function DiagnosticsView() {
};
return (
<div className="diagnostics-view flex-1 flex flex-col h-full overflow-hidden">
<div className="logs-view flex-1 flex flex-col h-full overflow-hidden">
<WindowDragRegion />
{/* Toolbar */}
<div className="diagnostics-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="logs-toolbar flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center gap-2 text-text-secondary">
<Terminal size={16} strokeWidth={1.8} />
<span className="text-[13px] font-semibold text-text-primary">Diagnostics Console</span>
<span className="text-[13px] font-semibold text-text-primary">Logs</span>
<span className="text-[11px] text-text-muted">({logs.length} entries)</span>
</div>
<div className="flex items-center gap-3">
@@ -110,7 +119,7 @@ export default function DiagnosticsView() {
<button
onClick={handleClear}
className="app-icon-button"
title="Clear console"
title="Clear displayed logs"
>
<Trash2 size={14} />
</button>
@@ -126,9 +135,9 @@ export default function DiagnosticsView() {
</div>
{/* Console */}
<div ref={scrollRef} className="diagnostics-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
<div ref={scrollRef} className="logs-console flex-1 overflow-y-auto p-3 font-mono text-[11px] leading-[1.5]">
{logs.length === 0 && (
<div className="text-text-muted italic select-none">Waiting for log entries...</div>
<div className="text-text-muted italic select-none">No persisted log entries are available yet.</div>
)}
{logs.filter(entry => levelFilter === 'All' || entry.level === levelFilter).map((entry, i) => (
<div key={i} className={`log-line ${severityClass(entry.level)}`}>
+73 -11
View File
@@ -26,6 +26,11 @@ const postActions: { value: PostQueueAction; label: string; icon: typeof Moon }[
{ value: 'shutdown', label: 'Shut down', icon: Power },
];
const minuteOfDay = (value: string) => {
const [hour, minute] = value.split(':').map(Number);
return hour * 60 + minute;
};
function nextScheduledRun(settings: SchedulerSettings): string {
if (!settings.enabled) return 'Scheduler is disabled';
@@ -55,6 +60,7 @@ export default function SchedulerView() {
const savedSettings = useSettingsStore(state => state.scheduler);
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const setScheduler = useSettingsStore(state => state.setScheduler);
const queues = useDownloadStore(state => state.queues);
const [draft, setDraft] = useState<SchedulerSettings>(savedSettings);
const { addToast } = useToast();
const [permissionMessage, setPermissionMessage] = useState('');
@@ -81,12 +87,41 @@ export default function SchedulerView() {
}));
};
const availableQueueIds = new Set(queues.map(queue => queue.id));
const selectedQueueIds = draft.selectedQueueIds.filter(queueId => availableQueueIds.has(queueId));
const effectiveSelectedQueueIds = selectedQueueIds.length > 0
? selectedQueueIds
: [MAIN_QUEUE_ID];
const toggleQueue = (queueId: string) => {
setDraft(current => {
const isSelected = current.selectedQueueIds.includes(queueId);
const availableSelectionCount = current.selectedQueueIds
.filter(id => availableQueueIds.has(id))
.length;
if (isSelected && availableSelectionCount === 1) return current;
return {
...current,
selectedQueueIds: isSelected
? current.selectedQueueIds.filter(id => id !== queueId)
: [...current.selectedQueueIds, queueId]
};
});
};
const save = () => {
if (!draft.everyday && draft.selectedDays.length === 0) {
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
return;
}
if (draft.stopTimeEnabled && minuteOfDay(draft.stopTime) <= minuteOfDay(draft.startTime)) {
addToast({ message: 'Stop time must be later than start time', variant: 'error', isActionable: true });
return;
}
const normalized = {
...draft,
selectedDays: draft.everyday || draft.selectedDays.length > 0
? draft.selectedDays
: savedSettings.selectedDays
selectedDays: draft.selectedDays,
selectedQueueIds: effectiveSelectedQueueIds
};
setScheduler(normalized);
setDraft(normalized);
@@ -94,18 +129,27 @@ export default function SchedulerView() {
};
const runNow = async () => {
const count = await useDownloadStore.getState().startQueue(MAIN_QUEUE_ID);
const results = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const count = results.reduce((total, ids) => total + ids.length, 0);
const acceptedIds = results.flat();
if (count > 0) {
useSettingsStore.getState().setSchedulerRunning(true);
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
} else {
addToast({ message: 'No paused or failed downloads to start', variant: 'info' });
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
}
};
const pauseNow = async () => {
const count = await useDownloadStore.getState().pauseQueue(MAIN_QUEUE_ID);
const counts = await Promise.all(
effectiveSelectedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
const count = counts.reduce((total, queueCount) => total + queueCount, 0);
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: count > 0 ? `Paused ${count} active download${count === 1 ? '' : 's'}` : 'No active downloads', variant: 'info' });
};
@@ -230,6 +274,9 @@ export default function SchedulerView() {
<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>
<p className="mt-4 text-[11px] text-text-muted">
If Firelink is asleep at the start time, it starts the selected queues when it returns later that day, unless the stop time has already passed.
</p>
<div className="my-5 border-t border-border-color" />
<label className="flex items-center gap-2 text-[13px] font-medium text-text-primary">
@@ -262,11 +309,26 @@ export default function SchedulerView() {
<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>
<div className="space-y-3">
{queues.map(queue => {
const selected = draft.selectedQueueIds.includes(queue.id);
return (
<label key={queue.id} className="flex items-center gap-3 text-[13px] text-text-primary">
<input
type="checkbox"
checked={selected}
onChange={() => toggleQueue(queue.id)}
disabled={!draft.enabled || (selected && selectedQueueIds.length === 1)}
className="accent-accent"
/>
{queue.name}
{queue.isMain && (
<span className="text-[11px] text-text-muted">Default queue</span>
)}
</label>
);
})}
</div>
</section>
<section className="app-card p-5">
+1 -1
View File
@@ -177,7 +177,7 @@ export default function SettingsView() {
const settings = useSettingsStore();
const activeTab = settings.activeSettingsTab;
// Local state for engine diagnostics
// Local state for engine status
const [engineStatus, setEngineStatus] = useState<EngineStatusItem[] | null>(null);
const [expandedEngine, setExpandedEngine] = useState<string | null>(null);
const [isRecheckingEngines, setIsRecheckingEngines] = useState(false);
+14 -2
View File
@@ -8,6 +8,7 @@ import {
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
import { WindowDragRegion } from './WindowDragRegion';
import { useToast } from '../contexts/ToastContext';
export type SidebarFilter = 'all' | 'active' | 'completed' | 'unfinished' | DownloadCategory | 'settings' | string;
@@ -20,6 +21,7 @@ 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 { addToast } = useToast();
const [isAddingQueue, setIsAddingQueue] = useState(false);
const [newQueueName, setNewQueueName] = useState('');
@@ -236,7 +238,7 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<div className="sidebar-section-label">Tools</div>
<ToolItem icon={CalendarClock} label="Scheduler" view="scheduler" />
<ToolItem icon={Gauge} label="Speed Limiter" view="speedLimiter" />
<ToolItem icon={Bug} label="Diagnostics" view="diagnostics" />
<ToolItem icon={Bug} label="Logs" view="logs" />
</section>
</div>
@@ -294,7 +296,17 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
{!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); }}
onClick={() => {
const queueId = contextMenu.id;
setContextMenu(null);
void removeQueue(queueId).catch(error => {
addToast({
message: `Could not delete queue: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}}
>
<Trash2 size={14} className="mr-2" />
Delete Queue
+26 -16
View File
@@ -1427,6 +1427,8 @@
.download-table-scroll {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
min-width: 0;
overflow-x: hidden;
@@ -1475,10 +1477,18 @@
}
.download-table-body {
height: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
}
.download-table-list {
height: 100%;
overflow: auto;
display: flex;
flex-direction: column;
}
.download-row {
height: 32px;
display: grid;
@@ -1727,20 +1737,20 @@ html[data-list-density="relaxed"] .download-ghost-row {
}
}
/* Diagnostics Console */
.diagnostics-toolbar {
/* Logs Console */
.logs-toolbar {
height: 42px;
border-bottom: 1px solid hsl(var(--border-color));
background: hsl(var(--statusbar-bg));
}
.diagnostics-console {
.logs-console {
background: hsl(0 0% 7%);
color: hsl(0 0% 82%);
font-family: "SF Mono", Monaco, "Cascadia Code", "Fira Code", "JetBrains Mono", monospace;
}
.diagnostics-console .log-line {
.logs-console .log-line {
display: flex;
gap: 8px;
padding: 0 4px;
@@ -1751,39 +1761,39 @@ html[data-list-density="relaxed"] .download-ghost-row {
word-break: break-all;
}
.diagnostics-console .log-level-tag {
.logs-console .log-level-tag {
flex-shrink: 0;
font-weight: 600;
min-width: 52px;
font-size: 10px;
}
.diagnostics-console .log-message {
.logs-console .log-message {
flex: 1;
min-width: 0;
}
.diagnostics-console .log-error .log-level-tag,
.diagnostics-console .log-error .log-message {
.logs-console .log-error .log-level-tag,
.logs-console .log-error .log-message {
color: hsl(0 72% 58%);
}
.diagnostics-console .log-warn .log-level-tag,
.diagnostics-console .log-warn .log-message {
.logs-console .log-warn .log-level-tag,
.logs-console .log-warn .log-message {
color: hsl(45 100% 50%);
}
.diagnostics-console .log-info .log-level-tag,
.diagnostics-console .log-info .log-message {
.logs-console .log-info .log-level-tag,
.logs-console .log-info .log-message {
color: hsl(0 0% 75%);
}
.diagnostics-console .log-debug .log-level-tag,
.diagnostics-console .log-debug .log-message {
.logs-console .log-debug .log-level-tag,
.logs-console .log-debug .log-message {
color: hsl(0 0% 45%);
}
.diagnostics-console .log-line:hover {
.logs-console .log-line:hover {
background: hsl(0 0% 100% / 0.04);
}
+9 -7
View File
@@ -12,6 +12,7 @@ import type { PostQueueAction } from './bindings/PostQueueAction';
import type { ReleaseCheckOutcome } from './bindings/ReleaseCheckOutcome';
import type { PairingTokenHydration } from './bindings/PairingTokenHydration';
import type { EnqueueItem } from './bindings/EnqueueItem';
import type { EnqueueAccepted } from './bindings/EnqueueAccepted';
type CommandMap = {
fetch_metadata: {
@@ -28,14 +29,14 @@ type CommandMap = {
get_deno_engine_status: { args: undefined; result: EngineStatusItem };
reveal_in_file_manager: { args: { path: string }; result: void };
open_downloaded_file: { args: { path: string }; result: void };
trash_download_assets: { args: { path: string; partialPaths: string[] }; result: void };
pause_download: { args: { id: string }; result: void };
resume_download: { args: { id: string }; result: boolean };
remove_download: { args: { id: string; filepath: string | null }; result: void };
remove_download: { args: { id: string; deleteAssets: boolean }; result: void };
detach_download_for_reconfigure: { args: { id: string }; 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 };
ack_schedule_trigger: { args: { action: 'start' | 'stop'; key: string }; 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 };
@@ -67,10 +68,11 @@ type CommandMap = {
result: void;
};
export_logs: { args: { destPath: string }; result: string };
get_pending_order: { args: undefined; result: string[] };
enqueue_download: { args: { item: EnqueueItem }; result: string };
read_logs: { args: { limit: number }; result: string[] };
get_pending_order: { args: { queueId: string | null }; result: string[] };
enqueue_download: { args: { item: EnqueueItem }; result: EnqueueAccepted };
enqueue_many: { args: { items: EnqueueItem[] }; result: import('./bindings/EnqueueResult').EnqueueResult[] };
move_in_queue: { args: { id: string; direction: 'up' | 'down' }; result: string[] };
move_in_queue: { args: { id: string; queueId: string; direction: 'up' | 'down' }; result: string[] };
remove_from_queue: { args: { id: string }; result: boolean };
};
@@ -83,13 +85,13 @@ export function invokeCommand<K extends CommandName>(
...args: CommandArgs<K> extends undefined ? [] : [args: CommandArgs<K>]
): Promise<CommandResult<K>> {
return tauriInvoke<CommandResult<K>>(command, args[0]).catch(err => {
logError(`Invoke command ${command} failed: ${err}`);
void logError(`Invoke command ${command} failed: ${err}`).catch(() => undefined);
throw err;
});
}
type EventMap = {
'schedule-trigger': 'start' | 'stop';
'schedule-trigger': { action: 'start' | 'stop'; key: string };
'download-progress': DownloadProgressEvent;
'download-state': DownloadStateEvent;
'download-complete': string;
+26
View File
@@ -4,6 +4,32 @@ import "./index.css";
import App from "./App";
import { ErrorBoundary } from "./ErrorBoundary";
import { ToastProvider } from "./contexts/ToastContext";
import { error as logError, warn as logWarn } from "@tauri-apps/plugin-log";
const serializeConsoleArguments = (values: unknown[]) => values.map(value => {
if (value instanceof Error) return `${value.name}: ${value.message}\n${value.stack || ''}`;
if (typeof value === 'string') return value;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}).join(' ');
const redactConsoleMessage = (message: string) => message
.replace(/(authorization|cookie|password|token|secret)\s*[:=]\s*([^\s,;]+)/gi, '$1=[redacted]')
.replace(/(https?:\/\/[^\s?]+)\?[^\s]+/g, '$1?[redacted]');
const originalConsoleError = console.error.bind(console);
const originalConsoleWarn = console.warn.bind(console);
console.error = (...values: unknown[]) => {
originalConsoleError(...values);
void logError(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
};
console.warn = (...values: unknown[]) => {
originalConsoleWarn(...values);
void logWarn(redactConsoleMessage(serializeConsoleArguments(values))).catch(() => undefined);
};
const rootElement = document.getElementById("root");
if (rootElement) {
+71 -5
View File
@@ -71,7 +71,7 @@ describe('useDownloadStore', () => {
});
const dispatched = await useDownloadStore.getState().startQueue('MAIN');
expect(dispatched).toBe(2); // Both items counted as dispatched/handled
expect(dispatched).toEqual(['1', '2']);
const calls = vi.mocked(ipc.invokeCommand).mock.calls;
const enqueues = calls.filter(c => c[0] === 'enqueue_download');
@@ -79,6 +79,34 @@ describe('useDownloadStore', () => {
expect((enqueues[0] as any)[1].item.id).toBe('2');
});
it('does not overwrite a downloading event received while starting a queue', async () => {
useDownloadStore.setState({
downloads: [
{ id: '1', url: 'http://test1', fileName: 'f1', destination: '/tmp', status: 'queued', category: 'Other', dateAdded: '', queueId: 'MAIN', hasBeenDispatched: false },
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'enqueue_download') {
useDownloadStore.getState().updateDownload('1', {
status: 'downloading',
speed: '1 MB/s',
eta: '10s'
});
}
if (cmd === 'get_pending_order') return ['1'];
return undefined;
});
expect(await useDownloadStore.getState().startQueue('MAIN')).toEqual(['1']);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
status: 'downloading',
speed: '1 MB/s',
eta: '10s',
hasBeenDispatched: true
});
});
it('resumeDownload unregisters ID and re-dispatches if un-resumable', async () => {
useDownloadStore.setState({
downloads: [
@@ -113,9 +141,9 @@ describe('useDownloadStore', () => {
}, { type: 'add-to-queue', queueId: 'queue-b' });
const item = useDownloadStore.getState().downloads[0];
expect(item.status).toBe('queued');
expect(item.status).toBe('staged');
expect(item.queueId).toBe('queue-b');
expect(useDownloadStore.getState().pendingOrder).toContain('queue-1');
expect(item.queuePosition).toBe(0);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
});
@@ -207,7 +235,7 @@ describe('useDownloadStore', () => {
).toHaveLength(4);
});
it('assigns selected unfinished downloads to a queue without moving completed items', () => {
it('assigns selected unfinished downloads to a queue without moving completed items', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'ready', status: 'ready', queueId: 'old' },
@@ -215,12 +243,50 @@ describe('useDownloadStore', () => {
] as any[]
});
useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
await useDownloadStore.getState().assignToQueue(['ready', 'done'], 'new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'ready')?.queueId).toBe('new');
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
});
it('retains the UI item when backend removal fails', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'active', url: 'https://example.com/file', fileName: 'file', status: 'downloading', category: 'Other', dateAdded: '', queueId: 'main' }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockRejectedValueOnce(new Error('writer did not stop'));
await expect(useDownloadStore.getState().removeDownload('active', true))
.rejects.toThrow('writer did not stop');
expect(useDownloadStore.getState().downloads.map(download => download.id))
.toEqual(['active']);
});
it('starts staged queue items in their persisted queue order', async () => {
useDownloadStore.setState({
downloads: [
{ id: 'later', url: 'https://example.com/later', fileName: 'later', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 1 },
{ id: 'first', url: 'https://example.com/first', fileName: 'first', status: 'staged', category: 'Other', dateAdded: '', queueId: 'queue-a', queuePosition: 0 }
] as any[]
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'get_pending_order') {
return [(args as { queueId: string }).queueId === 'queue-a' ? 'first' : 'later'];
}
return undefined;
});
expect(await useDownloadStore.getState().startQueue('queue-a')).toEqual(['first', 'later']);
const enqueuedIds = vi.mocked(ipc.invokeCommand).mock.calls
.filter(call => call[0] === 'enqueue_download')
.map(call => (call[1] as any).item.id);
expect(enqueuedIds).toEqual(['first', 'later']);
expect((vi.mocked(ipc.invokeCommand).mock.calls.find(call =>
call[0] === 'enqueue_download'
)?.[1] as any).item.queue_id).toBe('queue-a');
});
it('preserves extension request headers and cookies for the Add modal', () => {
useDownloadStore.getState().handleExtensionDownload({
urls: ['https://example.com/file.bin'],
+130 -61
View File
@@ -7,11 +7,9 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
import type { ExtensionDownload } from '../bindings/ExtensionDownload';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import { categoryForFileName, isActiveDownloadStatus, normalizeSpeedLimitForBackend, redactDownloadForPersistence } from '../utils/downloads';
import {
expandTilde,
resolveCategoryDestination,
resolveDownloadFilePath
resolveCategoryDestination
} from '../utils/downloadLocations';
import { canPauseDownload, canStartDownload } from '../utils/downloadActions';
@@ -42,6 +40,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
const enqueueItem = {
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination,
filename: item.fileName,
@@ -61,8 +60,15 @@ export async function dispatchItem(id: string): Promise<boolean> {
is_media: item.isMedia || false
};
await invoke('enqueue_download', { item: enqueueItem });
const order = await invoke('get_pending_order');
const accepted = await invoke('enqueue_download', { item: enqueueItem });
const acceptedFilename = accepted?.filename || item.fileName;
if (acceptedFilename !== item.fileName) {
useDownloadStore.getState().updateDownload(id, {
fileName: acceptedFilename,
category: categoryForFileName(acceptedFilename)
});
}
const order = await invoke('get_pending_order', { queueId: item.queueId || MAIN_QUEUE_ID });
useDownloadStore.getState().setPendingOrder(order);
useDownloadStore.getState().registerBackendIds([id]);
return true;
@@ -126,16 +132,26 @@ const syncSystemIntegrations = () => {
}
};
const resolveDownloadPath = async (destination: string, fileName: string) => {
return resolveDownloadFilePath(await expandTilde(destination), fileName);
};
const effectiveDestinationForItem = async (
item: Pick<DownloadItem, 'destination' | 'category'>,
settings: ReturnType<typeof useSettingsStore.getState>
): Promise<string> =>
item.destination || resolveCategoryDestination(settings, item.category);
const normalizeQueuePositions = (downloads: DownloadItem[]): DownloadItem[] => {
const nextPosition = new Map<string, number>();
return downloads.map(download => {
const queueId = download.queueId || MAIN_QUEUE_ID;
const position = nextPosition.get(queueId) || 0;
nextPosition.set(queueId, position + 1);
return {
...download,
queueId,
queuePosition: download.queuePosition ?? position
};
});
};
export type { DownloadStatus };
export const MAIN_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -186,15 +202,15 @@ interface DownloadState {
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string) => Promise<void>;
startQueue: (queueId: string) => Promise<number>;
resumeDownload: (id: string) => Promise<boolean>;
startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>;
pauseAll: () => Promise<number>;
assignToQueue: (ids: string[], queueId: string) => void;
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
addQueue: (name: string) => void;
renameQueue: (id: string, name: string) => void;
removeQueue: (id: string) => void;
removeQueue: (id: string) => Promise<void>;
initDB: () => Promise<void>;
}
@@ -205,8 +221,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
pendingOrder: [],
setPendingOrder: (order) => set({ pendingOrder: order }),
moveInQueue: async (id, direction) => {
const item = get().downloads.find(download => download.id === id);
if (!item) return;
const queueId = item.queueId || MAIN_QUEUE_ID;
const queueItems = get().downloads
.filter(download => (download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed')
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
const index = queueItems.findIndex(download => download.id === id);
const target = direction === 'up' ? index - 1 : index + 1;
if (index < 0 || target < 0 || target >= queueItems.length) return;
const reordered = [...queueItems];
[reordered[index], reordered[target]] = [reordered[target], reordered[index]];
const positions = new Map(reordered.map((download, position) => [download.id, position]));
set(state => ({
downloads: state.downloads.map(download => positions.has(download.id)
? { ...download, queuePosition: positions.get(download.id) }
: download)
}));
if (!get().backendRegisteredIds.has(id)) return;
try {
const order = await invoke('move_in_queue', { id, direction });
const order = await invoke('move_in_queue', { id, queueId, direction });
set({ pendingOrder: order });
} catch (e) {
console.error("Failed to move item in queue:", e);
@@ -284,20 +319,21 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
addDownload: async (item, action) => {
const settings = useSettingsStore.getState();
const destPath = await effectiveDestinationForItem(item, settings);
const queueId = action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID;
const queuePosition = get().downloads.filter(download =>
(download.queueId || MAIN_QUEUE_ID) === queueId && download.status !== 'completed'
).length;
const ownedItem: DownloadItem = {
...item,
destination: destPath,
status: action.type === 'add-to-queue' ? 'queued' : 'ready',
queueId: action.type === 'add-to-queue' ? action.queueId : MAIN_QUEUE_ID,
status: action.type === 'add-to-queue' ? 'staged' : 'ready',
queueId,
queuePosition,
hasBeenDispatched: false
};
set((state) => ({ downloads: [...state.downloads, ownedItem] }));
if (action.type === 'add-to-queue') {
const order = useDownloadStore.getState().pendingOrder;
if (!order.includes(item.id)) {
useDownloadStore.getState().setPendingOrder([...order, item.id]);
}
info(`Download ${item.id} added to queue ${action.queueId}`);
} else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) {
@@ -315,7 +351,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
throw new Error("Cannot change properties while transfer is active. Pause it first.");
}
if (item.status === 'ready' || item.status === 'completed' || item.status === 'failed') {
if (item.status === 'ready' || item.status === 'staged' || item.status === 'completed' || item.status === 'failed') {
state.updateDownload(id, updates);
return;
}
@@ -373,18 +409,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
removeDownload: async (id, deleteFile = false) => {
const item = get().downloads.find(d => d.id === id);
if (item && deleteFile) {
const filepath = await resolveDownloadPath(item.destination || '~/Downloads', item.fileName);
const partialPaths = [`${filepath}.aria2`, `${filepath}.part`];
await invoke('trash_download_assets', { path: filepath, partialPaths });
}
if (item) {
try {
await invoke('remove_download', { id, filepath: null });
} catch (e) {
console.error("Failed to terminate download on backend during deletion, but will still remove from UI:", e);
}
await invoke('remove_download', { id, deleteAssets: deleteFile });
}
set((state) => ({
@@ -464,19 +490,20 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
},
resumeDownload: async (id) => {
const targetItem = get().downloads.find(d => d.id === id);
if (!targetItem) return;
if (!targetItem) return false;
try {
if (targetItem.status === 'ready') {
if (targetItem.status === 'ready' || targetItem.status === 'staged') {
if (await dispatchItem(id)) {
get().updateDownload(id, { hasBeenDispatched: true });
return true;
}
return;
return false;
}
const resumedExisting = await invoke('resume_download', { id });
if (resumedExisting) {
return;
return true;
}
get().unregisterBackendIds([id]);
@@ -492,38 +519,50 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (!await dispatchItem(id)) {
console.error("Failed to re-enqueue for resume");
return false;
}
return true;
} catch (e) {
console.error("Failed to resume download:", e);
return false;
}
},
startQueue: async (queueId) => {
const runnable = get().downloads
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)));
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
.sort((left, right) => (left.queuePosition ?? 0) - (right.queuePosition ?? 0));
if (runnable.length === 0) return 0;
if (runnable.length === 0) return [];
let dispatchedCount = 0;
const promises = runnable.map(async (item) => {
if (item.status === 'ready' || item.status === 'failed' || !item.hasBeenDispatched) {
const acceptedIds: string[] = [];
for (const item of runnable) {
if (
item.status === 'ready' ||
item.status === 'staged' ||
item.status === 'failed' ||
!item.hasBeenDispatched ||
!get().backendRegisteredIds.has(item.id)
) {
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true, status: 'queued' });
dispatchedCount++;
const current = get().downloads.find(download => download.id === item.id);
get().updateDownload(item.id, {
hasBeenDispatched: true,
...(current?.status === item.status ? { status: 'queued' as const } : {})
});
acceptedIds.push(item.id);
}
} else if (item.status === 'paused' || item.status === 'queued') {
// If it's queued but already dispatched, it might be waiting.
// If it's paused, we resume it.
if (item.status === 'paused') {
await get().resumeDownload(item.id);
if (!await get().resumeDownload(item.id)) continue;
}
dispatchedCount++;
acceptedIds.push(item.id);
}
});
}
await Promise.all(promises);
info(`Queue ${queueId} started, ${dispatchedCount} items dispatched/resumed`);
return dispatchedCount;
info(`Queue ${queueId} started, ${acceptedIds.length} items dispatched/resumed`);
return acceptedIds;
},
pauseQueue: async (queueId) => {
const activeIds = get().downloads
@@ -556,7 +595,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
.map(item => item.queueId || MAIN_QUEUE_ID)
);
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
return results.reduce((total, count) => total + count, 0);
return results.reduce((total, ids) => total + ids.length, 0);
},
pauseAll: async () => {
const activeIds = get().downloads
@@ -571,12 +610,39 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
syncSystemIntegrations();
return pausedCount;
},
assignToQueue: (ids, queueId) => {
assignToQueue: async (ids, queueId) => {
const selectedIds = new Set(ids);
const selected = get().downloads.filter(item => selectedIds.has(item.id));
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
if (locked) {
throw new Error(`Pause ${locked.fileName} before moving it to another queue.`);
}
for (const item of selected) {
if (!get().backendRegisteredIds.has(item.id)) continue;
if (item.status === 'queued') {
await invoke('remove_from_queue', { id: item.id });
} else if (item.status === 'paused') {
await invoke('detach_download_for_reconfigure', { id: item.id });
}
get().unregisterBackendIds([item.id]);
}
const nextPosition = get().downloads.filter(item =>
!selectedIds.has(item.id) &&
(item.queueId || MAIN_QUEUE_ID) === queueId &&
item.status !== 'completed'
).length;
set(state => ({
downloads: state.downloads.map(item =>
selectedIds.has(item.id) && item.status !== 'completed'
? { ...item, queueId }
? {
...item,
queueId,
queuePosition: nextPosition + selected.findIndex(selectedItem => selectedItem.id === item.id),
status: 'staged' as const,
hasBeenDispatched: false
}
: item
)
}));
@@ -599,9 +665,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
})
}));
},
removeQueue: (id) => {
removeQueue: async (id) => {
if (id === MAIN_QUEUE_ID) return;
const unfinishedIds = get().downloads
.filter(download => download.queueId === id && download.status !== 'completed')
.map(download => download.id);
if (unfinishedIds.length > 0) {
await get().assignToQueue(unfinishedIds, MAIN_QUEUE_ID);
}
set((state) => ({
queues: state.queues.filter(q => q.id !== id),
downloads: state.downloads.map(d =>
@@ -619,10 +690,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
set(state => ({
queues: queues.length > 0 ? queues : state.queues,
downloads: downloads.length > 0
? downloads.map(download => ({
...download,
queueId: download.queueId || MAIN_QUEUE_ID
}))
? normalizeQueuePositions(downloads)
: state.downloads
}));
@@ -655,6 +723,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
await resolveCategoryDestination(settings, item.category);
itemsToEnqueue.push({
id: item.id,
queue_id: item.queueId || MAIN_QUEUE_ID,
url: item.url,
destination: destPath,
filename: item.fileName,
@@ -677,7 +746,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
const registeredIds = results.filter(result => result.success).map(result => result.id);
const failedIds = new Set(results.filter(result => !result.success).map(result => result.id));
const order = await invoke('get_pending_order');
const order = await invoke('get_pending_order', { queueId: null });
set(state => ({
pendingOrder: order,
backendRegisteredIds: new Set([
+24 -1
View File
@@ -19,6 +19,7 @@ import {
} from '../utils/downloadLocations';
let settingsSave = Promise.resolve();
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
const tauriStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => {
@@ -80,6 +81,7 @@ export interface SettingsState {
activeSettingsTab: SettingsTab;
scheduler: SchedulerSettings;
schedulerRunning: boolean;
schedulerActiveDownloadIds: string[];
schedulerLastStartKey: string;
schedulerLastStopKey: string;
lastCustomSpeedLimitKiB: number;
@@ -112,6 +114,7 @@ export interface SettingsState {
setActiveSettingsTab: (tab: SettingsTab) => void;
setScheduler: (settings: SchedulerSettings) => void;
setSchedulerRunning: (running: boolean) => void;
setSchedulerActiveDownloadIds: (ids: string[]) => void;
setSchedulerLastStartKey: (key: string) => void;
setSchedulerLastStopKey: (key: string) => void;
setLastCustomSpeedLimitKiB: (limit: number) => void;
@@ -187,9 +190,11 @@ export const useSettingsStore = create<SettingsState>()(
stopTime: '08:00',
everyday: true,
selectedDays: [0, 1, 2, 3, 4, 5, 6],
selectedQueueIds: [DEFAULT_SCHEDULER_QUEUE_ID],
postQueueAction: 'none'
},
schedulerRunning: false,
schedulerActiveDownloadIds: [],
schedulerLastStartKey: '',
schedulerLastStopKey: '',
lastCustomSpeedLimitKiB: 1024,
@@ -231,6 +236,7 @@ export const useSettingsStore = create<SettingsState>()(
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
@@ -301,7 +307,7 @@ export const useSettingsStore = create<SettingsState>()(
{
name: 'firelink-settings',
storage: createJSONStorage(() => tauriStorage),
version: 2,
version: 3,
migrate: (persistedState) => {
if (!persistedState || typeof persistedState !== 'object') {
return persistedState as SettingsState;
@@ -316,6 +322,15 @@ export const useSettingsStore = create<SettingsState>()(
return {
...persisted,
...locations,
scheduler: persisted.scheduler
? {
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: [DEFAULT_SCHEDULER_QUEUE_ID]
}
: persisted.scheduler,
siteLogins: Array.isArray(persisted.siteLogins) ? persisted.siteLogins : []
} as SettingsState;
},
@@ -360,6 +375,14 @@ export const useSettingsStore = create<SettingsState>()(
...currentState,
...persisted,
...locations,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: currentState.scheduler.selectedQueueIds
},
appFontSize: persisted.appFontSize || currentState.appFontSize,
listRowDensity: persisted.listRowDensity || currentState.listRowDensity,
siteLogins: Array.isArray(persisted.siteLogins)
+2 -1
View File
@@ -2,6 +2,7 @@ import type { DownloadStatus } from '../bindings/DownloadStatus';
const STARTABLE_STATUSES: ReadonlySet<DownloadStatus> = new Set([
'ready',
'staged',
'paused',
'failed',
]);
@@ -29,7 +30,7 @@ export const canRedownload = (status: DownloadStatus): boolean =>
REDOWNLOADABLE_STATUSES.has(status);
export const startActionLabel = (status: DownloadStatus): 'Start' | 'Resume' =>
status === 'ready' || status === 'failed' ? 'Start' : 'Resume';
status === 'ready' || status === 'staged' || status === 'failed' ? 'Start' : 'Resume';
export const isTransferLocked = (status: DownloadStatus): boolean =>
status === 'downloading' || status === 'processing' || status === 'retrying';
+9
View File
@@ -83,6 +83,15 @@ export const fileNameFromUrl = (rawUrl: string): string => {
return 'download';
};
export const canonicalizeDownloadFileName = (fileName: string): string => {
const leaf = fileName.replace(/\\/g, '/').split('/').pop() || 'download';
const sanitized = leaf
.replace(/[\u0000-\u001f<>:"/\\|?*]/g, '-')
.trim()
.replace(/[. ]+$/g, '');
return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'download';
};
export const isMediaUrl = (rawUrl: string): boolean => {
try {
const url = new URL(rawUrl);