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
+105 -23
View File
@@ -21,16 +21,27 @@ import { openUrl } from '@tauri-apps/plugin-opener';
let automaticUpdateCheckStarted = false;
const processingScheduleKeys = new Set<string>();
const waitForSettingsHydration = (): Promise<void> => {
if (useSettingsStore.persist.hasHydrated()) return Promise.resolve();
return new Promise(resolve => {
const unsubscribe = useSettingsStore.persist.onFinishHydration(() => {
unsubscribe();
resolve();
});
});
};
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];
return selectedQueueIds;
};
function App() {
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
@@ -58,6 +69,12 @@ function App() {
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef<string | null>(null);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const activeTransferCount = downloads.filter(download =>
download.status === 'downloading' ||
download.status === 'processing' ||
download.status === 'retrying'
).length;
const acknowledgePairingTokenChange = () => {
invoke('acknowledge_pairing_token_change').catch(error => {
@@ -94,9 +111,24 @@ function App() {
const { addToast } = useToast();
useEffect(() => {
useDownloadStore.getState().initDB();
useSettingsStore.getState().hydratePairingToken()
.then(changed => {
let active = true;
const initialize = async () => {
try {
await waitForSettingsHydration();
await useDownloadStore.getState().initDB();
if (active) setCoreReady(true);
} catch (error) {
console.error('Failed to initialize Firelink state:', error);
addToast({
message: `Could not initialize saved downloads: ${String(error)}`,
variant: 'error',
isActionable: true
});
return;
}
try {
const changed = await useSettingsStore.getState().hydratePairingToken();
if (changed) {
addToast({
variant: 'warning',
@@ -143,10 +175,14 @@ function App() {
)
});
}
})
.catch(error => {
} catch (error) {
console.error('Failed to hydrate extension pairing token:', error);
});
}
};
void initialize();
return () => {
active = false;
};
}, [addToast]);
useEffect(() => {
@@ -205,6 +241,19 @@ function App() {
invoke('update_dock_badge', { count: showDockBadge ? activeDownloadCount : 0 }).catch(() => {});
}, [showDockBadge, activeDownloadCount]);
useEffect(() => {
invoke('set_prevent_sleep', {
prevent: preventsSleepWhileDownloading && activeTransferCount > 0
}).catch(error => {
console.error('Failed to update sleep prevention:', error);
addToast({
message: `Could not update sleep prevention: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}, [addToast, preventsSleepWhileDownloading, activeTransferCount]);
useEffect(() => {
invoke('toggle_tray_icon', { show: showMenuBarIcon }).catch(console.error);
}, [showMenuBarIcon]);
@@ -228,6 +277,7 @@ function App() {
}, [globalSpeedLimit]);
useEffect(() => {
if (!coreReady) return;
const unlisten = listen('schedule-trigger', async (event) => {
const state = useSettingsStore.getState();
const payload = event.payload;
@@ -235,17 +285,48 @@ function App() {
processingScheduleKeys.add(payload.key);
try {
if (payload.action === 'start') {
const scheduledQueueIds = getScheduledQueueIds();
if (scheduledQueueIds.length === 0) {
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
addToast({
message: 'Scheduler has no valid queues selected. Update Scheduler settings.',
variant: 'warning',
isActionable: true
});
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
return;
}
const startedResults = await Promise.all(
getScheduledQueueIds().map(queueId => useDownloadStore.getState().startQueue(queueId))
scheduledQueueIds.map(queueId => useDownloadStore.getState().startQueue(queueId))
);
const acceptedIds = startedResults.flat();
state.setSchedulerActiveDownloadIds(acceptedIds);
state.setSchedulerRunning(acceptedIds.length > 0);
const scheduledQueueSet = new Set(scheduledQueueIds);
const trackedIds = useDownloadStore.getState().downloads
.filter(download =>
scheduledQueueSet.has(download.queueId || MAIN_QUEUE_ID) &&
isActiveDownloadStatus(download.status)
)
.map(download => download.id);
const activeIds = [...new Set([...acceptedIds, ...trackedIds])];
state.setSchedulerActiveDownloadIds(activeIds);
state.setSchedulerRunning(activeIds.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))
);
const trackedIds = state.schedulerActiveDownloadIds;
if (trackedIds.length > 0) {
const pauseResults = await Promise.allSettled(
trackedIds.map(id => invoke('pause_download', { id }))
);
const failedPauses = pauseResults.filter(result => result.status === 'rejected').length;
if (failedPauses > 0) {
addToast({
message: `Scheduler could not pause ${failedPauses} download${failedPauses === 1 ? '' : 's'}.`,
variant: 'error',
isActionable: true
});
}
}
state.setSchedulerActiveDownloadIds([]);
state.setSchedulerRunning(false);
await invoke('ack_schedule_trigger', { action: 'stop', key: payload.key });
@@ -258,33 +339,34 @@ function App() {
return () => {
unlisten.then(f => f()).catch(console.error);
};
}, []);
}, [addToast, coreReady]);
useEffect(() => {
if (!schedulerRunning) return;
if (schedulerActiveDownloadIds.length === 0) return;
const scheduledIds = new Set(schedulerActiveDownloadIds);
const hasPendingScheduledWork = downloads.some(download =>
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');
if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return;
const allCompleted = scheduledItems.every(item => item?.status === 'completed');
settings.setSchedulerActiveDownloadIds([]);
settings.setSchedulerRunning(false);
if (hasFailures) {
if (!allCompleted) {
addToast({
message: 'Scheduled downloads finished with failures. The post-queue system action was skipped.',
message: 'Scheduled downloads did not all complete. 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);
addToast({
message: `Scheduled system action failed: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, };
+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 ? (
<>
+1 -1
View File
@@ -40,13 +40,13 @@ type CommandMap = {
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 };
check_automation_permission: { args: undefined; result: void };
open_automation_settings: { args: undefined; result: void };
get_free_space: { args: { path: string }; result: string };
set_keychain_password: { args: { id: string; password: string }; result: void };
get_keychain_password: { args: { id: string }; result: string };
delete_keychain_password: { args: { id: string }; result: void };
check_file_exists: { args: { path: string }; result: boolean };
delete_file: { args: { path: string }; result: void };
toggle_tray_icon: { args: { show: boolean }; result: void };
set_extension_pairing_token: { args: { token: string }; result: void };
get_extension_server_port: { args: undefined; result: number | null };
+23
View File
@@ -0,0 +1,23 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useDownloadProgressStore } from './downloadStore';
describe('useDownloadProgressStore', () => {
beforeEach(() => {
useDownloadProgressStore.setState({ progressMap: {} });
});
it('prunes terminal progress entries', () => {
useDownloadProgressStore.getState().updateDownloadProgress('download-1', {
id: 'download-1',
fraction: 0.5,
speed: '1 MB/s',
eta: '10s',
size: '2 MB',
size_is_final: false
});
useDownloadProgressStore.getState().clearDownloadProgress('download-1');
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
});
});
+19 -7
View File
@@ -7,6 +7,7 @@ import { listenEvent as listen } from '../ipc';
interface DownloadProgressState {
progressMap: Record<string, DownloadProgressEvent>;
updateDownloadProgress: (id: string, payload: DownloadProgressEvent) => void;
clearDownloadProgress: (id: string) => void;
}
import { useDownloadStore } from './useDownloadStore';
@@ -20,6 +21,13 @@ export const useDownloadProgressStore = create<DownloadProgressState>((set) => (
[id]: payload,
},
})),
clearDownloadProgress: (id) =>
set((state) => {
if (!(id in state.progressMap)) return state;
const next = { ...state.progressMap };
delete next[id];
return { progressMap: next };
}),
}));
let unlistenProgress: UnlistenFn | null = null;
@@ -36,12 +44,9 @@ export async function initDownloadListener() {
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
mainStore.updateDownload(payload.id, {
fraction: payload.fraction,
speed: payload.speed,
eta: payload.eta,
...(shouldUpdateSize ? { size: payload.size! } : {}),
});
if (shouldUpdateSize && current.size !== payload.size) {
mainStore.updateDownload(payload.id, { size: payload.size! });
}
}
});
@@ -52,7 +57,11 @@ export async function initDownloadListener() {
const current = mainStore.downloads.find(d => d.id === payload.id);
if (current) {
const status = payload.status as DownloadStatus;
const updates: Partial<any> = { status };
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<any> = {
status,
...(progress ? { fraction: progress.fraction } : {})
};
if (status !== 'downloading') {
updates.speed = '-';
updates.eta = '-';
@@ -72,6 +81,9 @@ export async function initDownloadListener() {
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
if (status === 'completed' || status === 'failed' || status === 'paused') {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}
});
}
+48
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDownloadStore } from './useDownloadStore';
import { useSettingsStore } from './useSettingsStore';
import * as ipc from '../ipc';
vi.mock('../ipc', () => ({
@@ -172,6 +173,26 @@ describe('useDownloadStore', () => {
);
});
it('reports a rejected immediate start instead of claiming success', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'enqueue_download') {
throw new Error('backend unavailable');
}
return undefined;
});
const added = await useDownloadStore.getState().addDownload({
id: 'rejected-start',
url: 'https://example.com/rejected.bin',
fileName: 'rejected.bin',
category: 'Other',
dateAdded: ''
}, { type: 'start-now' });
expect(added).toBe(false);
expect(useDownloadStore.getState().downloads[0].status).toBe('failed');
});
it('redownloads fallback media without requiring a format selector', async () => {
useDownloadStore.setState({
downloads: [{
@@ -280,6 +301,33 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads.find(item => item.id === 'done')?.queueId).toBe('old');
});
it('disables scheduler when its last selected queue is deleted', async () => {
const originalSettings = useSettingsStore.getState();
const setScheduler = vi.fn();
vi.mocked(useSettingsStore.getState).mockReturnValue({
scheduler: {
enabled: true,
selectedQueueIds: ['queue-a']
},
setScheduler
} as any);
useDownloadStore.setState({
queues: [
{ id: '00000000-0000-0000-0000-000000000001', name: 'Main Queue', isMain: true },
{ id: 'queue-a', name: 'Scheduled', isMain: false }
],
downloads: []
});
await useDownloadStore.getState().removeQueue('queue-a');
expect(setScheduler).toHaveBeenCalledWith(expect.objectContaining({
enabled: false,
selectedQueueIds: []
}));
vi.mocked(useSettingsStore.getState).mockReturnValue(originalSettings);
});
it('retains the UI item when backend removal fails', async () => {
useDownloadStore.setState({
downloads: [
+16 -7
View File
@@ -125,11 +125,6 @@ const syncSystemIntegrations = () => {
const settings = useSettingsStore.getState();
const activeCount = useDownloadStore.getState().downloads.filter(d => d.status === 'downloading').length;
invoke('update_dock_badge', { count: settings.showDockBadge ? activeCount : 0 }).catch(() => {});
if (settings.preventsSleepWhileDownloading) {
invoke('set_prevent_sleep', { prevent: activeCount > 0 }).catch(() => {});
} else {
invoke('set_prevent_sleep', { prevent: false }).catch(() => {});
}
};
const effectiveDestinationForItem = async (
@@ -198,7 +193,7 @@ interface DownloadState {
openDeleteModal: (downloadIds?: string | string[]) => void;
closeDeleteModal: () => void;
setSelectedPropertiesDownloadId: (id: string | null) => void;
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<void>;
addDownload: (item: DownloadDraft, action: AddDownloadAction) => Promise<boolean>;
updateDownload: (id: string, updates: Partial<DownloadItem>) => void;
removeDownload: (id: string, deleteFile?: boolean) => Promise<void>;
redownload: (id: string) => Promise<void>;
@@ -335,12 +330,16 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
if (action.type === 'add-to-queue') {
info(`Download ${item.id} added to queue ${action.queueId}`);
return true;
} else if (action.type === 'start-now') {
if (await dispatchItem(item.id)) {
get().updateDownload(item.id, { hasBeenDispatched: true });
info(`Download ${item.id} started`);
return true;
}
info(`Download ${item.id} started`);
return false;
}
return false;
},
applyProperties: async (id, updates) => {
const state = get();
@@ -676,6 +675,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
d.queueId === id ? { ...d, queueId: MAIN_QUEUE_ID } : d
)
}));
const settings = useSettingsStore.getState();
if (settings.scheduler.selectedQueueIds.includes(id)) {
const selectedQueueIds = settings.scheduler.selectedQueueIds.filter(queueId => queueId !== id);
settings.setScheduler({
...settings.scheduler,
enabled: selectedQueueIds.length > 0 ? settings.scheduler.enabled : false,
selectedQueueIds
});
}
},
initDB: async () => {
try {
@@ -764,6 +772,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
}
} catch (e) {
console.error("Failed to init DB", e);
throw e;
}
}
}));
+2 -1
View File
@@ -261,7 +261,6 @@ export const useSettingsStore = create<SettingsState>()(
setPreventsSleepWhileDownloading: (preventsSleepWhileDownloading) => {
info('Settings updated: preventsSleepWhileDownloading');
set({ preventsSleepWhileDownloading });
if (!preventsSleepWhileDownloading) invoke('set_prevent_sleep', { prevent: false }).catch(console.error);
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setCategorySubfolder: (category, subfolder) => {
@@ -344,6 +343,8 @@ export const useSettingsStore = create<SettingsState>()(
isSidebarVisible: state.isSidebarVisible,
activeSettingsTab: state.activeSettingsTab,
scheduler: state.scheduler,
schedulerRunning: state.schedulerRunning,
schedulerActiveDownloadIds: state.schedulerActiveDownloadIds,
schedulerLastStartKey: state.schedulerLastStartKey,
schedulerLastStopKey: state.schedulerLastStopKey,
lastCustomSpeedLimitKiB: state.lastCustomSpeedLimitKiB,