diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 39433422..4a061676 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6904,6 +6904,8 @@ dependencies = [ "rand 0.8.6", "reqwest 0.12.28", "rodio", + "rustls", + "rustls-native-certs", "serde", "serde_json", "souvlaki", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c62fa91c..f30551dd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -27,6 +27,8 @@ portpicker = "0.1" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "gzip", "brotli", "deflate", "stream"] } +rustls = { version = "0.23", default-features = false, features = ["std"] } +rustls-native-certs = "0.8" base64 = "0.22" url = "2.5" keyring = { version = "3.6", features = ["apple-native", "windows-native", "sync-secret-service"] } diff --git a/src-tauri/src/lastfm.rs b/src-tauri/src/lastfm.rs index 7728bdbc..a287c53d 100644 --- a/src-tauri/src/lastfm.rs +++ b/src-tauri/src/lastfm.rs @@ -1,4 +1,4 @@ -use crate::{CommandError, KEYRING_SERVICE}; +use crate::{http_client_builder, CommandError, KEYRING_SERVICE}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -131,7 +131,11 @@ async fn signed_lastfm_post Deserialize<'de>>( params.insert("api_sig".to_string(), signature); params.insert("format".to_string(), "json".to_string()); - let response = reqwest::Client::new() + let response = http_client_builder() + .build() + .map_err(|error| CommandError { + message: format!("Last.fm client creation failed: {error}"), + })? .post(LASTFM_API_URL) .form(¶ms) .send() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d0e4719..25335ac6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,7 @@ use std::io::{Read, Write}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, TcpListener}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock}; +use std::sync::{Arc, LazyLock, Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -577,6 +577,24 @@ fn cache_error(message: impl Into) -> CommandError { } } +// OS trust store, read once; bundled roots alone reject TLS-inspecting proxies/AV (#143). +static NATIVE_ROOT_CERTS: LazyLock> = LazyLock::new(|| { + rustls_native_certs::load_native_certs() + .certs + .into_iter() + // One cert rustls rejects would fail every client build. + .filter(|cert| rustls::RootCertStore::empty().add(cert.clone()).is_ok()) + .filter_map(|cert| reqwest::Certificate::from_der(&cert).ok()) + .collect() +}); + +pub(crate) fn http_client_builder() -> reqwest::ClientBuilder { + NATIVE_ROOT_CERTS + .iter() + .cloned() + .fold(reqwest::Client::builder(), reqwest::ClientBuilder::add_root_certificate) +} + fn signed_googlevideo_local_address(url: &url::Url) -> Option { if !url .host_str() @@ -1825,8 +1843,10 @@ fn delete_youtube_music_cookie_entries() -> Result<(), CommandError> { } for index in 0..YOUTUBE_COOKIE_MAX_CHUNKS { + // Chunks are contiguous, so stop at the first gap; sweeping every slot floods D-Bus (#144). match youtube_cookie_chunk_entry(index)?.delete_credential() { - Ok(()) | Err(keyring::Error::NoEntry) => {} + Ok(()) => {} + Err(keyring::Error::NoEntry) => break, Err(error) => { return Err(CommandError { message: format!("YouTube Music session chunk {index} delete failed: {error}"), @@ -2445,6 +2465,22 @@ async fn sign_in_youtube_music( let window = build_login_window(&app, true, Arc::new(AtomicBool::new(false)), &candidate_slot_id)?; eprintln!("[internal][tauri][info] sign_in_youtube_music login window created"); + let result = poll_for_sign_in(&app, &jar, &account_lock, &window, &candidate_slot_id); + // Any failure (cancel, timeout, keyring error) must not leave the window up or the partition behind (#144). + if result.is_err() { + let _ = window.close(); + remove_login_partition(&app, &candidate_slot_id); + } + result +} + +fn poll_for_sign_in( + app: &tauri::AppHandle, + jar: &YoutubeCookieJar, + account_lock: &AccountStoreLock, + window: &tauri::WebviewWindow, + candidate_slot_id: &str, +) -> Result { let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError { message: format!("invalid YouTube Music sign-in URL: {error}"), })?; @@ -2454,19 +2490,24 @@ async fn sign_in_youtube_music( eprintln!("[internal][tauri][info] sign_in_youtube_music navigated to Google sign-in"); for poll in 1..=300 { - if let Some(cookie_header) = harvest_session_cookie(&window)? { + let harvested = match harvest_session_cookie(window) { + // Closing the window mid-read fails the read on macOS; that is still a cancel (#143). + Err(_) if app.get_webview_window(YOUTUBE_LOGIN_WINDOW).is_none() => None, + other => other?, + }; + if let Some(cookie_header) = harvested { let (slot_id, account_changed, reused_existing_slot) = { let _guard = account_lock.0.lock().map_err(|_| CommandError { message: "account store lock unavailable".to_string(), })?; - let mut store = load_account_store(&app)?; + let mut store = load_account_store(app)?; let (slot_id, account_changed) = - store.upsert_signed_in_account(&cookie_header, candidate_slot_id.clone()); - save_account_store(&app, &store)?; + store.upsert_signed_in_account(&cookie_header, candidate_slot_id.to_string()); + save_account_store(app, &store)?; (slot_id.clone(), account_changed, slot_id != candidate_slot_id) }; if reused_existing_slot { - remove_login_partition(&app, &candidate_slot_id); + remove_login_partition(app, candidate_slot_id); } if let Ok(mut state) = jar.0.lock() { state.cookie = Some(cookie_header.clone()); @@ -2493,7 +2534,6 @@ async fn sign_in_youtube_music( "[internal][tauri][warn] sign_in_youtube_music cancelled poll={}", poll ); - remove_login_partition(&app, &candidate_slot_id); return Err(CommandError { message: "YouTube Music sign-in was cancelled.".to_string(), }); @@ -2501,8 +2541,6 @@ async fn sign_in_youtube_music( thread::sleep(Duration::from_secs(1)); } - let _ = window.close(); - remove_login_partition(&app, &candidate_slot_id); eprintln!("[internal][tauri][warn] sign_in_youtube_music timed out"); Err(CommandError { message: "YouTube Music sign-in timed out.".to_string(), @@ -2545,34 +2583,37 @@ async fn refresh_youtube_music_cookie( let loaded = Arc::new(AtomicBool::new(false)); let window = build_login_window(&app, false, loaded.clone(), &active_slot_id)?; - let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError { - message: format!("invalid YouTube Music sign-in URL: {error}"), - })?; - window.navigate(login_url).map_err(|error| CommandError { - message: format!("unable to renew the YouTube Music session silently: {error}"), - })?; + // Closed on every exit: an early error used to leave the hidden window running. + let result = (|| { + let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError { + message: format!("invalid YouTube Music sign-in URL: {error}"), + })?; + window.navigate(login_url).map_err(|error| CommandError { + message: format!("unable to renew the YouTube Music session silently: {error}"), + })?; - for poll in 1..=YOUTUBE_SILENT_REFRESH_POLLS { - // Only after a load: harvesting early returns the same stale cookie we came in with, - // because nothing has been through a round trip to Google yet. - if loaded.load(Ordering::Relaxed) { - if let Some(cookie_header) = harvest_session_cookie(&window)? { - eprintln!( - "[internal][tauri][info] refresh_youtube_music_cookie renewed poll={} credential_bytes={}", - poll, - cookie_header.len() - ); - persist_active_account_cookie(&app, &account_lock, &jar, &active_slot_id, &cookie_header)?; - let _ = window.close(); - return Ok(Some(cookie_header)); + for poll in 1..=YOUTUBE_SILENT_REFRESH_POLLS { + // Only after a load: harvesting early returns the same stale cookie we came in with, + // because nothing has been through a round trip to Google yet. + if loaded.load(Ordering::Relaxed) { + if let Some(cookie_header) = harvest_session_cookie(&window)? { + eprintln!( + "[internal][tauri][info] refresh_youtube_music_cookie renewed poll={} credential_bytes={}", + poll, + cookie_header.len() + ); + persist_active_account_cookie(&app, &account_lock, &jar, &active_slot_id, &cookie_header)?; + return Ok(Some(cookie_header)); + } } + thread::sleep(Duration::from_secs(1)); } - thread::sleep(Duration::from_secs(1)); - } + eprintln!("[internal][tauri][info] refresh_youtube_music_cookie found no usable session"); + Ok(None) + })(); let _ = window.close(); - eprintln!("[internal][tauri][info] refresh_youtube_music_cookie found no usable session"); - Ok(None) + result } /// Signs out of whichever account is currently active — today's one-account "sign out". @@ -3063,7 +3104,7 @@ async fn fetch_audio_bytes( let request_url = url::Url::parse(&url).map_err(|error| CommandError { message: format!("audio URL parse failed: {error}"), })?; - let mut client_builder = reqwest::Client::builder(); + let mut client_builder = http_client_builder(); if let Some(local_address) = signed_googlevideo_local_address(&request_url) { eprintln!( "[internal][tauri][info] fetch_audio_bytes forcing signed IP family family={}", @@ -3263,7 +3304,7 @@ fn audio_chunk_size(total: u64) -> u64 { } fn offline_http_client(request_url: &url::Url) -> Result { - let mut client_builder = reqwest::Client::builder(); + let mut client_builder = http_client_builder(); if let Some(local_address) = signed_googlevideo_local_address(request_url) { client_builder = client_builder.local_address(local_address); } @@ -4451,7 +4492,9 @@ async fn fetch_youtube_music_audio(video_id: String) -> Result { await placeMiniPlayerAtBottomCenter(miniWin); } catch (_) {} - await miniWin.show(); - if (isLinux) { - try { - await placeMiniPlayerAtBottomCenter(miniWin); - } catch (_) {} - } - await miniWin.setFocus(); + // Focus coming back mid-show destroys the window under these calls ("window not found"). + try { + await miniWin.show(); + if (isLinux) { + try { + await placeMiniPlayerAtBottomCenter(miniWin); + } catch (_) {} + } + await miniWin.setFocus(); + } catch (_) {} }; /*