feat(settings): add keychain access permission flow

This commit is contained in:
NimBold
2026-06-24 15:32:43 +03:30
parent 8d23f0752f
commit af2c101ca8
7 changed files with 176 additions and 9 deletions
+8 -1
View File
@@ -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(
+40 -4
View File
@@ -3069,11 +3069,12 @@ fn hydrate_extension_pairing_token(
app_state: tauri::State<'_, AppState>,
) -> Result<PairingTokenHydration, String> {
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<PairingTokenHydration, String> {
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,
+2
View File
@@ -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() {
<AddDownloadsModal />
<PropertiesModal />
<DeleteConfirmationModal />
<KeychainPermissionModal />
</div>
);
@@ -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<string | null>(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 (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 animate-fade-in">
<div
className="bg-bg-modal rounded-xl w-full max-w-md overflow-hidden flex flex-col shadow-2xl border border-border-modal scale-in"
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b border-border-modal flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-full flex items-center justify-center">
<KeyRound size={20} className="text-blue-500" />
</div>
<h2 className="text-lg font-semibold text-text-primary m-0">Keychain Access Needed</h2>
</div>
<div className="px-5 py-6 flex-1 text-sm text-text-secondary leading-relaxed space-y-4">
<p>
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.
</p>
<p>
<strong>Note:</strong> 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.
</p>
{error && (
<div className="flex items-start gap-2 text-red-400 bg-red-400/10 p-3 rounded-lg border border-red-400/20 text-xs">
<ShieldAlert size={14} className="mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="bg-bg-modal-accent p-3 rounded-lg border border-border-modal text-xs">
<strong>Hint:</strong> If you select Later, the extension will only work for this session.
You can grant access anytime from <strong>Settings &gt; Integrations</strong>.
</div>
</div>
<div className="px-5 py-4 border-t border-border-modal flex justify-end gap-3 bg-bg-modal-accent">
<button
onClick={handleLater}
disabled={isGranting}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors text-text-secondary hover:bg-item-hover hover:text-text-primary disabled:opacity-50"
>
Later
</button>
<button
onClick={handleGrant}
disabled={isGranting}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors bg-accent text-white hover:bg-accent/90 disabled:opacity-50"
>
{isGranting ? 'Granting...' : 'Grant Access'}
</button>
</div>
</div>
</div>
);
};
+20 -1
View File
@@ -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
</div>
</div>
{!settings.isPairingTokenPersistent && (
<div className="bg-orange-500/10 border border-orange-500/20 rounded-lg p-4 flex items-start gap-3">
<ShieldAlert className="w-5 h-5 text-orange-500 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h4 className="text-sm font-semibold text-text-primary mb-1">Keychain Access Needed</h4>
<p className="text-xs text-text-secondary mb-3">
Firelink needs macOS Keychain access to securely save your pairing token across app restarts.
Currently, your extension will only stay connected for this session.
</p>
<button
onClick={() => settings.setShowKeychainModal(true)}
className="app-button primary text-xs py-1"
>
Grant Keychain Access
</button>
</div>
</div>
)}
{/* Step Guide Cards */}
<div className="grid grid-cols-3 gap-4">
+1
View File
@@ -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 };
+12 -3
View File
@@ -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<void>;
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
hydratePairingToken: () => Promise<boolean>;
setShowKeychainModal: (show: boolean) => void;
}
const generateSecureToken = () => {
@@ -220,7 +223,9 @@ export const useSettingsStore = create<SettingsState>()(
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<SettingsState>()(
},
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',