fix(downloads): allow confirmed credentialless resume

- distinguish redacted session credentials from intrinsic URL authentication

- requeue confirmed resumes without saved secrets from the table and Properties window

- scope batch overrides to approved rows and harden malformed bridge payloads

- add regression coverage and localized confirmation copy
This commit is contained in:
NimBold
2026-08-15 23:32:43 +03:30
parent 3f3a3514a3
commit d135a17f1b
12 changed files with 222 additions and 36 deletions
+33 -5
View File
@@ -1875,19 +1875,28 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const handleResume = useCallback(async (item: DownloadItem) => {
try {
const resumed = await useDownloadStore.getState().resumeDownload(item.id);
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
});
if (!resumed) {
const current = useDownloadStore.getState().downloads.find(
const latest = useDownloadStore.getState().downloads.find(
download => download.id === item.id
);
const reason = current?.lastError?.trim();
const reason = latest?.lastError?.trim();
throw new Error(reason || t($ => $.downloadTable.backendRejectedStart));
}
} catch (error) {
console.error("Failed to resume:", error);
showInteractionError(t($ => $.downloadTable.resumeFailed, { fileName: item.fileName }), error);
}
}, [showInteractionError]);
}, [showInteractionError, t]);
const getCurrentSelectedDownloads = useCallback(() => {
const selected = selectedIdsRef.current;
@@ -1919,7 +1928,26 @@ export const DownloadTable: React.FC<DownloadTableProps> = ({ filter, onSummaryC
const handleResumeSelected = useCallback(() => {
const ids = Array.from(selectedIdsRef.current);
if (ids.length === 0) return;
void startSelected(ids).catch(error => {
const selected = useDownloadStore.getState().downloads.filter(download => ids.includes(download.id));
const credentialMarkedIds = selected
.filter(download => download.credentialsRequired === true)
.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 => {
showInteractionError(t($ => $.downloadTable.resumeFailed), error);
});
}, [showInteractionError, startSelected, t]);
+10 -1
View File
@@ -1236,7 +1236,16 @@ export const PropertiesWindowApp = () => {
&& !window.confirm(t($ => $.downloadTable.nonResumableOne))) {
return;
}
void requestAction('pause-resume');
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,
);
}}
>
{lifecycleAction === 'pause' ? <Pause size={14} /> : <Play size={14} />}
@@ -476,7 +476,14 @@ export const PropertiesWindowBridgeHost = () => {
throw new Error('The download did not reach a paused or terminal state');
}
} else {
const resumed = await store.resumeDownload(request.downloadId);
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,
);
if (!resumed) {
throw new Error(i18n.t($ => $.downloadTable.backendRejectedStart));
}
+2 -1
View File
@@ -272,7 +272,8 @@ 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 are required again after restart. Add them in Advanced and resume this download.',
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.',
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',
+2 -1
View File
@@ -272,7 +272,8 @@ const fa = {
liveSpeedLimitFailed: 'به‌روزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانه‌ای هنگام اجرا در دسترس نیست.',
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگی‌ها ممکن نیست.',
credentialsRequired: 'پس از راه‌اندازی مجدد، دوباره اطلاعات ورود لازم است. آن‌ها را در بخش پیشرفته وارد و دانلود را ادامه دهید.',
credentialsRequired: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های درخواستِ نشست قبلی ذخیره نشده‌اند. آن‌ها را در بخش پیشرفته وارد کنید یا ادامه‌دادن بدون آن‌ها را تأیید کنید.',
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکی‌ها یا سرصفحه‌های این دانلود دیگر در دسترس نیستند. دانلود بدون آن‌ها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
liveTorrentUploadLimitHint: 'برای تورنت‌های فعال و در حال سید اعمال می‌شود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
+2 -1
View File
@@ -272,7 +272,8 @@ const he = {
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
credentialsRequired: 'לאחר הפעלה מחדש נדרשים שוב פרטי התחברות. הוסף אותם במתקדם והמשך את ההורדה.',
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
+2 -1
View File
@@ -272,7 +272,8 @@ const ru = {
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
credentialsRequired: 'После перезапуска снова нужны учётные данные. Добавьте их в разделе «Дополнительно» и возобновите загрузку.',
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
+2 -1
View File
@@ -272,7 +272,8 @@ const uk = {
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
credentialsRequired: 'Після перезапуску облікові дані потрібні знову. Додайте їх у розділі «Додатково» та відновіть завантаження.',
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
+2 -1
View File
@@ -272,7 +272,8 @@ const zhCN = {
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
editingUnavailable: '下载进行时无法编辑这些属性。',
credentialsRequired: '重启后需要再次提供凭据。请在“高级”中添加凭据,然后恢复下载。',
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
liveTorrentUploadLimit: '实时种子上传限速',
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
liveTorrentUploadLimitPlaceholder: '例如 1024K',
+2 -1
View File
@@ -301,7 +301,8 @@ export type PropertiesActionRequest = {
payload?: PropertiesPatch
| { selectedIndices: number[] | null }
| { limit: string | null }
| { maxPeers: string | null; peerSpeedLimit: string | null };
| { maxPeers: string | null; peerSpeedLimit: string | null }
| { resumeWithoutCredentials: boolean };
};
export type PropertiesActionResult = {
+106
View File
@@ -1536,6 +1536,68 @@ describe('useDownloadStore', () => {
expect(enqueueIds).toEqual(['selected-undispatched-a', 'selected-undispatched-b']);
});
it('limits credentialless selected resume to the explicitly approved rows', async () => {
useDownloadStore.setState({
downloads: [
{
id: 'selected-with-credentials',
url: 'http://with-credentials',
fileName: 'with-credentials',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'selection-credential-scope',
queuePosition: 0,
password: 'secret',
},
{
id: 'selected-without-credentials',
url: 'http://without-credentials',
fileName: 'without-credentials',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
queueId: 'selection-credential-scope',
queuePosition: 1,
credentialsRequired: true,
},
] as any[],
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string, args?: unknown) => {
if (command === 'enqueue_download') {
const item = (args as { item: { id: string; password: string | null } }).item;
return { id: item.id, filename: item.id };
}
if (command === 'get_pending_order') return [
'selected-with-credentials',
'selected-without-credentials',
];
if (command === 'move_many_in_queue') return [
'selected-with-credentials',
'selected-without-credentials',
];
return undefined;
});
await expect(useDownloadStore.getState().startSelected([
'selected-with-credentials',
'selected-without-credentials',
], {
resumeWithoutCredentialsIds: ['selected-without-credentials'],
})).resolves.toBe(2);
const enqueues = vi.mocked(ipc.invokeCommand).mock.calls
.filter(([command]) => command === 'enqueue_download')
.map(([, args]) => (args as { item: { id: string; password: string | null } }).item);
expect(enqueues).toEqual([
expect.objectContaining({ id: 'selected-with-credentials', password: 'secret' }),
expect.objectContaining({ id: 'selected-without-credentials', password: null }),
]);
});
it('pauses queued items through the global pause action', async () => {
useDownloadStore.setState({
downloads: [
@@ -2247,6 +2309,50 @@ describe('useDownloadStore', () => {
expect(useDownloadStore.getState().downloads[0].status).toBe('paused');
});
it('explicitly requeues a credential-marked download without saved credentials', async () => {
useDownloadStore.setState({
downloads: [{
id: 'credentialless-resume',
url: 'https://example.com/file.bin',
fileName: 'file.bin',
destination: '/tmp',
status: 'paused',
category: 'Other',
dateAdded: '',
credentialsRequired: true,
hasBeenDispatched: true,
}] as any[],
backendRegisteredIds: new Set(['credentialless-resume'])
});
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'get_pending_order') return ['credentialless-resume'];
if (command === 'enqueue_download') return { id: 'credentialless-resume', filename: 'file.bin' };
return undefined;
});
await expect(useDownloadStore.getState().resumeDownload('credentialless-resume', {
resumeWithoutCredentials: true
})).resolves.toBe(true);
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('resume_download', expect.anything());
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
expect(ipc.invokeCommand).toHaveBeenCalledWith(
'enqueue_download',
expect.objectContaining({
item: expect.objectContaining({
username: null,
password: null,
cookies: null,
headers: null,
})
})
);
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
credentialsRequired: false,
status: 'queued',
});
});
it('preserves backend rejection reasons while auto-resuming saved queued items', async () => {
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
if (cmd === 'db_get_all_queues') return [];
+51 -22
View File
@@ -45,6 +45,11 @@ const downloadControlIntents = new Map<string, DownloadControlIntent>();
export interface ResumeDownloadOptions {
preserveQueuePosition?: boolean;
forceRequeue?: boolean;
resumeWithoutCredentials?: boolean;
}
export interface StartSelectedOptions {
resumeWithoutCredentialsIds?: readonly string[];
}
// State events do not carry a lifecycle generation. Keep the intent that
@@ -1059,7 +1064,7 @@ interface DownloadState {
pauseDownload: (id: string) => Promise<void>;
redownload: (id: string) => Promise<void>;
resumeDownload: (id: string, options?: ResumeDownloadOptions) => Promise<boolean>;
startSelected: (ids: string[]) => Promise<number>;
startSelected: (ids: string[], options?: StartSelectedOptions) => Promise<number>;
startQueue: (queueId: string) => Promise<string[]>;
pauseQueue: (queueId: string) => Promise<number>;
startAll: () => Promise<number>;
@@ -1223,26 +1228,48 @@ 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: undefined,
});
targetItem = get().downloads.find(download => download.id === id);
if (!targetItem) return false;
}
if (targetItem.credentialsRequired === true
&& !hasCredentialMaterial(targetItem.password)
&& !hasCredentialMaterial(targetItem.cookies)
&& !hasCredentialMaterial(targetItem.headers)) {
const settings = useSettingsStore.getState();
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 (!resumeWithoutCredentials) {
const settings = useSettingsStore.getState();
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 (!hasCredentialMaterial(keychainPassword)) {
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
if (!hasCredentialMaterial(keychainPassword)) {
if (login && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
settings.setShowKeychainModal(true);
}
markCredentialsRequired(id);
return false;
}
markCredentialsRequired(id);
return false;
}
clearCredentialsRequired(id);
}
@@ -1250,7 +1277,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
setDownloadControlIntent(id, 'resume');
let previousStatus = targetItem.status;
try {
if (options.forceRequeue) {
if (forceRequeue) {
// Fence any older enqueue before replacing a paused backend lifecycle.
// Otherwise a late addUri result can win the race and make this
// selection start outside the requested order.
@@ -1267,7 +1294,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
const currentTargetItem = targetItem;
if (
options.forceRequeue &&
forceRequeue &&
currentTargetItem.status === 'paused' &&
get().backendRegisteredIds.has(id)
) {
@@ -1275,7 +1302,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
get().unregisterBackendIds([id]);
}
if (options.forceRequeue) {
if (forceRequeue) {
set(state => ({
pendingOrder: state.pendingOrder.filter(value => value !== id)
}));
@@ -1311,7 +1338,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
(d.queueId || MAIN_QUEUE_ID) === (currentTargetItem.queueId || MAIN_QUEUE_ID)
);
const maxPos = queueItems.reduce((max, d) => Math.max(max, d.queuePosition ?? 0), -1);
const queuePosition = options.preserveQueuePosition
const queuePosition = preserveQueuePosition
? currentTargetItem.queuePosition
: maxPos + 1;
@@ -1340,7 +1367,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
return false;
}
const resumedExisting = options.forceRequeue
const resumedExisting = forceRequeue
? false
: await invoke('resume_download', {
id,
@@ -1987,9 +2014,10 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
true,
preemptDispatch
),
startSelected: (ids) => {
startSelected: (ids, options = {}) => {
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();
@@ -2033,7 +2061,8 @@ export const useDownloadStore = create<DownloadState>((set, get) => {
}
const resumed = await resumeDownloadInternal(id, {
preserveQueuePosition: true,
forceRequeue: true
forceRequeue: true,
resumeWithoutCredentials: resumeWithoutCredentialsIds.has(id)
});
if (resumed && !isCurrentQueueControlGeneration(queueId, generation)) {
// A queue pause can win while this item's requeue is in flight. The