fix(keychain): gate credential-store startup access

This commit is contained in:
NimBold
2026-07-15 11:07:48 +03:30
parent 8e02a61c3f
commit 52b00e5cb4
12 changed files with 612 additions and 122 deletions
+24 -1
View File
@@ -4964,6 +4964,29 @@ fn hydrate_extension_pairing_token(
})
}
/// Return the per-launch pairing token without touching the OS credential
/// store. Startup uses this while the frontend explains credential access and
/// waits for an explicit user decision.
#[tauri::command]
fn get_session_pairing_token(
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
let token = app_state
.extension_pairing_token
.read()
.map_err(|_| "Extension pairing token lock is unavailable".to_string())?
.clone();
if token.trim().is_empty() {
return Err("Session pairing token is not initialized".to_string());
}
Ok(PairingTokenHydration {
token,
token_changed: false,
persistent: false,
error: None,
})
}
#[tauri::command]
fn regenerate_pairing_token(
database: tauri::State<'_, crate::db::DbState>,
@@ -7132,7 +7155,7 @@ pub fn run() {
ack_schedule_trigger,
check_automation_permission, request_automation_permission, open_automation_settings,
set_keychain_password, get_keychain_password, delete_keychain_password,
hydrate_extension_pairing_token, regenerate_pairing_token, grant_keychain_access,
hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access,
acknowledge_pairing_token_change,
check_file_exists, toggle_tray_icon, set_extension_pairing_token,
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
+52 -3
View File
@@ -21,7 +21,9 @@ import { WindowControls } from "./components/WindowControls";
import { useToast } from "./contexts/ToastContext";
import { setLogStreamActive } from './utils/logger';
import { openUrl } from '@tauri-apps/plugin-opener';
import { usePlatformInfo } from './utils/platform';
import { getPlatformInfo, usePlatformInfo } from './utils/platform';
import { getKeychainStartupDecision } from './utils/keychainStartup';
import { getVersion } from '@tauri-apps/api/app';
import type { PostQueueAction } from './bindings/PostQueueAction';
import { PanelLeft } from 'lucide-react';
@@ -94,6 +96,7 @@ function App() {
const platform = usePlatformInfo();
const [filter, setFilter] = useState<SidebarFilter>('all');
const [coreReady, setCoreReady] = useState(false);
const [appVersion, setAppVersion] = useState('');
const [sidebarWidth, setSidebarWidth] = useState(() => {
const stored = Number(window.localStorage.getItem('firelink-sidebar-width'));
@@ -111,6 +114,7 @@ function App() {
const showDockBadge = useSettingsStore(state => state.showDockBadge);
const showMenuBarIcon = useSettingsStore(state => state.showMenuBarIcon);
const extensionPairingToken = useSettingsStore(state => state.extensionPairingToken);
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const downloads = useDownloadStore(state => state.downloads);
const activeDownloadCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
const queuedCount = downloads.filter(download =>
@@ -120,6 +124,7 @@ function App() {
const schedulerRunning = useSettingsStore(state => state.schedulerRunning);
const schedulerActiveDownloadIds = useSettingsStore(state => state.schedulerActiveDownloadIds);
const pendingPostActionTimer = useRef<number | null>(null);
const startupResumeStarted = useRef(false);
const maxConcurrentDownloads = useSettingsStore(state => state.maxConcurrentDownloads);
const preventsSleepWhileDownloading = useSettingsStore(state => state.preventsSleepWhileDownloading);
const activeTransferCount = downloads.filter(download => isTransferActiveStatus(download.status)).length;
@@ -326,8 +331,39 @@ function App() {
return;
}
const [currentAppVersion, currentPlatform] = await Promise.all([
getVersion().catch(() => ''),
getPlatformInfo().catch(() => null)
]);
if (!active) return;
setAppVersion(currentAppVersion);
try {
const changed = await useSettingsStore.getState().hydratePairingToken();
const settings = useSettingsStore.getState();
const { deferKeychainHydration, showKeychainPrompt } = getKeychainStartupDecision({
portable: currentPlatform?.portable === true,
appVersion: currentAppVersion,
approvedVersion: settings.keychainAccessVersion,
accessGranted: settings.keychainAccessGranted,
promptDismissed: settings.keychainPromptDismissed
});
let changed = false;
if (deferKeychainHydration) {
settings.setKeychainAccessReady(false);
// This token is already owned by the backend and does not access
// the OS credential store. Render our explanation before any native
// Keychain/Credential Manager prompt can be user-triggered.
await settings.hydrateSessionPairingToken();
if (showKeychainPrompt) {
settings.setShowKeychainModal(true);
}
} else {
changed = await settings.hydratePairingToken();
settings.setKeychainAccessReady(
currentPlatform?.portable !== true && useSettingsStore.getState().isPairingTokenPersistent
);
}
if (changed) {
addToast({
variant: 'warning',
@@ -397,6 +433,19 @@ function App() {
};
}, [addToast]);
useEffect(() => {
if (!coreReady || showKeychainModal || startupResumeStarted.current) return;
startupResumeStarted.current = true;
useDownloadStore.getState().resumePendingDownloads().catch(error => {
console.error('Failed to resume saved downloads after startup:', error);
addToast({
message: `Could not resume saved downloads: ${String(error)}`,
variant: 'error',
isActionable: true
});
});
}, [addToast, coreReady, showKeychainModal]);
useEffect(() => {
window.document.documentElement.setAttribute('data-font-size', appFontSize);
}, [appFontSize]);
@@ -747,7 +796,7 @@ function App() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal />
<KeychainPermissionModal appVersion={appVersion} />
</div>
);
+14 -6
View File
@@ -86,7 +86,12 @@ export const AddDownloadsModal = () => {
addDownload,
queues
} = useDownloadStore();
const { baseDownloadFolder, perServerConnections } = useSettingsStore();
const {
baseDownloadFolder,
perServerConnections,
keychainAccessReady,
keychainPromptDismissed
} = useSettingsStore();
const [urls, setUrls] = useState('');
const [selectedItemIndex, setSelectedItemIndex] = useState<number | null>(null);
@@ -323,12 +328,16 @@ export const AddDownloadsModal = () => {
try {
const settingsStore = useSettingsStore.getState();
const proxy = await getProxyArgs(settingsStore);
const login = getSiteLogin(row.sourceUrl, settingsStore);
if (login && !useAuth && !keychainAccessReady && !keychainPromptDismissed) {
settingsStore.setShowKeychainModal(true);
return;
}
if (row.isMedia) {
const { mediaCookieSource } = settingsStore;
const browserArg = mediaCookieSource !== 'none' ? mediaCookieSource : null;
const login = getSiteLogin(row.sourceUrl, settingsStore);
let keychainPassword = null;
if (login) {
if (login && !useAuth && keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
@@ -388,9 +397,8 @@ export const AddDownloadsModal = () => {
throw new Error("Invalid media metadata or no formats found");
}
} else {
const login = getSiteLogin(row.sourceUrl, settingsStore);
let keychainPassword = null;
if (login) {
if (login && !useAuth && keychainAccessReady) {
try {
keychainPassword = await invoke('get_keychain_password', { id: login.id });
} catch (e) {
@@ -450,7 +458,7 @@ export const AddDownloadsModal = () => {
}
})();
}
}, [parsedItems, pendingAddFilename, pendingAddMediaUrls]);
}, [keychainAccessReady, keychainPromptDismissed, parsedItems, pendingAddFilename, pendingAddMediaUrls, useAuth]);
useEffect(() => {
if (parsedItems.length === 0) {
+20 -9
View File
@@ -3,8 +3,13 @@ import { useSettingsStore } from '../store/useSettingsStore';
import { invokeCommand as invoke } from '../ipc';
import { KeyRound, ShieldAlert } from 'lucide-react';
import { usePlatformInfo } from '../utils/platform';
import { getVersion } from '@tauri-apps/api/app';
export const KeychainPermissionModal: React.FC = () => {
type KeychainPermissionModalProps = {
appVersion: string;
};
export const KeychainPermissionModal: React.FC<KeychainPermissionModalProps> = ({ appVersion }) => {
const showKeychainModal = useSettingsStore(state => state.showKeychainModal);
const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt);
const platform = usePlatformInfo();
@@ -14,18 +19,18 @@ export const KeychainPermissionModal: React.FC = () => {
useEffect(() => {
if (!showKeychainModal || isGranting) return;
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissKeychainPrompt();
if (event.key === 'Escape') dismissKeychainPrompt(appVersion);
};
window.addEventListener('keydown', handleEscape);
return () => window.removeEventListener('keydown', handleEscape);
}, [dismissKeychainPrompt, isGranting, showKeychainModal]);
}, [appVersion, dismissKeychainPrompt, isGranting, showKeychainModal]);
if (!showKeychainModal) {
return null;
}
const isMac = platform.os === 'macos';
const storeName =
const pairingStoreName =
platform.portable
? 'the portable Firelink data folder'
: platform.os === 'windows'
@@ -35,8 +40,11 @@ export const KeychainPermissionModal: React.FC = () => {
: platform.os === 'macos'
? 'macOS Keychain'
: "this system's credential store";
const siteCredentialStoreName = platform.portable
? "the system's credential store"
: pairingStoreName;
const grantLabel = platform.portable
? 'Enable Portable Pairing'
? 'Continue'
: isMac
? 'Grant Access'
: 'Enable Secure Storage';
@@ -48,17 +56,20 @@ export const KeychainPermissionModal: React.FC = () => {
try {
const result = await invoke('grant_keychain_access');
if (result.persistent) {
const grantedVersion = appVersion || await getVersion().catch(() => '');
// Keep state in sync with the grant result instead of rehydrating
// before Zustand has persisted keychainAccessGranted.
useSettingsStore.setState({
keychainAccessGranted: true,
keychainAccessVersion: grantedVersion,
keychainAccessReady: true,
extensionPairingToken: result.token,
isPairingTokenPersistent: true,
keychainPromptDismissed: false,
showKeychainModal: false
});
} else {
setError(result.error || `${storeName} is unavailable.`);
setError(result.error || `${siteCredentialStoreName} is unavailable.`);
}
} catch (e: any) {
setError(e.toString());
@@ -68,7 +79,7 @@ export const KeychainPermissionModal: React.FC = () => {
};
const handleLater = () => {
dismissKeychainPrompt();
dismissKeychainPrompt(appVersion);
};
return (
@@ -94,12 +105,12 @@ export const KeychainPermissionModal: React.FC = () => {
<div className="px-5 py-6 flex-1 min-h-0 overflow-y-auto text-sm text-text-secondary leading-relaxed space-y-4">
<p>
Firelink uses the browser extension to capture downloads. To keep the extension paired after restarts,
Firelink stores its pairing token in {storeName}.
Firelink stores its pairing token in {pairingStoreName}. Optional site credentials are stored in {siteCredentialStoreName}.
</p>
<p>
{platform.portable
? 'The pairing token is portable with this folder. Treat the folder as sensitive and do not share it.'
? 'The pairing token is portable with this folder. Site credentials remain in the system credential store; a system prompt may appear after you grant access.'
: isMac
? 'macOS may show a Keychain prompt after you grant access.'
: 'This usually completes silently. If the credential service is unavailable, Firelink will show the error here and the extension will stay paired for this session only.'}
+10
View File
@@ -540,6 +540,12 @@ runEngineChecks(false);
}
setLoginFieldErrors({});
const id = crypto.randomUUID();
if (!settings.keychainAccessReady) {
settings.setShowKeychainModal(true);
setLoginError('Grant credential-store access before saving a site login.');
return;
}
if (loginPass) {
try {
@@ -1049,6 +1055,10 @@ runEngineChecks(false);
</div>
<button
onClick={async () => {
if (!settings.keychainAccessReady) {
settings.setShowKeychainModal(true);
return;
}
try {
await invoke('delete_keychain_password', { id: login.id });
settings.removeSiteLogin(login.id);
+1
View File
@@ -54,6 +54,7 @@ type CommandMap = {
set_extension_pairing_token: { args: { token: string }; result: void };
get_extension_server_port: { args: undefined; result: number | null };
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
get_session_pairing_token: { args: undefined; result: PairingTokenHydration };
regenerate_pairing_token: { args: undefined; result: PairingTokenHydration };
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
acknowledge_pairing_token_change: { args: undefined; result: void };
+128 -1
View File
@@ -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
View File
@@ -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;
+41
View File
@@ -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);
});
});
+28 -3
View File
@@ -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
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import { getKeychainStartupDecision } from './keychainStartup';
describe('getKeychainStartupDecision', () => {
it('defers persistent hydration and shows the explanation after an update', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '1.0.5',
approvedVersion: '1.0.4',
accessGranted: true,
promptDismissed: false
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: true
});
});
it('hydrates automatically after the current version was explicitly approved', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '1.0.5',
approvedVersion: '1.0.5',
accessGranted: true,
promptDismissed: false
})).toEqual({
deferKeychainHydration: false,
showKeychainPrompt: false
});
});
it('shows the explanation on first run without touching the credential store', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '1.0.5',
approvedVersion: '',
accessGranted: false,
promptDismissed: false
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: true
});
});
it('keeps a deliberately deferred session quiet on later launches', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '1.0.5',
approvedVersion: '1.0.5',
accessGranted: false,
promptDismissed: true
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: false
});
});
it('reoffers the explanation after a later update even if the previous one was deferred', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '1.0.6',
approvedVersion: '1.0.5',
accessGranted: false,
promptDismissed: true
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: true
});
});
it('does not repeat a deferred prompt when the version API is unavailable', () => {
expect(getKeychainStartupDecision({
portable: false,
appVersion: '',
approvedVersion: '',
accessGranted: false,
promptDismissed: true
})).toEqual({
deferKeychainHydration: true,
showKeychainPrompt: false
});
});
it('never gates portable pairing on credential-store access', () => {
expect(getKeychainStartupDecision({
portable: true,
appVersion: '1.0.5',
approvedVersion: '',
accessGranted: false,
promptDismissed: false
})).toEqual({
deferKeychainHydration: false,
showKeychainPrompt: false
});
});
});
+33
View File
@@ -0,0 +1,33 @@
export type KeychainStartupState = {
portable: boolean;
appVersion: string;
approvedVersion: string;
accessGranted: boolean;
promptDismissed: boolean;
};
export type KeychainStartupDecision = {
deferKeychainHydration: boolean;
showKeychainPrompt: boolean;
};
export const getKeychainStartupDecision = ({
portable,
appVersion,
approvedVersion,
accessGranted,
promptDismissed
}: KeychainStartupState): KeychainStartupDecision => {
if (portable) {
return {
deferKeychainHydration: false,
showKeychainPrompt: false
};
}
const versionChanged = Boolean(appVersion) && approvedVersion !== appVersion;
return {
deferKeychainHydration: !accessGranted || versionChanged,
showKeychainPrompt: versionChanged || (!accessGranted && !promptDismissed)
};
};