mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(scheduler): run post-completion system actions
This commit is contained in:
+94
-61
@@ -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
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
};
|
||||
Reference in New Issue
Block a user