mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(keychain): gate credential-store startup access
This commit is contained in:
+24
-1
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.'}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user