mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-19 15:46:17 +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(())
|
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 existing = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID).ok();
|
||||||
let generated = generate_pairing_token();
|
let generated = generate_pairing_token();
|
||||||
let decision = decide_pairing_token(
|
let decision = decide_pairing_token(
|
||||||
|
|||||||
+40
-4
@@ -3069,11 +3069,12 @@ fn hydrate_extension_pairing_token(
|
|||||||
app_state: tauri::State<'_, AppState>,
|
app_state: tauri::State<'_, AppState>,
|
||||||
) -> Result<PairingTokenHydration, String> {
|
) -> Result<PairingTokenHydration, String> {
|
||||||
let mut connection = database.lock()?;
|
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 {
|
Ok((token, token_changed)) => Ok(PairingTokenHydration {
|
||||||
token,
|
token,
|
||||||
token_changed,
|
token_changed,
|
||||||
persistent: true,
|
persistent: false, // Explicitly false since we skipped the keychain
|
||||||
error: None,
|
error: None,
|
||||||
}),
|
}),
|
||||||
Err(error) => {
|
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]
|
#[tauri::command]
|
||||||
fn acknowledge_pairing_token_change(
|
fn acknowledge_pairing_token_change(
|
||||||
state: tauri::State<'_, crate::db::DbState>,
|
state: tauri::State<'_, crate::db::DbState>,
|
||||||
@@ -3857,7 +3893,7 @@ pub fn run() {
|
|||||||
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
.map_err(|error| format!("failed to initialize persistence: {error}"))?;
|
||||||
let initial_pairing_token = {
|
let initial_pairing_token = {
|
||||||
let mut connection = database.lock()?;
|
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,
|
Ok((token, _)) => token,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!(
|
log::warn!(
|
||||||
@@ -4216,7 +4252,7 @@ pub fn run() {
|
|||||||
ack_schedule_trigger,
|
ack_schedule_trigger,
|
||||||
check_automation_permission, request_automation_permission, open_automation_settings,
|
check_automation_permission, request_automation_permission, open_automation_settings,
|
||||||
set_keychain_password, get_keychain_password, delete_keychain_password,
|
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,
|
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,
|
get_extension_server_port, set_extension_frontend_ready, set_concurrent_limit, set_global_speed_limit, remove_download,
|
||||||
detach_download_for_reconfigure,
|
detach_download_for_reconfigure,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isPermissionGranted, requestPermission, sendNotification } from '@tauri
|
|||||||
import SchedulerView from "./components/SchedulerView";
|
import SchedulerView from "./components/SchedulerView";
|
||||||
import SpeedLimiterView from "./components/SpeedLimiterView";
|
import SpeedLimiterView from "./components/SpeedLimiterView";
|
||||||
import LogsView from "./components/LogsView";
|
import LogsView from "./components/LogsView";
|
||||||
|
import { KeychainPermissionModal } from "./components/KeychainPermissionModal";
|
||||||
import { useToast } from "./contexts/ToastContext";
|
import { useToast } from "./contexts/ToastContext";
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||||
import { usePlatformInfo } from './utils/platform';
|
import { usePlatformInfo } from './utils/platform';
|
||||||
@@ -611,6 +612,7 @@ function App() {
|
|||||||
<AddDownloadsModal />
|
<AddDownloadsModal />
|
||||||
<PropertiesModal />
|
<PropertiesModal />
|
||||||
<DeleteConfirmationModal />
|
<DeleteConfirmationModal />
|
||||||
|
<KeychainPermissionModal />
|
||||||
|
|
||||||
</div>
|
</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';
|
} from '../store/useSettingsStore';
|
||||||
import {
|
import {
|
||||||
Download, Palette, Globe, Folder, Key,
|
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';
|
} from 'lucide-react';
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
import { getVersion } from '@tauri-apps/api/app';
|
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>
|
||||||
</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 */}
|
{/* Step Guide Cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ type CommandMap = {
|
|||||||
set_extension_pairing_token: { args: { token: string }; result: void };
|
set_extension_pairing_token: { args: { token: string }; result: void };
|
||||||
get_extension_server_port: { args: undefined; result: number | null };
|
get_extension_server_port: { args: undefined; result: number | null };
|
||||||
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
hydrate_extension_pairing_token: { args: undefined; result: PairingTokenHydration };
|
||||||
|
grant_keychain_access: { args: undefined; result: PairingTokenHydration };
|
||||||
acknowledge_pairing_token_change: { args: undefined; result: void };
|
acknowledge_pairing_token_change: { args: undefined; result: void };
|
||||||
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
set_extension_frontend_ready: { args: { ready: boolean }; result: void };
|
||||||
get_system_proxy: { args: undefined; result: string | null };
|
get_system_proxy: { args: undefined; result: string | null };
|
||||||
|
|||||||
@@ -105,7 +105,9 @@ export interface SettingsState {
|
|||||||
mediaCookieSource: MediaCookieSource;
|
mediaCookieSource: MediaCookieSource;
|
||||||
siteLogins: SiteLogin[];
|
siteLogins: SiteLogin[];
|
||||||
extensionPairingToken: string;
|
extensionPairingToken: string;
|
||||||
|
isPairingTokenPersistent: boolean;
|
||||||
autoCheckUpdates: boolean;
|
autoCheckUpdates: boolean;
|
||||||
|
showKeychainModal: boolean;
|
||||||
|
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
setBaseDownloadFolder: (path: string) => void;
|
setBaseDownloadFolder: (path: string) => void;
|
||||||
@@ -145,6 +147,7 @@ export interface SettingsState {
|
|||||||
regeneratePairingToken: () => Promise<void>;
|
regeneratePairingToken: () => Promise<void>;
|
||||||
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
setAutoCheckUpdates: (autoCheckUpdates: boolean) => void;
|
||||||
hydratePairingToken: () => Promise<boolean>;
|
hydratePairingToken: () => Promise<boolean>;
|
||||||
|
setShowKeychainModal: (show: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateSecureToken = () => {
|
const generateSecureToken = () => {
|
||||||
@@ -220,7 +223,9 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
mediaCookieSource: 'none',
|
mediaCookieSource: 'none',
|
||||||
siteLogins: [],
|
siteLogins: [],
|
||||||
extensionPairingToken: '',
|
extensionPairingToken: '',
|
||||||
|
isPairingTokenPersistent: true,
|
||||||
autoCheckUpdates: true,
|
autoCheckUpdates: true,
|
||||||
|
showKeychainModal: false,
|
||||||
|
|
||||||
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
setTheme: (theme) => { info('Settings updated: theme'); set({ theme }); },
|
||||||
setBaseDownloadFolder: (path) => {
|
setBaseDownloadFolder: (path) => {
|
||||||
@@ -310,13 +315,17 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
},
|
},
|
||||||
hydratePairingToken: async () => {
|
hydratePairingToken: async () => {
|
||||||
const result = await invoke('hydrate_extension_pairing_token');
|
const result = await invoke('hydrate_extension_pairing_token');
|
||||||
set({ extensionPairingToken: result.token });
|
set({
|
||||||
if (!result.persistent && result.error) {
|
extensionPairingToken: result.token,
|
||||||
throw new Error(`Session-only browser pairing token: ${result.error}`);
|
isPairingTokenPersistent: result.persistent
|
||||||
|
});
|
||||||
|
if (!result.persistent) {
|
||||||
|
set({ showKeychainModal: true });
|
||||||
}
|
}
|
||||||
return result.tokenChanged;
|
return result.tokenChanged;
|
||||||
},
|
},
|
||||||
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
setAutoCheckUpdates: (autoCheckUpdates) => set({ autoCheckUpdates }),
|
||||||
|
setShowKeychainModal: (show) => set({ showKeychainModal: show }),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'firelink-settings',
|
name: 'firelink-settings',
|
||||||
|
|||||||
Reference in New Issue
Block a user