From af2c101ca862c31e30858ded299dbabdc7a30ec0 Mon Sep 17 00:00:00 2001 From: NimBold Date: Wed, 24 Jun 2026 15:32:43 +0330 Subject: [PATCH] feat(settings): add keychain access permission flow --- src-tauri/src/db.rs | 9 ++- src-tauri/src/lib.rs | 44 +++++++++- src/App.tsx | 2 + src/components/KeychainPermissionModal.tsx | 93 ++++++++++++++++++++++ src/components/SettingsView.tsx | 21 ++++- src/ipc.ts | 1 + src/store/useSettingsStore.ts | 15 +++- 7 files changed, 176 insertions(+), 9 deletions(-) create mode 100644 src/components/KeychainPermissionModal.tsx diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 140fbac..aaa42b9 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -718,7 +718,14 @@ pub fn consume_notice(connection: &Connection, key: &str) -> Result<(), String> Ok(()) } -pub fn hydrate_pairing_token(connection: &mut Connection) -> Result<(String, bool), String> { +pub fn hydrate_pairing_token( + connection: &mut Connection, + skip_keychain: bool, +) -> Result<(String, bool), String> { + if skip_keychain { + return Ok((generate_pairing_token(), false)); + } + let existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok(); let generated = generate_pairing_token(); let decision = decide_pairing_token( diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c718a69..a33af5b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3069,11 +3069,12 @@ fn hydrate_extension_pairing_token( app_state: tauri::State<'_, AppState>, ) -> Result { let mut connection = database.lock()?; - match crate::db::hydrate_pairing_token(&mut connection) { + // Frontend always skips keychain on regular hydration to avoid system prompts. + match crate::db::hydrate_pairing_token(&mut connection, true) { Ok((token, token_changed)) => Ok(PairingTokenHydration { token, token_changed, - persistent: true, + persistent: false, // Explicitly false since we skipped the keychain error: None, }), Err(error) => { @@ -3092,6 +3093,41 @@ fn hydrate_extension_pairing_token( } } +#[tauri::command] +fn grant_keychain_access( + database: tauri::State<'_, crate::db::DbState>, + app_state: tauri::State<'_, AppState>, +) -> Result { + let mut connection = database.lock()?; + match crate::db::hydrate_pairing_token(&mut connection, false) { + Ok((token, token_changed)) => { + // Update the extension server's token in memory + if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { + *pairing_token = token.clone(); + } + Ok(PairingTokenHydration { + token, + token_changed, + persistent: true, + error: None, + }) + } + Err(error) => { + let token = app_state + .extension_pairing_token + .read() + .map_err(|_| "Extension pairing token lock is unavailable".to_string())? + .clone(); + Ok(PairingTokenHydration { + token, + token_changed: false, + persistent: false, + error: Some(error), + }) + } + } +} + #[tauri::command] fn acknowledge_pairing_token_change( state: tauri::State<'_, crate::db::DbState>, @@ -3857,7 +3893,7 @@ pub fn run() { .map_err(|error| format!("failed to initialize persistence: {error}"))?; let initial_pairing_token = { let mut connection = database.lock()?; - match crate::db::hydrate_pairing_token(&mut connection) { + match crate::db::hydrate_pairing_token(&mut connection, true) { Ok((token, _)) => token, Err(error) => { log::warn!( @@ -4216,7 +4252,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, acknowledge_pairing_token_change, + hydrate_extension_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, detach_download_for_reconfigure, diff --git a/src/App.tsx b/src/App.tsx index 5e5a1bf..e4d5af1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri import SchedulerView from "./components/SchedulerView"; import SpeedLimiterView from "./components/SpeedLimiterView"; import LogsView from "./components/LogsView"; +import { KeychainPermissionModal } from "./components/KeychainPermissionModal"; import { useToast } from "./contexts/ToastContext"; import { openUrl } from '@tauri-apps/plugin-opener'; import { usePlatformInfo } from './utils/platform'; @@ -611,6 +612,7 @@ function App() { + ); diff --git a/src/components/KeychainPermissionModal.tsx b/src/components/KeychainPermissionModal.tsx new file mode 100644 index 0000000..59e3a1a --- /dev/null +++ b/src/components/KeychainPermissionModal.tsx @@ -0,0 +1,93 @@ +import React, { useState } from 'react'; +import { useSettingsStore } from '../store/useSettingsStore'; +import { invokeCommand as invoke } from '../ipc'; +import { KeyRound, ShieldAlert } from 'lucide-react'; + +export const KeychainPermissionModal: React.FC = () => { + const showKeychainModal = useSettingsStore(state => state.showKeychainModal); + const setShowKeychainModal = useSettingsStore(state => state.setShowKeychainModal); + const [isGranting, setIsGranting] = useState(false); + const [error, setError] = useState(null); + + if (!showKeychainModal) { + return null; + } + + const handleGrant = async () => { + setIsGranting(true); + setError(null); + try { + const result = await invoke('grant_keychain_access'); + if (result.persistent) { + await useSettingsStore.getState().hydratePairingToken(); + setShowKeychainModal(false); + } else { + setError(result.error || 'Failed to grant keychain access.'); + } + } catch (e: any) { + setError(e.toString()); + } finally { + setIsGranting(false); + } + }; + + const handleLater = () => { + setShowKeychainModal(false); + }; + + return ( +
+
e.stopPropagation()} + > +
+
+ +
+

