fix(downloads): preserve live progress across lifecycle edges

This commit is contained in:
NimBold
2026-07-15 20:50:46 +03:30
parent 1d197432b2
commit 6e6ae51395
9 changed files with 150 additions and 23 deletions
+2
View File
@@ -762,12 +762,14 @@ function App() {
};
window.addEventListener('focus', handleForegroundChange);
window.addEventListener('blur', handleForegroundChange);
document.addEventListener('visibilitychange', handleForegroundChange);
handleForegroundChange();
return () => {
active = false;
window.removeEventListener('focus', handleForegroundChange);
window.removeEventListener('blur', handleForegroundChange);
document.removeEventListener('visibilitychange', handleForegroundChange);
};
}, [autoAddClipboardLinks, coreReady]);
+1 -1
View File
@@ -801,7 +801,7 @@ export const AddDownloadsModal = () => {
category,
dateAdded: new Date().toISOString(),
connections: Number(connections),
speedLimit: speedLimitEnabled ? `${speedLimit}K` : '0',
speedLimit: speedLimitEnabled ? `${speedLimit}K` : undefined,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(item.sourceUrl) || undefined,
+45
View File
@@ -11,6 +11,7 @@ vi.mock('../ipc', () => ({
describe('useDownloadProgressStore', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(ipc.invokeCommand).mockResolvedValue(undefined);
useDownloadProgressStore.setState({ progressMap: {} });
});
@@ -156,4 +157,48 @@ describe('useDownloadProgressStore', () => {
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
it('snapshots live progress before clearing it on a terminal transition', async () => {
const handlers: Record<string, (event: any) => void> = {};
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
handlers[event] = handler as (event: any) => void;
return Promise.resolve(vi.fn());
});
useDownloadStore.setState({
downloads: [{
id: 'snapshot',
url: 'https://example.com/file',
fileName: 'file.bin',
status: 'downloading',
category: 'Other',
dateAdded: ''
}]
});
const release = await initDownloadListener();
handlers['download-progress']({ payload: {
id: 'snapshot',
fraction: 0.8,
speed: '1 MB/s',
eta: '2s',
size: '8 MB',
size_is_final: false,
downloaded_bytes: 8192,
total_bytes: 10240,
total_is_estimate: true
} });
handlers['download-state']({ payload: {
id: 'snapshot',
status: 'paused'
} });
const row = useDownloadStore.getState().downloads[0];
expect(row.status).toBe('paused');
expect(row.fraction).toBe(0.8);
expect(row.downloadedBytes).toBe(8192);
expect(row.totalBytes).toBe(10240);
expect(row.totalIsEstimate).toBe(true);
expect(useDownloadProgressStore.getState().progressMap).toEqual({});
release();
});
});
+14 -6
View File
@@ -61,7 +61,7 @@ const startDownloadListeners = async () => {
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
updates.totalBytes = payload.total_bytes;
}
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
updates.totalIsEstimate = payload.total_is_estimate;
}
if (Object.keys(updates).length > 0) {
@@ -84,13 +84,24 @@ const startDownloadListeners = async () => {
return;
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
const progress = useDownloadProgressStore.getState().progressMap[payload.id];
const updates: Partial<DownloadItem> = {
status,
...(progress ? { fraction: progress.fraction } : {}),
...(progress ? {
fraction: progress.fraction,
...(progress.downloaded_bytes != null
? { downloadedBytes: progress.downloaded_bytes }
: {}),
...(progress.total_bytes != null
? { totalBytes: progress.total_bytes }
: {}),
...(progress.total_is_estimate != null
? { totalIsEstimate: progress.total_is_estimate }
: {})
} : {}),
...(payload.error ? { lastError: payload.error } : {}),
...((status === 'downloading' || status === 'retrying')
? { lastTry: new Date().toISOString() }
@@ -122,9 +133,6 @@ const startDownloadListeners = async () => {
} else if (status === 'completed' || status === 'failed') {
mainStore.unregisterBackendIds([payload.id]);
}
if (['queued', 'retrying', 'completed', 'failed', 'paused'].includes(status)) {
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
}
}),
listen('tray-action', (event) => {
const mainStore = useDownloadStore.getState();
+32
View File
@@ -571,6 +571,38 @@ describe('useDownloadStore', () => {
);
});
it('treats the legacy media zero sentinel as inheriting the global limit', async () => {
const defaultSettings = useSettingsStore.getState();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...defaultSettings,
globalSpeedLimit: '2M'
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'get_pending_order') return ['legacy-media-limit'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'legacy-media-limit',
url: 'https://www.youtube.com/watch?v=legacy',
fileName: 'media.mp4',
category: 'Movies',
dateAdded: '',
isMedia: true,
speedLimit: '0'
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'legacy-media-limit',
speed_limit: '2M'
})
})
);
});
it('reports a rejected immediate start instead of claiming success', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'enqueue_download') {
+6
View File
@@ -147,6 +147,12 @@ const speedLimitForDispatch = (
globalSpeedLimit: string,
isMedia: boolean | undefined
): string | null => {
// Older Add-window rows used "0" as the no-override sentinel. Media
// downloads do not have aria2's daemon-wide cap, so preserve the intended
// inherit-global behavior when dispatching those persisted rows.
if (isMedia && itemSpeedLimit?.trim() === '0') {
return normalizeSpeedLimitForBackend(globalSpeedLimit);
}
const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit);
if (explicitLimit !== null || !isMedia) return explicitLimit;
return normalizeSpeedLimitForBackend(globalSpeedLimit);
+11
View File
@@ -30,4 +30,15 @@ describe('download persistence progress snapshots', () => {
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
});
it.each(['queued', 'staged', 'retrying', 'processing'] as const)(
'keeps byte counters for %s snapshots',
(status) => {
const persisted = redactDownloadForPersistence(item(status));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
}
);
});
+5 -8
View File
@@ -120,12 +120,7 @@ export const isMediaUrl = (rawUrl: string): boolean => {
*/
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'ready',
'staged',
'queued',
'downloading',
'processing',
'retrying'
'downloading'
]);
/**
@@ -133,8 +128,10 @@ const VOLATILE_PROGRESS_STATUSES = new Set([
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
* existing persistence path. Numeric byte totals remain for paused, failed,
* and completed rows so those snapshots keep their accurate Size-column
* display after restart; active-transfer counters stay in memory to avoid a
* database write for every progress tick.
* display after restart; counters for the actively ticking `downloading`
* state stay in memory to avoid a database write for every progress tick.
* Non-ticking states retain counters so paused, queued, staged, retrying, and
* processing snapshots remain useful across restart and reconfiguration.
*
* Note: standard persistence intentionally retains `url` because it is the
* download source. The backend applies a stricter portable-mode policy: URL