mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-09 17:25:42 +00:00
fix(windows): repair properties chrome and credential recovery
Refs #37. - Keep the transparent Properties window renderer-owned at its rounded corners without native shadow bleed. - Keep the complete custom caption-control rail outside Tauri drag hit-testing so every button area receives clicks. - Retry credential-marked downloads through a fresh lifecycle with restored keychain credentials or a safe credentialless request. - Preserve keychain consent boundaries, sanitize unavailable request credentials, and retain retryable errors when recovery fails. - Remove the obsolete manual credentialless retry confirmation and keep all start/resume entry points consistent.
This commit is contained in:
@@ -455,7 +455,11 @@ pub async fn open_download_properties_window(
|
||||
// A hidden WebView2 must not request focus during construction. The
|
||||
// native reveal path focuses it after the window is visible.
|
||||
.focused(false)
|
||||
.transparent(true);
|
||||
.transparent(true)
|
||||
// The rounded surface is painted by the child renderer. Tao enables
|
||||
// its undecorated Windows shadow by default, which leaves an opaque
|
||||
// native frame outside that renderer surface at the corners.
|
||||
.shadow(false);
|
||||
#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))]
|
||||
let builder = builder.decorations(false);
|
||||
let build_result = builder.build();
|
||||
|
||||
@@ -723,7 +723,11 @@ export const AddDownloadsModal = () => {
|
||||
url: row.sourceUrl,
|
||||
cookieBrowser: browserArg,
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
username: useAuth
|
||||
? username.trim() || null
|
||||
: typeof keychainPassword === 'string' && keychainPassword.trim()
|
||||
? login?.username || null
|
||||
: null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: rowHeaders || null,
|
||||
cookies: rowCookies || null,
|
||||
@@ -827,7 +831,11 @@ export const AddDownloadsModal = () => {
|
||||
const meta = await invoke('fetch_metadata', {
|
||||
url: row.sourceUrl,
|
||||
userAgent: settingsStore.customUserAgent.trim() || null,
|
||||
username: useAuth ? username.trim() || null : login?.username || null,
|
||||
username: useAuth
|
||||
? username.trim() || null
|
||||
: typeof keychainPassword === 'string' && keychainPassword.trim()
|
||||
? login?.username || null
|
||||
: null,
|
||||
password: useAuth ? password || null : keychainPassword,
|
||||
headers: headersForRow(contextUrl) || null,
|
||||
cookies: cookiesForRow(contextUrl, row.sourceUrl) || null,
|
||||
|
||||
@@ -251,9 +251,7 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
: waitingForPeers
|
||||
? t($ => $.downloads.status.waitingForPeers)
|
||||
: t($ => $.downloads.status[download.status]);
|
||||
const visibleErrorStatusLabel = download.credentialsRequired === true
|
||||
? t($ => $.properties.credentialsRequired)
|
||||
: download.lastErrorKind === 'nameResolution'
|
||||
const visibleErrorStatusLabel = download.lastErrorKind === 'nameResolution'
|
||||
? download.status === 'retrying' && download.lastResolverFallback === true
|
||||
? t($ => $.downloads.errors.nameResolutionRetrying)
|
||||
: download.status === 'failed'
|
||||
@@ -371,7 +369,6 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
download.status === 'failed'
|
||||
|| download.status === 'retrying'
|
||||
|| download.lastErrorKind === 'destinationAccess'
|
||||
|| download.credentialsRequired === true
|
||||
)
|
||||
? download.lastError
|
||||
: (download.status === 'queued' || download.status === 'staged') && queueIndex !== -1
|
||||
@@ -487,14 +484,10 @@ export const DownloadItem = React.memo<DownloadItemProps>(({
|
||||
onClick={() => isBulkSelection ? handleResumeSelected() : handleResume(download)}
|
||||
className="app-icon-button main-control-button"
|
||||
title={resumeSelectionCount === null
|
||||
? download.credentialsRequired === true
|
||||
? t($ => $.properties.retryWithoutCredentials)
|
||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||
aria-label={resumeSelectionCount === null
|
||||
? download.credentialsRequired === true
|
||||
? t($ => $.properties.retryWithoutCredentials)
|
||||
: download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
? download.status === 'paused' ? t($ => $.downloads.actions.resume) : t($ => $.downloads.actions.start)
|
||||
: `${t($ => $.downloadTable.startResume)} (${selectedCountLabel(resumeSelectionCount)})`}
|
||||
>
|
||||
<Play size={14} fill="currentColor" />
|
||||
|
||||
@@ -1878,15 +1878,12 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
try {
|
||||
const current = useDownloadStore.getState().downloads.find(download => download.id === item.id);
|
||||
if (!current) return;
|
||||
let resumeWithoutCredentials = false;
|
||||
if (current.credentialsRequired === true) {
|
||||
resumeWithoutCredentials = window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
if (!resumeWithoutCredentials) return;
|
||||
}
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id, {
|
||||
resumeWithoutCredentials
|
||||
});
|
||||
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
|
||||
if (!resumed) {
|
||||
// A configured site login opens the keychain consent modal instead of
|
||||
// starting a credentialless request. That is a pending user decision,
|
||||
// not a backend rejection, so do not show a second misleading error.
|
||||
if (useSettingsStore.getState().showKeychainModal) return;
|
||||
const latest = useDownloadStore.getState().downloads.find(
|
||||
download => download.id === item.id
|
||||
);
|
||||
@@ -1929,42 +1926,13 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
|
||||
const handleResumeSelected = useCallback(() => {
|
||||
const ids = Array.from(selectedIdsRef.current);
|
||||
if (ids.length === 0) return;
|
||||
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
|
||||
const credentialMarkedIds = selected
|
||||
.filter(download => download.credentialsRequired === true && canStartDownload(download.status))
|
||||
.map(download => download.id);
|
||||
if (credentialMarkedIds.length > 0
|
||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
||||
// Continue ordinary selected resumes. Credential-marked rows remain
|
||||
// fail-closed and can be handled individually after the user supplies
|
||||
// credentials or confirms a credentialless retry.
|
||||
const credentialMarkedIdSet = new Set(credentialMarkedIds);
|
||||
const ordinaryIds = ids.filter(id => !credentialMarkedIdSet.has(id));
|
||||
if (ordinaryIds.length === 0) return;
|
||||
void startSelected(ordinaryIds).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
void startSelected(ids, {
|
||||
resumeWithoutCredentialsIds: credentialMarkedIds
|
||||
}).catch(error => {
|
||||
void startSelected(ids).catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
}, [showInteractionError, startSelected, t]);
|
||||
|
||||
const handleStartAll = useCallback(() => {
|
||||
const credentialMarkedIds = useDownloadStore.getState().downloads
|
||||
.filter(download =>
|
||||
download.credentialsRequired === true
|
||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
||||
)
|
||||
.map(download => download.id);
|
||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
||||
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
void startAll({
|
||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
||||
}).catch(error => {
|
||||
void startAll().catch(error => {
|
||||
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
|
||||
});
|
||||
}, [showInteractionError, startAll, t]);
|
||||
|
||||
@@ -1225,15 +1225,13 @@ export const PropertiesWindowApp = () => {
|
||||
);
|
||||
const progressPercent = allocationPending ? '—' : `${Math.round(progress * 100)}%`;
|
||||
const statusTone = allocationPending ? 'downloading' : propertiesStatusTone(snapshot.status);
|
||||
const lifecycleLabel = snapshot.credentialsRequired === true
|
||||
? t($ => $.properties.retryWithoutCredentials)
|
||||
: lifecycleAction === 'pause'
|
||||
? t($ => $.downloads.actions.pause)
|
||||
: lifecycleAction === 'resume'
|
||||
? t($ => $.downloads.actions.resume)
|
||||
: lifecycleAction === 'retry'
|
||||
? t($ => $.downloads.actions.retry)
|
||||
: t($ => $.downloads.actions.start);
|
||||
const lifecycleLabel = lifecycleAction === 'pause'
|
||||
? t($ => $.downloads.actions.pause)
|
||||
: lifecycleAction === 'resume'
|
||||
? t($ => $.downloads.actions.resume)
|
||||
: lifecycleAction === 'retry'
|
||||
? t($ => $.downloads.actions.retry)
|
||||
: t($ => $.downloads.actions.start);
|
||||
const tabLabel = (tab: PropertiesTab) => {
|
||||
switch (tab) {
|
||||
case 'overview': return t($ => $.properties.tabs.overview);
|
||||
@@ -1278,16 +1276,7 @@ export const PropertiesWindowApp = () => {
|
||||
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
|
||||
return;
|
||||
}
|
||||
const resumeWithoutCredentials = (lifecycleAction === 'resume' || lifecycleAction === 'retry')
|
||||
&& snapshot.credentialsRequired === true;
|
||||
if (resumeWithoutCredentials
|
||||
&& !window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm))) {
|
||||
return;
|
||||
}
|
||||
void requestAction(
|
||||
'pause-resume',
|
||||
resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined,
|
||||
);
|
||||
void requestAction('pause-resume');
|
||||
}}
|
||||
>
|
||||
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
|
||||
@@ -1704,7 +1693,6 @@ export const PropertiesWindowApp = () => {
|
||||
|
||||
{activeTab === 'advanced' && <div className="space-y-4">
|
||||
<p className="text-xs text-text-muted">{t($ => $.properties.advancedTransfer)}</p>
|
||||
{snapshot.credentialsRequired === true && <p className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-xs text-amber-200" role="alert">{t($ => $.properties.credentialsRequired)}</p>}
|
||||
{isSftp && <label className="block max-w-2xl text-xs text-text-muted">{t($ => $.properties.sftpHostKeyMd)}<input className="app-control mt-1 w-full font-mono" value={sftpHostKeyMd} onChange={event => { setSftpHostKeyMd(event.target.value); setDraftTab('advanced'); }} placeholder={t($ => $.properties.sftpHostKeyMdHint)} disabled={!editingEnabled} autoComplete="off" /><span className="mt-1 block text-[11px]">{t($ => $.properties.sftpHostKeyMdDescription)}</span></label>}
|
||||
<div className="grid max-w-2xl gap-3 rounded-lg border border-border-modal bg-bg-input/30 p-3 text-xs sm:grid-cols-2">
|
||||
<div><span className="text-text-muted">{connectionHeaderLabel}</span><p className="mt-1">{connectionValue}</p></div>
|
||||
|
||||
@@ -538,15 +538,12 @@ export const PropertiesWindowBridgeHost = () => {
|
||||
throw new Error('The download did not reach a paused or terminal state');
|
||||
}
|
||||
} else {
|
||||
const resumeWithoutCredentials = typeof request.payload === 'object'
|
||||
&& request.payload !== null
|
||||
&& 'resumeWithoutCredentials' in request.payload
|
||||
&& request.payload.resumeWithoutCredentials === true;
|
||||
const resumed = await store.resumeDownload(
|
||||
request.downloadId,
|
||||
resumeWithoutCredentials ? { resumeWithoutCredentials: true } : undefined,
|
||||
);
|
||||
const resumed = await store.resumeDownload(request.downloadId);
|
||||
if (!resumed) {
|
||||
// The resume request may have opened the main window's
|
||||
// keychain consent modal. It is a pending user decision, not
|
||||
// a backend rejection to report from the child window.
|
||||
if (useSettingsStore.getState().showKeychainModal) break;
|
||||
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
|
||||
}
|
||||
// resumeDownload returns after the lifecycle request has been
|
||||
|
||||
@@ -7,12 +7,11 @@ import {
|
||||
ChevronDown,
|
||||
type LucideIcon
|
||||
} from 'lucide-react';
|
||||
import { useDownloadStore, DownloadCategory, Queue, MAIN_QUEUE_ID } from '../store/useDownloadStore';
|
||||
import { useDownloadStore, DownloadCategory, Queue } from '../store/useDownloadStore';
|
||||
import { ActiveView, useSettingsStore } from '../store/useSettingsStore';
|
||||
import { WindowDragRegion } from './WindowDragRegion';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { isTransferActiveStatus } from '../utils/downloads';
|
||||
import { canStartDownload } from '../utils/downloadActions';
|
||||
import { clampFloatingPosition } from '../utils/floatingPosition';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -525,19 +524,8 @@ export const Sidebar: React.FC<SidebarProps> = (props) => {
|
||||
className="w-full text-start px-3 py-1.5 flex items-center hover:bg-item-hover"
|
||||
onClick={() => {
|
||||
const queueId = contextMenu.id;
|
||||
const credentialMarkedIds = downloads
|
||||
.filter(download =>
|
||||
(download.queueId || MAIN_QUEUE_ID) === queueId
|
||||
&& download.credentialsRequired === true
|
||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
||||
)
|
||||
.map(download => download.id);
|
||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
||||
&& window.confirm(t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
setContextMenu(null);
|
||||
void startQueue(queueId, {
|
||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
||||
}).catch(error => {
|
||||
void startQueue(queueId).catch(error => {
|
||||
addToast({
|
||||
message: t($ => $.sidebar.startQueueFailed, { detail: String(error) }),
|
||||
variant: 'error',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { Maximize2, Minus, X } from 'lucide-react';
|
||||
import type { PointerEvent } from 'react';
|
||||
import type { MouseEvent, PointerEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ResolvedWindowControlStyle } from '../utils/windowControlStyle';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
const stopTitlebarDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
const stopTitlebarDrag = (event: PointerEvent<HTMLElement> | MouseEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
@@ -23,6 +23,9 @@ export function WindowControls({ side, controlStyle }: WindowControlsProps) {
|
||||
className={`window-controls window-controls--${side} window-controls--style-${controlStyle}`}
|
||||
aria-label={t($ => $.window.controls)}
|
||||
role="group"
|
||||
data-tauri-drag-region="false"
|
||||
onPointerDown={stopTitlebarDrag}
|
||||
onMouseDown={stopTitlebarDrag}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -275,9 +275,6 @@ const common = {
|
||||
liveSpeedLimitFailed: 'Could not update live speed cap: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Live speed control is unavailable for media downloads while running.',
|
||||
editingUnavailable: 'These properties cannot be edited while the download is active.',
|
||||
credentialsRequired: 'Credentials, cookies, or request headers from the previous session were not saved. Add them in Advanced, or confirm a retry without them.',
|
||||
resumeWithoutCredentialsConfirm: 'This download used credentials, cookies, or request headers that are no longer available. Retry without them? If access is required, the server may reject the request.',
|
||||
retryWithoutCredentials: 'Retry without saved credentials',
|
||||
liveTorrentUploadLimit: 'Live Torrent upload limit',
|
||||
liveTorrentUploadLimitHint: 'Applies to active Torrent downloads and seeding. Clear it to remove the per-Torrent upload cap.',
|
||||
liveTorrentUploadLimitPlaceholder: 'e.g. 1024K',
|
||||
|
||||
@@ -275,9 +275,6 @@ const fa = {
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
||||
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
||||
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیرهشده',
|
||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
|
||||
@@ -275,9 +275,6 @@ const he = {
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
||||
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
||||
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
|
||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
|
||||
@@ -275,9 +275,6 @@ const ru = {
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
||||
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
|
||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
|
||||
@@ -275,9 +275,6 @@ const uk = {
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
||||
retryWithoutCredentials: 'Повторити без збережених даних для входу',
|
||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
|
||||
@@ -275,9 +275,6 @@ const zhCN = {
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
||||
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
||||
retryWithoutCredentials: '不使用已保存凭据重试',
|
||||
liveTorrentUploadLimit: '实时种子上传限速',
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
|
||||
@@ -318,6 +318,13 @@ html[data-font-family="monospace"] {
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
body.is-resizing,
|
||||
@@ -609,6 +616,7 @@ html[data-list-density="relaxed"] {
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.properties-window-titlebar span {
|
||||
@@ -2433,6 +2441,7 @@ html[data-list-density="relaxed"] {
|
||||
right: auto;
|
||||
z-index: 80;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Native-decorated windows do not render the custom control rail. Keep the
|
||||
@@ -3253,6 +3262,7 @@ html[data-list-density="relaxed"] {
|
||||
direction: ltr;
|
||||
gap: 9px;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -3288,6 +3298,7 @@ html[data-list-density="relaxed"] {
|
||||
color 120ms ease,
|
||||
transform 120ms ease;
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.window-control.close {
|
||||
@@ -3481,6 +3492,8 @@ html[data-list-density="relaxed"] {
|
||||
direction: ltr;
|
||||
border-bottom: 1px solid hsl(var(--border-color));
|
||||
background: hsl(var(--statusbar-bg));
|
||||
-webkit-app-region: drag;
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.app-workspace--sidebar-right .main-titlebar {
|
||||
@@ -3523,6 +3536,7 @@ html[data-list-density="relaxed"] {
|
||||
border: 1px solid hsl(var(--border-modal));
|
||||
background: hsl(var(--bg-input));
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button {
|
||||
@@ -3534,6 +3548,7 @@ html[data-list-density="relaxed"] {
|
||||
color: hsl(var(--text-secondary));
|
||||
border-inline-end: 1px solid hsl(var(--border-color));
|
||||
-webkit-app-region: no-drag;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.main-control-button svg {
|
||||
|
||||
@@ -74,7 +74,6 @@ const PROPERTIES_SNAPSHOT_KEYS = [
|
||||
'queuePosition',
|
||||
'hasBeenDispatched',
|
||||
'lastError',
|
||||
'credentialsRequired',
|
||||
'lastErrorKind',
|
||||
'lastResolverFallback',
|
||||
'lastTry',
|
||||
@@ -303,8 +302,7 @@ export type PropertiesActionRequest = {
|
||||
payload?: PropertiesPatch
|
||||
| { selectedIndices: number[] | null }
|
||||
| { limit: string | null }
|
||||
| { maxPeers: string | null; peerSpeedLimit: string | null }
|
||||
| { resumeWithoutCredentials: boolean };
|
||||
| { maxPeers: string | null; peerSpeedLimit: string | null };
|
||||
};
|
||||
|
||||
export type PropertiesActionResult = {
|
||||
|
||||
@@ -5,10 +5,8 @@ import type { DownloadErrorKind } from '../bindings/DownloadErrorKind';
|
||||
import { listenEvent as listen } from '../ipc';
|
||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||
import { canStartDownload } from '../utils/downloadActions';
|
||||
import { categoryForDownload, isDownloadStatus } from '../utils/downloads';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import i18n from '../i18n';
|
||||
|
||||
import {
|
||||
clearDownloadControlIntent,
|
||||
@@ -535,17 +533,7 @@ const startDownloadListeners = async () => {
|
||||
if (event.payload === 'pause-all') {
|
||||
void mainStore.pauseAll();
|
||||
} else if (event.payload === 'resume-all') {
|
||||
const credentialMarkedIds = mainStore.downloads
|
||||
.filter(download =>
|
||||
download.credentialsRequired === true
|
||||
&& (download.status === 'queued' || canStartDownload(download.status))
|
||||
)
|
||||
.map(download => download.id);
|
||||
const resumeWithoutCredentials = credentialMarkedIds.length > 0
|
||||
&& window.confirm(i18n.t($ => $.properties.resumeWithoutCredentialsConfirm));
|
||||
void mainStore.startAll({
|
||||
resumeWithoutCredentialsIds: resumeWithoutCredentials ? credentialMarkedIds : []
|
||||
});
|
||||
void mainStore.startAll();
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1760,7 +1760,7 @@ describe('useDownloadStore', () => {
|
||||
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
|
||||
});
|
||||
|
||||
it('limits credentialless selected resume to the explicitly approved rows', async () => {
|
||||
it('automatically retries credential-marked rows during a selected start', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [
|
||||
{
|
||||
@@ -1809,9 +1809,7 @@ describe('useDownloadStore', () => {
|
||||
await expect(useDownloadStore.getState().startSelected([
|
||||
'selected-with-credentials',
|
||||
'selected-without-credentials',
|
||||
], {
|
||||
resumeWithoutCredentialsIds: ['selected-without-credentials'],
|
||||
})).resolves.toBe(2);
|
||||
])).resolves.toBe(2);
|
||||
|
||||
const enqueues = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'enqueue_download')
|
||||
@@ -2608,7 +2606,134 @@ describe('useDownloadStore', () => {
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
it('does not resume a paused backend lifecycle without restored credentials', async () => {
|
||||
it('keeps a configured site login available until keychain access is decided', async () => {
|
||||
const setShowKeychainModal = vi.fn();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'resume-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
setShowKeychainModal
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credential-gated-resume',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
credentialsRequired: true,
|
||||
username: 'user'
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credential-gated-resume'])
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credential-gated-resume'))
|
||||
.resolves.toBe(false);
|
||||
|
||||
expect(setShowKeychainModal).toHaveBeenCalledWith(true);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('detach_download_for_reconfigure', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
credentialsRequired: true,
|
||||
username: 'user'
|
||||
});
|
||||
});
|
||||
|
||||
it('re-enqueues a recovery-marked download so restored keychain credentials reach the backend', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'restored-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: true,
|
||||
keychainPromptDismissed: false,
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credential-recovery-requeue',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'paused',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
credentialsRequired: true,
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credential-recovery-requeue'])
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'get_keychain_password') return 'secret';
|
||||
if (command === 'enqueue_download') {
|
||||
return { id: 'credential-recovery-requeue', filename: 'file.bin' };
|
||||
}
|
||||
if (command === 'get_pending_order') return ['credential-recovery-requeue'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credential-recovery-requeue'))
|
||||
.resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'detach_download_for_reconfigure',
|
||||
{ id: 'credential-recovery-requeue' }
|
||||
);
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
username: 'user',
|
||||
password: 'secret',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
credentialsRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not synthesize a site-login username when its password is unavailable', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'dismissed-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: true,
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'username-without-password',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(),
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') return { id: 'username-without-password', filename: 'file.bin' };
|
||||
if (command === 'get_pending_order') return ['username-without-password'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(dispatchItem('username-without-password')).resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
username: null,
|
||||
password: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('automatically retries a paused credential-marked download without saved credentials', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credential-resume-gated',
|
||||
@@ -2624,25 +2749,46 @@ describe('useDownloadStore', () => {
|
||||
backendRegisteredIds: new Set(['credential-resume-gated'])
|
||||
});
|
||||
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') {
|
||||
return { id: 'credential-resume-gated', filename: 'file.bin' };
|
||||
}
|
||||
if (command === 'get_pending_order') return ['credential-resume-gated'];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credential-resume-gated'))
|
||||
.resolves.toBe(false);
|
||||
.resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'resume_download',
|
||||
expect.anything()
|
||||
);
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_download',
|
||||
expect.objectContaining({
|
||||
item: expect.objectContaining({
|
||||
username: null,
|
||||
password: null,
|
||||
cookies: null,
|
||||
headers: 'Referer: https://example.com/page',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
credentialsRequired: false,
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
headers: 'Referer: https://example.com/page',
|
||||
});
|
||||
});
|
||||
|
||||
it('explicitly requeues a credential-marked download without saved credentials', async () => {
|
||||
it('does not ask for keychain access when automatically retrying a credential-marked download', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{
|
||||
id: 'example-login',
|
||||
urlPattern: 'example.com',
|
||||
username: 'alice',
|
||||
}],
|
||||
keychainAccessReady: true,
|
||||
siteLogins: [],
|
||||
keychainAccessReady: false,
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
@@ -2655,7 +2801,7 @@ describe('useDownloadStore', () => {
|
||||
dateAdded: '',
|
||||
credentialsRequired: true,
|
||||
hasBeenDispatched: true,
|
||||
headers: 'Referer: https://example.com/page?session=secret#part\nAuthorization: Bearer secret\nUser-Agent: Browser',
|
||||
headers: 'User-Agent: Browser',
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credentialless-resume'])
|
||||
});
|
||||
@@ -2665,9 +2811,7 @@ describe('useDownloadStore', () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-resume', {
|
||||
resumeWithoutCredentials: true
|
||||
})).resolves.toBe(true);
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-resume')).resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||
@@ -2678,7 +2822,7 @@ describe('useDownloadStore', () => {
|
||||
username: null,
|
||||
password: null,
|
||||
cookies: null,
|
||||
headers: 'Referer: https://example.com/page\nUser-Agent: Browser',
|
||||
headers: 'User-Agent: Browser',
|
||||
})
|
||||
})
|
||||
);
|
||||
@@ -2699,7 +2843,7 @@ describe('useDownloadStore', () => {
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
credentialsRequired: true,
|
||||
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
|
||||
headers: 'User-Agent: Browser',
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credentialless-queued-lifecycle'])
|
||||
});
|
||||
@@ -2711,9 +2855,7 @@ describe('useDownloadStore', () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-queued-lifecycle', {
|
||||
resumeWithoutCredentials: true
|
||||
})).resolves.toBe(true);
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-queued-lifecycle')).resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'detach_download_for_reconfigure',
|
||||
@@ -2731,7 +2873,7 @@ describe('useDownloadStore', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps credential recovery available when credentialless detach fails', async () => {
|
||||
it('keeps the recovery marker when automatic credentialless detach fails', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id: 'credentialless-detach-failure',
|
||||
@@ -2743,8 +2885,7 @@ describe('useDownloadStore', () => {
|
||||
dateAdded: '',
|
||||
credentialsRequired: true,
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
headers: 'Authorization: Bearer secret\nUser-Agent: Browser',
|
||||
headers: 'User-Agent: Browser',
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set(['credentialless-detach-failure'])
|
||||
});
|
||||
@@ -2755,9 +2896,7 @@ describe('useDownloadStore', () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-detach-failure', {
|
||||
resumeWithoutCredentials: true
|
||||
})).resolves.toBe(false);
|
||||
await expect(useDownloadStore.getState().resumeDownload('credentialless-detach-failure')).resolves.toBe(false);
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
@@ -2765,6 +2904,7 @@ describe('useDownloadStore', () => {
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
headers: 'User-Agent: Browser',
|
||||
lastError: 'detach unavailable',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2816,7 +2956,54 @@ describe('useDownloadStore', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('durably pauses startup media rows when no recoverable credential source exists', async () => {
|
||||
it('does not strip a configured site login during startup without keychain access', async () => {
|
||||
const disposePersistence = initializeDownloadPersistence('main');
|
||||
const id = 'startup-keychain-gated';
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'startup-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
id,
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
username: 'user',
|
||||
credentialsRequired: true,
|
||||
hasBeenDispatched: true,
|
||||
queueId: MAIN_QUEUE_ID,
|
||||
}] as any[],
|
||||
pendingOrder: [id],
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'get_pending_order') return [id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
try {
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
id,
|
||||
status: 'queued',
|
||||
username: 'user',
|
||||
credentialsRequired: true,
|
||||
});
|
||||
expect(useDownloadStore.getState().pendingOrder).toContain(id);
|
||||
} finally {
|
||||
disposePersistence();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps credentialless startup rows retryable when the proxy is unavailable', async () => {
|
||||
const disposePersistence = initializeDownloadPersistence('main');
|
||||
const id = 'startup-media-credential-block';
|
||||
const persistedSnapshots: Array<Array<{ id: string; status: string }>> = [];
|
||||
@@ -2859,22 +3046,22 @@ describe('useDownloadStore', () => {
|
||||
await flushDownloadPersistence();
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_system_proxy', expect.anything());
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_system_proxy');
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
id,
|
||||
status: 'paused',
|
||||
status: 'queued',
|
||||
credentialsRequired: true,
|
||||
});
|
||||
expect(useDownloadStore.getState().pendingOrder).not.toContain(id);
|
||||
expect(useDownloadStore.getState().pendingOrder).toContain(id);
|
||||
expect(persistedSnapshots.some(snapshot => snapshot.some(item =>
|
||||
item.id === id && item.status === 'paused'
|
||||
item.id === id && item.status === 'queued'
|
||||
))).toBe(true);
|
||||
} finally {
|
||||
disposePersistence();
|
||||
}
|
||||
});
|
||||
|
||||
it('treats an invalid media-cookie source as unavailable during recovery', async () => {
|
||||
it('automatically retries media downloads without an unavailable cookie source', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
mediaCookieSource: undefined
|
||||
@@ -2895,17 +3082,23 @@ describe('useDownloadStore', () => {
|
||||
backendRegisteredIds: new Set([id])
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') return { id, filename: 'video.mp4' };
|
||||
if (command === 'get_pending_order') return [id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(true);
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
credentialsRequired: true
|
||||
status: 'queued',
|
||||
credentialsRequired: false
|
||||
});
|
||||
});
|
||||
|
||||
it('applies one explicit credentialless approval to queue and global starts', async () => {
|
||||
it('automatically retries credential-marked rows from queue and global starts', async () => {
|
||||
const ids = ['queue-recovery-approved', 'global-recovery-approved'];
|
||||
useDownloadStore.setState({
|
||||
downloads: ids.map((id, index) => ({
|
||||
@@ -2936,13 +3129,9 @@ describe('useDownloadStore', () => {
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().startQueue('recovery-queue-0', {
|
||||
resumeWithoutCredentialsIds: [ids[0]]
|
||||
})).resolves.toEqual([ids[0]]);
|
||||
await expect(useDownloadStore.getState().startQueue('recovery-queue-0')).resolves.toEqual([ids[0]]);
|
||||
useDownloadStore.getState().updateDownload(ids[0], { status: 'completed' });
|
||||
await expect(useDownloadStore.getState().startAll({
|
||||
resumeWithoutCredentialsIds: [ids[1]]
|
||||
})).resolves.toBe(1);
|
||||
await expect(useDownloadStore.getState().startAll()).resolves.toBe(1);
|
||||
|
||||
const enqueuedItems = vi.mocked(ipc.invokeCommand).mock.calls
|
||||
.filter(([command]) => command === 'enqueue_download')
|
||||
@@ -2976,12 +3165,18 @@ describe('useDownloadStore', () => {
|
||||
backendRegisteredIds: new Set([id]),
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(false);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
|
||||
if (command === 'enqueue_download') return { id, filename: 'private.bin' };
|
||||
if (command === 'get_pending_order') return [id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await expect(useDownloadStore.getState().resumeDownload(id)).resolves.toBe(true);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'paused',
|
||||
credentialsRequired: true,
|
||||
status: 'queued',
|
||||
credentialsRequired: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+196
-186
@@ -46,15 +46,6 @@ const downloadControlIntents = new Map<string, DownloadControlIntent>();
|
||||
export interface ResumeDownloadOptions {
|
||||
preserveQueuePosition?: boolean;
|
||||
forceRequeue?: boolean;
|
||||
resumeWithoutCredentials?: boolean;
|
||||
}
|
||||
|
||||
export interface StartSelectedOptions {
|
||||
resumeWithoutCredentialsIds?: readonly string[];
|
||||
}
|
||||
|
||||
export interface StartQueueOptions {
|
||||
resumeWithoutCredentialsIds?: readonly string[];
|
||||
}
|
||||
|
||||
// State events do not carry a lifecycle generation. Keep the intent that
|
||||
@@ -125,9 +116,6 @@ const waitForPendingStartupResume = async (): Promise<void> => {
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
};
|
||||
|
||||
const credentialsRequiredMessage = (): string =>
|
||||
i18n.t($ => $.properties.credentialsRequired);
|
||||
|
||||
const hasCredentialMaterial = (value: string | null | undefined): boolean =>
|
||||
typeof value === 'string' && value.trim().length > 0;
|
||||
|
||||
@@ -162,14 +150,22 @@ const credentialsNeedRecovery = (
|
||||
&& !hasCredentialMaterial(keychainPassword)
|
||||
&& !hasConfiguredMediaCookieSource(item, settings);
|
||||
|
||||
const markCredentialsRequired = (id: string): void => {
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
status: 'paused',
|
||||
lastError: credentialsRequiredMessage(),
|
||||
});
|
||||
useDownloadStore.setState(state => ({
|
||||
pendingOrder: state.pendingOrder.filter(value => value !== id),
|
||||
}));
|
||||
const credentiallessRetryUpdates = (
|
||||
item: Pick<DownloadItem, 'username' | 'password' | 'cookies' | 'headers'>,
|
||||
) => ({
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
cookies: undefined,
|
||||
headers: headersWithoutCredentialMaterial(item.headers),
|
||||
// Keep the recovery marker until the credentialless lifecycle is accepted.
|
||||
// If detach/enqueue fails, a later retry must not reuse a paused daemon
|
||||
// payload that may still contain the old credential material.
|
||||
credentialsRequired: true,
|
||||
});
|
||||
|
||||
const prepareCredentiallessRetry = (id: string, item: DownloadItem): DownloadItem | undefined => {
|
||||
useDownloadStore.getState().updateDownload(id, credentiallessRetryUpdates(item));
|
||||
return useDownloadStore.getState().downloads.find(download => download.id === id);
|
||||
};
|
||||
|
||||
const clearCredentialsRequired = (id: string): void => {
|
||||
@@ -381,10 +377,10 @@ async function dispatchItemInternal(
|
||||
const promise = (async () => {
|
||||
let lifecycleGeneration: bigint | null = null;
|
||||
let backendAccepted = false;
|
||||
const withoutSavedCredentials = options.withoutSavedCredentials === true;
|
||||
let withoutSavedCredentials = options.withoutSavedCredentials === true;
|
||||
try {
|
||||
const state = useDownloadStore.getState();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
let item = state.downloads.find(d => d.id === id);
|
||||
if (!item) return false;
|
||||
if (state.backendRegisteredIds.has(id)) return true;
|
||||
if (!['ready', 'staged', 'failed', 'queued'].includes(item.status)) return false;
|
||||
@@ -398,7 +394,12 @@ async function dispatchItemInternal(
|
||||
const login = withoutSavedCredentials || item.isTorrent === true
|
||||
? null
|
||||
: getSiteLogin(item.url, settings);
|
||||
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||
if (
|
||||
login
|
||||
&& !item.password
|
||||
&& !settings.keychainAccessReady
|
||||
&& !settings.keychainPromptDismissed
|
||||
) {
|
||||
settings.setShowKeychainModal(true);
|
||||
return false;
|
||||
}
|
||||
@@ -413,11 +414,19 @@ async function dispatchItemInternal(
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
if (!withoutSavedCredentials && credentialsNeedRecovery(item, settings, keychainPassword)) {
|
||||
markCredentialsRequired(id);
|
||||
await commitDownloadState();
|
||||
return false;
|
||||
// Persisted request credentials are intentionally redacted. Once the
|
||||
// marker tells us that they are unavailable, a fresh lifecycle is the
|
||||
// only honest resume path: retry with the safe request context and
|
||||
// let the server's actual response determine whether access is still
|
||||
// possible.
|
||||
withoutSavedCredentials = true;
|
||||
}
|
||||
if (withoutSavedCredentials) {
|
||||
const sanitizedItem = prepareCredentiallessRetry(id, item);
|
||||
if (!sanitizedItem) return false;
|
||||
item = sanitizedItem;
|
||||
await commitDownloadState();
|
||||
}
|
||||
if (item.credentialsRequired === true) clearCredentialsRequired(id);
|
||||
|
||||
const proxy = proxyOverride === undefined
|
||||
? await getProxyArgs(settings)
|
||||
@@ -436,12 +445,20 @@ async function dispatchItemInternal(
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: withoutSavedCredentials || item.isTorrent === true
|
||||
? null
|
||||
: item.username || (login ? login.username : null),
|
||||
: item.username || (
|
||||
login && (hasCredentialMaterial(item.password) || hasCredentialMaterial(keychainPassword))
|
||||
? login.username
|
||||
: null
|
||||
),
|
||||
password: withoutSavedCredentials || item.isTorrent === true
|
||||
? null
|
||||
: item.password || keychainPassword,
|
||||
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
|
||||
headers: item.isTorrent === true ? null : item.headers || null,
|
||||
headers: item.isTorrent === true
|
||||
? null
|
||||
: withoutSavedCredentials
|
||||
? headersWithoutCredentialMaterial(item.headers) || null
|
||||
: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: withoutSavedCredentials || item.isTorrent === true
|
||||
? null
|
||||
@@ -527,6 +544,9 @@ async function dispatchItemInternal(
|
||||
useDownloadStore.getState().updateDownload(id, {
|
||||
lastError: undefined,
|
||||
lastErrorKind: undefined,
|
||||
...((withoutSavedCredentials || item.credentialsRequired === true)
|
||||
? { credentialsRequired: false }
|
||||
: {}),
|
||||
replaceExistingFingerprint: undefined
|
||||
});
|
||||
return true;
|
||||
@@ -1160,10 +1180,10 @@ interface DownloadState {
|
||||
pauseDownload: (id: string) => Promise<void>;
|
||||
redownload: (id: string) => Promise<void>;
|
||||
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
|
||||
startSelected: (ids: string[], options?: StartSelectedOptions) => Promise<number>;
|
||||
startQueue: (queueId: string, options?: StartQueueOptions) => Promise<string[]>;
|
||||
startSelected: (ids: string[]) => Promise<number>;
|
||||
startQueue: (queueId: string) => Promise<string[]>;
|
||||
pauseQueue: (queueId: string) => Promise<number>;
|
||||
startAll: (options?: StartQueueOptions) => Promise<number>;
|
||||
startAll: () => Promise<number>;
|
||||
pauseAll: () => Promise<number>;
|
||||
assignToQueue: (ids: string[], queueId: string) => Promise<void>;
|
||||
setDownloadSpeedLimit: (id: string, limit: string | null) => Promise<void>;
|
||||
@@ -1342,57 +1362,54 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
let targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) return false;
|
||||
|
||||
const requestedResumeWithoutCredentials = options.resumeWithoutCredentials === true;
|
||||
const resumeWithoutCredentials = requestedResumeWithoutCredentials
|
||||
&& targetItem.credentialsRequired === true;
|
||||
const forceRequeue = options.forceRequeue === true || resumeWithoutCredentials;
|
||||
const preserveQueuePosition = options.preserveQueuePosition === true || resumeWithoutCredentials;
|
||||
|
||||
if (resumeWithoutCredentials) {
|
||||
// An explicit credentialless retry must not reuse secrets retained by
|
||||
// the in-memory row or by a paused daemon lifecycle. Requeueing below
|
||||
// creates a fresh backend lifecycle with the redacted payload.
|
||||
get().updateDownload(id, {
|
||||
username: undefined,
|
||||
password: undefined,
|
||||
cookies: undefined,
|
||||
headers: headersWithoutCredentialMaterial(targetItem.headers),
|
||||
});
|
||||
targetItem = get().downloads.find(download => download.id === id);
|
||||
if (!targetItem) return false;
|
||||
}
|
||||
let withoutSavedCredentials = false;
|
||||
let forceRequeue = options.forceRequeue === true;
|
||||
let preserveQueuePosition = options.preserveQueuePosition === true;
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
if (credentialsNeedRecovery(targetItem, settings)) {
|
||||
if (!resumeWithoutCredentials) {
|
||||
const login = getSiteLogin(targetItem.url, settings);
|
||||
let keychainPassword: string | null = null;
|
||||
if (login && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch keychain password for resume:', error);
|
||||
}
|
||||
}
|
||||
if (credentialsNeedRecovery(targetItem, settings, keychainPassword)) {
|
||||
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||
settings.setShowKeychainModal(true);
|
||||
}
|
||||
markCredentialsRequired(id);
|
||||
await commitDownloadState();
|
||||
return false;
|
||||
}
|
||||
// A normal resume has now proved that a configured credential source
|
||||
// is available. Clear the durable marker before accepting the
|
||||
// existing lifecycle. Explicit credentialless retries defer this
|
||||
// until fresh admission succeeds so a detach/enqueue failure leaves
|
||||
// the recovery action available.
|
||||
clearCredentialsRequired(id);
|
||||
if (targetItem.credentialsRequired === true && targetItem.isTorrent !== true) {
|
||||
const login = getSiteLogin(targetItem.url, settings);
|
||||
if (
|
||||
login
|
||||
&& !targetItem.password
|
||||
&& !settings.keychainAccessReady
|
||||
&& !settings.keychainPromptDismissed
|
||||
) {
|
||||
// Do not silently discard a configured site login while access to its
|
||||
// password is unavailable. The user can grant access and retry, or
|
||||
// explicitly dismiss the prompt to opt into the credentialless path.
|
||||
settings.setShowKeychainModal(true);
|
||||
return false;
|
||||
}
|
||||
let keychainPassword: string | null = null;
|
||||
if (login && !targetItem.password && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch keychain password for resume:', error);
|
||||
}
|
||||
}
|
||||
if (credentialsNeedRecovery(targetItem, settings, keychainPassword)) {
|
||||
withoutSavedCredentials = true;
|
||||
}
|
||||
// A recovery marker means the paused/queued daemon lifecycle may still
|
||||
// contain a credentialless or otherwise stale payload. Always replace
|
||||
// it through fresh admission so keychain, browser-cookie, or newly
|
||||
// entered request credentials reach the backend.
|
||||
forceRequeue = true;
|
||||
preserveQueuePosition = true;
|
||||
} else if (targetItem.isTorrent === true && targetItem.credentialsRequired === true) {
|
||||
clearCredentialsRequired(id);
|
||||
}
|
||||
|
||||
if (withoutSavedCredentials) {
|
||||
// A credentialless retry must not reuse secrets retained by an
|
||||
// in-memory row or by a paused daemon lifecycle. Requeueing below
|
||||
// creates a fresh backend lifecycle with the redacted payload.
|
||||
targetItem = prepareCredentiallessRetry(id, targetItem);
|
||||
if (!targetItem) return false;
|
||||
}
|
||||
|
||||
setDownloadControlIntent(id, 'resume');
|
||||
let previousStatus = targetItem.status;
|
||||
try {
|
||||
@@ -1406,7 +1423,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
if (
|
||||
!targetItem
|
||||
|| (!canStartDownload(targetItem.status)
|
||||
&& !(resumeWithoutCredentials && targetItem.status === 'queued'))
|
||||
&& !(forceRequeue && targetItem.status === 'queued'))
|
||||
) {
|
||||
clearDownloadControlIntent(id, 'resume');
|
||||
return false;
|
||||
@@ -1418,8 +1435,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
|
||||
if (
|
||||
forceRequeue &&
|
||||
get().backendRegisteredIds.has(id) &&
|
||||
(currentTargetItem.status === 'paused' || resumeWithoutCredentials)
|
||||
get().backendRegisteredIds.has(id)
|
||||
) {
|
||||
await invoke('detach_download_for_reconfigure', { id });
|
||||
get().unregisterBackendIds([id]);
|
||||
@@ -1451,7 +1467,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
if (await dispatchItemInternal(
|
||||
id,
|
||||
undefined,
|
||||
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
|
||||
withoutSavedCredentials ? { withoutSavedCredentials: true } : undefined
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
@@ -1511,7 +1527,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
dispatchSucceeded = await dispatchItemInternal(
|
||||
id,
|
||||
undefined,
|
||||
resumeWithoutCredentials ? { withoutSavedCredentials: true } : undefined
|
||||
withoutSavedCredentials ? { withoutSavedCredentials: true } : undefined
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1532,7 +1548,14 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
console.error("Failed to resume download:", e);
|
||||
const current = get().downloads.find(download => download.id === id);
|
||||
if (current?.status === 'queued') {
|
||||
get().updateDownload(id, { status: previousStatus });
|
||||
get().updateDownload(id, {
|
||||
status: previousStatus,
|
||||
lastError: errorMessage(e)
|
||||
});
|
||||
} else if (current?.status === previousStatus) {
|
||||
// Detach can fail before the paused row is re-queued. Keep the row
|
||||
// retryable and retain the actual failure for the next user action.
|
||||
get().updateDownload(id, { lastError: errorMessage(e) });
|
||||
}
|
||||
clearDownloadControlIntent(id, 'resume');
|
||||
return false;
|
||||
@@ -2197,10 +2220,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
true,
|
||||
preemptDispatch
|
||||
),
|
||||
startSelected: (ids, options = {}) => {
|
||||
startSelected: (ids) => {
|
||||
const orderedIds = [...new Set(ids)];
|
||||
if (orderedIds.length === 0) return Promise.resolve(0);
|
||||
const resumeWithoutCredentialsIds = new Set(options.resumeWithoutCredentialsIds ?? []);
|
||||
|
||||
return runDownloadLifecycleOperations(orderedIds, 'start-selected', async () => {
|
||||
await waitForPendingStartupResume();
|
||||
@@ -2244,8 +2266,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
const resumed = await resumeDownloadInternal(id, {
|
||||
preserveQueuePosition: true,
|
||||
forceRequeue: true,
|
||||
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(id)
|
||||
forceRequeue: true
|
||||
});
|
||||
if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) {
|
||||
// A queue pause can win while this item's requeue is in flight. The
|
||||
@@ -2289,8 +2310,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
return startedCount;
|
||||
});
|
||||
},
|
||||
startQueue: (queueId, options = {}) => {
|
||||
const resumeWithoutCredentialsIds = new Set(options.resumeWithoutCredentialsIds ?? []);
|
||||
startQueue: (queueId) => {
|
||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||
const operation = previousOperation.catch(() => []).then(async () => {
|
||||
@@ -2304,50 +2324,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
|
||||
if (runnable.length === 0 || !isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
|
||||
|
||||
const settings = useSettingsStore.getState();
|
||||
let credentialStateChanged = false;
|
||||
const credentialBlockedIds = new Set<string>();
|
||||
for (const item of runnable) {
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
|
||||
const currentItem = get().downloads.find(download => download.id === item.id);
|
||||
if (
|
||||
!currentItem
|
||||
|| (currentItem.queueId || MAIN_QUEUE_ID) !== queueId
|
||||
|| (currentItem.status !== 'queued' && !canStartDownload(currentItem.status))
|
||||
|| resumeWithoutCredentialsIds.has(item.id)
|
||||
) continue;
|
||||
const login = currentItem.isTorrent === true ? null : getSiteLogin(currentItem.url, settings);
|
||||
let keychainPassword: string | null = null;
|
||||
if (login && !currentItem.password && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch keychain password for queue start:', error);
|
||||
}
|
||||
}
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) return [];
|
||||
const latestItem = get().downloads.find(download => download.id === item.id);
|
||||
if (
|
||||
!latestItem
|
||||
|| (latestItem.queueId || MAIN_QUEUE_ID) !== queueId
|
||||
|| (latestItem.status !== 'queued' && !canStartDownload(latestItem.status))
|
||||
) continue;
|
||||
if (credentialsNeedRecovery(latestItem, settings, keychainPassword)) {
|
||||
markCredentialsRequired(latestItem.id);
|
||||
credentialBlockedIds.add(latestItem.id);
|
||||
credentialStateChanged = true;
|
||||
} else if (latestItem.credentialsRequired === true) {
|
||||
// A row can retain the durable marker after a configured browser
|
||||
// source or keychain credential becomes available again. Clear the
|
||||
// marker before accepting an already-queued backend lifecycle so the
|
||||
// UI does not keep advertising a credentialless retry indefinitely.
|
||||
clearCredentialsRequired(latestItem.id);
|
||||
credentialStateChanged = true;
|
||||
}
|
||||
}
|
||||
if (credentialStateChanged) await commitDownloadState();
|
||||
const runnableForStart = runnable.filter(item => !credentialBlockedIds.has(item.id));
|
||||
if (runnableForStart.length === 0) return [];
|
||||
const runnableForStart = runnable;
|
||||
|
||||
const needsNewDispatch = runnableForStart.some(item => {
|
||||
const currentItem = get().downloads.find(download => download.id === item.id);
|
||||
@@ -2395,37 +2372,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const backendRegistered = get().backendRegisteredIds.has(item.id);
|
||||
const backendPending = get().pendingOrder.includes(item.id);
|
||||
|
||||
// An explicit recovery approval always replaces the old lifecycle,
|
||||
// even if a stale renderer projection still says that the row is
|
||||
// queued and pending. This is the admission point that makes the
|
||||
// user confirmation meaningful across restart and replayed events.
|
||||
if (currentItem.credentialsRequired === true
|
||||
&& resumeWithoutCredentialsIds.has(item.id)) {
|
||||
const resumed = await resumeDownloadInternal(item.id, {
|
||||
preserveQueuePosition: true,
|
||||
forceRequeue: true,
|
||||
resumeWithoutCredentials: true
|
||||
});
|
||||
if (!resumed) continue;
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
||||
const afterResume = get().downloads.find(download => download.id === item.id);
|
||||
if (
|
||||
backendDispatchPromises.has(item.id) ||
|
||||
get().backendRegisteredIds.has(item.id) ||
|
||||
(afterResume && canPauseDownload(afterResume.status))
|
||||
) {
|
||||
await get().pauseDownload(item.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
acceptedIds.push(item.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentItem.status === 'paused') {
|
||||
const resumed = await get().resumeDownload(item.id, {
|
||||
preserveQueuePosition: true,
|
||||
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(item.id)
|
||||
preserveQueuePosition: true
|
||||
});
|
||||
if (!resumed) continue;
|
||||
if (!isCurrentQueueControlGeneration(queueId, requestedGeneration)) {
|
||||
@@ -2445,8 +2394,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
|
||||
if (currentItem.status === 'queued' && backendRegistered && !backendPending) {
|
||||
if (await get().resumeDownload(item.id, {
|
||||
preserveQueuePosition: true,
|
||||
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(item.id)
|
||||
preserveQueuePosition: true
|
||||
})) {
|
||||
acceptedIds.push(item.id);
|
||||
}
|
||||
@@ -2522,7 +2470,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
syncSystemIntegrations();
|
||||
return pausedCount;
|
||||
},
|
||||
startAll: async (options = {}) => {
|
||||
startAll: async () => {
|
||||
set(state => ({
|
||||
downloads: state.downloads.map(item =>
|
||||
item.queueId ? item : { ...item, queueId: MAIN_QUEUE_ID }
|
||||
@@ -2533,7 +2481,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
.filter(item => item.status === 'queued' || canStartDownload(item.status))
|
||||
.map(item => item.queueId || MAIN_QUEUE_ID)
|
||||
);
|
||||
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId, options)));
|
||||
const results = await Promise.all(Array.from(queueIds, queueId => get().startQueue(queueId)));
|
||||
return results.reduce((total, ids) => total + ids.length, 0);
|
||||
},
|
||||
pauseAll: async () => {
|
||||
@@ -2845,19 +2793,32 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
lifecycleGeneration: bigint;
|
||||
login: ReturnType<typeof getSiteLogin>;
|
||||
keychainPassword: string | null;
|
||||
withoutSavedCredentials: boolean;
|
||||
}> = [];
|
||||
|
||||
// Credential admission must happen before any global prerequisite
|
||||
// such as proxy resolution. Otherwise a proxy failure can leave a
|
||||
// credential-marked row queued and make every subsequent startup
|
||||
// silently retry the same unavailable lifecycle.
|
||||
// Normalize each restartable row before any global prerequisite such
|
||||
// as proxy resolution. A row whose request credentials were redacted
|
||||
// is converted to a safe credentialless lifecycle here, so a proxy
|
||||
// failure cannot restore the old credential-gated UI state.
|
||||
for (const pendingItem of active) {
|
||||
const item = get().downloads.find(download => download.id === pendingItem.id);
|
||||
let item = get().downloads.find(download => download.id === pendingItem.id);
|
||||
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
|
||||
const lifecycleGeneration = currentDownloadLifecycle(item.id);
|
||||
|
||||
const login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
|
||||
let login = item.isTorrent === true ? null : getSiteLogin(item.url, settings);
|
||||
let keychainPassword: string | null = null;
|
||||
if (
|
||||
login
|
||||
&& !item.password
|
||||
&& !settings.keychainAccessReady
|
||||
&& !settings.keychainPromptDismissed
|
||||
) {
|
||||
// App startup normally holds this operation behind the keychain
|
||||
// consent modal. Keep this lower-level path safe as well: a
|
||||
// configured login must never be reduced to a username-only or
|
||||
// anonymous request while its password is still inaccessible.
|
||||
continue;
|
||||
}
|
||||
if (login && !item.password && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
@@ -2866,14 +2827,24 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
}
|
||||
if (currentDownloadLifecycle(item.id) !== lifecycleGeneration) continue;
|
||||
const latestItem = get().downloads.find(download => download.id === item.id);
|
||||
let latestItem = get().downloads.find(download => download.id === item.id);
|
||||
if (!latestItem || latestItem.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
|
||||
if (credentialsNeedRecovery(latestItem, settings, keychainPassword)) {
|
||||
markCredentialsRequired(latestItem.id);
|
||||
continue;
|
||||
const withoutSavedCredentials = credentialsNeedRecovery(latestItem, settings, keychainPassword);
|
||||
if (withoutSavedCredentials) {
|
||||
latestItem = prepareCredentiallessRetry(latestItem.id, latestItem);
|
||||
if (!latestItem) continue;
|
||||
// A site-login username is still a credential. Do not let the
|
||||
// startup batch reintroduce it after the row was sanitized.
|
||||
login = null;
|
||||
keychainPassword = null;
|
||||
}
|
||||
if (latestItem.credentialsRequired === true) clearCredentialsRequired(latestItem.id);
|
||||
preparedCandidates.push({ id: latestItem.id, lifecycleGeneration, login, keychainPassword });
|
||||
preparedCandidates.push({
|
||||
id: latestItem.id,
|
||||
lifecycleGeneration,
|
||||
login,
|
||||
keychainPassword,
|
||||
withoutSavedCredentials,
|
||||
});
|
||||
}
|
||||
await commitDownloadState();
|
||||
if (preparedCandidates.length === 0) return;
|
||||
@@ -2898,7 +2869,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
const preparedById = new Map(preparedCandidates.map(candidate => [candidate.id, candidate]));
|
||||
const itemsToEnqueue = [];
|
||||
for (const pendingItem of active) {
|
||||
const item = get().downloads.find(download => download.id === pendingItem.id);
|
||||
let item = get().downloads.find(download => download.id === pendingItem.id);
|
||||
const prepared = preparedById.get(pendingItem.id);
|
||||
if (
|
||||
!item
|
||||
@@ -2907,11 +2878,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
|| get().backendRegisteredIds.has(item.id)
|
||||
|| currentDownloadLifecycle(item.id) !== prepared.lifecycleGeneration
|
||||
) continue;
|
||||
if (credentialsNeedRecovery(item, settings, prepared.keychainPassword)) {
|
||||
markCredentialsRequired(item.id);
|
||||
continue;
|
||||
let withoutSavedCredentials = prepared.withoutSavedCredentials;
|
||||
const hasNewCredentialMaterial = hasCredentialMaterial(item.username)
|
||||
|| hasCredentialMaterial(item.password)
|
||||
|| hasCredentialMaterial(item.cookies)
|
||||
|| hasCredentialBearingHeaders(item.headers);
|
||||
if (withoutSavedCredentials && hasNewCredentialMaterial) {
|
||||
// A user may have supplied fresh credentials while the startup
|
||||
// prerequisite was resolving. Honor that new input.
|
||||
withoutSavedCredentials = false;
|
||||
}
|
||||
if (!withoutSavedCredentials && credentialsNeedRecovery(item, settings, prepared.keychainPassword)) {
|
||||
withoutSavedCredentials = true;
|
||||
}
|
||||
prepared.withoutSavedCredentials = withoutSavedCredentials;
|
||||
if (withoutSavedCredentials) {
|
||||
const sanitizedItem = prepareCredentiallessRetry(item.id, item);
|
||||
if (!sanitizedItem) continue;
|
||||
item = sanitizedItem;
|
||||
prepared.login = null;
|
||||
prepared.keychainPassword = null;
|
||||
}
|
||||
if (item.credentialsRequired === true) clearCredentialsRequired(item.id);
|
||||
const destPath = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
if (
|
||||
@@ -2928,12 +2915,27 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
? null
|
||||
: resolveDownloadConnections(item.connections, settings.perServerConnections),
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit, item.isMedia),
|
||||
username: item.isTorrent === true ? null : item.username || (prepared.login ? prepared.login.username : null),
|
||||
password: item.isTorrent === true ? null : item.password || prepared.keychainPassword,
|
||||
username: item.isTorrent === true || withoutSavedCredentials
|
||||
? null
|
||||
: item.username || (
|
||||
prepared.login && (
|
||||
hasCredentialMaterial(item.password)
|
||||
|| hasCredentialMaterial(prepared.keychainPassword)
|
||||
)
|
||||
? prepared.login.username
|
||||
: null
|
||||
),
|
||||
password: item.isTorrent === true || withoutSavedCredentials
|
||||
? null
|
||||
: item.password || prepared.keychainPassword,
|
||||
sftp_host_key_md: item.isTorrent === true ? undefined : item.sftpHostKeyMd || undefined,
|
||||
headers: item.isTorrent === true ? null : item.headers || null,
|
||||
headers: item.isTorrent === true
|
||||
? null
|
||||
: withoutSavedCredentials
|
||||
? headersWithoutCredentialMaterial(item.headers) || null
|
||||
: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.isTorrent === true ? null : item.cookies || null,
|
||||
cookies: item.isTorrent === true || withoutSavedCredentials ? null : item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
user_agent: settings.customUserAgent.trim() || null,
|
||||
max_tries: settings.maxAutomaticRetries,
|
||||
@@ -3028,6 +3030,11 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
);
|
||||
const acceptedIdSet = new Set(registeredIds);
|
||||
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
|
||||
const recoveryMarkerIdSet = new Set(
|
||||
dispatchableItems
|
||||
.filter(item => currentItems.get(item.id)?.credentialsRequired === true)
|
||||
.map(item => item.id)
|
||||
);
|
||||
|
||||
// Commit backend ownership as soon as enqueue_many accepts an item.
|
||||
// The order query is a separate best-effort view read; if it fails,
|
||||
@@ -3076,6 +3083,9 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
|
||||
}
|
||||
: {}),
|
||||
hasBeenDispatched: true,
|
||||
...(recoveryMarkerIdSet.has(download.id)
|
||||
? { credentialsRequired: false }
|
||||
: {}),
|
||||
lastError: undefined,
|
||||
replaceExistingFingerprint: undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user