mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-08 02:13:24 +00:00
fix: harden scheduler, permissions, and download safety
- Implement scheduler hydration barrier to prevent premature triggers - Track scheduler exact runs using keys to avoid false stops - Use 'System Events' for accurate macOS automation permissions - Prevent system-sleep via proper idle assertions - Ensure download pauses use channel acknowledgements (PauseWithAck) - Require Firelink ownership before replacing files in add/conflict UI - Retain partial download assets when removing entries without deletion - Clear progress state in store when downloads complete or pause to reduce churn - Handle empty/invalid queue selections gracefully
This commit is contained in:
@@ -414,9 +414,20 @@ export const AddDownloadsModal = () => {
|
||||
});
|
||||
} catch (e) {}
|
||||
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
newConflicts.push({ id: i.toString(), fileName: finalFile, reason: { type: 'file', msg: 'File exists at destination' }, resolution: 'rename' });
|
||||
}
|
||||
if (fileExistsInStore || fileExistsOnDisk) {
|
||||
newConflicts.push({
|
||||
id: i.toString(),
|
||||
fileName: finalFile,
|
||||
reason: {
|
||||
type: 'file',
|
||||
msg: fileExistsInStore
|
||||
? 'Existing Firelink download uses this destination'
|
||||
: 'File exists on disk; rename or skip to avoid deleting unrelated data'
|
||||
},
|
||||
resolution: 'rename',
|
||||
replaceAllowed: fileExistsInStore
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +514,7 @@ export const AddDownloadsModal = () => {
|
||||
|
||||
itemsToAdd[idx] = { ...item, file: newName };
|
||||
} else if (res.resolution === 'replace') {
|
||||
if (conflict?.reason.type !== 'file') {
|
||||
if (conflict?.reason.type !== 'file' || !conflict.replaceAllowed) {
|
||||
itemsToAdd[idx] = null;
|
||||
continue;
|
||||
}
|
||||
@@ -516,8 +527,6 @@ export const AddDownloadsModal = () => {
|
||||
const itemLocation = useSharedDestination
|
||||
? finalLocation
|
||||
: destinationOverrides[idx] || await categoryLocationForFile(finalFile);
|
||||
const fullPath = await resolveDownloadFilePath(itemLocation, finalFile);
|
||||
|
||||
const store = useDownloadStore.getState();
|
||||
let existingItem;
|
||||
const currentSettings = useSettingsStore.getState();
|
||||
@@ -525,8 +534,8 @@ export const AddDownloadsModal = () => {
|
||||
const destination = download.destination ||
|
||||
await resolveCategoryDestination(currentSettings, download.category);
|
||||
if (
|
||||
(download.url === item.downloadUrl ||
|
||||
(destination === itemLocation && download.fileName === finalFile)) &&
|
||||
destination === itemLocation &&
|
||||
download.fileName === finalFile &&
|
||||
download.status !== 'failed'
|
||||
) {
|
||||
existingItem = download;
|
||||
@@ -538,10 +547,10 @@ export const AddDownloadsModal = () => {
|
||||
throw new Error(`Pause ${existingItem.fileName} before replacing it.`);
|
||||
}
|
||||
|
||||
await invoke('delete_file', { path: fullPath });
|
||||
if (existingItem) {
|
||||
await store.removeDownload(existingItem.id);
|
||||
if (!existingItem) {
|
||||
throw new Error(`Cannot replace ${finalFile}: file is not owned by a Firelink download.`);
|
||||
}
|
||||
await store.removeDownload(existingItem.id, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,7 +574,7 @@ export const AddDownloadsModal = () => {
|
||||
}
|
||||
|
||||
const category = categoryForFileName(finalFile);
|
||||
await addDownload({
|
||||
const added = await addDownload({
|
||||
id,
|
||||
url: item.downloadUrl,
|
||||
fileName: finalFile,
|
||||
@@ -588,6 +597,9 @@ export const AddDownloadsModal = () => {
|
||||
mediaFormatSelector: formatSelector,
|
||||
size: item.size || (item.sizeBytes ? formatBytes(item.sizeBytes) : undefined)
|
||||
}, action);
|
||||
if (!added) {
|
||||
throw new Error('Backend rejected download start.');
|
||||
}
|
||||
addedCount += 1;
|
||||
} catch (e) {
|
||||
console.error("Invalid URL or failed to add:", e);
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface DuplicateConflict {
|
||||
fileName: string;
|
||||
reason: DuplicateReason;
|
||||
resolution: DuplicateResolution;
|
||||
replaceAllowed?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -44,7 +45,7 @@ export const DuplicateResolutionModal = ({ conflicts: initialConflicts, onConfir
|
||||
className="app-control w-24 shrink-0 px-2 py-1 text-xs"
|
||||
>
|
||||
<option value="rename">Rename</option>
|
||||
{conflict.reason.type === 'file' && <option value="replace">Replace</option>}
|
||||
{conflict.reason.type === 'file' && conflict.replaceAllowed && <option value="replace">Replace</option>}
|
||||
<option value="skip">Skip</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useDownloadStore, DownloadItem } from '../store/useDownloadStore';
|
||||
import { useDownloadProgressStore } from '../store/downloadStore';
|
||||
import { useSettingsStore } from '../store/useSettingsStore';
|
||||
import { ChevronDown, ChevronRight, FolderPlus, Info, CheckCircle, AlertCircle, Play, Pause } from 'lucide-react';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
@@ -19,6 +20,11 @@ export const PropertiesModal = () => {
|
||||
? state.downloads.find(d => d.id === selectedPropertiesDownloadId) ?? null
|
||||
: null
|
||||
);
|
||||
const liveProgress = useDownloadProgressStore(state =>
|
||||
selectedPropertiesDownloadId
|
||||
? state.progressMap[selectedPropertiesDownloadId]
|
||||
: undefined
|
||||
);
|
||||
|
||||
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
|
||||
|
||||
@@ -152,6 +158,15 @@ export const PropertiesModal = () => {
|
||||
|
||||
const identityLocked = getIdentityLocked(item.status);
|
||||
const transferLocked = getTransferLocked(item.status);
|
||||
const displayedFraction = item.status === 'completed'
|
||||
? 1
|
||||
: liveProgress?.fraction ?? item.fraction ?? 0;
|
||||
const displayedSpeed = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.speed ?? item.speed ?? '-';
|
||||
const displayedEta = item.status === 'completed'
|
||||
? '-'
|
||||
: liveProgress?.eta ?? item.eta ?? '-';
|
||||
|
||||
let statusColor = 'text-text-secondary';
|
||||
let StatusIcon = Info;
|
||||
@@ -176,14 +191,14 @@ export const PropertiesModal = () => {
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-border-color rounded-full h-1.5 overflow-hidden mb-4">
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${(item.status === 'completed' ? 1 : item.fraction || 0) * 100}%` }}></div>
|
||||
<div className={`h-1.5 rounded-full transition-all duration-300 ${item.status === 'completed' ? 'bg-green-500' : item.status === 'paused' ? 'bg-orange-500' : item.status === 'failed' ? 'bg-red-500' : 'bg-blue-500'}`} style={{ width: `${displayedFraction * 100}%` }}></div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '100%' : ((item.fraction || 0) * 100).toFixed(0) + '%'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.speed || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{item.status === 'completed' ? '-' : item.eta || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
|
||||
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Connections</span><span className="text-text-secondary truncate">{item.connections || perServerConnections || '-'}</span></div>
|
||||
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[60px] shrink-0">Speed cap</span><span className="text-text-secondary truncate">{item.speedLimit || '-'}</span></div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Pause, Play, Power, RotateCcw, Save
|
||||
} from 'lucide-react';
|
||||
import { PostQueueAction, SchedulerSettings, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { useDownloadStore, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { MAIN_QUEUE_ID, useDownloadStore } from '../store/useDownloadStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
@@ -89,9 +89,7 @@ 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 effectiveSelectedQueueIds = selectedQueueIds;
|
||||
|
||||
const toggleQueue = (queueId: string) => {
|
||||
setDraft(current => {
|
||||
@@ -114,6 +112,10 @@ export default function SchedulerView() {
|
||||
addToast({ message: 'Select at least one day for the scheduler', variant: 'error', isActionable: true });
|
||||
return;
|
||||
}
|
||||
if (effectiveSelectedQueueIds.length === 0) {
|
||||
addToast({ message: 'Select at least one queue 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;
|
||||
@@ -132,12 +134,19 @@ export default function SchedulerView() {
|
||||
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) {
|
||||
const selectedQueueSet = new Set(effectiveSelectedQueueIds);
|
||||
const trackedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
selectedQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
|
||||
['queued', 'downloading', 'processing', 'retrying'].includes(download.status)
|
||||
)
|
||||
.map(download => download.id);
|
||||
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
|
||||
if (activeIds.length > 0) {
|
||||
useSettingsStore.getState().setSchedulerRunning(true);
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(acceptedIds);
|
||||
addToast({ message: `Started ${count} download${count === 1 ? '' : 's'}`, variant: 'success' });
|
||||
useSettingsStore.getState().setSchedulerActiveDownloadIds(activeIds);
|
||||
addToast({ message: `Tracking ${activeIds.length} scheduled download${activeIds.length === 1 ? '' : 's'}`, variant: 'success' });
|
||||
} else {
|
||||
addToast({ message: 'No downloads in the selected queues can be started', variant: 'info' });
|
||||
}
|
||||
@@ -157,7 +166,7 @@ export default function SchedulerView() {
|
||||
if (!isMac) return;
|
||||
|
||||
try {
|
||||
await invoke('request_automation_permission');
|
||||
await invoke('check_automation_permission');
|
||||
setAutomationPermissionGranted(true);
|
||||
if (showMessage) {
|
||||
setPermissionMessage('Automation permission is available.');
|
||||
@@ -165,7 +174,7 @@ export default function SchedulerView() {
|
||||
} catch {
|
||||
setAutomationPermissionGranted(false);
|
||||
if (showMessage) {
|
||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for Finder in System Settings.');
|
||||
setPermissionMessage('Automation permission is missing. Enable Firelink under Automation for System Events in System Settings.');
|
||||
}
|
||||
}
|
||||
}, [isMac]);
|
||||
@@ -204,7 +213,7 @@ export default function SchedulerView() {
|
||||
|
||||
const handlePermissionAction = async () => {
|
||||
if (automationPermissionGranted) {
|
||||
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke it in System Settings, then return to Firelink.');
|
||||
await openAutomationSettings('macOS does not allow Firelink to revoke Automation permission directly. Revoke System Events access in System Settings, then return to Firelink.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -215,7 +224,7 @@ export default function SchedulerView() {
|
||||
setPermissionMessage('Automation permission is available.');
|
||||
} catch {
|
||||
setAutomationPermissionGranted(false);
|
||||
await openAutomationSettings('Enable Firelink under Automation for Finder in System Settings, then return to Firelink.');
|
||||
await openAutomationSettings('Enable Firelink under Automation for System Events in System Settings, then return to Firelink.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -361,7 +370,7 @@ export default function SchedulerView() {
|
||||
<div className="mb-2 flex items-center gap-2 font-semibold text-text-primary">
|
||||
<LockKeyhole size={17} className="text-accent" /> System Permissions
|
||||
</div>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for Finder.</p>
|
||||
<p className="mb-4 text-[12px] text-text-muted">Sleep, restart, and shut down require macOS Automation permission for System Events.</p>
|
||||
<div className="mb-4 flex items-center gap-2 text-[12px]">
|
||||
{automationPermissionGranted ? (
|
||||
<>
|
||||
|
||||
Reference in New Issue
Block a user