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
+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,