mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 07:36:17 +00:00
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:
@@ -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]);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -272,7 +272,8 @@ const fa = {
|
||||
liveSpeedLimitFailed: 'بهروزرسانی سقف سرعت زنده ممکن نیست: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'تغییر زنده سرعت دانلودهای رسانهای هنگام اجرا در دسترس نیست.',
|
||||
editingUnavailable: 'هنگام فعال بودن دانلود، ویرایش این ویژگیها ممکن نیست.',
|
||||
credentialsRequired: 'پس از راهاندازی مجدد، دوباره اطلاعات ورود لازم است. آنها را در بخش پیشرفته وارد و دانلود را ادامه دهید.',
|
||||
credentialsRequired: 'اطلاعات ورود، کوکیها یا سرصفحههای درخواستِ نشست قبلی ذخیره نشدهاند. آنها را در بخش پیشرفته وارد کنید یا ادامهدادن بدون آنها را تأیید کنید.',
|
||||
resumeWithoutCredentialsConfirm: 'اطلاعات ورود، کوکیها یا سرصفحههای این دانلود دیگر در دسترس نیستند. دانلود بدون آنها دوباره امتحان شود؟ اگر دسترسی لازم باشد، سرور ممکن است درخواست را رد کند.',
|
||||
liveTorrentUploadLimit: 'محدودیت زنده آپلود تورنت',
|
||||
liveTorrentUploadLimitHint: 'برای تورنتهای فعال و در حال سید اعمال میشود. برای حذف محدودیت آپلود تورنت، آن را پاک کنید.',
|
||||
liveTorrentUploadLimitPlaceholder: 'مثلاً 1024K',
|
||||
|
||||
@@ -272,7 +272,8 @@ const he = {
|
||||
liveSpeedLimitFailed: 'לא ניתן לעדכן את הגבלת המהירות בזמן אמת: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'שליטה במהירות בזמן אמת אינה זמינה להורדות מדיה בזמן שהן פועלות.',
|
||||
editingUnavailable: 'לא ניתן לערוך את המאפיינים האלה בזמן שההורדה פעילה.',
|
||||
credentialsRequired: 'לאחר הפעלה מחדש נדרשים שוב פרטי התחברות. הוסף אותם במתקדם והמשך את ההורדה.',
|
||||
credentialsRequired: 'פרטי התחברות, קובצי Cookie או כותרות בקשה מההפעלה הקודמת לא נשמרו. הוסף אותם במתקדם, או אשר ניסיון חוזר בלעדיהם.',
|
||||
resumeWithoutCredentialsConfirm: 'ההורדה הזו השתמשה בפרטי התחברות, בקובצי Cookie או בכותרות בקשה שאינם זמינים עוד. לנסות שוב בלעדיהם? אם נדרשת הרשאה, השרת עלול לדחות את הבקשה.',
|
||||
liveTorrentUploadLimit: 'הגבלת העלאת טורנט בזמן אמת',
|
||||
liveTorrentUploadLimitHint: 'חל על הורדות טורנט פעילות ושיתוף. נקה כדי להסיר את הגבלת ההעלאה של הטורנט.',
|
||||
liveTorrentUploadLimitPlaceholder: 'לדוגמה 1024K',
|
||||
|
||||
@@ -272,7 +272,8 @@ const ru = {
|
||||
liveSpeedLimitFailed: 'Не удалось обновить текущее ограничение скорости: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Изменение скорости медиазагрузок во время работы недоступно.',
|
||||
editingUnavailable: 'Эти свойства нельзя изменять во время активной загрузки.',
|
||||
credentialsRequired: 'После перезапуска снова нужны учётные данные. Добавьте их в разделе «Дополнительно» и возобновите загрузку.',
|
||||
credentialsRequired: 'Данные для входа, cookie или заголовки запроса из предыдущего сеанса не сохранены. Добавьте их в разделе «Дополнительно» или подтвердите повторную попытку без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Эта загрузка использовала данные для входа, cookie или заголовки запроса, которые больше недоступны. Повторить без них? Если доступ обязателен, сервер может отклонить запрос.',
|
||||
liveTorrentUploadLimit: 'Текущий лимит отдачи торрента',
|
||||
liveTorrentUploadLimitHint: 'Применяется к активным торрентам и раздаче. Очистите поле, чтобы убрать лимит отдачи для торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'например, 1024K',
|
||||
|
||||
@@ -272,7 +272,8 @@ const uk = {
|
||||
liveSpeedLimitFailed: 'Не вдалося оновити поточне обмеження швидкості: {{detail}}',
|
||||
liveSpeedLimitUnavailable: 'Зміна швидкості медіазавантажень під час роботи недоступна.',
|
||||
editingUnavailable: 'Ці властивості не можна змінювати під час активного завантаження.',
|
||||
credentialsRequired: 'Після перезапуску облікові дані потрібні знову. Додайте їх у розділі «Додатково» та відновіть завантаження.',
|
||||
credentialsRequired: 'Дані для входу, cookie або заголовки запиту з попереднього сеансу не збережено. Додайте їх у розділі «Додатково» або підтвердьте повторну спробу без них.',
|
||||
resumeWithoutCredentialsConfirm: 'Це завантаження використовувало дані для входу, cookie або заголовки запиту, які більше недоступні. Повторити без них? Якщо доступ обов’язковий, сервер може відхилити запит.',
|
||||
liveTorrentUploadLimit: 'Поточний ліміт віддачі торрента',
|
||||
liveTorrentUploadLimitHint: 'Застосовується до активних торрентів і роздачі. Очистіть поле, щоб прибрати ліміт віддачі торрента.',
|
||||
liveTorrentUploadLimitPlaceholder: 'наприклад, 1024K',
|
||||
|
||||
@@ -272,7 +272,8 @@ const zhCN = {
|
||||
liveSpeedLimitFailed: '无法更新实时速度上限:{{detail}}',
|
||||
liveSpeedLimitUnavailable: '媒体下载运行时无法使用实时速度控制。',
|
||||
editingUnavailable: '下载进行时无法编辑这些属性。',
|
||||
credentialsRequired: '重启后需要再次提供凭据。请在“高级”中添加凭据,然后恢复下载。',
|
||||
credentialsRequired: '上一个会话中的凭据、Cookie 或请求标头未被保存。请在“高级”中添加,或确认不使用它们重试。',
|
||||
resumeWithoutCredentialsConfirm: '此下载使用过的凭据、Cookie 或请求标头已不可用。要不使用它们重试吗?如果需要访问权限,服务器可能会拒绝请求。',
|
||||
liveTorrentUploadLimit: '实时种子上传限速',
|
||||
liveTorrentUploadLimitHint: '适用于活跃的种子下载和做种。清空后可移除该种子的上传限速。',
|
||||
liveTorrentUploadLimitPlaceholder: '例如 1024K',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user