mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-28 19:47:26 +00:00
fix(scheduler): run post-completion system actions
This commit is contained in:
+93
-60
@@ -1,5 +1,6 @@
|
|||||||
import { initMediaDomains, isActiveDownloadStatus, normalizeSpeedLimitForBackend } from './utils/downloads';
|
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 { Sidebar, SidebarFilter } from "./components/Sidebar";
|
||||||
import { DownloadTable } from "./components/DownloadTable";
|
import { DownloadTable } from "./components/DownloadTable";
|
||||||
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
import { AddDownloadsModal } from "./components/AddDownloadsModal";
|
||||||
@@ -20,6 +21,7 @@ import { WindowControls } from "./components/WindowControls";
|
|||||||
import { useToast } from "./contexts/ToastContext";
|
import { useToast } from "./contexts/ToastContext";
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import { usePlatformInfo } from './utils/platform';
|
import { usePlatformInfo } from './utils/platform';
|
||||||
|
import type { PostQueueAction } from './bindings/PostQueueAction';
|
||||||
|
|
||||||
let automaticUpdateCheckStarted = false;
|
let automaticUpdateCheckStarted = false;
|
||||||
const processingScheduleKeys = new Set<string>();
|
const processingScheduleKeys = new Set<string>();
|
||||||
@@ -103,6 +105,7 @@ function App() {
|
|||||||
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
|
||||||
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
const globalSpeedLimit = useSettingsStore(state => state.globalSpeedLimit);
|
||||||
const previousSpeedLimit = useRef<string | null>(null);
|
const previousSpeedLimit = useRef<string | null>(null);
|
||||||
|
const pendingPostActionTimer = useRef<number | null>(null);
|
||||||
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
|
||||||
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
|
||||||
const activeTransferCount = downloads.filter(download =>
|
const activeTransferCount = downloads.filter(download =>
|
||||||
@@ -110,6 +113,7 @@ function App() {
|
|||||||
download.status === 'processing' ||
|
download.status === 'processing' ||
|
||||||
download.status === 'retrying'
|
download.status === 'retrying'
|
||||||
).length;
|
).length;
|
||||||
|
const { addToast } = useToast();
|
||||||
|
|
||||||
const acknowledgePairingTokenChange = () => {
|
const acknowledgePairingTokenChange = () => {
|
||||||
invoke('acknowledge_pairing_token_change').catch(error => {
|
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>) => {
|
const startSidebarResize = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const startX = event.clientX;
|
const startX = event.clientX;
|
||||||
@@ -138,13 +210,15 @@ function App() {
|
|||||||
window.addEventListener('pointerup', handlePointerUp);
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return clearPendingPostActionTimer;
|
||||||
|
}, [clearPendingPostActionTimer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initMediaDomains();
|
initMediaDomains();
|
||||||
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
window.localStorage.setItem('firelink-sidebar-width', String(sidebarWidth));
|
||||||
}, [sidebarWidth]);
|
}, [sidebarWidth]);
|
||||||
|
|
||||||
const { addToast } = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
const initialize = async () => {
|
const initialize = async () => {
|
||||||
@@ -331,6 +405,7 @@ function App() {
|
|||||||
processingScheduleKeys.add(payload.key);
|
processingScheduleKeys.add(payload.key);
|
||||||
try {
|
try {
|
||||||
if (payload.action === 'start') {
|
if (payload.action === 'start') {
|
||||||
|
clearPendingPostActionTimer();
|
||||||
const scheduledQueueIds = getScheduledQueueIds();
|
const scheduledQueueIds = getScheduledQueueIds();
|
||||||
if (scheduledQueueIds.length === 0) {
|
if (scheduledQueueIds.length === 0) {
|
||||||
state.setSchedulerActiveDownloadIds([]);
|
state.setSchedulerActiveDownloadIds([]);
|
||||||
@@ -359,6 +434,7 @@ function App() {
|
|||||||
state.setSchedulerRunning(activeIds.length > 0);
|
state.setSchedulerRunning(activeIds.length > 0);
|
||||||
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
await invoke('ack_schedule_trigger', { action: 'start', key: payload.key });
|
||||||
} else if (payload.action === 'stop') {
|
} else if (payload.action === 'stop') {
|
||||||
|
clearPendingPostActionTimer();
|
||||||
const trackedIds = state.schedulerActiveDownloadIds;
|
const trackedIds = state.schedulerActiveDownloadIds;
|
||||||
if (trackedIds.length > 0) {
|
if (trackedIds.length > 0) {
|
||||||
const pauseResults = await Promise.allSettled(
|
const pauseResults = await Promise.allSettled(
|
||||||
@@ -385,79 +461,36 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
unlisten.then(f => f()).catch(console.error);
|
unlisten.then(f => f()).catch(console.error);
|
||||||
};
|
};
|
||||||
}, [addToast, coreReady]);
|
}, [addToast, clearPendingPostActionTimer, coreReady]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!schedulerRunning) return;
|
if (!schedulerRunning) return;
|
||||||
if (schedulerActiveDownloadIds.length === 0) return;
|
if (schedulerActiveDownloadIds.length === 0) return;
|
||||||
|
clearPendingPostActionTimer();
|
||||||
const settings = useSettingsStore.getState();
|
const settings = useSettingsStore.getState();
|
||||||
const scheduledItems = schedulerActiveDownloadIds.map(id =>
|
const completionState = schedulerCompletionState(downloads, schedulerActiveDownloadIds);
|
||||||
downloads.find(download => download.id === id)
|
if (completionState === 'active') return;
|
||||||
);
|
|
||||||
if (scheduledItems.some(item => item && isActiveDownloadStatus(item.status))) return;
|
|
||||||
|
|
||||||
const allCompleted = scheduledItems.every(item => item?.status === 'completed');
|
|
||||||
settings.setSchedulerActiveDownloadIds([]);
|
settings.setSchedulerActiveDownloadIds([]);
|
||||||
settings.setSchedulerRunning(false);
|
settings.setSchedulerRunning(false);
|
||||||
|
|
||||||
let timer: number | undefined;
|
if (completionState !== 'completed') {
|
||||||
if (!allCompleted) {
|
|
||||||
addToast({
|
addToast({
|
||||||
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
message: 'Scheduled downloads did not all complete. The post-queue system action was skipped.',
|
||||||
variant: 'warning',
|
variant: 'warning',
|
||||||
isActionable: true
|
isActionable: true
|
||||||
});
|
});
|
||||||
} else if (settings.scheduler.postQueueAction !== 'none') {
|
} else if (settings.scheduler.postQueueAction !== 'none') {
|
||||||
const action = settings.scheduler.postQueueAction;
|
schedulePostQueueAction(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);
|
|
||||||
}
|
}
|
||||||
|
}, [
|
||||||
return () => {
|
addToast,
|
||||||
if (timer !== undefined) {
|
clearPendingPostActionTimer,
|
||||||
window.clearTimeout(timer);
|
downloads,
|
||||||
}
|
schedulePostQueueAction,
|
||||||
};
|
schedulerRunning,
|
||||||
}, [addToast, downloads, schedulerRunning, schedulerActiveDownloadIds]);
|
schedulerActiveDownloadIds
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initNotifications = async () => {
|
const initNotifications = async () => {
|
||||||
|
|||||||
+29
-1
@@ -271,12 +271,40 @@
|
|||||||
cursor: default;
|
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;
|
user-select: auto;
|
||||||
-webkit-user-select: auto;
|
-webkit-user-select: auto;
|
||||||
cursor: text;
|
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 {
|
:focus-visible {
|
||||||
outline: 2px solid hsl(var(--accent-color) / 0.5);
|
outline: 2px solid hsl(var(--accent-color) / 0.5);
|
||||||
outline-offset: 2px;
|
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