Keychain Access Needed

+
+ +
+

+ Firelink uses a browser extension to seamlessly capture downloads. + To securely store the pairing token that connects the app and the extension, + we need access to the macOS Keychain. +

+

+ Note: Firelink only requests access to its own dedicated entry in the Keychain. It cannot and will not access any other passwords or Keychain items on your system. +

+ + {error && ( +
+ + {error} +
+ )} + +
+ Hint: If you select Later, the extension will only work for this session. + You can grant access anytime from Settings > Integrations. +
+
+ +
+ + +
+
+
+ ); +}; diff --git a/src/components/SettingsView.tsx b/src/components/SettingsView.tsx index 5e1e9ac..d6c6422 100644 --- a/src/components/SettingsView.tsx +++ b/src/components/SettingsView.tsx @@ -8,7 +8,7 @@ import { } from '../store/useSettingsStore'; import { Download, Palette, Globe, Folder, Key, - Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code + Moon, Terminal, Puzzle, Info, Plus, Trash2, Copy, RefreshCw, Code, ShieldAlert } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { getVersion } from '@tauri-apps/api/app'; @@ -1055,6 +1055,25 @@ className="app-button px-3 py-1.5 text-[12px] flex items-center gap-1.5 disabled + {!settings.isPairingTokenPersistent && ( +
+ +
+

Keychain Access Needed

+

+ Firelink needs macOS Keychain access to securely save your pairing token across app restarts. + Currently, your extension will only stay connected for this session. +

+ +
+
+ )} + {/* Step Guide Cards */}
diff --git a/src/ipc.ts b/src/ipc.ts index f12424b..eb25c16 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -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 }; + grant_keychain_access: { args: undefined; result: PairingTokenHydration }; acknowledge_pairing_token_change: { args: undefined; result: void }; set_extension_frontend_ready: { args: { ready: boolean }; result: void }; get_system_proxy: { args: undefined; result: string | null }; diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 7634ad7..3a7c4bd 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -105,7 +105,9 @@ export interface SettingsState { mediaCookieSource: MediaCookieSource; siteLogins: SiteLogin[]; extensionPairingToken: string; + isPairingTokenPersistent: boolean; autoCheckUpdates: boolean; + showKeychainModal: boolean; setTheme: (theme: Theme) => void; setBaseDownloadFolder: (path: string) => void; @@ -145,6 +147,7 @@ export interface SettingsState { regeneratePairingToken: () => Promise; setAutoCheckUpdates: (autoCheckUpdates: boolean) => void; hydratePairingToken: () => Promise; + setShowKeychainModal: (show: boolean) => void; } const generateSecureToken = () => { @@ -220,7 +223,9 @@ export const useSettingsStore = create()( mediaCookieSource: 'none', siteLogins: [], extensionPairingToken: '', + isPairingTokenPersistent: true, autoCheckUpdates: true, + showKeychainModal: false, setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); }, setBaseDownloadFolder: (path) => { @@ -310,13 +315,17 @@ export const useSettingsStore = create()( }, hydratePairingToken: async () => { const result = await invoke('hydrate_extension_pairing_token'); - set({ extensionPairingToken: result.token }); - if (!result.persistent && result.error) { - throw new Error(`Session-only browser pairing token: ${result.error}`); + set({ + extensionPairingToken: result.token, + isPairingTokenPersistent: result.persistent + }); + if (!result.persistent) { + set({ showKeychainModal: true }); } return result.tokenChanged; }, setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }), + setShowKeychainModal: (show) => set({ showKeychainModal: show }), }), { name: 'firelink-settings',