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:
NimBold
2026-06-22 18:05:04 +03:30
parent 6b3753949b
commit 7c835b022f
17 changed files with 529 additions and 181 deletions
+24 -12
View File
@@ -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);
+2 -1
View File
@@ -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>
+19 -4
View File
@@ -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>
+22 -13
View File
@@ -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 ? (
<>