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:
NimBold
2026-08-28 05:07:54 +03:30
parent 7477a26378
commit 9d1e8d994a
19 changed files with 503 additions and 366 deletions
+5 -1
View File
@@ -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();
+10 -2
View File
@@ -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,
+3 -10
View File
@@ -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" />
+7 -39
View File
@@ -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]);
+8 -20
View File
@@ -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
+2 -14
View File
@@ -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',
+5 -2
View File
@@ -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"
-3
View File
@@ -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',
-3
View File
@@ -275,9 +275,6 @@ const fa = {
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگی‌ها ممکن نیست.',
credentialsRequired: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های درخواستِ نشست قبلی ذخیره نشده‌اند. آن‌ها را در بخش پیشرفته وارد کنید یا ادامه‌دادن بدون آن‌ها را تأیید کنید.',
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های این دانلود دیگر در دسترس نیستند. دانلود بدون آن‌ها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
retryWithoutCredentials: 'تلاش دوباره بدون اطلاعات ذخیره‌شده',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
-3
View File
@@ -275,9 +275,6 @@ const he = {
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
retryWithoutCredentials: 'נסה שוב ללא פרטי התחברות שמורים',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
-3
View File
@@ -275,9 +275,6 @@ const ru = {
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
retryWithoutCredentials: 'Повторить без сохранённых данных для входа',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
-3
View File
@@ -275,9 +275,6 @@ const uk = {
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
retryWithoutCredentials: 'Повторити без збережених даних для входу',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
-3
View File
@@ -275,9 +275,6 @@ const zhCN = {
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
editingUnavailable: '下载进行时无法编辑这些属性。',
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
retryWithoutCredentials: '不使用已保存凭据重试',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
+15
View File
@@ -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 {
+1 -3
View File
@@ -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 = {
+1 -13
View File
@@ -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();
}
}),
]);
+245 -50
View File
@@ -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
View File
@@ -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
}