feat(downloads): add live limits clipboard capture and byte progress

This commit is contained in:
NimBold
2026-07-15 20:35:05 +03:30
parent 9917f29743
commit 1d197432b2
18 changed files with 536 additions and 25 deletions
+77
View File
@@ -8,6 +8,7 @@ import SettingsView from "./components/SettingsView";
import { PropertiesModal } from "./components/PropertiesModal";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal";
import { extractValidDownloadUrls } from './utils/url';
import { readClipboardDownloadUrls } from './utils/clipboard';
import { listenEvent as listen, invokeCommand as invoke } from "./ipc";
import { useDownloadStore, MAIN_QUEUE_ID } from './store/useDownloadStore';
import { initDownloadListener } from './store/downloadStore';
@@ -110,6 +111,7 @@ function App() {
const appFontSize = useSettingsStore(state => state.appFontSize);
const listRowDensity = useSettingsStore(state => state.listRowDensity);
const autoCheckUpdates = useSettingsStore(state => state.autoCheckUpdates);
const autoAddClipboardLinks = useSettingsStore(state => state.autoAddClipboardLinks);
const showNotifications = useSettingsStore(state => state.showNotifications);
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
@@ -695,6 +697,81 @@ function App() {
return () => window.removeEventListener('paste', handlePaste);
}, []);
useEffect(() => {
if (!coreReady || !autoAddClipboardLinks) return;
let active = true;
let wasForeground = false;
let readInFlight = false;
let lastClipboardKey: string | null = null;
const isForeground = () =>
document.visibilityState === 'visible' &&
(typeof document.hasFocus !== 'function' || document.hasFocus());
const readClipboardOnForeground = async () => {
if (!active || readInFlight || !isForeground()) return;
const storeBeforeRead = useDownloadStore.getState();
const requestVersionBeforeRead = storeBeforeRead.pendingAddRequestVersion;
readInFlight = true;
try {
const clipboardUrls = await readClipboardDownloadUrls();
if (!active) return;
const currentSettings = useSettingsStore.getState();
const currentStore = useDownloadStore.getState();
// A user action or extension handoff won while the native clipboard
// read was pending. Let that newer Add-modal request win unchanged.
if (
!currentSettings.autoAddClipboardLinks ||
currentStore.pendingAddRequestVersion !== requestVersionBeforeRead
) {
return;
}
const clipboardKey = [...clipboardUrls].sort().join('\n');
if (clipboardKey === lastClipboardKey) return;
lastClipboardKey = clipboardKey;
if (clipboardUrls.length === 0) return;
const existingUrls = new Set(extractValidDownloadUrls(currentStore.pendingAddUrls));
const newUrls = clipboardUrls.filter(url => !existingUrls.has(url));
if (newUrls.length > 0) {
currentStore.openAddModalWithUrls(newUrls.join('\n'));
}
} catch (error) {
// Clipboard permissions are optional and this feature is explicitly
// opt-in, so a read failure should not interrupt normal app use.
console.warn('Automatic clipboard capture failed:', error);
} finally {
readInFlight = false;
}
};
const handleForegroundChange = () => {
const foreground = isForeground();
if (!foreground) {
wasForeground = false;
return;
}
if (!wasForeground) {
wasForeground = true;
void readClipboardOnForeground();
}
};
window.addEventListener('focus', handleForegroundChange);
document.addEventListener('visibilitychange', handleForegroundChange);
handleForegroundChange();
return () => {
active = false;
window.removeEventListener('focus', handleForegroundChange);
document.removeEventListener('visibilitychange', handleForegroundChange);
};
}, [autoAddClipboardLinks, coreReady]);
useEffect(() => {
const root = window.document.documentElement;
+1 -1
View File
@@ -2,4 +2,4 @@
import type { DownloadCategory } from "./DownloadCategory";
import type { DownloadStatus } from "./DownloadStatus";
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
export type DownloadItem = { id: string, url: string, fileName: string, status: DownloadStatus, fraction?: number, speed?: string, eta?: string, size?: string, downloadedBytes?: number, totalBytes?: number, totalIsEstimate?: boolean, category: DownloadCategory, dateAdded: string, resumable?: boolean, connections?: number, speedLimit?: string, username?: string, password?: string, headers?: string, checksum?: string, cookies?: string, mirrors?: string, destination?: string, isMedia?: boolean, mediaFormatSelector?: string, queueId?: string, queuePosition?: number, hasBeenDispatched?: boolean, lastError?: string, lastTry?: string, };
+1 -1
View File
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, };
export type DownloadProgressEvent = { id: string, fraction: number, speed: string, eta: string, size: string | null, size_is_final: boolean, downloaded_bytes?: number, total_bytes?: number, total_is_estimate?: boolean, };
+1 -1
View File
@@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab";
import type { SiteLogin } from "./SiteLogin";
import type { Theme } from "./Theme";
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfoldersEnabled: boolean, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array<string>, maxConcurrentDownloads: number, globalSpeedLimit: string, speedLimitPresetValues: Array<number>, logsEnabled: boolean, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array<string>, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, lastCustomSpeedLimitUnit: string, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, autoAddClipboardLinks: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array<SiteLogin>, autoCheckUpdates: boolean, keychainAccessGranted: boolean, };
+29 -2
View File
@@ -6,6 +6,10 @@ import { Play, Pause, MoreVertical, Clock, ArrowUp, ArrowDown } from 'lucide-rea
import type { DownloadItem as DownloadItemType } from '../bindings/DownloadItem';
import { canPauseDownload, canStartDownload, startActionLabel } from '../utils/downloadActions';
import { isActiveDownloadStatus } from '../utils/downloads';
import {
downloadProgressColorClass,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
interface DownloadItemProps {
downloadId: string;
@@ -64,6 +68,13 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
: download.status === 'processing'
? 'Muxing...'
: '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? download.downloadedBytes,
totalBytes: liveProgress?.total_bytes ?? download.totalBytes,
totalIsEstimate: liveProgress?.total_is_estimate ?? download.totalIsEstimate,
fallbackSize: download.size
});
const hasDownloadedAmount = Boolean(sizeDisplay.downloaded && sizeDisplay.total);
return (
<div
@@ -85,8 +96,24 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
</div>
<div className="download-cell-truncate">
<span className="tabular-nums" title={download.size && download.size !== '-' ? download.size : 'Unknown'}>
{download.size && download.size !== '-' ? download.size : 'Unknown'}
<span
className="tabular-nums"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? '~' : ''}${sizeDisplay.total}`
: sizeDisplay.fallback}
aria-label={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total}`
: sizeDisplay.fallback}
>
{hasDownloadedAmount ? (
<>
<span className={downloadProgressColorClass(download.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
<span>
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total}
</span>
</>
) : sizeDisplay.fallback}
</span>
</div>
+30 -1
View File
@@ -10,6 +10,10 @@ import {
isIdentityLocked as getIdentityLocked,
isTransferLocked as getTransferLocked
} from '../utils/downloadActions';
import {
downloadProgressColorClass,
resolveDownloadSizeDisplay
} from '../utils/downloadProgress';
type LoginMode = 'matching' | 'custom' | 'none';
@@ -185,6 +189,13 @@ export const PropertiesModal = () => {
const displayedEta = item.status === 'completed'
? '-'
: liveProgress?.eta ?? item.eta ?? '-';
const sizeDisplay = resolveDownloadSizeDisplay({
downloadedBytes: liveProgress?.downloaded_bytes ?? item.downloadedBytes,
totalBytes: liveProgress?.total_bytes ?? item.totalBytes,
totalIsEstimate: liveProgress?.total_is_estimate ?? item.totalIsEstimate,
fallbackSize: item.size
});
const hasDownloadedAmount = Boolean(sizeDisplay.downloaded && sizeDisplay.total);
let statusColor = 'text-text-secondary';
let StatusIcon = Info;
@@ -221,7 +232,25 @@ export const PropertiesModal = () => {
<div className="grid grid-cols-4 gap-y-2 gap-x-4 text-[11px] leading-tight">
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[90px] shrink-0">Progress</span><span className="text-text-secondary truncate">{`${(displayedFraction * 100).toFixed(0)}%`}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Size</span><span className="text-text-secondary truncate">{item.size || '-'}</span></div>
<div className="flex gap-1.5 min-w-0">
<span className="text-text-muted font-medium w-[40px] shrink-0">Size</span>
<span
className="truncate"
title={hasDownloadedAmount
? `${sizeDisplay.downloaded} downloaded of ${sizeDisplay.totalIsEstimate ? 'approximately ' : ''}${sizeDisplay.total}`
: sizeDisplay.fallback}
>
{hasDownloadedAmount ? (
<>
<span className={downloadProgressColorClass(item.status)}>{sizeDisplay.downloaded}</span>
<span className="text-text-muted"> / </span>
<span className="text-text-secondary">
{sizeDisplay.totalIsEstimate ? '~' : ''}{sizeDisplay.total}
</span>
</>
) : sizeDisplay.fallback}
</span>
</div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[40px] shrink-0">Speed</span><span className="text-text-secondary truncate">{displayedSpeed}</span></div>
<div className="flex gap-1.5 min-w-0"><span className="text-text-muted font-medium w-[30px] shrink-0">ETA</span><span className="text-text-secondary truncate">{displayedEta}</span></div>
+12
View File
@@ -700,6 +700,18 @@ runEngineChecks(false);
className="mac-switch"
/>
</label>
<label className="mac-settings-row cursor-default">
<div className="settings-row-label">
<span>Add clipboard links when Firelink becomes active</span>
<small>Opens supported copied links in the Add window. Off by default.</small>
</div>
<input
type="checkbox"
checked={settings.autoAddClipboardLinks}
onChange={(e) => settings.setAutoAddClipboardLinks(e.target.checked)}
className="mac-switch"
/>
</label>
</div>
</div>
)}
+9
View File
@@ -55,6 +55,15 @@ const startDownloadListeners = async () => {
if (shouldUpdateSize && current.size !== payload.size) {
updates.size = payload.size!;
}
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
updates.downloadedBytes = payload.downloaded_bytes;
}
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
updates.totalBytes = payload.total_bytes;
}
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
updates.totalIsEstimate = payload.total_is_estimate;
}
if (Object.keys(updates).length > 0) {
mainStore.updateDownload(payload.id, updates);
}
+32 -1
View File
@@ -510,7 +510,7 @@ describe('useDownloadStore', () => {
);
});
it('uses the global speed limit only when an item has no explicit speed override', async () => {
it('does not copy the global speed limit into a normal download task', async () => {
const defaultSettings = useSettingsStore.getState();
vi.mocked(useSettingsStore.getState).mockReturnValue({
...defaultSettings,
@@ -534,6 +534,37 @@ describe('useDownloadStore', () => {
expect.objectContaining({
item: expect.objectContaining({
id: 'inherits-global',
speed_limit: null
})
})
);
});
it('passes the global speed limit to a new media process when it has no item override', 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 ['inherits-global-media'];
return undefined;
});
await useDownloadStore.getState().addDownload({
id: 'inherits-global-media',
url: 'https://www.youtube.com/watch?v=example',
fileName: 'media.mp4',
category: 'Movies',
dateAdded: '',
isMedia: true
}, { type: 'start-now' });
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
id: 'inherits-global-media',
speed_limit: '2M'
})
})
+16 -3
View File
@@ -134,11 +134,21 @@ const stripCookieHeaders = (value: string | null | undefined): string =>
.join('\n')
.trim();
const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLimit: string): string | null => {
const explicitSpeedLimitForDispatch = (itemSpeedLimit: string | undefined): string | null => {
const explicitLimit = itemSpeedLimit?.trim();
if (explicitLimit) {
return normalizeSpeedLimitForBackend(explicitLimit) || (explicitLimit === '0' ? '0' : null);
}
return null;
};
const speedLimitForDispatch = (
itemSpeedLimit: string | undefined,
globalSpeedLimit: string,
isMedia: boolean | undefined
): string | null => {
const explicitLimit = explicitSpeedLimitForDispatch(itemSpeedLimit);
if (explicitLimit !== null || !isMedia) return explicitLimit;
return normalizeSpeedLimitForBackend(globalSpeedLimit);
};
@@ -186,7 +196,7 @@ export async function dispatchItem(id: string): Promise<boolean> {
destination,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
@@ -842,6 +852,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
fraction: 0,
speed: '-',
eta: '-',
downloadedBytes: undefined,
totalBytes: undefined,
totalIsEstimate: undefined,
hasBeenDispatched: false,
dateAdded: new Date().toISOString()
});
@@ -1159,7 +1172,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
destination: destPath,
filename: item.fileName,
connections: item.connections || settings.perServerConnections || null,
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
username: item.username || (login ? login.username : null),
password: item.password || keychainPassword,
headers: item.headers || null,
+9
View File
@@ -143,6 +143,7 @@ export interface SettingsState {
maxAutomaticRetries: number;
showNotifications: boolean;
playCompletionSound: boolean;
autoAddClipboardLinks: boolean;
appFontSize: AppFontSize;
listRowDensity: ListRowDensity;
showDockBadge: boolean;
@@ -186,6 +187,7 @@ export interface SettingsState {
setMaxAutomaticRetries: (count: number) => void;
setShowNotifications: (show: boolean) => void;
setPlayCompletionSound: (play: boolean) => void;
setAutoAddClipboardLinks: (enabled: boolean) => void;
setAppFontSize: (size: AppFontSize) => void;
setListRowDensity: (density: ListRowDensity) => void;
setShowDockBadge: (show: boolean) => void;
@@ -250,6 +252,7 @@ export const useSettingsStore = create<SettingsState>()(
maxAutomaticRetries: 3,
showNotifications: true,
playCompletionSound: false,
autoAddClipboardLinks: false,
appFontSize: 'standard',
listRowDensity: 'standard',
showDockBadge: true,
@@ -319,6 +322,7 @@ export const useSettingsStore = create<SettingsState>()(
}),
setShowNotifications: (showNotifications) => set({ showNotifications }),
setPlayCompletionSound: (playCompletionSound) => set({ playCompletionSound }),
setAutoAddClipboardLinks: (autoAddClipboardLinks) => set({ autoAddClipboardLinks }),
setAppFontSize: (appFontSize) => set({ appFontSize }),
setListRowDensity: (listRowDensity) => set({ listRowDensity }),
setShowDockBadge: (showDockBadge) => {
@@ -477,6 +481,7 @@ export const useSettingsStore = create<SettingsState>()(
maxAutomaticRetries: state.maxAutomaticRetries,
showNotifications: state.showNotifications,
playCompletionSound: state.playCompletionSound,
autoAddClipboardLinks: state.autoAddClipboardLinks,
appFontSize: state.appFontSize,
listRowDensity: state.listRowDensity,
showDockBadge: state.showDockBadge,
@@ -525,6 +530,10 @@ export const useSettingsStore = create<SettingsState>()(
: currentState.activeSettingsTab,
showNotifications: persistedBoolean(persisted.showNotifications, currentState.showNotifications),
playCompletionSound: persistedBoolean(persisted.playCompletionSound, currentState.playCompletionSound),
autoAddClipboardLinks: persistedBoolean(
persisted.autoAddClipboardLinks,
currentState.autoAddClipboardLinks
),
showDockBadge: persistedBoolean(persisted.showDockBadge, currentState.showDockBadge),
showMenuBarIcon: persistedBoolean(persisted.showMenuBarIcon, currentState.showMenuBarIcon),
askWhereToSaveEachFile: persistedBoolean(
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { formatDownloadBytes, resolveDownloadSizeDisplay } from './downloadProgress';
describe('download progress size display', () => {
it('formats byte counts using the binary units used by the download engines', () => {
expect(formatDownloadBytes(0)).toBe('0 B');
expect(formatDownloadBytes(1.2 * 1024 ** 3)).toBe('1.20 GB');
});
it('keeps estimated totals distinguishable from exact totals', () => {
expect(resolveDownloadSizeDisplay({
downloadedBytes: 1.2 * 1024 ** 3,
totalBytes: 2.4 * 1024 ** 3,
totalIsEstimate: true,
fallbackSize: 'Unknown'
})).toEqual({
downloaded: '1.20 GB',
total: '2.40 GB',
totalIsEstimate: true,
fallback: 'Unknown'
});
});
});
+61
View File
@@ -0,0 +1,61 @@
export interface DownloadSizeDisplay {
downloaded: string | null;
total: string | null;
totalIsEstimate: boolean;
fallback: string;
}
const isUsableByteCount = (value: number | null | undefined): value is number =>
typeof value === 'number' && Number.isFinite(value) && value >= 0;
export const formatDownloadBytes = (bytes: number): string => {
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = -1;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
return `${value.toFixed(precision)} ${units[unitIndex]}`;
};
export const resolveDownloadSizeDisplay = ({
downloadedBytes,
totalBytes,
totalIsEstimate = false,
fallbackSize
}: {
downloadedBytes?: number | null;
totalBytes?: number | null;
totalIsEstimate?: boolean;
fallbackSize?: string | null;
}): DownloadSizeDisplay => ({
downloaded: isUsableByteCount(downloadedBytes) ? formatDownloadBytes(downloadedBytes) : null,
total: isUsableByteCount(totalBytes) && totalBytes > 0 ? formatDownloadBytes(totalBytes) : null,
totalIsEstimate: Boolean(totalIsEstimate && isUsableByteCount(totalBytes) && totalBytes > 0),
fallback: fallbackSize && fallbackSize !== '-' ? fallbackSize : 'Unknown'
});
export const downloadProgressColorClass = (status: string): string => {
switch (status) {
case 'completed':
return 'download-status-completed';
case 'paused':
return 'download-status-paused';
case 'failed':
return 'download-status-failed';
case 'processing':
return 'download-status-processing';
case 'queued':
case 'staged':
return 'download-status-queued';
case 'retrying':
return 'download-status-retrying';
default:
return 'download-status-downloading';
}
};
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import type { DownloadItem } from '../bindings/DownloadItem';
import { redactDownloadForPersistence } from './downloads';
const item = (status: DownloadItem['status']): DownloadItem => ({
id: 'download-1',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
status,
category: 'Other',
dateAdded: '2026-07-15T00:00:00.000Z',
downloadedBytes: 1024,
totalBytes: 4096,
totalIsEstimate: false
});
describe('download persistence progress snapshots', () => {
it('does not write active byte counters on every progress event', () => {
const persisted = redactDownloadForPersistence(item('downloading'));
expect(persisted.downloadedBytes).toBeUndefined();
expect(persisted.totalBytes).toBeUndefined();
expect(persisted.totalIsEstimate).toBeUndefined();
});
it('keeps byte counters for paused snapshots', () => {
const persisted = redactDownloadForPersistence(item('paused'));
expect(persisted.downloadedBytes).toBe(1024);
expect(persisted.totalBytes).toBe(4096);
expect(persisted.totalIsEstimate).toBe(false);
});
});
+17 -1
View File
@@ -119,11 +119,22 @@ export const isMediaUrl = (rawUrl: string): boolean => {
* persistence boundary so the user-data database contains no plaintext credentials.
*/
const DOWNLOAD_SECRET_FIELDS = ['password', 'cookies', 'headers'] as const;
const VOLATILE_PROGRESS_STATUSES = new Set([
'ready',
'staged',
'queued',
'downloading',
'processing',
'retrying'
]);
/**
* Returns a shallow copy of `item` with secret fields removed. Volatile
* progress fields (`fraction`, `speed`, `eta`) are also dropped as in the
* existing persistence path.
* 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.
*
* Note: standard persistence intentionally retains `url` because it is the
* download source. The backend applies a stricter portable-mode policy: URL
@@ -135,6 +146,11 @@ export const redactDownloadForPersistence = (item: DownloadItem): DownloadItem =
delete copy.fraction;
delete copy.speed;
delete copy.eta;
if (VOLATILE_PROGRESS_STATUSES.has(item.status)) {
delete copy.downloadedBytes;
delete copy.totalBytes;
delete copy.totalIsEstimate;
}
for (const field of DOWNLOAD_SECRET_FIELDS) {
delete copy[field];
}