import React, { useEffect, useRef, useState } from 'react'; import { useSettingsStore } from '../store/useSettingsStore'; import { invokeCommand as invoke } from '../ipc'; import { KeyRound, ShieldAlert } from 'lucide-react'; import { usePlatformInfo } from '../utils/platform'; import { getKeychainConsentVersion } from '../utils/keychainStartup'; import { getVersion } from '@tauri-apps/api/app'; import type { PairingTokenHydration } from '../bindings/PairingTokenHydration'; import { useTranslation } from 'react-i18next'; const KEYCHAIN_GRANT_TIMEOUT_MS = 30_000; type KeychainPermissionModalProps = { consentVersion: string; }; export const KeychainPermissionModal: React.FC = ({ consentVersion }) => { const { t } = useTranslation(); const showKeychainModal = useSettingsStore(state => state.showKeychainModal); const dismissKeychainPrompt = useSettingsStore(state => state.dismissKeychainPrompt); const platform = usePlatformInfo(); const [isGranting, setIsGranting] = useState(false); const [grantRequestPending, setGrantRequestPending] = useState(false); const [error, setError] = useState(null); const grantRequestRef = useRef | null>(null); useEffect(() => { if (!showKeychainModal || isGranting || grantRequestPending) return; const handleEscape = (event: KeyboardEvent) => { if (event.key !== 'Escape') return; if (consentVersion.trim()) { dismissKeychainPrompt(consentVersion); } else { useSettingsStore.getState().setShowKeychainModal(false); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); }, [consentVersion, dismissKeychainPrompt, grantRequestPending, isGranting, showKeychainModal]); if (!showKeychainModal) { return null; } const isMac = platform.os === 'macos'; const pairingStoreName = platform.portable ? t($ => $.keychain.stores.portable) : platform.os === 'windows' ? t($ => $.keychain.stores.windows) : platform.os === 'linux' ? t($ => $.keychain.stores.linux) : platform.os === 'macos' ? t($ => $.keychain.stores.macos) : t($ => $.keychain.stores.system); const siteCredentialStoreName = platform.portable ? t($ => $.keychain.stores.siteCredentials) : pairingStoreName; const grantLabel = platform.portable ? t($ => $.keychain.grantLabelPortable) : isMac ? t($ => $.keychain.grantLabelMacos) : t($ => $.keychain.grantLabelDefault); const handleGrant = async () => { // A native credential-store call cannot be cancelled from the webview. // Keep the request identity until it settles so a UI timeout cannot // launch a second OS prompt while the first one is still outstanding. if (grantRequestRef.current) return; setIsGranting(true); setError(null); let timeoutId: number | undefined; let persistentGrantApplied = false; const applyPersistentGrant = async (result: PairingTokenHydration): Promise => { if (!result.persistent || persistentGrantApplied) return result.persistent; persistentGrantApplied = true; const grantedVersion = consentVersion || getKeychainConsentVersion(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 }); return true; }; const grantRequest = invoke('grant_keychain_access'); grantRequestRef.current = grantRequest; setGrantRequestPending(true); void grantRequest.then( () => { if (grantRequestRef.current === grantRequest) grantRequestRef.current = null; setGrantRequestPending(false); }, () => { if (grantRequestRef.current === grantRequest) grantRequestRef.current = null; setGrantRequestPending(false); } ); // A native credential-store call cannot be cancelled by the webview. Keep // a late successful result useful even if the UI timeout has already // returned control to the explanation. grantRequest.then(applyPersistentGrant).catch(() => undefined); try { const result = await Promise.race([ grantRequest, new Promise((_, reject) => { timeoutId = window.setTimeout( () => reject(new Error(t($ => $.keychain.timeout))), KEYCHAIN_GRANT_TIMEOUT_MS ); }) ]); if (!(await applyPersistentGrant(result))) { setError(result.error || t($ => $.keychain.unavailable, { store: siteCredentialStoreName })); } } catch (e: any) { setError(e.toString()); } finally { if (timeoutId !== undefined) window.clearTimeout(timeoutId); setIsGranting(false); } }; const handleLater = () => { if (consentVersion.trim()) { dismissKeychainPrompt(consentVersion); } else { // A modal opened by an early user action can render before the async // app-version lookup completes. Do not persist a dismissal for an // unknown build; startup must make the final consent decision once the // identity is known. useSettingsStore.getState().setShowKeychainModal(false); } }; return (
{ if (event.target === event.currentTarget && !isGranting && !grantRequestPending) handleLater(); }} role="dialog" aria-modal="true" >
e.stopPropagation()} >

{t($ => $.keychain.title)}

{t($ => $.keychain.description, { pairingStore: pairingStoreName, siteCredentialStore: siteCredentialStoreName })}

{platform.portable ? t($ => $.keychain.portableExplanation) : isMac ? t($ => $.keychain.macosExplanation) : t($ => $.keychain.defaultExplanation)}

{t($ => $.keychain.note)}{' '} {platform.portable ? t($ => $.keychain.portableNote) : t($ => $.keychain.defaultNote)}

{error && (
{error}
)}
{t($ => $.keychain.hint)}{' '} {platform.portable ? t($ => $.keychain.portableHint) : t($ => $.keychain.defaultHint)}{' '} {t($ => $.keychain.enableFromSettings)}
); };