mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
feat(settings): add keychain access permission flow
This commit is contained in:
+8
-1
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 > 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>
|
||||
);
|
||||
};
|
||||
@@ -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">
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user