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
+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