mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-10 19:47:42 +00:00
fix(keychain): gate credential-store startup access
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||
import { dispatchItem, getProxyArgs, getSiteLogin, normalizeCustomProxy, useDownloadStore } from './useDownloadStore';
|
||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||
import { useSettingsStore } from './useSettingsStore';
|
||||
import * as ipc from '../ipc';
|
||||
@@ -48,6 +48,9 @@ vi.mock('./useSettingsStore', () => ({
|
||||
Other: 'Other',
|
||||
},
|
||||
categoryDirectoryOverrides: {},
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
setShowKeychainModal: vi.fn(),
|
||||
})),
|
||||
}
|
||||
}));
|
||||
@@ -77,6 +80,9 @@ describe('useDownloadStore', () => {
|
||||
Other: 'Other',
|
||||
},
|
||||
categoryDirectoryOverrides: {},
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
setShowKeychainModal: vi.fn(),
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
useDownloadStore.setState({
|
||||
downloads: [],
|
||||
@@ -555,6 +561,35 @@ describe('useDownloadStore', () => {
|
||||
expect(useDownloadStore.getState().downloads[0].lastError).toBe('backend unavailable');
|
||||
});
|
||||
|
||||
it('blocks credential-backed dispatch until the custom access decision is made', async () => {
|
||||
const setShowKeychainModal = vi.fn();
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'dispatch-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-dispatch',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
destination: '/tmp',
|
||||
status: 'ready',
|
||||
category: 'Other',
|
||||
dateAdded: ''
|
||||
}] as any[],
|
||||
backendRegisteredIds: new Set()
|
||||
});
|
||||
|
||||
await expect(dispatchItem('credential-gated-dispatch')).resolves.toBe(false);
|
||||
|
||||
expect(setShowKeychainModal).toHaveBeenCalledWith(true);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('get_keychain_password', expect.anything());
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_download', expect.anything());
|
||||
});
|
||||
|
||||
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 [];
|
||||
@@ -582,6 +617,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
@@ -613,6 +649,7 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-accepted')).toBe(true);
|
||||
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||
@@ -656,11 +693,101 @@ describe('useDownloadStore', () => {
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(useDownloadStore.getState().downloads[0].status).toBe('completed');
|
||||
expect(useDownloadStore.getState().backendRegisteredIds.has('startup-completed')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not read saved credentials during database initialization', async () => {
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'secure-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: false
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-credential-gated',
|
||||
url: 'https://secure.example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
hasBeenDispatched: true
|
||||
})];
|
||||
}
|
||||
if (cmd === 'get_keychain_password') return 'secret';
|
||||
if (cmd === 'enqueue_many') return [{ id: 'startup-credential-gated', success: true }];
|
||||
if (cmd === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith(
|
||||
'get_keychain_password',
|
||||
{ id: 'secure-login' }
|
||||
);
|
||||
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('enqueue_many', expect.anything());
|
||||
|
||||
vi.mocked(useSettingsStore.getState).mockReturnValue({
|
||||
...useSettingsStore.getState(),
|
||||
siteLogins: [{ id: 'secure-login', urlPattern: 'secure.example.com', username: 'user' }],
|
||||
keychainAccessReady: true
|
||||
} as unknown as ReturnType<typeof useSettingsStore.getState>);
|
||||
await useDownloadStore.getState().resumePendingDownloads();
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_keychain_password', { id: 'secure-login' });
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith(
|
||||
'enqueue_many',
|
||||
expect.objectContaining({
|
||||
items: [expect.objectContaining({ password: 'secret' })]
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('shares one startup-resume operation when callers race', async () => {
|
||||
let releaseEnqueue!: () => void;
|
||||
const enqueueReleased = new Promise<void>(resolve => {
|
||||
releaseEnqueue = resolve;
|
||||
});
|
||||
vi.mocked(ipc.invokeCommand).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'db_get_all_queues') return [];
|
||||
if (cmd === 'db_get_all_downloads') {
|
||||
return [JSON.stringify({
|
||||
id: 'startup-single-flight',
|
||||
url: 'https://example.com/file.bin',
|
||||
fileName: 'file.bin',
|
||||
status: 'queued',
|
||||
category: 'Other',
|
||||
dateAdded: '',
|
||||
queueId: '00000000-0000-0000-0000-000000000001',
|
||||
hasBeenDispatched: true
|
||||
})];
|
||||
}
|
||||
if (cmd === 'enqueue_many') {
|
||||
await enqueueReleased;
|
||||
return [{ id: 'startup-single-flight', success: true }];
|
||||
}
|
||||
if (cmd === 'get_pending_order') return [];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
await useDownloadStore.getState().initDB();
|
||||
const first = useDownloadStore.getState().resumePendingDownloads();
|
||||
const second = useDownloadStore.getState().resumePendingDownloads();
|
||||
expect(second).toBe(first);
|
||||
await vi.waitFor(() => {
|
||||
expect(vi.mocked(ipc.invokeCommand).mock.calls.filter(call => call[0] === 'enqueue_many')).toHaveLength(1);
|
||||
});
|
||||
|
||||
releaseEnqueue();
|
||||
await Promise.all([first, second]);
|
||||
});
|
||||
|
||||
it('redownloads fallback media without requiring a format selector', async () => {
|
||||
useDownloadStore.setState({
|
||||
downloads: [{
|
||||
|
||||
+166
-99
@@ -21,6 +21,12 @@ const downloadLifecycleGenerations = new Map<string, bigint>();
|
||||
const queueReorderPromises = new Map<string, Promise<void>>();
|
||||
const queueStartPromises = new Map<string, Promise<string[]>>();
|
||||
const queueControlGenerations = new Map<string, number>();
|
||||
let pendingStartupResume: Promise<void> | null = null;
|
||||
|
||||
const waitForPendingStartupResume = async (): Promise<void> => {
|
||||
const pending = pendingStartupResume;
|
||||
if (pending) await pending.catch(() => undefined);
|
||||
};
|
||||
|
||||
const currentQueueControlGeneration = (queueId: string): number =>
|
||||
queueControlGenerations.get(queueId) ?? 0;
|
||||
@@ -137,6 +143,7 @@ const speedLimitForDispatch = (itemSpeedLimit: string | undefined, globalSpeedLi
|
||||
};
|
||||
|
||||
export async function dispatchItem(id: string): Promise<boolean> {
|
||||
await waitForPendingStartupResume();
|
||||
if (backendDispatchPromises.has(id)) return backendDispatchPromises.get(id)!;
|
||||
|
||||
const promise = (async () => {
|
||||
@@ -155,8 +162,12 @@ export async function dispatchItem(id: string): Promise<boolean> {
|
||||
if (!isCurrentDownloadLifecycle(id, lifecycleGeneration)) return false;
|
||||
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
if (login && !item.password && !settings.keychainAccessReady && !settings.keychainPromptDismissed) {
|
||||
settings.setShowKeychainModal(true);
|
||||
return false;
|
||||
}
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
if (login && !item.password && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
@@ -437,6 +448,7 @@ interface DownloadState {
|
||||
addQueue: (name: string) => void;
|
||||
renameQueue: (id: string, name: string) => void;
|
||||
removeQueue: (id: string) => Promise<void>;
|
||||
resumePendingDownloads: () => Promise<void>;
|
||||
initDB: () => Promise<void>;
|
||||
|
||||
}
|
||||
@@ -689,6 +701,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return false;
|
||||
},
|
||||
applyProperties: async (id, updates) => {
|
||||
await waitForPendingStartupResume();
|
||||
const wasDispatching = await invalidateAndWaitForDispatch(id);
|
||||
const state = get();
|
||||
const item = state.downloads.find(d => d.id === id);
|
||||
@@ -758,6 +771,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
removeDownload: async (id, deleteFile = false, preserveResumable = false) => {
|
||||
await waitForPendingStartupResume();
|
||||
const { pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
@@ -784,6 +798,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
syncSystemIntegrations();
|
||||
},
|
||||
pauseDownload: async (id) => {
|
||||
await waitForPendingStartupResume();
|
||||
const { generation, pendingDispatch } = await invalidateDispatch(id);
|
||||
if (pendingDispatch) {
|
||||
await pendingDispatch;
|
||||
@@ -798,6 +813,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
redownload: async (id) => {
|
||||
await waitForPendingStartupResume();
|
||||
const targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) {
|
||||
throw new Error('Cannot redownload: download was not found.');
|
||||
@@ -839,6 +855,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
resumeDownload: async (id) => {
|
||||
await waitForPendingStartupResume();
|
||||
const targetItem = get().downloads.find(d => d.id === id);
|
||||
if (!targetItem) return false;
|
||||
|
||||
@@ -895,6 +912,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
const requestedGeneration = currentQueueControlGeneration(queueId);
|
||||
const previousOperation = queueStartPromises.get(queueId) ?? Promise.resolve([]);
|
||||
const operation = previousOperation.catch(() => []).then(async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const runnable = get().downloads
|
||||
.filter(item => item.queueId === queueId && (item.status === 'queued' || canStartDownload(item.status)))
|
||||
.sort(queuePositionComparator);
|
||||
@@ -965,6 +983,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return trackedOperation;
|
||||
},
|
||||
pauseQueue: async (queueId) => {
|
||||
await waitForPendingStartupResume();
|
||||
// Invalidate queued starts before taking the snapshot. This prevents a
|
||||
// start loop that is waiting on metadata/IPC from dispatching later rows
|
||||
// after the user has already requested Pause Queue.
|
||||
@@ -1003,6 +1022,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return results.reduce((total, ids) => total + ids.length, 0);
|
||||
},
|
||||
pauseAll: async () => {
|
||||
await waitForPendingStartupResume();
|
||||
const queueIds = new Set(
|
||||
get().downloads.map(item => item.queueId || MAIN_QUEUE_ID)
|
||||
);
|
||||
@@ -1020,6 +1040,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
return pausedCount;
|
||||
},
|
||||
assignToQueue: async (ids, queueId) => {
|
||||
await waitForPendingStartupResume();
|
||||
const selectedIds = new Set(ids);
|
||||
const selected = get().downloads.filter(item => selectedIds.has(item.id));
|
||||
const locked = selected.find(item => isActiveDownloadStatus(item.status) && item.status !== 'queued');
|
||||
@@ -1081,6 +1102,7 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
},
|
||||
removeQueue: async (id) => {
|
||||
if (id === MAIN_QUEUE_ID) return;
|
||||
await waitForPendingStartupResume();
|
||||
const unfinishedIds = get().downloads
|
||||
.filter(download => download.queueId === id && download.status !== 'completed')
|
||||
.map(download => download.id);
|
||||
@@ -1103,6 +1125,149 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
resumePendingDownloads: () => {
|
||||
if (pendingStartupResume) return pendingStartupResume;
|
||||
|
||||
const operation = (async () => {
|
||||
const active = get().downloads
|
||||
.filter(d => d.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
if (active.length === 0) return;
|
||||
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const itemsToEnqueue = [];
|
||||
for (const pendingItem of active) {
|
||||
const item = get().downloads.find(download => download.id === pendingItem.id);
|
||||
if (!item || item.status !== 'queued' || get().backendRegisteredIds.has(item.id)) continue;
|
||||
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login && !item.password && settings.keychainAccessReady) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
const destPath = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
itemsToEnqueue.push({
|
||||
id: item.id,
|
||||
queue_id: item.queueId || MAIN_QUEUE_ID,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
user_agent: settings.customUserAgent.trim() || null,
|
||||
max_tries: settings.maxAutomaticRetries,
|
||||
proxy: await getProxyArgs(settings),
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
is_media: item.isMedia || false,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const currentItems = new Map(get().downloads.map(item => [item.id, item]));
|
||||
const dispatchableItems = itemsToEnqueue.filter(item => {
|
||||
const current = currentItems.get(item.id);
|
||||
return current &&
|
||||
current.status === 'queued' &&
|
||||
!get().backendRegisteredIds.has(item.id) &&
|
||||
!backendDispatchPromises.has(item.id) &&
|
||||
currentDownloadLifecycle(item.id).toString() === item.lifecycle_generation;
|
||||
});
|
||||
if (dispatchableItems.length === 0) return;
|
||||
|
||||
const results = await invoke('enqueue_many', { items: dispatchableItems });
|
||||
const registeredIds = results.filter(result => result.success).map(result => result.id);
|
||||
const failedErrors = new Map(
|
||||
results
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
);
|
||||
const acceptedIdSet = new Set(registeredIds);
|
||||
const generationById = new Map(dispatchableItems.map(item => [item.id, item.lifecycle_generation]));
|
||||
|
||||
// Commit backend ownership as soon as enqueue_many accepts an item.
|
||||
// The order query is a separate best-effort view read; if it fails,
|
||||
// forgetting these registrations would let a later queue start
|
||||
// enqueue the same backend lifecycle a second time.
|
||||
set(state => {
|
||||
// A very fast backend transfer can emit a terminal event before
|
||||
// this batch result is merged. Preserve that event's ownership
|
||||
// cleanup instead of re-registering an already-terminal ID.
|
||||
const liveAcceptedIds = new Set(
|
||||
state.downloads
|
||||
.filter(download =>
|
||||
acceptedIdSet.has(download.id) &&
|
||||
download.status !== 'completed' &&
|
||||
download.status !== 'failed' &&
|
||||
currentDownloadLifecycle(download.id).toString() === generationById.get(download.id)
|
||||
)
|
||||
.map(download => download.id)
|
||||
);
|
||||
return {
|
||||
backendRegisteredIds: new Set([
|
||||
...state.backendRegisteredIds,
|
||||
...liveAcceptedIds
|
||||
]),
|
||||
downloads: state.downloads.map(download =>
|
||||
failedErrors.has(download.id)
|
||||
? {
|
||||
...download,
|
||||
status: 'failed' as const,
|
||||
lastError: failedErrors.get(download.id)
|
||||
}
|
||||
: liveAcceptedIds.has(download.id)
|
||||
? { ...download, hasBeenDispatched: true, lastError: undefined }
|
||||
: download
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
// A cancellation can race with the batch RPC. Use the lifecycle-aware
|
||||
// cancellation command for any accepted generation that is no longer
|
||||
// current; never remove by ID because a newer lifecycle could already
|
||||
// own that same download row.
|
||||
await Promise.all(registeredIds
|
||||
.filter(id => !get().backendRegisteredIds.has(id))
|
||||
.map(id => {
|
||||
const generation = generationById.get(id);
|
||||
if (generation === undefined) return Promise.resolve();
|
||||
return invoke('cancel_enqueue_generation', {
|
||||
id,
|
||||
generation
|
||||
}).catch(error => {
|
||||
console.warn(`Failed to cancel stale startup enqueue for ${id}:`, error);
|
||||
});
|
||||
}));
|
||||
|
||||
try {
|
||||
const order = await invoke('get_pending_order', { queueId: null });
|
||||
set({ pendingOrder: order });
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh pending order after auto-resume:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to auto-resume active downloads:", e);
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
const trackedOperation = operation.finally(() => {
|
||||
if (pendingStartupResume === trackedOperation) pendingStartupResume = null;
|
||||
});
|
||||
pendingStartupResume = trackedOperation;
|
||||
return trackedOperation;
|
||||
},
|
||||
initDB: async () => {
|
||||
try {
|
||||
const queues = (await invoke('db_get_all_queues')).map(value => JSON.parse(value) as Queue);
|
||||
@@ -1126,104 +1291,6 @@ export const useDownloadStore = create<DownloadState>((set, get) => ({
|
||||
)
|
||||
}));
|
||||
|
||||
// Auto resume downloads that were active or queued
|
||||
const active = get().downloads
|
||||
.filter(d => d.status === 'queued')
|
||||
.sort((a, b) => (a.queuePosition ?? 0) - (b.queuePosition ?? 0));
|
||||
if (active.length > 0) {
|
||||
try {
|
||||
const settings = useSettingsStore.getState();
|
||||
const itemsToEnqueue = [];
|
||||
for (const item of active) {
|
||||
const login = getSiteLogin(item.url, settings);
|
||||
let keychainPassword = null;
|
||||
if (login) {
|
||||
try {
|
||||
keychainPassword = await invoke('get_keychain_password', { id: login.id });
|
||||
} catch (e) {
|
||||
console.warn("Could not fetch keychain password for login:", e);
|
||||
}
|
||||
}
|
||||
const destPath = item.destination ||
|
||||
await resolveCategoryDestination(settings, item.category);
|
||||
itemsToEnqueue.push({
|
||||
id: item.id,
|
||||
queue_id: item.queueId || MAIN_QUEUE_ID,
|
||||
url: item.url,
|
||||
destination: destPath,
|
||||
filename: item.fileName,
|
||||
connections: item.connections || settings.perServerConnections || null,
|
||||
speed_limit: speedLimitForDispatch(item.speedLimit, settings.globalSpeedLimit),
|
||||
username: item.username || (login ? login.username : null),
|
||||
password: item.password || keychainPassword,
|
||||
headers: item.headers || null,
|
||||
checksum: item.checksum || null,
|
||||
cookies: item.cookies || null,
|
||||
mirrors: item.mirrors || null,
|
||||
user_agent: settings.customUserAgent.trim() || null,
|
||||
max_tries: settings.maxAutomaticRetries,
|
||||
proxy: await getProxyArgs(settings),
|
||||
format_selector: item.mediaFormatSelector || null,
|
||||
cookie_source: settings.mediaCookieSource !== 'none' ? settings.mediaCookieSource : null,
|
||||
is_media: item.isMedia || false,
|
||||
lifecycle_generation: currentDownloadLifecycle(item.id).toString(),
|
||||
});
|
||||
}
|
||||
const results = await invoke('enqueue_many', { items: itemsToEnqueue });
|
||||
const registeredIds = results.filter(result => result.success).map(result => result.id);
|
||||
const failedErrors = new Map(
|
||||
results
|
||||
.filter(result => !result.success)
|
||||
.map(result => [result.id, result.error || 'Backend rejected the queued download.'])
|
||||
);
|
||||
const acceptedIdSet = new Set(registeredIds);
|
||||
|
||||
// Commit backend ownership as soon as enqueue_many accepts an item.
|
||||
// The order query is a separate best-effort view read; if it fails,
|
||||
// forgetting these registrations would let a later queue start
|
||||
// enqueue the same backend lifecycle a second time.
|
||||
set(state => {
|
||||
// A very fast backend transfer can emit a terminal event before
|
||||
// this batch result is merged. Preserve that event's ownership
|
||||
// cleanup instead of re-registering an already-terminal ID.
|
||||
const liveAcceptedIds = new Set(
|
||||
state.downloads
|
||||
.filter(download =>
|
||||
acceptedIdSet.has(download.id) &&
|
||||
download.status !== 'completed' &&
|
||||
download.status !== 'failed'
|
||||
)
|
||||
.map(download => download.id)
|
||||
);
|
||||
return {
|
||||
backendRegisteredIds: new Set([
|
||||
...state.backendRegisteredIds,
|
||||
...liveAcceptedIds
|
||||
]),
|
||||
downloads: state.downloads.map(download =>
|
||||
failedErrors.has(download.id)
|
||||
? {
|
||||
...download,
|
||||
status: 'failed' as const,
|
||||
lastError: failedErrors.get(download.id)
|
||||
}
|
||||
: liveAcceptedIds.has(download.id)
|
||||
? { ...download, hasBeenDispatched: true, lastError: undefined }
|
||||
: download
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const order = await invoke('get_pending_order', { queueId: null });
|
||||
set({ pendingOrder: order });
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh pending order after auto-resume:", e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to auto-resume active downloads:", e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to init DB", e);
|
||||
throw e;
|
||||
|
||||
@@ -25,3 +25,44 @@ describe('useSettingsStore global speed limit persistence', () => {
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('set_global_speed_limit', { limit: '3M' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSettingsStore credential-store startup flow', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
showKeychainModal: false
|
||||
});
|
||||
});
|
||||
|
||||
it('loads the session pairing token without invoking the credential store', async () => {
|
||||
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
|
||||
token: 'session-token',
|
||||
tokenChanged: false,
|
||||
persistent: false,
|
||||
error: null
|
||||
});
|
||||
|
||||
await useSettingsStore.getState().hydrateSessionPairingToken();
|
||||
|
||||
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
|
||||
expect(useSettingsStore.getState().extensionPairingToken).toBe('session-token');
|
||||
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
|
||||
});
|
||||
|
||||
it('clears the approved startup state when the user defers credential access', () => {
|
||||
useSettingsStore.setState({ keychainAccessGranted: true });
|
||||
|
||||
useSettingsStore.getState().dismissKeychainPrompt('1.0.5');
|
||||
|
||||
expect(useSettingsStore.getState().keychainAccessGranted).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessReady).toBe(false);
|
||||
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
|
||||
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,7 @@ const SETTINGS_TAB_VALUES = [
|
||||
|
||||
type PersistedSettingsSnapshot = PersistedSettings & {
|
||||
keychainPromptDismissed: boolean;
|
||||
keychainAccessVersion: string;
|
||||
};
|
||||
|
||||
const clampSettingInteger = (
|
||||
@@ -157,6 +158,8 @@ export interface SettingsState {
|
||||
extensionPairingToken: string;
|
||||
isPairingTokenPersistent: boolean;
|
||||
keychainAccessGranted: boolean;
|
||||
keychainAccessVersion: string;
|
||||
keychainAccessReady: boolean;
|
||||
keychainPromptDismissed: boolean;
|
||||
autoCheckUpdates: boolean;
|
||||
showKeychainModal: boolean;
|
||||
@@ -204,7 +207,9 @@ export interface SettingsState {
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||
hydratePairingToken: () => Promise<boolean>;
|
||||
setShowKeychainModal: (show: boolean) => void;
|
||||
dismissKeychainPrompt: () => void;
|
||||
setKeychainAccessReady: (ready: boolean) => void;
|
||||
dismissKeychainPrompt: (version?: string) => void;
|
||||
hydrateSessionPairingToken: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
@@ -260,6 +265,8 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
extensionPairingToken: '',
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessGranted: false,
|
||||
keychainAccessVersion: '',
|
||||
keychainAccessReady: false,
|
||||
keychainPromptDismissed: false,
|
||||
autoCheckUpdates: true,
|
||||
showKeychainModal: false,
|
||||
@@ -388,12 +395,25 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
});
|
||||
return result.tokenChanged;
|
||||
},
|
||||
hydrateSessionPairingToken: async () => {
|
||||
const result = await invoke('get_session_pairing_token');
|
||||
set({
|
||||
extensionPairingToken: result.token,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false
|
||||
});
|
||||
},
|
||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => set({ autoCheckUpdates }),
|
||||
setShowKeychainModal: (show: boolean) => set({ showKeychainModal: show }),
|
||||
dismissKeychainPrompt: () => set({
|
||||
setKeychainAccessReady: (ready: boolean) => set({ keychainAccessReady: ready }),
|
||||
dismissKeychainPrompt: (version?: string) => set(state => ({
|
||||
keychainAccessGranted: false,
|
||||
isPairingTokenPersistent: false,
|
||||
keychainAccessReady: false,
|
||||
keychainAccessVersion: version || state.keychainAccessVersion,
|
||||
keychainPromptDismissed: true,
|
||||
showKeychainModal: false
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: 'firelink-settings',
|
||||
@@ -470,6 +490,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
mediaCookieSource: state.mediaCookieSource,
|
||||
siteLogins: state.siteLogins,
|
||||
keychainAccessGranted: state.keychainAccessGranted,
|
||||
keychainAccessVersion: state.keychainAccessVersion,
|
||||
keychainPromptDismissed: state.keychainPromptDismissed,
|
||||
autoCheckUpdates: state.autoCheckUpdates
|
||||
}),
|
||||
@@ -483,6 +504,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
...persisted,
|
||||
...locations,
|
||||
extensionPairingToken: currentState.extensionPairingToken,
|
||||
keychainAccessReady: currentState.keychainAccessReady,
|
||||
theme: isAllowedSetting(THEME_VALUES, persisted.theme)
|
||||
? persisted.theme
|
||||
: currentState.theme,
|
||||
@@ -517,6 +539,9 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
persisted.keychainAccessGranted,
|
||||
currentState.keychainAccessGranted
|
||||
),
|
||||
keychainAccessVersion: typeof persisted.keychainAccessVersion === 'string'
|
||||
? persisted.keychainAccessVersion
|
||||
: currentState.keychainAccessVersion,
|
||||
keychainPromptDismissed: persistedBoolean(
|
||||
persisted.keychainPromptDismissed,
|
||||
currentState.keychainPromptDismissed
|
||||
|
||||
Reference in New Issue
Block a user