diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d283e23..70470b7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 { + 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, diff --git a/src/App.tsx b/src/App.tsx index d2d2dab..b19ff57 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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(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() { - + ); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index 3163327..443ac02 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -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(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) { diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx index ca6242f..92a59c4 100644 --- a/src/components/KeychainPermissionModal.tsx +++ b/src/components/KeychainPermissionModal.tsx @@ -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 = ({ 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 = () => {

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}.

{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.'} diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index c351b48..253e3cb 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -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);