mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 11:37:21 +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:
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user