diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index 4a4c42b..ff8981e 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -45,18 +45,17 @@ struct LegacyData { } pub fn init(storage_layout: &crate::storage::StorageLayout) -> Result { - init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable(), false) + init_at_path_internal(storage_layout.data_dir(), storage_layout.is_portable()) } #[cfg(test)] fn init_at_path(app_data_dir: &Path) -> Result { - init_at_path_internal(app_data_dir, false, false) + init_at_path_internal(app_data_dir, false) } fn init_at_path_internal( app_data_dir: &Path, portable: bool, - migrate_legacy_keychain: bool, ) -> Result { fs::create_dir_all(app_data_dir) .map_err(|error| format!("failed to create app data directory: {error}"))?; @@ -78,12 +77,7 @@ fn init_at_path_internal( } migrate_schema(&mut connection, version)?; - import_legacy_data( - &mut connection, - app_data_dir, - portable, - migrate_legacy_keychain, - )?; + import_legacy_data(&mut connection, app_data_dir, portable)?; if portable { sanitize_persisted_downloads(&mut connection)?; } @@ -188,7 +182,6 @@ fn import_legacy_data( connection: &mut Connection, app_data_dir: &Path, portable: bool, - migrate_keychain: bool, ) -> Result<(), String> { let legacy_app_dir = app_data_dir .parent() @@ -226,31 +219,10 @@ fn import_legacy_data( let mut pending_pairing_token = None; if !portable { if let Some(token) = legacy.pairing_token.take() { - let migrated = if migrate_keychain { - let keychain_has_token = get_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID) - .ok() - .is_some_and(|value| !value.trim().is_empty()); - if !keychain_has_token { - if let Err(error) = - set_keychain_password(PAIRING_TOKEN_KEYCHAIN_ID, &token) - { - log::warn!( - "Legacy pairing token could not be migrated to the credential store; it will remain pending in the current database: {}", - error - ); - false - } else { - true - } - } else { - true - } - } else { - false - }; - if !migrated { - pending_pairing_token = Some(token); - } + // Legacy migration is deliberately deferred until the + // explicit frontend consent action. Database initialization + // must never touch the OS credential store. + pending_pairing_token = Some(token); } } if portable { @@ -1738,7 +1710,7 @@ mod tests { .unwrap(); drop(connection); - let state = init_at_path_internal(temp.path(), true, true).unwrap(); + let state = init_at_path_internal(temp.path(), true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); @@ -1828,7 +1800,7 @@ mod tests { }); fs::write(&store_path, serde_json::to_vec(&store).unwrap()).unwrap(); - let state = init_at_path_internal(¤t, true, true).unwrap(); + let state = init_at_path_internal(¤t, true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); @@ -2104,7 +2076,7 @@ mod tests { drop(connection); drop(state); - let state = init_at_path_internal(temp.path(), true, true).unwrap(); + let state = init_at_path_internal(temp.path(), true).unwrap(); let connection = state.lock().unwrap(); let saved: Value = serde_json::from_str(&load_downloads(&connection).unwrap()[0]).unwrap(); assert!(saved.get("password").is_none()); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d179ef7..e889433 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2859,6 +2859,12 @@ pub enum TaskHandle { pub struct AppState { pub download_coordinator: download::DownloadCoordinator, pub storage_layout: crate::storage::StorageLayout, + /// Credential-store access is denied until the frontend has completed the + /// startup consent decision for this process. This is intentionally + /// process-local: a new binary must pass through the consent barrier + /// again before any stale or early IPC caller can reach the OS store. + pub keychain_access_authorized: Arc, + pub keychain_grant_in_progress: Arc, pub extension_pairing_token: extension_server::SharedExtensionToken, pub extension_frontend_ready: extension_server::SharedFrontendReady, pub extension_acks: extension_server::SharedExtensionAcks, @@ -2872,6 +2878,38 @@ pub struct AppState { pub queue_manager: Arc, } +const KEYCHAIN_CONSENT_REQUIRED_ERROR: &str = + "Credential-store access requires explicit user consent"; +const KEYCHAIN_GRANT_IN_PROGRESS_ERROR: &str = + "Credential-store access is already being requested"; + +fn require_keychain_access(state: &AppState) -> Result<(), String> { + if state + .keychain_access_authorized + .load(Ordering::Acquire) + { + Ok(()) + } else { + Err(KEYCHAIN_CONSENT_REQUIRED_ERROR.to_string()) + } +} + +struct KeychainGrantGuard(Arc); + +impl Drop for KeychainGrantGuard { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +fn begin_keychain_grant(state: &AppState) -> Result { + state + .keychain_grant_in_progress + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .map(|_| KeychainGrantGuard(Arc::clone(&state.keychain_grant_in_progress))) + .map_err(|_| KEYCHAIN_GRANT_IN_PROGRESS_ERROR.to_string()) +} + #[derive(Clone, Serialize, TS)] #[ts(export, export_to = "../../src/bindings/")] pub struct DownloadProgressEvent { @@ -5676,6 +5714,7 @@ fn set_keychain_password( crate::db::save_pairing_token_to_settings(&connection, &password, true)?; return Ok(()); } + require_keychain_access(&state)?; crate::db::set_keychain_password(&id, &password) } @@ -5689,6 +5728,7 @@ fn get_keychain_password( { return Err("portable pairing token is stored in portable settings".to_string()); } + require_keychain_access(&state)?; crate::db::get_keychain_password(&id) } @@ -5702,17 +5742,20 @@ fn delete_keychain_password( { return Ok(()); } + require_keychain_access(&state)?; crate::db::delete_keychain_password(&id) } #[tauri::command] fn save_site_login( database: tauri::State<'_, crate::db::DbState>, + state: tauri::State<'_, AppState>, id: String, url_pattern: String, username: String, password: String, ) -> Result<(), String> { + require_keychain_access(&state)?; let connection = database.lock()?; crate::db::save_site_login(&connection, &id, &url_pattern, &username, &password) } @@ -5720,12 +5763,28 @@ fn save_site_login( #[tauri::command] fn delete_site_login( database: tauri::State<'_, crate::db::DbState>, + state: tauri::State<'_, AppState>, id: String, ) -> Result<(), String> { + require_keychain_access(&state)?; let connection = database.lock()?; crate::db::delete_site_login(&connection, &id) } +/// Arm credential-store access after the frontend has completed its startup +/// decision. The process-local gate prevents any stale or early IPC caller +/// from producing a native OS prompt before the explanation is visible. +#[tauri::command] +fn authorize_keychain_access(state: tauri::State<'_, AppState>) -> Result<(), String> { + if state.storage_layout.is_portable() { + return Ok(()); + } + state + .keychain_access_authorized + .store(true, Ordering::Release); + Ok(()) +} + #[derive(Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../../src/bindings/")] @@ -5761,6 +5820,7 @@ fn hydrate_extension_pairing_token( }); } + require_keychain_access(&app_state)?; let migration_error = crate::db::migrate_legacy_pairing_token(&connection).err(); let keychain_token = crate::db::get_keychain_password(crate::db::PAIRING_TOKEN_KEYCHAIN_ID) .ok() @@ -5821,6 +5881,7 @@ fn regenerate_pairing_token( if app_state.storage_layout.is_portable() { crate::db::save_pairing_token_to_settings(&connection, &generated, true)?; } else { + require_keychain_access(&app_state)?; if let Err(error) = crate::db::migrate_legacy_pairing_token(&connection) { let token = app_state .extension_pairing_token @@ -5868,6 +5929,7 @@ fn grant_keychain_access( database: tauri::State<'_, crate::db::DbState>, app_state: tauri::State<'_, AppState>, ) -> Result { + let _grant_guard = begin_keychain_grant(&app_state)?; let connection = database.lock()?; if app_state.storage_layout.is_portable() { @@ -5882,6 +5944,9 @@ fn grant_keychain_access( if let Ok(mut pairing_token) = app_state.extension_pairing_token.write() { *pairing_token = token.clone(); } + app_state + .keychain_access_authorized + .store(true, Ordering::Release); return Ok(PairingTokenHydration { token, token_changed: false, @@ -5933,6 +5998,9 @@ fn grant_keychain_access( *pairing_token = token.clone(); } } + app_state + .keychain_access_authorized + .store(true, Ordering::Release); Ok(PairingTokenHydration { token, token_changed: false, @@ -8442,6 +8510,8 @@ pub fn run() { app.manage(AppState { download_coordinator: download::DownloadCoordinator::spawn(app.handle().clone()), storage_layout, + keychain_access_authorized: Arc::new(AtomicBool::new(false)), + keychain_grant_in_progress: Arc::new(AtomicBool::new(false)), extension_pairing_token, extension_frontend_ready, extension_acks, @@ -8971,6 +9041,7 @@ pub fn run() { set_keychain_password, get_keychain_password, delete_keychain_password, save_site_login, delete_site_login, hydrate_extension_pairing_token, get_session_pairing_token, regenerate_pairing_token, grant_keychain_access, + authorize_keychain_access, acknowledge_pairing_token_change, check_file_exists, toggle_tray_icon, set_extension_pairing_token, get_extension_server_port, set_extension_frontend_ready, ack_extension_download, set_concurrent_limit, set_global_speed_limit, remove_download, diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json index 73c52f3..13081c2 100644 --- a/src-tauri/tauri.macos.conf.json +++ b/src-tauri/tauri.macos.conf.json @@ -8,17 +8,9 @@ "height": 760, "minWidth": 960, "minHeight": 640, - "titleBarStyle": "Overlay", - "trafficLightPosition": { - "x": 17, - "y": 28 - }, - "hiddenTitle": true, "transparent": true, - "windowEffects": { - "effects": ["sidebar"], - "state": "active" - } + "decorations": false, + "shadow": false } ] }, diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index a8f7b84..652302c 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -10,10 +10,7 @@ "minHeight": 640, "transparent": true, "decorations": false, - "windowEffects": { - "effects": ["mica"], - "state": "active" - } + "shadow": false } ] }, diff --git a/src/App.tsx b/src/App.tsx index 41b627a..3063e5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -450,6 +450,12 @@ function App() { settings.setShowKeychainModal(true); } } else { + // The backend keeps credential-store access disabled for every new + // process. Arm it only after the persisted startup decision has + // confirmed that this build was already approved; the hydrate call + // below is then the first operation allowed to touch the OS store. + await invoke('authorize_keychain_access'); + if (!active) return; changed = await settings.hydratePairingToken(isStartupActive); if (!active) return; const currentSettings = useSettingsStore.getState(); diff --git a/src/components/AddDownloadsModal.tsx b/src/components/AddDownloadsModal.tsx index e904330..1afd95c 100644 --- a/src/components/AddDownloadsModal.tsx +++ b/src/components/AddDownloadsModal.tsx @@ -25,7 +25,7 @@ import { getPlatformInfo } from '../utils/platform'; import { isTransferLocked } from '../utils/downloadActions'; import { useToast } from '../contexts/ToastContext'; import { useTranslation } from 'react-i18next'; -import { localePluralVariant } from '../i18n/locales'; +import { localeDirection, localePluralVariant, resolveAppLocale } from '../i18n/locales'; import { canSubmitMetadataRows, appendRequestUrlsAfterVersion, @@ -116,6 +116,7 @@ const extensionHeaders = (context: PendingAddRequestContext | undefined) => [ export const AddDownloadsModal = () => { const { t, i18n } = useTranslation(); + const isRtl = localeDirection(resolveAppLocale(i18n.language)) === 'rtl'; const { addToast } = useToast(); const { isAddModalOpen, @@ -1247,7 +1248,9 @@ export const AddDownloadsModal = () => {