From f726b058f7c2df4b7d21abd49bd16170b061947a Mon Sep 17 00:00:00 2001 From: NimBold Date: Sat, 4 Jul 2026 17:24:16 +0330 Subject: [PATCH] fix(scheduler): run post-completion system actions --- src/App.tsx | 155 ++++++++++++++++---------- src/index.css | 30 ++++- src/utils/schedulerCompletion.test.ts | 42 +++++++ src/utils/schedulerCompletion.ts | 21 ++++ 4 files changed, 186 insertions(+), 62 deletions(-) create mode 100644 src/utils/schedulerCompletion.test.ts create mode 100644 src/utils/schedulerCompletion.ts diff --git a/src/App.tsx b/src/App.tsx index 12bd593..e08c2b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads'; -import { useEffect, useRef, useState } from "react"; +import { schedulerCompletionState } from './utils/schedulerCompletion'; +import { useCallback, useEffect, useRef, useState } from "react"; import { Sidebar, SidebarFilter } from "./components/Sidebar"; import { DownloadTable } from "./components/DownloadTable"; import { AddDownloadsModal } from "./components/AddDownloadsModal"; @@ -20,6 +21,7 @@ import { WindowControls } from "./components/WindowControls"; import { useToast } from "./contexts/ToastContext"; import { openUrl } from '@tauri-apps/plugin-opener'; import { usePlatformInfo } from './utils/platform'; +import type { PostQueueAction } from './bindings/PostQueueAction'; let automaticUpdateCheckStarted = false; const processingScheduleKeys = new Set(); @@ -103,6 +105,7 @@ function App() { const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds); const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit); const previousSpeedLimit = useRef(null); + const pendingPostActionTimer = useRef(null); const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads); const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading); const activeTransferCount = downloads.filter(download => @@ -110,6 +113,7 @@ function App() { download.status === 'processing' || download.status === 'retrying' ).length; + const { addToast } = useToast(); const acknowledgePairingTokenChange = () => { invoke('acknowledge_pairing_token_change').catch(error => { @@ -117,6 +121,74 @@ function App() { }); }; + const clearPendingPostActionTimer = useCallback(() => { + if (pendingPostActionTimer.current !== null) { + window.clearTimeout(pendingPostActionTimer.current); + pendingPostActionTimer.current = null; + } + }, []); + + const schedulePostQueueAction = useCallback((action: Exclude) => { + clearPendingPostActionTimer(); + + const actionLabel = action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'; + let timerId: number | null = null; + const cancel = () => { + if (timerId !== null) { + window.clearTimeout(timerId); + if (pendingPostActionTimer.current === timerId) { + pendingPostActionTimer.current = null; + } + timerId = null; + } + }; + + addToast({ + variant: 'warning', + isActionable: true, + message: ( +
+ {actionLabel} in 10 seconds. + +
+ ) + }); + + timerId = window.setTimeout(() => { + if (pendingPostActionTimer.current === timerId) { + pendingPostActionTimer.current = null; + } + timerId = null; + + const activeTransfers = useDownloadStore.getState().downloads.some(download => + isActiveDownloadStatus(download.status) + ); + if (activeTransfers) { + addToast({ + message: 'System action cancelled because another download is active.', + variant: 'warning', + isActionable: true + }); + return; + } + invoke('perform_system_action', { action }).catch(error => { + console.error('Scheduled post action failed:', error); + addToast({ + message: `Scheduled system action failed: ${String(error)}`, + variant: 'error', + isActionable: true + }); + }); + }, 10_000); + pendingPostActionTimer.current = timerId; + }, [addToast, clearPendingPostActionTimer]); + const startSidebarResize = (event: React.PointerEvent) => { event.preventDefault(); const startX = event.clientX; @@ -138,13 +210,15 @@ function App() { window.addEventListener('pointerup', handlePointerUp); }; + useEffect(() => { + return clearPendingPostActionTimer; + }, [clearPendingPostActionTimer]); + useEffect(() => { initMediaDomains(); window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth)); }, [sidebarWidth]); - const { addToast } = useToast(); - useEffect(() => { let active = true; const initialize = async () => { @@ -331,6 +405,7 @@ function App() { processingScheduleKeys.add(payload.key); try { if (payload.action === 'start') { + clearPendingPostActionTimer(); const scheduledQueueIds = getScheduledQueueIds(); if (scheduledQueueIds.length === 0) { state.setSchedulerActiveDownloadIds([]); @@ -359,6 +434,7 @@ function App() { state.setSchedulerRunning(activeIds.length > 0); await invoke('ack_schedule_trigger', { action: 'start', key: payload.key }); } else if (payload.action === 'stop') { + clearPendingPostActionTimer(); const trackedIds = state.schedulerActiveDownloadIds; if (trackedIds.length > 0) { const pauseResults = await Promise.allSettled( @@ -385,79 +461,36 @@ function App() { return () => { unlisten.then(f => f()).catch(console.error); }; - }, [addToast, coreReady]); + }, [addToast, clearPendingPostActionTimer, coreReady]); useEffect(() => { if (!schedulerRunning) return; if (schedulerActiveDownloadIds.length === 0) return; + clearPendingPostActionTimer(); const settings = useSettingsStore.getState(); - const scheduledItems = schedulerActiveDownloadIds.map(id => - downloads.find(download => download.id === id) - ); - if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return; + const completionState = schedulerCompletionState(downloads, schedulerActiveDownloadIds); + if (completionState === 'active') return; - const allCompleted = scheduledItems.every(item => item?.status === 'completed'); settings.setSchedulerActiveDownloadIds([]); settings.setSchedulerRunning(false); - - let timer: number | undefined; - if (!allCompleted) { + + if (completionState !== 'completed') { addToast({ message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.', variant: 'warning', isActionable: true }); } else if (settings.scheduler.postQueueAction !== 'none') { - const action = settings.scheduler.postQueueAction; - let cancelled = false; - addToast({ - variant: 'warning', - isActionable: true, - message: ( -
- {action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds. - -
- ) - }); - timer = window.setTimeout(() => { - if (cancelled) return; - const activeTransfers = useDownloadStore.getState().downloads.some(download => - isActiveDownloadStatus(download.status) - ); - if (activeTransfers) { - addToast({ - message: 'System action cancelled because another download is active.', - variant: 'warning', - isActionable: true - }); - return; - } - invoke('perform_system_action', { action }).catch(error => { - console.error('Scheduled post action failed:', error); - addToast({ - message: `Scheduled system action failed: ${String(error)}`, - variant: 'error', - isActionable: true - }); - }); - }, 10_000); + schedulePostQueueAction(settings.scheduler.postQueueAction); } - - return () => { - if (timer !== undefined) { - window.clearTimeout(timer); - } - }; - }, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]); + }, [ + addToast, + clearPendingPostActionTimer, + downloads, + schedulePostQueueAction, + schedulerRunning, + schedulerActiveDownloadIds + ]); useEffect(() => { const initNotifications = async () => { diff --git a/src/index.css b/src/index.css index 0598eb6..809f850 100644 --- a/src/index.css +++ b/src/index.css @@ -271,12 +271,40 @@ cursor: default; } - input, textarea { + input:not([type]), + input[type="date"], + input[type="datetime-local"], + input[type="email"], + input[type="month"], + input[type="number"], + input[type="password"], + input[type="search"], + input[type="tel"], + input[type="text"], + input[type="time"], + input[type="url"], + input[type="week"], + textarea { user-select: auto; -webkit-user-select: auto; cursor: text; } + input[type="checkbox"], + input[type="radio"], + input[type="range"], + input[type="color"], + input[type="file"], + label:has(input[type="checkbox"]:not(:disabled)), + label:has(input[type="radio"]:not(:disabled)) { + cursor: pointer; + } + + input:disabled, + label:has(input:disabled) { + cursor: default; + } + :focus-visible { outline: 2px solid hsl(var(--accent-color) / 0.5); outline-offset: 2px; diff --git a/src/utils/schedulerCompletion.test.ts b/src/utils/schedulerCompletion.test.ts new file mode 100644 index 0000000..21074cf --- /dev/null +++ b/src/utils/schedulerCompletion.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { DownloadItem } from '../bindings/DownloadItem'; +import { schedulerCompletionState } from './schedulerCompletion'; + +const download = (id: string, status: DownloadItem['status']): DownloadItem => ({ + id, + url: `https://example.com/${id}`, + fileName: `${id}.bin`, + status, + size: '0 B', + category: 'Other', + dateAdded: new Date().toISOString(), + destination: '/tmp', + queueId: 'queue', +}); + +describe('schedulerCompletionState', () => { + it('stays active while any tracked scheduler download can still progress', () => { + expect(schedulerCompletionState([ + download('a', 'completed'), + download('b', 'retrying'), + ], ['a', 'b'])).toBe('active'); + }); + + it('completes only when every tracked scheduler download completed', () => { + expect(schedulerCompletionState([ + download('a', 'completed'), + download('b', 'completed'), + ], ['a', 'b'])).toBe('completed'); + }); + + it('treats failed or missing tracked downloads as incomplete', () => { + expect(schedulerCompletionState([ + download('a', 'completed'), + download('b', 'failed'), + ], ['a', 'b'])).toBe('incomplete'); + + expect(schedulerCompletionState([ + download('a', 'completed'), + ], ['a', 'missing'])).toBe('incomplete'); + }); +}); diff --git a/src/utils/schedulerCompletion.ts b/src/utils/schedulerCompletion.ts new file mode 100644 index 0000000..190890e --- /dev/null +++ b/src/utils/schedulerCompletion.ts @@ -0,0 +1,21 @@ +import type { DownloadItem } from '../bindings/DownloadItem'; +import { isActiveDownloadStatus } from './downloads'; + +export type SchedulerCompletionState = 'active' | 'completed' | 'incomplete'; + +export const schedulerCompletionState = ( + downloads: DownloadItem[], + schedulerActiveDownloadIds: string[], +): SchedulerCompletionState => { + const scheduledItems = schedulerActiveDownloadIds.map(id => + downloads.find(download => download.id === id) + ); + + if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) { + return 'active'; + } + + return scheduledItems.every(item => item?.status === 'completed') + ? 'completed' + : 'incomplete'; +};