diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 2f9f3f0..1ef64f3 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -811,7 +811,7 @@ fn decide_pairing_token( } } -fn generate_pairing_token() -> String { +pub(crate) fn generate_pairing_token() -> String { format!( "{}{}", uuid::Uuid::new_v4().simple(), @@ -819,6 +819,51 @@ fn generate_pairing_token() -> String { ) } +/// Read the extension pairing token from the persisted settings JSON. +/// Returns `None` when the field is missing, empty, or the settings haven't +/// been saved yet. This is the **primary read path** — it does not touch the +/// OS keychain and therefore never triggers a system credential prompt. +pub fn load_pairing_token_from_settings(connection: &Connection) -> Result, String> { + let Some(settings_json) = load_settings(connection)? else { + return Ok(None); + }; + let value: serde_json::Value = serde_json::from_str(&settings_json) + .map_err(|error| format!("failed to decode settings: {error}"))?; + let state = value.get("state").unwrap_or(&value); + let token = state + .get("extensionPairingToken") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + Ok(token) +} + +/// Write (or update) the extension pairing token inside the persisted settings +/// JSON document. Keeps all other settings fields intact. +pub fn save_pairing_token_to_settings( + connection: &Connection, + token: &str, +) -> Result<(), String> { + let Some(settings_json) = load_settings(connection)? else { + // Settings haven't been persisted yet — nothing to update. + return Ok(()); + }; + let mut value: serde_json::Value = serde_json::from_str(&settings_json) + .map_err(|error| format!("failed to decode settings: {error}"))?; + let state = if value.get("state").is_some() { + value + .get_mut("state") + .expect("state is an object") + } else { + &mut value + }; + state["extensionPairingToken"] = + serde_json::Value::String(token.to_string()); + let updated = serde_json::to_string(&value) + .map_err(|error| format!("failed to encode settings: {error}"))?; + save_settings(connection, &updated) +} + pub fn set_keychain_password(id: &str, password: &str) -> Result<(), String> { let entry = keyring::Entry::new(KEYCHAIN_SERVICE, id).map_err(|error| error.to_string())?; entry diff --git a/src-tauri/src/ipc.rs b/src-tauri/src/ipc.rs index b40e997..5dbb265 100644 --- a/src-tauri/src/ipc.rs +++ b/src-tauri/src/ipc.rs @@ -273,10 +273,14 @@ pub struct PersistedSettings { pub prevents_sleep_while_downloading: bool, pub media_cookie_source: MediaCookieSource, pub site_logins: Vec, - // Note: `extension_pairing_token` is intentionally NOT persisted here. It - // is an HMAC shared secret and is stored in the OS keychain by the - // frontend. The field is kept on legacy persisted JSON only; serde ignores - // unknown fields when decoding, so existing installs migrate cleanly. + /// The HMAC shared secret for the browser extension. It is persisted in the + /// settings database so that startup never needs to touch the OS keychain. + /// The keychain is still used as defence-in-depth — grant_keychain_access + /// writes the token there — but the DB copy is the primary read path, + /// eliminating the OS credential prompt that macOS shows when the binary + /// signature changes after an update. + #[serde(default)] + pub extension_pairing_token: String, pub auto_check_updates: bool, #[serde(default)] pub keychain_access_granted: bool, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 341d202..58e5452 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3429,39 +3429,48 @@ struct PairingTokenHydration { error: Option, } +/// Hydrate the extension pairing token on startup **without touching the OS +/// keychain**. The token is read from the persisted settings (SQLite) so the +/// operating system never presents a credential-access prompt before the UI is +/// visible — even after a build update where the code signature changed. +/// +/// When no token has been persisted yet (fresh install) a new one is generated +/// and `persistent` is returned as `false`, which causes the frontend to show +/// the `KeychainPermissionModal`. #[tauri::command] fn hydrate_extension_pairing_token( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { - let mut connection = database.lock()?; - let skip_keychain = !crate::db::is_keychain_access_granted(&connection).unwrap_or(false); - match crate::db::hydrate_pairing_token(&mut connection, skip_keychain) { - Ok((token, token_changed)) => { - if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { - *pairing_token = token.clone(); - } - Ok(PairingTokenHydration { - token, - token_changed, - persistent: !skip_keychain, - 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), - }) + let connection = database.lock()?; + + // Primary path: read the token from the settings DB. This is always safe + // and never triggers an OS prompt. + if let Some(existing) = crate::db::load_pairing_token_from_settings(&connection)? { + if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { + *pairing_token = existing.clone(); } + return Ok(PairingTokenHydration { + token: existing, + token_changed: false, + persistent: true, + error: None, + }); } + + // No token in the DB yet — generate one and save it so future launches + // find it without prompting. + let generated = crate::db::generate_pairing_token(); + crate::db::save_pairing_token_to_settings(&connection, &generated)?; + if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { + *pairing_token = generated.clone(); + } + Ok(PairingTokenHydration { + token: generated, + token_changed: false, + persistent: false, + error: None, + }) } #[tauri::command] @@ -3471,12 +3480,17 @@ fn grant_keychain_access( ) -> Result { let mut connection = database.lock()?; - // Explicitly force migration of any legacy token to the keychain + // Explicitly force migration of any legacy token to the keychain. + // This is the ONLY code path that touches the OS keychain and it is + // reached exclusively through the frontend's "Grant Access" button, + // so any system prompt is user-initiated. let _ = crate::db::sanitize_current_settings_and_restore_token(&connection, true); match crate::db::hydrate_pairing_token(&mut connection, false) { Ok((token, token_changed)) => { - // Update the extension server's token in memory + // Persist the token to the settings DB so future startups + // can read it without touching the keychain at all. + let _ = crate::db::save_pairing_token_to_settings(&connection, &token); if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index da82e4d..3342c7c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -298,6 +298,7 @@ fn default_settings() -> PersistedSettings { prevents_sleep_while_downloading: true, media_cookie_source: MediaCookieSource::None, site_logins: Vec::new(), + extension_pairing_token: String::new(), auto_check_updates: true, keychain_access_granted: false, } diff --git a/src/bindings/PersistedSettings.ts b/src/bindings/PersistedSettings.ts index 91c90bc..ea75a91 100644 --- a/src/bindings/PersistedSettings.ts +++ b/src/bindings/PersistedSettings.ts @@ -8,4 +8,4 @@ import type { SettingsTab } from "./SettingsTab"; import type { SiteLogin } from "./SiteLogin"; import type { Theme } from "./Theme"; -export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; +export type PersistedSettings = { theme: Theme, baseDownloadFolder: string, categorySubfolders: { [key in string]: string }, categoryDirectoryOverrides: { [key in string]: string }, approvedDownloadRoots: Array, maxConcurrentDownloads: number, globalSpeedLimit: string, isSidebarVisible: boolean, activeSettingsTab: SettingsTab, scheduler: SchedulerSettings, schedulerRunning: boolean, schedulerActiveDownloadIds: Array, schedulerLastStartKey: string, schedulerLastStopKey: string, lastCustomSpeedLimitKiB: number, perServerConnections: number, maxAutomaticRetries: number, showNotifications: boolean, playCompletionSound: boolean, appFontSize: AppFontSize, listRowDensity: ListRowDensity, showDockBadge: boolean, showMenuBarIcon: boolean, proxyMode: ProxyMode, proxyHost: string, proxyPort: number, customUserAgent: string, askWhereToSaveEachFile: boolean, preventsSleepWhileDownloading: boolean, mediaCookieSource: MediaCookieSource, siteLogins: Array, extensionPairingToken: string, autoCheckUpdates: boolean, keychainAccessGranted: boolean, }; diff --git a/src/store/useSettingsStore.ts b/src/store/useSettingsStore.ts index 6014347..fec2b40 100644 --- a/src/store/useSettingsStore.ts +++ b/src/store/useSettingsStore.ts @@ -398,6 +398,7 @@ export const useSettingsStore = create()( preventsSleepWhileDownloading: state.preventsSleepWhileDownloading, mediaCookieSource: state.mediaCookieSource, siteLogins: state.siteLogins, + extensionPairingToken: state.extensionPairingToken, keychainAccessGranted: state.keychainAccessGranted, autoCheckUpdates: state.autoCheckUpdates }),