fix(scheduler): run post-completion system actions

This commit is contained in:
NimBold
2026-07-04 17:24:16 +03:30
parent 4a322196de
commit f726b058f7
4 changed files with 186 additions and 62 deletions
+94 -61
View File
@@ -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<string>();
@@ -103,6 +105,7 @@ function App() {
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
const previousSpeedLimit = useRef<string | null>(null);
const pendingPostActionTimer = useRef<number | null>(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<PostQueueAction, 'none'>) => {
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: (
<div className="flex items-center gap-3">
<span>{actionLabel} in 10 seconds.</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={cancel}
>
Cancel
</button>
</div>
)
});
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<HTMLDivElement>) => {
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: (
<div className="flex items-center gap-3">
<span>{action === 'shutdown' ? 'Shut down' : action === 'restart' ? 'Restart' : 'Sleep'} in 10 seconds.</span>
<button
type="button"
className="app-button px-2 py-1"
onClick={() => {
cancelled = true;
}}
>
Cancel
</button>
</div>
)
});
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 () => {
+29 -1
View File
@@ -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;
+42
View File
@@ -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');
});
});
+21
View File
@@ -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';
};