Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/lastfm.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -131,7 +131,11 @@ async fn signed_lastfm_post<T: for<'de> 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(&params)
.send()
Expand Down
125 changes: 87 additions & 38 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -577,6 +577,24 @@ fn cache_error(message: impl Into<String>) -> CommandError {
}
}

// OS trust store, read once; bundled roots alone reject TLS-inspecting proxies/AV (#143).
static NATIVE_ROOT_CERTS: LazyLock<Vec<reqwest::Certificate>> = 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<IpAddr> {
if !url
.host_str()
Expand Down Expand Up @@ -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}"),
Expand Down Expand Up @@ -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<SignInResult, CommandError> {
let login_url = YOUTUBE_LOGIN_URL.parse().map_err(|error| CommandError {
message: format!("invalid YouTube Music sign-in URL: {error}"),
})?;
Expand All @@ -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());
Expand All @@ -2493,16 +2534,13 @@ 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(),
});
}
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(),
Expand Down Expand Up @@ -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".
Expand Down Expand Up @@ -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={}",
Expand Down Expand Up @@ -3263,7 +3304,7 @@ fn audio_chunk_size(total: u64) -> u64 {
}

fn offline_http_client(request_url: &url::Url) -> Result<reqwest::Client, CommandError> {
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);
}
Expand Down Expand Up @@ -4451,7 +4492,9 @@ async fn fetch_youtube_music_audio(video_id: String) -> Result<AudioPayload, Com
video_id
);

let client = reqwest::Client::new();
let client = http_client_builder().build().map_err(|error| CommandError {
message: format!("HTTP client creation failed: {error}"),
})?;

// Mobile and TV clients are preferred because they are more likely to
// return direct media URLs that do not require player-JavaScript deciphering.
Expand Down Expand Up @@ -4882,7 +4925,7 @@ async fn try_youtube_api(
let audio_url_parsed = url::Url::parse(&audio_url).map_err(|error| CommandError {
message: format!("audio URL parse failed: {error}"),
})?;
let mut audio_client_builder = reqwest::Client::builder();
let mut audio_client_builder = http_client_builder();
if let Some(local_address) = signed_googlevideo_local_address(&audio_url_parsed) {
eprintln!(
"[internal][tauri][info] fetch_youtube_music_audio forcing signed IP family attempt={} family={}",
Expand Down Expand Up @@ -5027,7 +5070,7 @@ async fn proxy_http_request(
}
})?;

let mut client_builder = reqwest::Client::builder()
let mut client_builder = http_client_builder()
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36");

if let Some(timeout_ms) = input.timeout_ms {
Expand Down Expand Up @@ -5457,6 +5500,12 @@ mod tests {
use std::sync::{Arc, Mutex};
use std::time::Duration;

// Every request goes through this builder; a root reqwest refuses would break all of them (#143).
#[test]
fn http_client_builds_with_the_os_trust_store() {
assert!(super::http_client_builder().build().is_ok());
}

/**
* Which decoder a track is sent to.
*
Expand Down
17 changes: 10 additions & 7 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1772,13 +1772,16 @@ useEffect(() => {
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 (_) {}
};

/*
Expand Down
Loading