fix(ui): align action controls and protect keychain consent

This commit is contained in:
NimBold
2026-07-17 18:37:50 +03:30
parent 566396e629
commit c949cbb9ee
11 changed files with 199 additions and 86 deletions
+69
View File
@@ -5,6 +5,7 @@ import {
useSettingsStore
} from './useSettingsStore';
import * as ipc from '../ipc';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
vi.mock('../ipc', () => ({
invokeCommand: vi.fn()
@@ -69,6 +70,74 @@ describe('useSettingsStore credential-store startup flow', () => {
expect(useSettingsStore.getState().keychainAccessVersion).toBe('1.0.5');
expect(useSettingsStore.getState().keychainPromptDismissed).toBe(true);
});
it('opens the consent modal instead of regenerating through the credential store', async () => {
await expect(useSettingsStore.getState().regeneratePairingToken())
.rejects.toThrow('Grant credential-store access before regenerating the pairing token.');
expect(ipc.invokeCommand).not.toHaveBeenCalledWith('regenerate_pairing_token');
expect(useSettingsStore.getState().showKeychainModal).toBe(true);
});
it('does not apply pairing hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-token',
tokenChanged: true,
persistent: true,
error: null
});
await expect(useSettingsStore.getState().hydratePairingToken(() => false)).resolves.toBe(false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('hydrate_extension_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('does not apply session hydration after startup becomes inactive', async () => {
vi.mocked(ipc.invokeCommand).mockResolvedValueOnce({
token: 'stale-session-token',
tokenChanged: false,
persistent: false,
error: null
});
await useSettingsStore.getState().hydrateSessionPairingToken(() => false);
expect(ipc.invokeCommand).toHaveBeenCalledWith('get_session_pairing_token');
expect(useSettingsStore.getState().extensionPairingToken).toBe('');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(false);
});
it('shares a concurrent pairing hydration request', async () => {
let resolveRequest!: (value: PairingTokenHydration) => void;
const request = new Promise<PairingTokenHydration>(resolve => {
resolveRequest = resolve;
});
let hydrationRequestCount = 0;
vi.mocked(ipc.invokeCommand).mockImplementation(async (command: string) => {
if (command === 'hydrate_extension_pairing_token') {
hydrationRequestCount += 1;
return request;
}
return undefined;
});
const first = useSettingsStore.getState().hydratePairingToken();
const second = useSettingsStore.getState().hydratePairingToken();
expect(hydrationRequestCount).toBe(1);
resolveRequest({
token: 'shared-token',
tokenChanged: false,
persistent: true,
error: null
});
await Promise.all([first, second]);
expect(useSettingsStore.getState().extensionPairingToken).toBe('shared-token');
expect(useSettingsStore.getState().isPairingTokenPersistent).toBe(true);
});
});
describe('useSettingsStore persistence failures', () => {
+24 -5
View File
@@ -8,6 +8,7 @@ import type { ListRowDensity } from '../bindings/ListRowDensity';
import type { MediaCookieSource } from '../bindings/MediaCookieSource';
import type { PostQueueAction } from '../bindings/PostQueueAction';
import type { PersistedSettings } from '../bindings/PersistedSettings';
import type { PairingTokenHydration } from '../bindings/PairingTokenHydration';
import type { ProxyMode } from '../bindings/ProxyMode';
import type { SchedulerSettings } from '../bindings/SchedulerSettings';
import type { SettingsTab } from '../bindings/SettingsTab';
@@ -20,6 +21,7 @@ import {
import { normalizeSpeedLimitForBackend } from '../utils/downloads';
let settingsQueue: Promise<void> = Promise.resolve();
let pairingTokenHydrationRequest: Promise<PairingTokenHydration> | null = null;
const settingsPersistenceErrorListeners = new Set<() => void>();
let settingsPersistenceFailed = false;
const DEFAULT_SCHEDULER_QUEUE_ID = '00000000-0000-0000-0000-000000000001';
@@ -37,6 +39,16 @@ const enqueueSettingsTask = <T>(task: () => Promise<T>): Promise<T> => {
return result;
};
const requestPairingTokenHydration = (): Promise<PairingTokenHydration> => {
if (!pairingTokenHydrationRequest) {
pairingTokenHydrationRequest = invoke('hydrate_extension_pairing_token')
.finally(() => {
pairingTokenHydrationRequest = null;
});
}
return pairingTokenHydrationRequest;
};
export const runSettingsPersistenceTransaction = <T>(
operation: () => Promise<T>
): Promise<T> => enqueueSettingsTask(operation);
@@ -240,11 +252,11 @@ export interface SettingsState {
removeSiteLogin: (id: string) => void;
regeneratePairingToken: () => Promise<void>;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<boolean>;
hydratePairingToken: (isCurrent?: () => boolean) => Promise<boolean>;
setShowKeychainModal: (show: boolean) => void;
setKeychainAccessReady: (ready: boolean) => void;
dismissKeychainPrompt: (version?: string) => void;
hydrateSessionPairingToken: () => Promise<void>;
hydrateSessionPairingToken: (isCurrent?: () => boolean) => Promise<void>;
}
export const useSettingsStore = create<SettingsState>()(
@@ -410,6 +422,11 @@ export const useSettingsStore = create<SettingsState>()(
siteLogins: state.siteLogins.filter((login) => login.id !== id)
})),
regeneratePairingToken: async () => {
const current = get();
if (!current.keychainAccessReady && !current.isPairingTokenPersistent) {
set({ showKeychainModal: true });
throw new Error('Grant credential-store access before regenerating the pairing token.');
}
const result = await invoke('regenerate_pairing_token');
if (!result.persistent) {
throw new Error(result.error || 'Credential store access is unavailable.');
@@ -420,11 +437,12 @@ export const useSettingsStore = create<SettingsState>()(
showKeychainModal: false
});
},
hydratePairingToken: async () => {
hydratePairingToken: async (isCurrent) => {
// The backend migrates legacy settings copies and reads the token from
// the credential store after the app state is ready to receive it.
// Portable mode remains the explicit folder-contained exception.
const result = await invoke('hydrate_extension_pairing_token');
const result = await requestPairingTokenHydration();
if (isCurrent && !isCurrent()) return false;
set({
extensionPairingToken: result.token,
isPairingTokenPersistent: result.persistent,
@@ -432,8 +450,9 @@ export const useSettingsStore = create<SettingsState>()(
});
return result.tokenChanged;
},
hydrateSessionPairingToken: async () => {
hydrateSessionPairingToken: async (isCurrent) => {
const result = await invoke('get_session_pairing_token');
if (isCurrent && !isCurrent()) return;
set({
extensionPairingToken: result.token,
isPairingTokenPersistent: false,