fix(app): harden release contracts across shell, queue, and engine boundaries

- Shell & UI: restore focus on sidebar reveal, guard window drag regions, contain context menu Escape keys, and fallback gracefully when a filtered queue is deleted.
- Download table: fix sort order for estimated sizes, descending null values, 3-state sort cycle, and desktop keyboard row navigation.
- Add window: harden duplicate resolution against unmanaged disk targets, draft id keying, and media credential isolation.
- Properties window: add individual Torrent file path copy, horizontal scroll protection for narrow and RTL layouts, and strict numeric bounds on property edits.
- Settings & scheduler: add draft buffering for integer inputs, sanitize persisted scheduler options, synchronize post-queue action countdown cancellation, and localize token regeneration.
- Engine & backend: isolate loopback JSON-RPC from environment proxies with no_proxy, normalize media cookie sources, dispatch container formats case-insensitively, and preserve snake_case arguments in torrent Tauri commands.
- Verification & packaging: isolate Companion git tag resolution from sandboxed git configs, and add regression tests for workflow normalization and portable packaging.
This commit is contained in:
NimBold
2026-09-03 19:28:27 +03:30
parent 41b525ea18
commit 248b4ac460
39 changed files with 1480 additions and 219 deletions
+77 -29
View File
@@ -42,12 +42,18 @@ import { formatDownloadBytes } from './utils/downloadProgress';
import { synchronizeDocumentAppearance } from './utils/documentAppearance';
import { createMainWindowSizePersistence } from './utils/mainWindowState';
import { createSidebarResizeSession } from './utils/sidebarResize';
import {
resolveFallbackFilter,
shouldRestoreSidebarRevealFocus,
shouldRestoreSidebarToggleFocus
} from './utils/sidebarFocus';
import type { MainWindowSize } from './bindings/MainWindowSize';
import {
beginSchedulerControl,
consumeSchedulerHandoffIds,
handoffSupersededSchedulerIds,
isSchedulerControlCurrent
isSchedulerControlCurrent,
registerPostActionCanceller
} from './utils/schedulerControl';
import { createSerialTaskQueue } from './utils/serialTaskQueue';
@@ -202,7 +208,9 @@ function App() {
});
const sidebarResizeCleanupRef = useRef<(() => void) | null>(null);
const sidebarRevealRef = useRef<HTMLButtonElement>(null);
const sidebarToggleRef = useRef<HTMLButtonElement>(null);
const restoreSidebarFocusRef = useRef(false);
const restoreRevealFocusRef = useRef(false);
const theme = useSettingsStore(state => state.theme);
const windowControlStylePreference = useSettingsStore(state => state.windowControlStyle);
@@ -244,6 +252,18 @@ function App() {
const isAddModalOpen = useDownloadStore(state => state.isAddModalOpen);
const isDeleteModalOpen = useDownloadStore(state => state.deleteModalState.isOpen);
const downloads = useDownloadStore(state => state.downloads);
const queues = useDownloadStore(state => state.queues);
useEffect(() => {
const fallback = resolveFallbackFilter(
filter,
queues.map(queue => queue.id),
queues.length > 0,
);
if (fallback !== filter) {
setFilter(fallback as SidebarFilter);
}
}, [filter, queues]);
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const queuedCount = downloads.filter(download =>
download.status === 'queued' || download.status === 'staged'
@@ -262,6 +282,8 @@ function App() {
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null);
const pendingPostActionToastId = useRef<string | null>(null);
const pendingForceActionToastId = useRef<string | null>(null);
const startupResumeStarted = useRef(false);
const startupInputReady = useRef(false);
const extensionProcessing = useRef(createSerialTaskQueue());
@@ -308,7 +330,15 @@ function App() {
window.clearTimeout(pendingPostActionTimer.current);
pendingPostActionTimer.current = null;
}
}, []);
if (pendingPostActionToastId.current !== null) {
removeToast(pendingPostActionToastId.current);
pendingPostActionToastId.current = null;
}
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
}, [removeToast]);
const queueFrontendReadyUpdate = useCallback((ready: boolean) => {
const update = frontendReadyUpdate.current
@@ -341,13 +371,15 @@ function App() {
const actionLabel = t($ => $.scheduler.postActions[action]);
let timerId: number | null = null;
let toastId: string | null = null;
const showForceActionToast = () => {
let forceToastId: string | null = null;
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
const proceed = () => {
if (forceToastId !== null) {
removeToast(forceToastId);
forceToastId = null;
if (pendingForceActionToastId.current !== null) {
removeToast(pendingForceActionToastId.current);
pendingForceActionToastId.current = null;
}
invoke('perform_system_action', { action, force: true }).catch(error => {
console.error('Forced scheduled post action failed:', error);
@@ -358,7 +390,7 @@ function App() {
});
});
};
forceToastId = addToast({
pendingForceActionToastId.current = addToast({
variant: 'warning',
isActionable: true,
duration: 0,
@@ -391,16 +423,8 @@ function App() {
});
});
};
const cancel = () => {
clearPendingPostActionTimer();
timerId = null;
if (toastId !== null) {
removeToast(toastId);
toastId = null;
}
};
toastId = addToast({
const toastId = addToast({
variant: 'warning',
isActionable: true,
onDismiss: clearPendingPostActionTimer,
@@ -410,18 +434,19 @@ function App() {
<button
type="button"
className="app-button px-2 py-1"
onClick={cancel}
onClick={clearPendingPostActionTimer}
>
{t($ => $.actions.cancel)}
</button>
</div>
)
});
pendingPostActionToastId.current = toastId;
timerId = window.setTimeout(() => {
if (toastId !== null) {
if (pendingPostActionToastId.current === toastId) {
removeToast(toastId);
toastId = null;
pendingPostActionToastId.current = null;
}
if (pendingPostActionTimer.current === timerId) {
pendingPostActionTimer.current = null;
@@ -466,24 +491,43 @@ function App() {
}, []);
useEffect(() => {
if (isSidebarVisible) return;
if (restoreSidebarFocusRef.current) {
restoreSidebarFocusRef.current = false;
sidebarRevealRef.current?.focus({ preventScroll: true });
if (!isSidebarVisible) {
if (restoreSidebarFocusRef.current) {
restoreSidebarFocusRef.current = false;
sidebarRevealRef.current?.focus({ preventScroll: true });
}
return;
}
if (restoreRevealFocusRef.current) {
restoreRevealFocusRef.current = false;
sidebarToggleRef.current?.focus({ preventScroll: true });
}
}, [isSidebarVisible]);
const handleSidebarToggle = () => {
const activeElement = document.activeElement;
if (isSidebarVisible) {
const activeElement = document.activeElement;
restoreSidebarFocusRef.current = activeElement instanceof HTMLElement
&& Boolean(activeElement.closest('.app-sidebar-shell'));
restoreSidebarFocusRef.current = shouldRestoreSidebarRevealFocus(
activeElement,
document.querySelector('.app-sidebar-shell'),
);
restoreRevealFocusRef.current = false;
} else {
restoreRevealFocusRef.current = shouldRestoreSidebarToggleFocus(
activeElement,
sidebarRevealRef.current,
);
restoreSidebarFocusRef.current = false;
}
toggleSidebar();
};
useEffect(() => {
return clearPendingPostActionTimer;
const unregister = registerPostActionCanceller(clearPendingPostActionTimer);
return () => {
unregister();
clearPendingPostActionTimer();
};
}, [clearPendingPostActionTimer]);
useEffect(() => {
@@ -1209,6 +1253,7 @@ function App() {
>
<Sidebar
selectedFilter={filter}
toggleButtonRef={sidebarToggleRef}
onToggleSidebar={handleSidebarToggle}
onSelectFilter={(f) => {
setFilter(f);
@@ -1234,7 +1279,10 @@ function App() {
<button
type="button"
ref={sidebarRevealRef}
onClick={toggleSidebar}
data-tauri-drag-region="false"
onPointerDown={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
onClick={handleSidebarToggle}
className="app-icon-button app-sidebar-reveal-button h-7 w-7"
title={t($ => $.actions.showSidebar)}
aria-label={t($ => $.actions.showSidebar)}
+134 -71
View File
@@ -14,7 +14,7 @@ import { open } from '@tauri-apps/plugin-dialog';
import { writeText as writeClipboardText } from '@tauri-apps/plugin-clipboard-manager';
import { invokeCommand as invoke } from '../ipc';
import { DuplicateResolutionModal, DuplicateConflict } from './DuplicateResolutionModal';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForFileName, downloadFileNameWithSuffix, downloadFileNamesMatch, downloadMediaKindsMatch, headerNameHasCredentialMaterial, isMediaUrl, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, MAX_TORRENT_TRACKER_INTERVAL, MAX_TORRENT_TRACKER_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentWebSeedDrafts, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, serializeTorrentPreviewPriority, TORRENT_ENCRYPTION_POLICY_DISABLED, TORRENT_ENCRYPTION_POLICY_FORCE_ENCRYPTION, TORRENT_ENCRYPTION_POLICY_REQUIRE_CRYPTO, type TorrentEncryptionPolicy, type TorrentFileAllocation } from '../utils/downloads';
import { fetchMediaMetadataDeduped, fetchMediaPlaylistMetadataDeduped } from '../utils/mediaMetadata';
import {
expandTilde,
@@ -228,7 +228,7 @@ export const AddDownloadsModal = () => {
const modalRef = useModalFocus(isAddModalOpen);
const [pendingAction, setPendingAction] = useState<AddDownloadAction>({ type: 'start-now' });
const [pendingUseSharedDestination, setPendingUseSharedDestination] = useState(false);
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<number, string>>({});
const [pendingDestinationOverrides, setPendingDestinationOverrides] = useState<Record<string | number, string>>({});
const [resolvedLocation, setResolvedLocation] = useState('');
const [isQueueMenuOpen, setIsQueueMenuOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -322,15 +322,28 @@ export const AddDownloadsModal = () => {
const requestContextForUrl = (url: string) =>
pendingAddRequestContexts[normalizeComparableUrl(url)];
const hasExtensionRequestContext = Object.keys(pendingAddRequestContexts).length > 0;
const headersForRow = (sourceUrl: string) => {
const headersForRow = (sourceUrl: string, isMedia = false) => {
if (headersManuallyEditedRef.current) return headers.trim();
const context = requestContextForUrl(sourceUrl);
const media = isMedia || context?.media === true || isMediaUrl(sourceUrl);
if (media) {
const raw = context ? extensionHeaders(context) : (hasExtensionRequestContext ? '' : headers.trim());
return raw
.split(/\r?\n/)
.filter(line => {
const separator = line.indexOf(':');
return separator > 0 && !headerNameHasCredentialMaterial(line.slice(0, separator));
})
.join('\n')
.trim();
}
if (context) return extensionHeaders(context).trim();
return hasExtensionRequestContext ? '' : headers.trim();
};
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl) => {
const cookiesForRow = (sourceUrl: string, targetUrl = sourceUrl, isMedia = false) => {
if (cookiesManuallyEditedRef.current) return cookies.trim();
const context = requestContextForUrl(sourceUrl);
if (isMedia || context?.media === true || isMediaUrl(sourceUrl)) return '';
const scopedCookies = cookieScopeForUrl(context, targetUrl);
if (scopedCookies) return scopedCookies;
if (context && urlsHaveDifferentOrigins(sourceUrl, targetUrl)) return '';
@@ -440,7 +453,9 @@ export const AddDownloadsModal = () => {
pendingAddHeaders
].filter(Boolean).join('\n'));
headersManuallyEditedRef.current = false;
setCookies(initialContext?.cookies || pendingAddCookies);
const isSingleInitialMedia = initialContext?.media === true
|| (initialUrlLines.length === 1 && isMediaUrl(initialUrlLines[0]));
setCookies(isSingleInitialMedia ? '' : (initialContext?.cookies || pendingAddCookies));
cookiesManuallyEditedRef.current = false;
setMirrors('');
setIsQueueMenuOpen(false);
@@ -1011,6 +1026,11 @@ export const AddDownloadsModal = () => {
}
} catch (e) {
console.error("Failed to select folder:", e);
addToast({
message: e instanceof Error ? e.message : String(e),
variant: 'error',
isActionable: true
});
}
};
@@ -1178,7 +1198,7 @@ export const AddDownloadsModal = () => {
++folderPickerRequestRef.current;
let finalLocation = saveLocation;
let useSharedDestination = isSaveLocationManual;
const destinationOverrides: Record<number, string> = {};
const destinationOverrides: Record<string | number, string> = {};
const settings = useSettingsStore.getState();
const platform = await getPlatformInfo().catch(() => ({ os: 'unknown' }));
if (settings.askWhereToSaveEachFile && parsedItems.length > 0) {
@@ -1201,6 +1221,7 @@ export const AddDownloadsModal = () => {
if (selected && typeof selected === 'string') {
const approvedPath = await useSettingsStore.getState().approveDownloadRoot(selected);
destinationOverrides[index] = approvedPath;
destinationOverrides[item.id] = approvedPath;
const currentSettings = useSettingsStore.getState();
if (currentSettings.rememberLastUsedDownloadDirectory) {
pendingLastUsedDownloadDirectoryRef.current = approvedPath;
@@ -1213,6 +1234,11 @@ export const AddDownloadsModal = () => {
}
} catch (e) {
console.error("Failed to select folder:", e);
addToast({
message: e instanceof Error ? e.message : String(e),
variant: 'error',
isActionable: true
});
pendingLastUsedDownloadDirectoryRef.current = null;
isSubmittingRef.current = false;
setIsSubmitting(false);
@@ -1257,7 +1283,7 @@ export const AddDownloadsModal = () => {
);
if (urlMatch) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'url', msg: t($ => $.addDownloads.urlAlreadyQueued) },
resolution: 'rename',
@@ -1266,7 +1292,7 @@ export const AddDownloadsModal = () => {
});
} else if (hasBatchConflict) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'file', msg: t($ => $.addDownloads.destinationConflict) },
resolution: 'rename',
@@ -1313,7 +1339,7 @@ export const AddDownloadsModal = () => {
const canReplace = !reservedFilenameMatchIds.has(filenameMatch.id)
&& !isTransferLocked(filenameMatch.status);
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: { type: 'file', msg: t($ => $.addDownloads.matchingDownloadFilename) },
resolution: canReplace ? 'replace' : 'rename',
@@ -1368,7 +1394,7 @@ export const AddDownloadsModal = () => {
: false;
if (existingDownload || fileExistsOnDisk || hasFirelinkOwnedTarget) {
newConflicts.push({
id: i.toString(),
id: item.id,
fileName: finalFile,
reason: {
type: 'file',
@@ -1416,7 +1442,7 @@ export const AddDownloadsModal = () => {
resolution: 'rename' | 'replace' | 'skip';
replaceFingerprint?: string;
}[],
destinationOverrides: Record<number, string> = {}
destinationOverrides: Record<string | number, string> = {}
) => {
let itemsToAdd: Array<AddDownloadDraftRow | null> = parsedItems.map(item =>
item.selected === false ? null : item
@@ -1426,10 +1452,14 @@ export const AddDownloadsModal = () => {
if (resolutions) {
for (const res of resolutions) {
const idx = parseInt(res.id);
const idx = itemsToAdd.findIndex((candidate, index) =>
candidate !== null && (candidate.id === res.id || String(index) === res.id)
);
if (idx === -1) continue;
const item = itemsToAdd[idx];
if (!item) continue;
const conflict = conflicts.find(c => c.id === res.id);
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[idx];
if (res.resolution === 'skip') {
itemsToAdd[idx] = null;
@@ -1442,7 +1472,7 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx],
itemOverride,
item.isTorrent === true
);
@@ -1455,11 +1485,12 @@ export const AddDownloadsModal = () => {
const candidateFile = candidate.isMedia
? mediaFileNameForSelectedFormat(candidate.file, candidate)
: canonicalizeDownloadFileName(candidate.file);
const candidateOverride = destinationOverrides[candidate.id] ?? destinationOverrides[candidateIndex];
const candidateLocation = await destinationForFile(
candidateFile,
finalLocation,
useSharedDestination,
destinationOverrides[candidateIndex],
candidateOverride,
candidate.isTorrent === true
);
batchTargets.push({ location: candidateLocation, fileName: candidateFile });
@@ -1522,7 +1553,7 @@ export const AddDownloadsModal = () => {
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[idx],
itemOverride,
item.isTorrent === true
);
const store = useDownloadStore.getState();
@@ -1530,7 +1561,7 @@ export const AddDownloadsModal = () => {
? store.downloads.find(download => download.id === conflict.existingDownloadId)
: undefined;
const currentSettings = useSettingsStore.getState();
if (!existingItem && !conflict?.existingDownloadId) {
if (!existingItem) {
for (const download of store.downloads) {
const destination = download.destination ||
await resolveCategoryDestination(currentSettings, download.category);
@@ -1549,58 +1580,89 @@ export const AddDownloadsModal = () => {
}
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (existingItem && isTransferLocked(existingItem.status)) {
throw new Error(t($ => $.addDownloads.pauseBeforeReplace, { file: existingItem.fileName }));
}
if (!existingItem) {
if (!res.replaceFingerprint || conflict?.existingDownloadId) {
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: res.replaceFingerprint
};
continue;
}
const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
// Completed replacements must remove the old file so the
// new transfer cannot be treated as an already-complete
// aria2 target. A torrent replacement also needs a fresh
// identity because its cached metadata is keyed by the
// new row ID and its output contract differs from a normal
// file transfer. Unfinished ordinary rows use the in-place
// path to preserve their resumable assets and progress.
await store.removeDownload(existingItem.id, true, false);
} else {
const contextUrl = requestContextUrlForRow(item);
const replaced = await store.replaceDownload(existingItem.id, {
url: item.downloadUrl,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(contextUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl) || undefined,
mirrors: mirrors.trim() || undefined,
lastError: undefined
}, pendingAction);
if (!replaced) {
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
if (!existingItem) {
let diskTargetKind: string | null = null;
let diskTargetFingerprint: string | undefined;
let diskTargetOwner: string | undefined;
try {
const targetInfo = await invoke('inspect_download_target', {
path: await resolveDownloadFilePath(itemLocation, finalFile)
});
diskTargetKind = targetInfo.kind;
diskTargetFingerprint = targetInfo.fingerprint;
diskTargetOwner = targetInfo.ownedBy;
} catch (e) {
console.error("Failed to check if file exists on disk:", e);
}
// The existing row was updated in place; do not create a
// second identity for the same filename.
itemsToAdd[idx] = null;
updatedCount += 1;
continue;
}
}
}
if (diskTargetKind === 'regularFile' && diskTargetFingerprint && !diskTargetOwner) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: diskTargetFingerprint
};
continue;
}
if (diskTargetKind === 'missing' || !diskTargetKind) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: undefined
};
continue;
}
if (res.replaceFingerprint && diskTargetFingerprint === res.replaceFingerprint) {
itemsToAdd[idx] = {
...item,
replaceExistingFingerprint: res.replaceFingerprint
};
continue;
}
throw new Error(t($ => $.addDownloads.cannotReplace, { file: finalFile }));
}
const incomingMediaFormat = mediaFormatSelectorForRow(item);
const mediaFormatChanged = item.isMedia
&& existingItem.mediaFormatSelector !== incomingMediaFormat;
const torrentReplacement = Boolean(item.isTorrent) || Boolean(existingItem.isTorrent);
if (existingItem.status === 'completed' || mediaFormatChanged || torrentReplacement) {
// Completed replacements must remove the old file so the
// new transfer cannot be treated as an already-complete
// aria2 target. A torrent replacement also needs a fresh
// identity because its cached metadata is keyed by the
// new row ID and its output contract differs from a normal
// file transfer. Unfinished ordinary rows use the in-place
// path to preserve their resumable assets and progress.
await store.removeDownload(existingItem.id, true, false);
} else {
const contextUrl = requestContextUrlForRow(item);
const replaced = await store.replaceDownload(existingItem.id, {
url: item.downloadUrl,
username: useAuth ? username.trim() : undefined,
password: useAuth ? password.trim() : undefined,
headers: headersForRow(contextUrl, item.isMedia) || undefined,
cookies: cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
mirrors: mirrors.trim() || undefined,
lastError: undefined
}, pendingAction);
if (!replaced) {
const rejected = useDownloadStore.getState().downloads.find(download => download.id === existingItem.id);
throw new Error(rejected?.lastError || t($ => $.addDownloads.backendRejectedStart));
}
// The existing row was updated in place; do not create a
// second identity for the same filename.
itemsToAdd[idx] = null;
updatedCount += 1;
continue;
}
}
}
}
let addedCount = 0;
const failures: string[] = [];
@@ -1629,8 +1691,8 @@ export const AddDownloadsModal = () => {
id,
cache: true,
proxy: proxy ?? undefined,
headers: headersForRow(contextUrl) || undefined,
cookies: cookiesForRow(contextUrl, item.sourceUrl) || undefined,
headers: headersForRow(contextUrl, item.isMedia) || undefined,
cookies: cookiesForRow(contextUrl, item.sourceUrl, item.isMedia) || undefined,
cookieScopes: requestContextForUrl(contextUrl)?.cookieScopes || undefined,
torrent: true
});
@@ -1642,6 +1704,7 @@ export const AddDownloadsModal = () => {
: canonicalizeDownloadFileName(item.file);
let formatSelector = mediaFormatSelectorForRow(item);
const category = categoryForFileName(finalFile, item.isTorrent === true);
const itemOverride = destinationOverrides[item.id] ?? destinationOverrides[itemIndex];
const added = await addDownload({
id,
url: item.downloadUrl,
@@ -1658,18 +1721,18 @@ export const AddDownloadsModal = () => {
sftpHostKeyMd: !item.isTorrent && item.sourceUrl.trim().toLowerCase().startsWith('sftp:')
? sftpHostKeyMd.trim() || undefined
: undefined,
headers: item.isTorrent ? undefined : headersForRow(contextUrl) || undefined,
headers: item.isTorrent ? undefined : headersForRow(contextUrl, item.isMedia) || undefined,
checksum: checksumEnabled && checksumValue.trim()
? `${checksumAlgo}=${checksumValue.trim()}`
: undefined,
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl) || undefined,
cookies: item.isTorrent ? undefined : cookiesForRow(contextUrl, item.downloadUrl, item.isMedia) || undefined,
mirrors: mirrors.trim() || undefined,
destination: useSharedDestination || saveInDedicatedFolder || destinationOverrides[itemIndex]
destination: useSharedDestination || saveInDedicatedFolder || itemOverride
? await destinationForFile(
finalFile,
finalLocation,
useSharedDestination,
destinationOverrides[itemIndex],
itemOverride,
item.isTorrent === true
)
: undefined,
+17 -2
View File
@@ -50,6 +50,7 @@ interface DownloadItemProps {
onMoveInQueue: (id: string, direction: 'up' | 'down') => void;
onQueueDragStart: (id: string, event: React.PointerEvent<HTMLDivElement>) => void;
onClick: (e: React.MouseEvent, item: DownloadItemType) => void;
onRowKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>, download: DownloadItemType) => void;
}
export const DownloadItem = React.memo<DownloadItemProps>(({
@@ -74,6 +75,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
onMoveInQueue,
onQueueDragStart,
onClick,
onRowKeyDown,
}) => {
const { t, i18n } = useTranslation();
const calendarPreference = useSettingsStore(state => state.calendarPreference);
@@ -518,7 +520,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
<button
onClick={(e) => {
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX || rect.left;
const y = e.clientY || rect.bottom + 4;
setContextMenu({ x, y, id: download.id });
}}
className="app-icon-button main-control-button"
title={t($ => $.downloads.actions.options)}
@@ -584,11 +589,21 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
event.preventDefault();
event.stopPropagation();
onMoveInQueue(download.id, event.key === 'ArrowUp' ? 'up' : 'down');
return;
}
onRowKeyDown?.(event, download);
}}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, id: download.id });
const isKeyboard = (e.clientX === 0 && e.clientY === 0) || (e.button === 0 && e.detail === 0);
let x = e.clientX;
let y = e.clientY;
if (isKeyboard && rowRef.current) {
const rect = rowRef.current.getBoundingClientRect();
x = rect.left + 40;
y = rect.top + rect.height / 2;
}
setContextMenu({ x, y, id: download.id });
}}
>
<div
+141 -11
View File
@@ -739,6 +739,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
persistColumnWidths(widths);
persistColumnOrder(order);
persistColumnAlignments(alignments);
setQueueSortConfig(null);
setColumnMenu(null);
};
@@ -749,6 +750,10 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
};
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (contextMenuRef.current || columnMenuRef.current) {
event.preventDefault();
event.stopPropagation();
}
setContextMenu(null);
setColumnMenu(null);
}
@@ -1752,8 +1757,19 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [sortedDownloads]);
useEffect(() => {
setContextMenu(null);
setColumnMenu(null);
setQueueSortConfig(null);
}, [filter, isQueueFilter]);
const writeToClipboard = useCallback(async (text: string): Promise<void> => {
try {
await writeClipboardText(text);
} catch {
await navigator.clipboard.writeText(text);
}
}, []);
const handleItemClick = useCallback((e: React.MouseEvent, item: DownloadItem) => {
if (suppressQueueClickRef.current) {
clearQueueClickSuppression();
@@ -1789,6 +1805,82 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setContextMenu(menu);
}, [clampMenuPosition]);
const handleRowKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>, item: DownloadItem) => {
if (e.target instanceof Element && e.target.closest('button, a, input, textarea, select')) {
return;
}
if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) {
e.preventDefault();
e.stopPropagation();
const row = queueRowForId(item.id);
const rect = row?.getBoundingClientRect();
const x = rect ? rect.left + 40 : 100;
const y = rect ? rect.top + rect.height / 2 : 100;
handleContextMenu({ x, y, id: item.id });
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleDownloadDoubleClick(item);
return;
}
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const nextSelection = updateDownloadSelection({
orderedIds: sortedDownloadsRef.current.map(d => d.id),
selectedIds: selectedIdsRef.current,
lastSelectedId: lastSelectedIdRef.current,
targetId: item.id,
extendRange: e.shiftKey,
toggle: true,
});
setSelectedIds(nextSelection.selectedIds);
setLastSelectedId(nextSelection.lastSelectedId);
return;
}
if (
!e.altKey &&
!e.metaKey &&
!e.ctrlKey &&
(e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Home' || e.key === 'End')
) {
const items = sortedDownloadsRef.current;
const currentIndex = items.findIndex(d => d.id === item.id);
if (currentIndex === -1) return;
let targetIndex = currentIndex;
if (e.key === 'ArrowDown') targetIndex = Math.min(items.length - 1, currentIndex + 1);
else if (e.key === 'ArrowUp') targetIndex = Math.max(0, currentIndex - 1);
else if (e.key === 'Home') targetIndex = 0;
else if (e.key === 'End') targetIndex = items.length - 1;
if (targetIndex !== currentIndex) {
e.preventDefault();
e.stopPropagation();
const targetItem = items[targetIndex];
const nextSelection = updateDownloadSelection({
orderedIds: items.map(d => d.id),
selectedIds: selectedIdsRef.current,
lastSelectedId: lastSelectedIdRef.current,
targetId: targetItem.id,
extendRange: e.shiftKey,
toggle: false,
});
setSelectedIds(nextSelection.selectedIds);
setLastSelectedId(nextSelection.lastSelectedId);
const targetElement = queueRowForId(targetItem.id);
targetElement?.focus({ preventScroll: false });
targetElement?.scrollIntoView({ block: 'nearest' });
}
}
}, [handleContextMenu, handleDownloadDoubleClick]);
const handleMoveInQueue = useCallback((id: string, direction: 'up' | 'down') => {
if (
queueDragStateRef.current ||
@@ -1821,15 +1913,22 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
}, [moveInQueue, showInteractionError, t]);
const handleSort = (column: DownloadSortColumn) => {
const update = (current: DownloadSortConfig | null): DownloadSortConfig =>
current?.column === column
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' };
if (isQueueFilter) {
setQueueSortConfig(update);
setQueueSortConfig(current => {
if (current?.column !== column) {
return { column, direction: 'asc' };
}
if (current.direction === 'asc') {
return { column, direction: 'desc' };
}
return null;
});
} else {
setSortConfig(current => update(current));
setSortConfig(current =>
current?.column === column
? { column, direction: current.direction === 'asc' ? 'desc' : 'asc' }
: { column, direction: 'asc' }
);
}
};
@@ -2302,6 +2401,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
onMoveInQueue={handleMoveInQueue}
onQueueDragStart={stableHandleQueueDragStart}
onClick={handleItemClick}
onRowKeyDown={handleRowKeyDown}
/>
))}
<div className="flex-1 min-h-0 bg-transparent pointer-events-none" />
@@ -2346,6 +2446,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
className="download-column-menu app-modal fixed z-[70] min-w-[188px] max-h-[calc(100vh-16px)] overflow-y-auto overflow-x-hidden py-1.5 text-[12px] font-medium text-text-primary"
style={{ top: columnMenuPosition?.y, left: columnMenuPosition?.x }}
onClick={event => event.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const menu = columnMenuRef.current;
if (!menu) return;
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
if (buttons.length === 0) return;
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIdx = e.key === 'ArrowDown'
? (activeIdx + 1) % buttons.length
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
buttons[nextIdx]?.focus();
}
}}
>
<div className="download-column-menu-title px-3 py-1.5 text-text-muted">
{columnLabels.get(columnMenu.key) ?? columnMenu.key}
@@ -2394,6 +2509,21 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
left: contextMenuPosition?.x,
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
e.stopPropagation();
const menu = contextMenuRef.current;
if (!menu) return;
const buttons = Array.from(menu.querySelectorAll<HTMLButtonElement>('button:not(:disabled)'));
if (buttons.length === 0) return;
const activeIdx = buttons.indexOf(document.activeElement as HTMLButtonElement);
const nextIdx = e.key === 'ArrowDown'
? (activeIdx + 1) % buttons.length
: (activeIdx <= 0 ? buttons.length - 1 : activeIdx - 1);
buttons[nextIdx]?.focus();
}
}}
>
{selectedIds.size > 1 ? (() => {
const selectedDownloads = Array.from(selectedIds)
@@ -2457,7 +2587,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
.map(id => downloads.find(d => d.id === id)?.url)
.filter(Boolean)
.join('\n');
navigator.clipboard.writeText(urls).catch(error => {
writeToClipboard(urls).catch(error => {
showInteractionError(t($ => $.downloadTable.copyAddressesFailed), error);
});
}}
@@ -2564,7 +2694,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
<button
onClick={() => {
setContextMenu(null);
navigator.clipboard.writeText(contextItem.url).catch(error => {
writeToClipboard(contextItem.url).catch(error => {
showInteractionError(t($ => $.downloadTable.copyAddressFailed), error);
});
}}
@@ -2579,7 +2709,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
setContextMenu(null);
try {
const magnet = await invoke('get_torrent_magnet_link', { id: contextItem.id });
await writeClipboardText(magnet);
await writeToClipboard(magnet);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyMagnetFailed), error);
}
@@ -2600,7 +2730,7 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
return;
}
try {
await navigator.clipboard.writeText(fullPath);
await writeToClipboard(fullPath);
} catch (error) {
showInteractionError(t($ => $.downloadTable.copyPathFailed), error);
}
+3 -2
View File
@@ -55,6 +55,7 @@ import { getPropertiesTabIndex, getPropertiesTabs, PROPERTIES_TABS_OVERFLOW_BREA
import { getPropertiesConnectionPresentation, getPropertiesProgress } from '../utils/propertiesPresentation';
import { isTorrentLiveStatus } from '../utils/propertiesTorrentLifecycle';
import { isTorrentWaitingForPeers } from '../utils/torrentPresentation';
import { copyTorrentFilePath } from '../utils/torrentFilePath';
import { WindowControls } from './WindowControls';
import {
TORRENT_ENCRYPTION_POLICY_DISABLED,
@@ -1324,7 +1325,7 @@ export const PropertiesWindowApp = () => {
{connectionPresentation.showHeaderMetric && <div className="properties-metric-card"><Users size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{connectionHeaderLabel}</span>{connectionValue}</div></div>}
{isTorrent && <>
<div className="properties-metric-card"><Upload size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentUploaded)}</span><strong className="properties-metric-value">{formatDownloadBytes(snapshot.torrentUploadedBytes ?? 0)}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, 'en-US')}</strong></div></div>
<div className="properties-metric-card"><Activity size={14} /><div className="properties-metric-content"><span className="properties-metric-label">{t($ => $.properties.torrentRatio)}</span><strong className="properties-metric-value">{formatTorrentRatio(snapshot.torrentUploadedBytes ?? 0, snapshot.downloadedBytes ?? 0, snapshot.appearance.locale)}</strong></div></div>
</>}
</div>
<div className="properties-window-destination" title={snapshot.destination || undefined}><MapPin size={13} /><span>{snapshot.destination || '—'}</span></div>
@@ -1447,7 +1448,7 @@ export const PropertiesWindowApp = () => {
{activeTab === 'files' && isTorrent && <div className="space-y-3">
<div className="flex flex-wrap gap-2"><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { const all = fileProgress?.files.map(file => file.index) ?? []; setSelectedFiles(all); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionAll)}</button><button type="button" className="app-button px-3 text-xs" disabled={!fileSelectionEditingEnabled} onClick={() => { setSelectedFiles([]); setDraftTab('files'); }}>{t($ => $.properties.torrentFileSelectionClear)}</button><button type="button" className="app-button px-3 text-xs" aria-busy={diagnosticsLoading || diagnosticsRefreshing} onClick={() => downloadId && void refreshDiagnostics('files', downloadId, true)}><RefreshCw size={14} className={diagnosticsLoading || diagnosticsRefreshing ? 'animate-spin motion-reduce:animate-none' : undefined} />{t($ => $.properties.torrentFileProgressRefresh)}</button></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] truncate p-2" dir="auto">{file.relativePath}</td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
<div className="overflow-auto rounded-lg border border-border-modal"><table className="w-full min-w-[640px] text-xs" dir="ltr"><thead className="sticky top-0 bg-sidebar-bg text-left text-text-muted"><tr><th className="p-2">{t($ => $.properties.torrentFileProgressSelected)}</th><th className="p-2">#</th><th className="p-2">{t($ => $.properties.torrentFileProgressPath)}</th><th className="p-2">{t($ => $.properties.size)}</th><th className="p-2">{t($ => $.properties.torrentFileProgressCompleted)}</th></tr></thead><tbody>{fileProgress?.files.map(file => { const checked = selectedFiles === null ? file.selected : selectedFiles.includes(file.index); return <tr key={file.index} className="border-t border-border-modal/60"><td className="p-2"><input type="checkbox" checked={checked} disabled={!fileSelectionEditingEnabled} onChange={() => { const current = selectedFiles ?? fileProgress.files.filter(candidate => candidate.selected).map(candidate => candidate.index); const next = checked ? current.filter(index => index !== file.index) : [...current, file.index]; setSelectedFiles(next); setDraftTab('files'); }} aria-label={`${file.index} ${file.relativePath}`} /></td><td className="p-2">{file.index}</td><td className="max-w-[420px] p-2" dir="auto" title={file.relativePath}><div className="flex items-center gap-1.5 min-w-0"><span className="truncate flex-1 min-w-0">{file.relativePath}</span><button type="button" className="app-icon-button shrink-0 opacity-70 hover:opacity-100 focus-visible:opacity-100" aria-label={t($ => $.downloadTable.copyFilePath)} title={t($ => $.downloadTable.copyFilePath)} onClick={event => { event.preventDefault(); event.stopPropagation(); void copyTorrentFilePath(file.relativePath, writeClipboardText).then(() => setNotice(t($ => $.logs.copied))).catch(() => setErrorMessage(t($ => $.downloadTable.copyPathFailed))); }}><Copy size={12} aria-hidden="true" /></button></div></td><td className="p-2">{formatDownloadBytes(file.length)}</td><td className="properties-data-value p-2">{formatDownloadBytes(file.completedLength)} ({file.length ? Math.round(file.completedLength / file.length * 100) : 0}%)</td></tr>; })}</tbody></table></div>
{diagnosticPhase === 'initial' && diagnosticsLoading && !fileProgress && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressLoading)}</p>}
{diagnosticPhase === 'unavailable' && !fileProgress && !diagnosticError && <p className="text-xs text-text-muted">{t($ => $.properties.torrentFileProgressUnavailable)}</p>}
{diagnosticError && <p className="text-xs text-red-400" role="alert">{diagnosticError}</p>}
+23 -8
View File
@@ -205,6 +205,8 @@ export default function SchedulerView() {
variant: 'success'
});
} else {
useSettingsStore.getState().setSchedulerRunning(false);
useSettingsStore.getState().setSchedulerActiveDownloadIds([]);
addToast({ message: t($ => $.scheduler.noStartableDownloads), variant: 'info' });
}
};
@@ -213,17 +215,30 @@ export default function SchedulerView() {
const generation = beginSchedulerControl();
const savedQueueIds = savedSettings.selectedQueueIds
.filter(queueId => availableQueueIds.has(queueId));
const savedQueueSet = new Set(savedQueueIds);
const trackedIdsOutsideSavedQueues = useSettingsStore.getState().schedulerActiveDownloadIds
.filter(id => {
const queueId = useDownloadStore.getState().downloads.find(download => download.id === id)?.queueId || MAIN_QUEUE_ID;
return !savedQueueSet.has(queueId);
});
const targetQueueIds = new Set<string>([
...savedQueueIds,
...effectiveSelectedQueueIds
]);
const trackedDownloadIds = useSettingsStore.getState().schedulerActiveDownloadIds;
const downloads = useDownloadStore.getState().downloads;
for (const id of trackedDownloadIds) {
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
if (availableQueueIds.has(queueId)) {
targetQueueIds.add(queueId);
}
}
const targetQueueList = Array.from(targetQueueIds);
const targetQueueSet = new Set(targetQueueList);
const trackedIdsOutsideQueues = trackedDownloadIds.filter(id => {
const queueId = downloads.find(d => d.id === id)?.queueId || MAIN_QUEUE_ID;
return !targetQueueSet.has(queueId);
});
const counts = await Promise.all(
savedQueueIds.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
targetQueueList.map(queueId => useDownloadStore.getState().pauseQueue(queueId))
);
const directPauseResults = await Promise.allSettled(
trackedIdsOutsideSavedQueues.map(id => useDownloadStore.getState().pauseDownload(id))
trackedIdsOutsideQueues.map(id => useDownloadStore.getState().pauseDownload(id))
);
if (!isSchedulerControlCurrent(generation)) return;
const count = counts.reduce((total, queueCount) => total + queueCount, 0)
+47 -9
View File
@@ -466,6 +466,12 @@ const engineRunId = useRef(0);
const [maxConcurrentDownloadsInput, setMaxConcurrentDownloadsInput] = useState(
() => String(settings.maxConcurrentDownloads)
);
const [maxAutomaticRetriesInput, setMaxAutomaticRetriesInput] = useState(
() => String(settings.maxAutomaticRetries)
);
const [minimumNormalDownloadSpeedKiBInput, setMinimumNormalDownloadSpeedKiBInput] = useState(
() => String(settings.minimumNormalDownloadSpeedKiB)
);
const [proxyPortInput, setProxyPortInput] = useState(() => String(settings.proxyPort));
const [torrentMaxOpenFilesInput, setTorrentMaxOpenFilesInput] = useState(
() => String(settings.torrentMaxOpenFiles)
@@ -490,6 +496,14 @@ const engineRunId = useRef(0);
setMaxConcurrentDownloadsInput(String(settings.maxConcurrentDownloads));
}, [settings.maxConcurrentDownloads]);
useEffect(() => {
setMaxAutomaticRetriesInput(String(settings.maxAutomaticRetries));
}, [settings.maxAutomaticRetries]);
useEffect(() => {
setMinimumNormalDownloadSpeedKiBInput(String(settings.minimumNormalDownloadSpeedKiB));
}, [settings.minimumNormalDownloadSpeedKiB]);
useEffect(() => {
setProxyPortInput(String(settings.proxyPort));
}, [settings.proxyPort]);
@@ -1068,14 +1082,24 @@ runEngineChecks(false);
</div>
<input
type="number" min="0" max="10"
value={settings.maxAutomaticRetries}
onChange={(e) => settings.setMaxAutomaticRetries(Number(e.target.value))}
onBlur={(e) => {
const val = Number(e.target.value);
if (val < 0) settings.setMaxAutomaticRetries(0);
if (val > 10) settings.setMaxAutomaticRetries(10);
value={maxAutomaticRetriesInput}
onChange={(e) => {
const value = e.target.value;
setMaxAutomaticRetriesInput(value);
if (value !== '' && Number.isFinite(Number(value))) {
settings.setMaxAutomaticRetries(Number(value));
}
}}
onBlur={(e) => commitBoundedIntegerInput(
e.target.value,
settings.maxAutomaticRetries,
0,
10,
settings.setMaxAutomaticRetries,
setMaxAutomaticRetriesInput
)}
className="app-control w-24 text-center"
aria-label={t($ => $.settings.downloads.automaticRetries)}
/>
</div>
<div className="mac-settings-row">
@@ -1085,8 +1109,22 @@ runEngineChecks(false);
</div>
<input
type="number" min="0" max="1048576"
value={settings.minimumNormalDownloadSpeedKiB}
onChange={(event) => settings.setMinimumNormalDownloadSpeedKiB(Number(event.target.value))}
value={minimumNormalDownloadSpeedKiBInput}
onChange={(event) => {
const value = event.target.value;
setMinimumNormalDownloadSpeedKiBInput(value);
if (value !== '' && Number.isFinite(Number(value))) {
settings.setMinimumNormalDownloadSpeedKiB(Number(value));
}
}}
onBlur={(event) => commitBoundedIntegerInput(
event.target.value,
settings.minimumNormalDownloadSpeedKiB,
0,
1048576,
settings.setMinimumNormalDownloadSpeedKiB,
setMinimumNormalDownloadSpeedKiBInput
)}
className="app-control w-24 text-center"
aria-label={t($ => $.settings.downloads.minimumNormalDownloadSpeed)}
/>
@@ -2260,7 +2298,7 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled
}}
className="w-full bg-item-hover hover:bg-item-hover/80 text-text-primary border border-border-modal font-medium py-1 px-2 rounded text-[11px] flex items-center justify-center gap-1 transition-colors"
>
<RefreshCw size={11} /> Regenerate
<RefreshCw size={11} /> {t($ => $.settings.integrations.regenerateToken)}
</button>
</div>
</div>
+11 -2
View File
@@ -21,10 +21,11 @@ interface SidebarProps {
selectedFilter: SidebarFilter;
onToggleSidebar?: () => void;
onSelectFilter: (filter: SidebarFilter) => void;
toggleButtonRef?: React.Ref<HTMLButtonElement>;
}
export const Sidebar: React.FC<SidebarProps> = (props) => {
const { selectedFilter, onToggleSidebar, onSelectFilter } = props;
const { selectedFilter, onToggleSidebar, onSelectFilter, toggleButtonRef } = props;
const { downloads, queues, addQueue, renameQueue, removeQueue, startQueue, pauseQueue, setQueueConcurrency } = useDownloadStore();
const {
activeView,
@@ -116,7 +117,11 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
useEffect(() => {
const handleCloseMenu = () => setContextMenu(null);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') setContextMenu(null);
if (event.key === 'Escape' && contextMenuRef.current) {
event.preventDefault();
event.stopPropagation();
setContextMenu(null);
}
};
window.addEventListener('click', handleCloseMenu);
window.addEventListener('keydown', handleEscape);
@@ -388,6 +393,10 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
<button
type="button"
ref={toggleButtonRef}
data-tauri-drag-region="false"
onPointerDown={event => event.stopPropagation()}
onMouseDown={event => event.stopPropagation()}
onClick={onToggleSidebar ?? toggleSidebar}
className="sidebar-toggle-button"
title={t($ => $.actions.hideSidebar)}
+1
View File
@@ -1121,6 +1121,7 @@ const common = {
tokenCopied: 'Token copied to clipboard!',
tokenCopyFailed: 'Could not copy token: {{detail}}',
pairingTokenRegenerated: 'Pairing token regenerated',
regenerateToken: 'Regenerate Token',
regenerateFailed: 'Could not regenerate pairing token: {{detail}}',
getExtension: 'Get Extension',
extensionDescription: 'Install Firelink Companion for Firefox or Chromium browsers.',
+1
View File
@@ -1121,6 +1121,7 @@ const fa = {
tokenCopied: 'توکن در کلیپ‌بورد کپی شد!',
tokenCopyFailed: 'توکن کپی نشد: {{detail}}',
pairingTokenRegenerated: 'توکن جفت‌سازی دوباره ایجاد شد',
regenerateToken: 'ایجاد دوباره توکن',
regenerateFailed: 'ایجاد دوباره توکن جفت‌سازی ناموفق بود: {{detail}}',
getExtension: 'دریافت افزونه',
extensionDescription: 'Firelink Companion را برای مرورگرهای Firefox یا Chromium نصب کنید.',
+1
View File
@@ -1121,6 +1121,7 @@ const he = {
tokenCopied: 'האסימון הועתק ללוח!',
tokenCopyFailed: 'לא ניתן להעתיק את האסימון: {{detail}}',
pairingTokenRegenerated: 'אסימון הצימוד נוצר מחדש',
regenerateToken: 'צור אסימון מחדש',
regenerateFailed: 'לא ניתן ליצור מחדש אסימון צימוד: {{detail}}',
getExtension: 'קבלת התוסף',
extensionDescription: 'התקן את Firelink Companion עבור דפדפני Firefox או Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const ru = {
tokenCopied: 'Токен скопирован в буфер обмена!',
tokenCopyFailed: 'Не удалось скопировать токен: {{detail}}',
pairingTokenRegenerated: 'Токен сопряжения сгенерирован заново',
regenerateToken: 'Сгенерировать токен заново',
regenerateFailed: 'Не удалось сгенерировать токен сопряжения заново: {{detail}}',
getExtension: 'Получить расширение',
extensionDescription: 'Установите Firelink Companion для браузеров Firefox или Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const uk = {
tokenCopied: 'Токен скопійовано в буфер обміну!',
tokenCopyFailed: 'Не вдалося скопіювати токен: {{detail}}',
pairingTokenRegenerated: 'Токен підключення згенеровано наново',
regenerateToken: 'Згенерувати токен наново',
regenerateFailed: 'Не вдалося згенерувати токен підключення наново: {{detail}}',
getExtension: 'Отримати розширення',
extensionDescription: 'Встановіть Firelink Companion для браузерів Firefox або Chromium.',
+1
View File
@@ -1121,6 +1121,7 @@ const zhCN = {
tokenCopied: '令牌已复制到剪贴板!',
tokenCopyFailed: '无法复制令牌:{{detail}}',
pairingTokenRegenerated: '配对令牌已重新生成',
regenerateToken: '重新生成令牌',
regenerateFailed: '无法重新生成配对令牌:{{detail}}',
getExtension: '获取扩展',
extensionDescription: '安装适用于 Firefox 或 Chromium 浏览器的 Firelink Companion。',
+1
View File
@@ -2701,6 +2701,7 @@ html[data-list-density="relaxed"] {
color: hsl(var(--text-secondary));
background: transparent;
-webkit-app-region: no-drag;
app-region: no-drag;
}
.sidebar-toggle-button:hover {
+1
View File
@@ -154,6 +154,7 @@ type CommandMap = {
get_file_category: { args: { filename: string }; result: DownloadCategory };
check_for_updates: { args: undefined; result: ReleaseCheckOutcome };
get_supported_media_domains: { args: undefined; result: string[] };
is_supported_media: { args: { url: string }; result: boolean };
db_save_settings: { args: { data: string }; result: void };
db_load_settings: { args: undefined; result: string | null };
canonicalize_torrent_network_setting: {
+52
View File
@@ -641,4 +641,56 @@ describe('Properties window bridge', () => {
expect(assigned).toBe(unlisten);
expect(unlisten).not.toHaveBeenCalled();
});
it('strictly validates numeric boundaries, speed limits, and tracker syntax in patch copies', () => {
const baseItem = { isTorrent: false, status: 'ready' as const };
const torrentItem = { isTorrent: true, status: 'paused' as const };
// Connections bounds (1 to 16, whole numbers)
expect(() => copyEditablePropertiesPatch({ connections: 0 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: 17 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: 1.5 }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(() => copyEditablePropertiesPatch({ connections: Number.NaN }, baseItem)).toThrow('Connections must be a whole number from 1 to 16');
expect(copyEditablePropertiesPatch({ connections: 1 }, baseItem)).toMatchObject({ connections: 1 });
expect(copyEditablePropertiesPatch({ connections: 16 }, baseItem)).toMatchObject({ connections: 16 });
// Torrent max peers bounds (0 to 1000, whole numbers)
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: -1 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 1001 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(() => copyEditablePropertiesPatch({ torrentMaxPeers: 10.5 }, torrentItem)).toThrow('Torrent maximum peers must be a whole number from 0 to 1000');
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 0 }, torrentItem)).toMatchObject({ torrentMaxPeers: 0 });
expect(copyEditablePropertiesPatch({ torrentMaxPeers: 1000 }, torrentItem)).toMatchObject({ torrentMaxPeers: 1000 });
// Speed limit normalization and invalid formats
expect(() => copyEditablePropertiesPatch({ speedLimit: 'invalid' }, baseItem)).toThrow('Invalid download speed limit');
expect(() => copyEditablePropertiesPatch({ speedLimit: '0M' }, baseItem)).toThrow('Invalid download speed limit');
expect(() => copyEditablePropertiesPatch({ speedLimit: '-5M' }, baseItem)).toThrow('Invalid download speed limit');
expect(copyEditablePropertiesPatch({ speedLimit: '2M' }, baseItem)).toMatchObject({ speedLimit: '2M' });
expect(copyEditablePropertiesPatch({ speedLimit: ' 500K ' }, baseItem)).toMatchObject({ speedLimit: '500K' });
expect(copyEditablePropertiesPatch({ speedLimit: '' }, baseItem).speedLimit).toBeUndefined();
// Torrent seed settings
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: -1 }, torrentItem)).toThrow('Invalid torrentSeedTime');
expect(() => copyEditablePropertiesPatch({ torrentSeedTime: Number.NaN }, torrentItem)).toThrow('Invalid torrentSeedTime');
expect(() => copyEditablePropertiesPatch({ torrentSeedRatio: -0.1 }, torrentItem)).toThrow('Invalid torrentSeedRatio');
expect(copyEditablePropertiesPatch({ torrentSeedTime: 0 }, torrentItem)).toMatchObject({ torrentSeedTime: 0 });
expect(copyEditablePropertiesPatch({ torrentSeedRatio: 1.5 }, torrentItem)).toMatchObject({ torrentSeedRatio: 1.5 });
// Torrent stop timeout
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: -1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
expect(() => copyEditablePropertiesPatch({ torrentStopTimeout: 7 * 24 * 60 * 60 + 1 }, torrentItem)).toThrow('Invalid torrentStopTimeout');
expect(copyEditablePropertiesPatch({ torrentStopTimeout: 3600 }, torrentItem)).toMatchObject({ torrentStopTimeout: 3600 });
// Torrent trackers validation
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'not-a-url' }, torrentItem)).toThrow('Invalid Torrent tracker list');
expect(() => copyEditablePropertiesPatch({ torrentTrackers: 'ftp://unsupported.tracker/announce' }, torrentItem)).toThrow('Invalid Torrent tracker list');
expect(copyEditablePropertiesPatch({ torrentTrackers: 'https://tracker.example/announce' }, torrentItem))
.toMatchObject({ torrentTrackers: 'https://tracker.example/announce' });
// Torrent policies
expect(() => copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentEncryptionPolicy');
expect(() => copyEditablePropertiesPatch({ torrentFileAllocation: 'invalid' as any }, torrentItem)).toThrow('Invalid torrentFileAllocation');
expect(copyEditablePropertiesPatch({ torrentEncryptionPolicy: 'require-crypto' }, torrentItem)).toMatchObject({ torrentEncryptionPolicy: 'require-crypto' });
expect(copyEditablePropertiesPatch({ torrentFileAllocation: 'prealloc' }, torrentItem)).toMatchObject({ torrentFileAllocation: 'prealloc' });
});
});
+21
View File
@@ -331,6 +331,27 @@ describe('useDownloadStore', () => {
expect(state.pendingAddRequestContexts['https://example.com/file.bin']?.media).toBe(false);
});
it('discards legacy cookies and sensitive headers for explicit media and media domains', () => {
useDownloadStore.getState().toggleAddModal(false);
useDownloadStore.getState().openAddModalWithUrls(
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://www.youtube.com',
'video.mp4',
'Authorization: Bearer secret\nUser-Agent: FirelinkTest',
'session=leak',
true,
[{ url: 'https://www.youtube.com', cookies: 'session=leak' }]
);
const state = useDownloadStore.getState();
expect(state.pendingAddCookies).toBe('');
const context = state.pendingAddRequestContexts['https://www.youtube.com/watch?v=dQw4w9WgXcQ'];
expect(context?.cookies).toBe('');
expect(context?.cookieScopes).toBeUndefined();
expect(context?.headers).toBe('User-Agent: FirelinkTest');
expect(context?.media).toBe(true);
});
it('replaces a paused download URL in place and preserves its progress', async () => {
useDownloadStore.setState({
downloads: [{
+20 -14
View File
@@ -11,7 +11,7 @@ import type { ExtensionCookieScope } from '../bindings/ExtensionCookieScope';
import type { Queue } from '../bindings/Queue';
import { useSettingsStore } from './useSettingsStore';
import { useDownloadProgressStore } from './downloadProgressStore';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import { canonicalizeDownloadFileName, categoryForDownload, categoryForFileName, hasCredentialBearingHeaders, headerNameHasCredentialMaterial, headersWithoutCredentialMaterial, isActiveDownloadStatus, isMediaUrl, isTransferActiveStatus, isValidTorrentExcludeTrackerList, isValidTorrentTrackerList, MAX_TORRENT_STOP_TIMEOUT, normalizeSpeedLimitForBackend, normalizeTorrentEncryptionPolicy, normalizeTorrentFileAllocation, normalizeTorrentPrioritizePiece, normalizeTorrentTrackerInterval, normalizeTorrentTrackerTimeout, redactDownloadForPersistence, resolveDownloadConnections } from '../utils/downloads';
import {
resolveCategoryDestination
} from '../utils/downloadLocations';
@@ -1844,8 +1844,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const mergedUrls = existingUrls ? `${existingUrls}\n${urls}` : urls;
const cleanReferer = referer?.trim() || '';
const cleanFilename = filename?.trim() || '';
const cleanHeaders = headers?.trim() || '';
const cleanCookies = cookies?.trim() || '';
const isExplicitMedia = media === true;
const cleanHeaders = isExplicitMedia
? stripSensitiveMediaHeaders(headers)
: (headers?.trim() || '');
const cleanCookies = isExplicitMedia ? '' : (cookies?.trim() || '');
// Keep the first modal request's grouping decision stable while later
// handoffs append URLs. This avoids moving an already-visible destination
// when a second request races with the user's Add-window setup.
@@ -1853,12 +1856,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const nextBatchName = nextBatch
? (isAppending ? state.pendingAddBatchName : batchName?.trim() || '')
: '';
const cleanCookieScopes = cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const cleanCookieScopes = isExplicitMedia
? undefined
: cookieScopes
?.map(scope => ({
url: scope.url.trim(),
cookies: scope.cookies.trim()
}))
.filter(scope => scope.url && scope.cookies);
const requestVersion = state.pendingAddRequestVersion + 1;
const pendingAddRequestContexts = isAppending
? { ...state.pendingAddRequestContexts }
@@ -1876,14 +1881,15 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
} catch {
// The Add modal will mark malformed input invalid; retain its original key here.
}
const isItemMedia = isExplicitMedia || isMediaUrl(trimmedUrl);
pendingAddRequestContexts[key] = {
version: requestVersion,
referer: cleanReferer,
filename: cleanFilename,
headers: cleanHeaders,
cookies: cleanCookies,
...(cleanCookieScopes?.length ? { cookieScopes: cleanCookieScopes } : {}),
media,
headers: isItemMedia ? stripSensitiveMediaHeaders(cleanHeaders) : cleanHeaders,
cookies: isItemMedia ? '' : cleanCookies,
...(cleanCookieScopes?.length && !isItemMedia ? { cookieScopes: cleanCookieScopes } : {}),
media: isItemMedia,
...(torrent ? { torrent: true } : {})
};
}
@@ -1899,7 +1905,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
pendingAddReferer: cleanReferer,
pendingAddFilename: cleanFilename,
pendingAddHeaders: cleanHeaders,
pendingAddCookies: cleanCookies,
pendingAddCookies: isExplicitMedia ? '' : cleanCookies,
pendingAddMediaUrls,
pendingAddTorrentUrls,
pendingAddBatch: nextBatch,
+58
View File
@@ -91,6 +91,64 @@ describe('durable main-window and sidebar preferences', () => {
});
});
it('pins volatile navigation and sanitizes scheduler and cookie source during hydration', () => {
const merge = useSettingsStore.persist.getOptions().merge;
expect(merge).toBeTypeOf('function');
const current = useSettingsStore.getState();
const result = merge?.({
activeView: 'logs' as any,
showKeychainModal: true as any,
mediaCookieSource: 'internet-explorer' as any,
lastCustomSpeedLimitUnit: 'GB/s',
scheduler: {
enabled: 'yes' as any,
postQueueAction: 'self-destruct' as any,
selectedDays: [10, -1, 3],
selectedQueueIds: [' ', 'queue-1']
},
schedulerRunning: 'running' as any,
schedulerActiveDownloadIds: 'all' as any
}, current);
expect(result?.activeView).toBe(current.activeView);
expect(result?.showKeychainModal).toBe(false);
expect(result?.mediaCookieSource).toBe('none');
expect(result?.lastCustomSpeedLimitUnit).toBe(current.lastCustomSpeedLimitUnit);
expect(result?.scheduler.enabled).toBe(current.scheduler.enabled);
expect(result?.scheduler.postQueueAction).toBe('none');
expect(result?.scheduler.selectedDays).toEqual([3]);
expect(result?.scheduler.selectedQueueIds).toEqual(['queue-1']);
expect(result?.schedulerRunning).toBe(current.schedulerRunning);
expect(result?.schedulerActiveDownloadIds).toEqual(current.schedulerActiveDownloadIds);
const fallbackResult = merge?.({
scheduler: {
selectedDays: [-5, 99] as any,
selectedQueueIds: [' ', ''] as any
}
}, current);
expect(fallbackResult?.scheduler.selectedDays).toEqual(current.scheduler.selectedDays);
expect(fallbackResult?.scheduler.selectedQueueIds).toEqual(current.scheduler.selectedQueueIds);
});
it('sanitizes setter calls for enum and numeric settings', () => {
useSettingsStore.getState().setMediaCookieSource('invalid-browser' as any);
expect(useSettingsStore.getState().mediaCookieSource).toBe('none');
useSettingsStore.getState().setTheme('invalid-theme' as any);
expect(useSettingsStore.getState().theme).toBe('system');
useSettingsStore.getState().setLastCustomSpeedLimitKiB(-500);
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(1);
useSettingsStore.getState().setLastCustomSpeedLimitKiB(20_000_000);
expect(useSettingsStore.getState().lastCustomSpeedLimitKiB).toBe(10_485_760);
useSettingsStore.getState().setLastCustomSpeedLimitUnit('PB/s' as any);
expect(useSettingsStore.getState().lastCustomSpeedLimitUnit).toBe('MB/s');
});
it('uses the legacy localStorage value only when durable state is absent', () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
+80 -18
View File
@@ -126,6 +126,7 @@ const MEDIA_COOKIE_SOURCE_VALUES = [
const SETTINGS_TAB_VALUES = [
'downloads', 'lookandfeel', 'network', 'locations', 'sitelogins', 'power', 'engine', 'integrations', 'about'
] as const;
const POST_QUEUE_ACTION_VALUES = ['none', 'sleep', 'restart', 'shutdown'] as const;
type PersistedSettingsSnapshot = PersistedSettings & {
language: AppLocalePreference;
@@ -498,20 +499,31 @@ export const useSettingsStore = create<SettingsState>()(
autoCheckUpdates: true,
showKeychainModal: false,
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
setTheme: (theme) => {
const safe = isAllowedSetting(THEME_VALUES, theme) ? theme : 'system';
info('Settings updated: theme');
set({ theme: safe });
},
setFontFamily: (fontFamily) => {
const safe = isAllowedSetting(FONT_FAMILY_VALUES, fontFamily) ? fontFamily : 'system';
info('Settings updated: fontFamily');
set({ fontFamily });
set({ fontFamily: safe });
},
setWindowControlStyle: (windowControlStyle) => {
const safe = isAllowedSetting(WINDOW_CONTROL_STYLE_VALUES, windowControlStyle) ? windowControlStyle : 'auto';
info('Settings updated: windowControlStyle');
set({ windowControlStyle });
set({ windowControlStyle: safe });
},
setCalendarPreference: (calendarPreference) => {
const safe = isCalendarPreference(calendarPreference) ? calendarPreference : DEFAULT_CALENDAR_PREFERENCE;
info('Settings updated: calendarPreference');
set({ calendarPreference });
set({ calendarPreference: safe });
},
setLanguage: (language) => {
const safe = isAppLocalePreference(language) ? language : 'system';
info('Settings updated: language');
set({ language: safe });
},
setLanguage: (language) => { info('Settings updated: language'); set({ language }); },
setBaseDownloadFolder: (path) => {
info('Settings updated: baseDownloadFolder');
set({ baseDownloadFolder: path });
@@ -561,7 +573,10 @@ export const useSettingsStore = create<SettingsState>()(
},
setSpeedLimitPresetValues: (speedLimitPresetValues) => set({ speedLimitPresetValues }),
setLogsEnabled: (logsEnabled) => set({ logsEnabled }),
setSidebarPosition: (sidebarPosition) => set({ sidebarPosition }),
setSidebarPosition: (sidebarPosition) => {
const safe = isAllowedSetting(SIDEBAR_POSITION_VALUES, sidebarPosition) ? sidebarPosition : 'auto';
set({ sidebarPosition: safe });
},
setFoldersCollapsed: (isFoldersCollapsed) => set({ isFoldersCollapsed }),
toggleFoldersCollapsed: () => set(state => ({ isFoldersCollapsed: !state.isFoldersCollapsed })),
setMainWindowSize: (size) => {
@@ -569,14 +584,23 @@ export const useSettingsStore = create<SettingsState>()(
if (normalized) set({ mainWindowSize: normalized });
},
setActiveView: (view) => set({ activeView: view }),
setActiveSettingsTab: (activeSettingsTab) => set({ activeSettingsTab }),
setActiveSettingsTab: (activeSettingsTab) => {
const safe = isAllowedSetting(SETTINGS_TAB_VALUES, activeSettingsTab) ? activeSettingsTab : 'downloads';
set({ activeSettingsTab: safe });
},
setScheduler: (scheduler) => set({ scheduler }),
setSchedulerRunning: (schedulerRunning) => set({ schedulerRunning }),
setSchedulerActiveDownloadIds: (schedulerActiveDownloadIds) => set({ schedulerActiveDownloadIds }),
setSchedulerLastStartKey: (schedulerLastStartKey) => set({ schedulerLastStartKey }),
setSchedulerLastStopKey: (schedulerLastStopKey) => set({ schedulerLastStopKey }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({ lastCustomSpeedLimitKiB }),
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({ lastCustomSpeedLimitUnit }),
setLastCustomSpeedLimitKiB: (lastCustomSpeedLimitKiB) => set({
lastCustomSpeedLimitKiB: clampSettingInteger(lastCustomSpeedLimitKiB, 1, 10_485_760, 1024)
}),
setLastCustomSpeedLimitUnit: (lastCustomSpeedLimitUnit) => set({
lastCustomSpeedLimitUnit: lastCustomSpeedLimitUnit === 'KB/s' || lastCustomSpeedLimitUnit === 'MB/s'
? lastCustomSpeedLimitUnit
: 'MB/s'
}),
toggleSidebar: () => set((state) => ({ isSidebarVisible: !state.isSidebarVisible })),
setPerServerConnections: (perServerConnections) => set({
@@ -598,8 +622,14 @@ 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 }),
setAppFontSize: (appFontSize) => {
const safe = isAllowedSetting(APP_FONT_SIZE_VALUES, appFontSize) ? appFontSize : 'standard';
set({ appFontSize: safe });
},
setListRowDensity: (listRowDensity) => {
const safe = isAllowedSetting(LIST_ROW_DENSITY_VALUES, listRowDensity) ? listRowDensity : 'standard';
set({ listRowDensity: safe });
},
setShowDockBadge: (showDockBadge) => {
set(state => ({
showDockBadge,
@@ -607,7 +637,10 @@ export const useSettingsStore = create<SettingsState>()(
}));
},
setShowMenuBarIcon: (showMenuBarIcon) => set({ showMenuBarIcon }),
setProxyMode: (proxyMode) => set({ proxyMode }),
setProxyMode: (proxyMode) => {
const safe = isAllowedSetting(PROXY_MODE_VALUES, proxyMode) ? proxyMode : 'none';
set({ proxyMode: safe });
},
setProxyHost: (proxyHost) => set({ proxyHost }),
setProxyPort: (proxyPort) => set({
proxyPort: Number.isFinite(proxyPort)
@@ -687,7 +720,11 @@ export const useSettingsStore = create<SettingsState>()(
info('Settings updated: preventsDisplaySleepWhileDownloading');
set({ preventsDisplaySleepWhileDownloading });
},
setMediaCookieSource: (mediaCookieSource) => { info('Settings updated: mediaCookieSource'); set({ mediaCookieSource }); },
setMediaCookieSource: (mediaCookieSource) => {
const safe = isAllowedSetting(MEDIA_COOKIE_SOURCE_VALUES, mediaCookieSource) ? mediaCookieSource : 'none';
info('Settings updated: mediaCookieSource');
set({ mediaCookieSource: safe });
},
setRememberLastUsedDownloadDirectory: (rememberLastUsedDownloadDirectory) => {
info('Settings updated: rememberLastUsedDownloadDirectory');
set({
@@ -924,6 +961,9 @@ export const useSettingsStore = create<SettingsState>()(
// Never hydrate the remembered Add-window path from persisted data.
lastUsedDownloadDirectory: currentState.lastUsedDownloadDirectory,
keychainAccessReady: currentState.keychainAccessReady,
activeView: currentState.activeView,
showKeychainModal: currentState.showKeychainModal,
dockBadgeSyncVersion: currentState.dockBadgeSyncVersion,
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
? persisted.theme
: currentState.theme,
@@ -1118,12 +1158,34 @@ export const useSettingsStore = create<SettingsState>()(
: currentState.approvedDownloadRoots,
scheduler: {
...currentState.scheduler,
...persisted.scheduler,
selectedQueueIds: Array.isArray(persisted.scheduler?.selectedQueueIds)
&& persisted.scheduler.selectedQueueIds.length > 0
? persisted.scheduler.selectedQueueIds
: currentState.scheduler.selectedQueueIds
...(isRecord(persisted.scheduler) ? persisted.scheduler : {}),
enabled: persistedBoolean(persisted.scheduler?.enabled, currentState.scheduler.enabled),
startTime: persistedString(persisted.scheduler?.startTime, currentState.scheduler.startTime),
stopTimeEnabled: persistedBoolean(persisted.scheduler?.stopTimeEnabled, currentState.scheduler.stopTimeEnabled),
stopTime: persistedString(persisted.scheduler?.stopTime, currentState.scheduler.stopTime),
everyday: persistedBoolean(persisted.scheduler?.everyday, currentState.scheduler.everyday),
selectedDays: (() => {
const days = Array.isArray(persisted.scheduler?.selectedDays)
? persisted.scheduler.selectedDays.filter((day): day is number => typeof day === 'number' && Number.isInteger(day) && day >= 0 && day <= 6)
: [];
return days.length > 0 ? days : currentState.scheduler.selectedDays;
})(),
selectedQueueIds: (() => {
const queues = Array.isArray(persisted.scheduler?.selectedQueueIds)
? persisted.scheduler.selectedQueueIds.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)
: [];
return queues.length > 0 ? queues : currentState.scheduler.selectedQueueIds;
})(),
postQueueAction: isAllowedSetting(POST_QUEUE_ACTION_VALUES, persisted.scheduler?.postQueueAction)
? persisted.scheduler.postQueueAction
: currentState.scheduler.postQueueAction
},
schedulerRunning: persistedBoolean(persisted.schedulerRunning, currentState.schedulerRunning),
schedulerActiveDownloadIds: Array.isArray(persisted.schedulerActiveDownloadIds)
? persisted.schedulerActiveDownloadIds.filter((id): id is string => typeof id === 'string')
: currentState.schedulerActiveDownloadIds,
schedulerLastStartKey: persistedString(persisted.schedulerLastStartKey, currentState.schedulerLastStartKey),
schedulerLastStopKey: persistedString(persisted.schedulerLastStopKey, currentState.schedulerLastStopKey),
siteLogins: Array.isArray(persisted.siteLogins)
? sanitizeSiteLogins(persisted.siteLogins)
: currentState.siteLogins
+27
View File
@@ -336,6 +336,33 @@ describe('add download metadata workflow', () => {
expect(rows.some(item => item.isPlaylist)).toBe(false);
});
it('retains the playlist row when expansion has no valid video entries', () => {
const playlistUrl = 'https://www.youtube.com/playlist?list=PL_EMPTY';
const rows = reconcileDownloadRows(
playlistUrl,
[],
undefined,
new Set(),
undefined,
{},
{},
{
[playlistUrl]: {
title: 'Empty playlist',
playlist_id: 'PL_EMPTY',
entry_count: 0,
skipped_entries: 0,
truncated: false,
entries: []
}
}
);
expect(rows).toHaveLength(1);
expect(rows[0].sourceUrl).toBe(playlistUrl);
expect(rows[0].isPlaylist).toBe(true);
});
it('forces explicit extension media fetches through media metadata for any http page', () => {
const rows = reconcileDownloadRows(
'https://adult.example/watch/123',
+5 -1
View File
@@ -212,6 +212,7 @@ const parseInputLines = (
const expansion = playlistExpansions[sourceUrl];
if (expansion) {
const playlistSelected = selectedBySourceUrl[sourceUrl] !== false;
let validEntryCount = 0;
for (const [position, entry] of expansion.entries.entries()) {
let entryUrl: string;
try {
@@ -221,6 +222,7 @@ const parseInputLines = (
} catch {
continue;
}
validEntryCount++;
if (seen.has(entryUrl)) continue;
seen.add(entryUrl);
parsed.push({
@@ -239,7 +241,9 @@ const parseInputLines = (
// The playlist has been successfully discovered even when every
// entry was already represented by another input row. Do not put the
// source playlist back into loading state in that case.
continue;
if (validEntryCount > 0) {
continue;
}
}
}
+21
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
formatDownloadBytes,
formatDownloadTotal,
formatTorrentRatio,
resolveDownloadFraction,
resolveDownloadSizeDisplay,
} from './downloadProgress';
@@ -108,3 +109,23 @@ describe('resolveDownloadFraction', () => {
})).toBe(1);
});
});
describe('formatTorrentRatio', () => {
it('formats positive torrent ratio with two decimal places', () => {
expect(formatTorrentRatio(1500, 1000, 'en-US')).toBe('1.50');
expect(formatTorrentRatio(2345, 1000, 'en-US')).toBe('2.35');
});
it('formats torrent ratio using the specified locale', () => {
const formatted = formatTorrentRatio(1500, 1000, 'fa');
expect(formatted).toBe(new Intl.NumberFormat('fa', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(1.5));
});
it('returns a dash when ratio cannot be calculated', () => {
expect(formatTorrentRatio(0, 0, 'en-US')).toBe('—');
expect(formatTorrentRatio(-10, 1000, 'en-US')).toBe('—');
expect(formatTorrentRatio(1000, -10, 'en-US')).toBe('—');
expect(formatTorrentRatio(1000, 0, 'en-US')).toBe('—');
expect(formatTorrentRatio(Number.NaN, 1000, 'en-US')).toBe('—');
});
});
+60
View File
@@ -57,4 +57,64 @@ describe('download table sorting', () => {
expect(persisted.speed).toBeUndefined();
expect(persisted.eta).toBeUndefined();
});
it('parses estimated sizes with ~ or ≈ prefixes and sorts them accurately', () => {
expect(parseDownloadSize('~1.2 GB')).toBe(1.2 * 1024 ** 3);
expect(parseDownloadSize('~ 500 MB')).toBe(500 * 1024 ** 2);
expect(parseDownloadSize('≈250 KB')).toBe(250 * 1024);
expect(sortedIds([
item('missing', { size: '-' }),
item('est-1g', { size: '~1 GB' }),
item('final-500m', { size: '500 MB' }),
item('exact-bytes', { totalBytes: 2 * 1024 ** 3 }),
], { column: 'Size', direction: 'asc' })).toEqual([
'final-500m',
'est-1g',
'exact-bytes',
'missing'
]);
});
it('keeps unknown and missing values last in descending sort for Size, Speed, ETA, and Date Added', () => {
expect(sortedIds([
item('unknown-size', { size: '-' }),
item('small-size', { size: '100 MB' }),
item('large-size', { size: '1 GB' }),
], { column: 'Size', direction: 'desc' })).toEqual([
'large-size',
'small-size',
'unknown-size'
]);
expect(sortedIds([
item('unknown-speed', { speed: '-' }),
item('fast-speed', { speed: '10 MB/s' }),
item('slow-speed', { speed: '1 MB/s' }),
], { column: 'Speed', direction: 'desc' })).toEqual([
'fast-speed',
'slow-speed',
'unknown-speed'
]);
expect(sortedIds([
item('unknown-eta', { eta: '-' }),
item('long-eta', { eta: '2h' }),
item('short-eta', { eta: '5m' }),
], { column: 'ETA', direction: 'desc' })).toEqual([
'long-eta',
'short-eta',
'unknown-eta'
]);
expect(sortedIds([
item('unknown-date', { dateAdded: '' }),
item('newer-date', { dateAdded: '2026-08-01T00:00:00.000Z' }),
item('older-date', { dateAdded: '2026-01-01T00:00:00.000Z' }),
], { column: 'Date Added', direction: 'desc' })).toEqual([
'newer-date',
'older-date',
'unknown-date'
]);
});
});
+31 -11
View File
@@ -21,7 +21,11 @@ const SIZE_UNITS: Record<string, number> = {
};
const valueOrNull = (value?: string): string | null => {
const trimmed = value?.trim();
let trimmed = value?.trim();
if (!trimmed) return null;
if (trimmed.startsWith('~') || trimmed.startsWith('≈')) {
trimmed = trimmed.slice(1).trim();
}
return trimmed && trimmed !== '-' && !/^unknown$/i.test(trimmed) ? trimmed : null;
};
@@ -41,6 +45,16 @@ const parseUnitValue = (value?: string, units = SIZE_UNITS): number | null => {
export const parseDownloadSize = (value?: string): number | null => parseUnitValue(value);
export const resolveSortableSize = (download: DownloadItem): number | null => {
if (typeof download.totalBytes === 'number' && Number.isFinite(download.totalBytes) && download.totalBytes > 0) {
return download.totalBytes;
}
if (download.status === 'completed' && typeof download.downloadedBytes === 'number' && Number.isFinite(download.downloadedBytes) && download.downloadedBytes > 0) {
return download.downloadedBytes;
}
return parseDownloadSize(download.size);
};
export const parseDownloadSpeed = (value?: string): number | null =>
parseUnitValue(value, SIZE_UNITS);
@@ -65,12 +79,18 @@ export const parseDownloadEta = (value?: string): number | null => {
return matched && Number.isFinite(seconds) ? seconds : null;
};
const compareValues = (left: string | number | null, right: string | number | null): number => {
const compareValues = (
left: string | number | null,
right: string | number | null,
direction: DownloadSortDirection
): number => {
if (left === null && right === null) return 0;
if (left === null) return 1;
if (right === null) return -1;
if (typeof left === 'number' && typeof right === 'number') return left - right;
return String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
const comparison = typeof left === 'number' && typeof right === 'number'
? left - right
: String(left).localeCompare(String(right), undefined, { numeric: true, sensitivity: 'base' });
return direction === 'asc' ? comparison : -comparison;
};
const parseDownloadDate = (value?: string): number | null => {
@@ -84,27 +104,27 @@ export const sortDownloads = (downloads: DownloadItem[], config: DownloadSortCon
let comparison: number;
switch (config.column) {
case 'File Name':
comparison = compareValues(left.fileName || left.url, right.fileName || right.url);
comparison = compareValues(left.fileName || left.url, right.fileName || right.url, config.direction);
break;
case 'Size':
comparison = compareValues(parseDownloadSize(left.size), parseDownloadSize(right.size));
comparison = compareValues(resolveSortableSize(left), resolveSortableSize(right), config.direction);
break;
case 'Status':
comparison = compareValues(left.status, right.status);
comparison = compareValues(left.status, right.status, config.direction);
break;
case 'Speed':
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed));
comparison = compareValues(parseDownloadSpeed(left.speed), parseDownloadSpeed(right.speed), config.direction);
break;
case 'ETA':
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta));
comparison = compareValues(parseDownloadEta(left.eta), parseDownloadEta(right.eta), config.direction);
break;
case 'Date Added':
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded));
comparison = compareValues(parseDownloadDate(left.dateAdded), parseDownloadDate(right.dateAdded), config.direction);
break;
}
if (comparison === 0) comparison = left.id.localeCompare(right.id);
return config.direction === 'asc' ? comparison : -comparison;
return comparison;
});
return sorted;
};
+20 -1
View File
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest';
import {
beginSchedulerControl,
cancelPendingPostAction,
consumeSchedulerHandoffIds,
handoffSupersededSchedulerIds,
isSchedulerControlCurrent
isSchedulerControlCurrent,
registerPostActionCanceller
} from './schedulerControl';
describe('scheduler control generation', () => {
@@ -34,4 +36,21 @@ describe('scheduler control generation', () => {
expect(handoffSupersededSchedulerIds(['download-a'], () => 'queue-a')).toEqual(new Set());
expect(consumeSchedulerHandoffIds(pause)).toEqual(new Set());
});
it('cancels pending post actions when a new control generation begins', () => {
let cancelled = 0;
const unregister = registerPostActionCanceller(() => {
cancelled += 1;
});
beginSchedulerControl();
expect(cancelled).toBe(1);
cancelPendingPostAction();
expect(cancelled).toBe(2);
unregister();
beginSchedulerControl();
expect(cancelled).toBe(2);
});
});
+17 -1
View File
@@ -2,16 +2,32 @@ let schedulerControlGeneration = 0;
let latestRunQueueIds: ReadonlySet<string> | null = null;
const schedulerHandoffs = new Map<number, Set<string>>();
let postActionCanceller: (() => void) | null = null;
export const registerPostActionCanceller = (canceller: () => void): (() => void) => {
postActionCanceller = canceller;
return () => {
if (postActionCanceller === canceller) {
postActionCanceller = null;
}
};
};
export const cancelPendingPostAction = (): void => {
postActionCanceller?.();
};
/**
* Start a new scheduler control lifecycle. A later manual pause or scheduler
* event invalidates earlier asynchronous queue operations before they can
* publish stale running state.
* publish stale running state, and cancels any pending post-queue action.
*/
export const beginSchedulerControl = (runQueueIds?: readonly string[]): number => {
schedulerControlGeneration += 1;
latestRunQueueIds = runQueueIds ? new Set(runQueueIds) : null;
schedulerHandoffs.clear();
if (runQueueIds) schedulerHandoffs.set(schedulerControlGeneration, new Set());
postActionCanceller?.();
return schedulerControlGeneration;
};
+86
View File
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import {
resolveFallbackFilter,
shouldRestoreSidebarRevealFocus,
shouldRestoreSidebarToggleFocus,
} from './sidebarFocus';
describe('sidebar focus restoration', () => {
it('restores focus to reveal button when activeElement was inside the sidebar shell', () => {
const sidebarShell = {
contains: (el: unknown) => el === buttonInside,
};
const buttonInside = {
closest: (selector: string) => (selector === '.app-sidebar-shell' ? sidebarShell : null),
};
expect(shouldRestoreSidebarRevealFocus(buttonInside, sidebarShell)).toBe(true);
});
it('does not restore focus to reveal button when activeElement was outside the sidebar shell', () => {
const sidebarShell = {
contains: () => false,
};
const tableButton = {
closest: () => null,
};
expect(shouldRestoreSidebarRevealFocus(tableButton, sidebarShell)).toBe(false);
expect(shouldRestoreSidebarRevealFocus(null, sidebarShell)).toBe(false);
});
it('restores focus to sidebar toggle button when reveal button was activated', () => {
const revealButton = {
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
};
expect(shouldRestoreSidebarToggleFocus(revealButton, revealButton)).toBe(true);
const iconInside = {
closest: (selector: string) => (selector === '.app-sidebar-reveal-button' ? revealButton : null),
};
expect(shouldRestoreSidebarToggleFocus(iconInside, revealButton)).toBe(true);
});
it('does not restore focus to sidebar toggle button when reveal was not focused', () => {
const revealButton = {
closest: () => null,
};
const otherButton = {
closest: () => null,
};
expect(shouldRestoreSidebarToggleFocus(otherButton, revealButton)).toBe(false);
expect(shouldRestoreSidebarToggleFocus(null, revealButton)).toBe(false);
});
});
describe('sidebar queue filter fallback', () => {
it('falls back to all downloads when the active filtered queue is removed', () => {
const activeFilter = 'queue:custom-queue-1';
const remainingQueues = ['00000000-0000-0000-0000-000000000001', 'custom-queue-2'];
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('all');
});
it('preserves the active filter when the filtered queue remains present', () => {
const activeFilter = 'queue:custom-queue-1';
const remainingQueues = ['custom-queue-1', '00000000-0000-0000-0000-000000000001'];
expect(resolveFallbackFilter(activeFilter, remainingQueues, true)).toBe('queue:custom-queue-1');
});
it('preserves queue filter while queues are not yet hydrated', () => {
const activeFilter = 'queue:custom-queue-1';
expect(resolveFallbackFilter(activeFilter, [], false)).toBe('queue:custom-queue-1');
});
it('never modifies non-queue category or status filters', () => {
expect(resolveFallbackFilter('all', [], true)).toBe('all');
expect(resolveFallbackFilter('active', [], true)).toBe('active');
expect(resolveFallbackFilter('completed', [], true)).toBe('completed');
expect(resolveFallbackFilter('unfinished', [], true)).toBe('unfinished');
expect(resolveFallbackFilter('Musics', [], true)).toBe('Musics');
});
});
+45
View File
@@ -0,0 +1,45 @@
/**
* Helpers for accessible focus preservation during sidebar collapse/reveal
* and active filter recovery when queues are deleted.
*/
export type FocusableTarget = {
contains?: (other: any) => boolean;
closest?: (selector: string) => any;
} | null;
export const shouldRestoreSidebarRevealFocus = (
activeElement: FocusableTarget,
sidebarShell: FocusableTarget,
): boolean => {
if (!activeElement || !sidebarShell) return false;
if (typeof activeElement.closest === 'function') {
return Boolean(activeElement.closest('.app-sidebar-shell'));
}
return Boolean(sidebarShell.contains?.(activeElement));
};
export const shouldRestoreSidebarToggleFocus = (
activeElement: FocusableTarget,
revealButton: FocusableTarget,
): boolean => {
if (!activeElement || !revealButton) return false;
if (activeElement === revealButton) return true;
if (typeof activeElement.closest === 'function') {
return Boolean(activeElement.closest('.app-sidebar-reveal-button'));
}
return Boolean(revealButton.contains?.(activeElement));
};
export const resolveFallbackFilter = (
filter: string,
availableQueueIds: Iterable<string>,
queuesHydrated: boolean,
): string => {
if (!filter.startsWith('queue:') || !queuesHydrated) return filter;
const queueId = filter.slice(6);
const queueSet = availableQueueIds instanceof Set
? availableQueueIds
: new Set(availableQueueIds);
return queueSet.has(queueId) ? filter : 'all';
};