diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index d7cd378..5883a83 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -56,6 +56,28 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ashpd" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" +dependencies = [ + "async-fs", + "async-net", + "enumflags2", + "futures-channel", + "futures-util", + "rand 0.9.4", + "raw-window-handle", + "serde", + "serde_repr", + "url", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -94,6 +116,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + [[package]] name = "async-io" version = "2.6.0" @@ -123,6 +156,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + [[package]] name = "async-process" version = "2.5.0" @@ -989,6 +1033,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + [[package]] name = "dlopen2" version = "0.8.2" @@ -1027,6 +1080,12 @@ dependencies = [ "tendril 0.5.0", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -3183,7 +3242,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3228,6 +3287,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + [[package]] name = "potential_utf" version = "0.1.5" @@ -3351,6 +3416,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + [[package]] name = "quinn" version = "0.11.9" @@ -3706,6 +3780,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed" +dependencies = [ + "ashpd", + "block2", + "dispatch2", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "pollster", + "raw-window-handle", + "urlencoding", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.59.0", +] + [[package]] name = "rfd" version = "0.16.0" @@ -3927,6 +4025,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -4193,6 +4297,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -4731,7 +4846,7 @@ checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" dependencies = [ "log", "raw-window-handle", - "rfd", + "rfd 0.16.0", "serde", "serde_json", "tauri", @@ -5822,6 +5937,66 @@ dependencies = [ "semver", ] +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +dependencies = [ + "bitflags 2.11.1", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.11.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +dependencies = [ + "proc-macro2", + "quick-xml 0.39.4", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.95" @@ -6659,8 +6834,10 @@ dependencies = [ "discord-rich-presence", "md5", "reqwest 0.12.28", + "rfd 0.15.4", "serde", "serde_json", + "sha1", "souvlaki", "tauri", "tauri-build", @@ -6851,6 +7028,7 @@ dependencies = [ "endi", "enumflags2", "serde", + "url", "winnow 0.7.15", "zvariant_derive", "zvariant_utils", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29b0b22..f39453d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -55,6 +55,8 @@ tauri-plugin-process = "2.3.1" tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" tauri-plugin-notification = "2" +rfd = "0.15" +sha1 = "0.10" # OS media controls: on Windows this is the System Media Transport Controls # (the media tile in Quick Settings / the volume flyout, the lock screen, and # the hardware media keys). We create the SMTC ourselves from the host process diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f0b2a16..903a3e6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,7 +3,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::path::PathBuf; use std::process::Stdio; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -200,6 +200,226 @@ struct AccountSummary { is_active: bool, } +#[derive(Default)] +struct LoginConfirm(StdMutex>>); + +/// One parsed line from a Netscape cookie-jar export. +#[derive(Clone, Debug)] +struct NetscapeLine { + domain: String, + path: String, + secure: bool, + expiry: i64, + name: String, + value: String, +} + +/// Parse a Netscape cookie-jar export. Supports `#HttpOnly_` prefix lines +/// and skips comment / header lines. +fn parse_netscape_cookies(text: &str) -> Result, String> { + let mut out = Vec::new(); + for raw_line in text.lines() { + let line = raw_line.trim_end(); + if line.is_empty() { + continue; + } + let line = if let Some(rest) = line.strip_prefix("#HttpOnly_") { + rest + } else if line.starts_with('#') { + continue; + } else { + line + }; + let fields: Vec<&str> = line.splitn(7, '\t').collect(); + if fields.len() < 7 { + return Err(format!( + "invalid cookie line (expected 7 tab-separated fields): {}", + line.chars().take(80).collect::() + )); + } + out.push(NetscapeLine { + domain: fields[0].to_string(), + path: fields[2].to_string(), + secure: fields[3] == "TRUE", + expiry: fields[4].parse().unwrap_or(0), + name: fields[5].to_string(), + value: fields[6].to_string(), + }); + } + if out.is_empty() { + return Err("no cookies found in input".into()); + } + Ok(out) +} + +/// Require at least one auth cookie InnerTube can use (youtube or google). +fn validate_yt_auth_cookies(lines: &[NetscapeLine]) -> Result<(), String> { + let auth_names = [ + "SAPISID", + "__Secure-1PSID", + "__Secure-3PAPISID", + "SID", + "LOGIN_INFO", + ]; + let ok = lines.iter().any(|c| { + let bare = c.domain.trim_start_matches('.'); + let is_google = bare == "google.com" || bare.ends_with(".google.com"); + let is_yt = bare == "youtube.com" || bare.ends_with(".youtube.com"); + (is_google || is_yt) && auth_names.contains(&c.name.as_str()) + }); + if ok { + Ok(()) + } else { + Err( + "missing required auth cookies — export cookies for both youtube.com and google.com \ + (need SAPISID, __Secure-1PSID, or LOGIN_INFO)" + .into(), + ) + } +} + +/// Serialize parsed lines back to Netscape format, keeping only +/// google/youtube domains (same filter as `cookies_to_netscape`). +/// Normalizes every line to `.{domain}\tTRUE` so `read_innertube_cookie_header` +/// can match `music.youtube.com` — browser exporters often emit +/// `youtube.com\tFALSE`, which would silently drop SAPISID / PSID. +fn netscape_lines_to_text(lines: &[NetscapeLine]) -> String { + let mut out = String::from("# Netscape HTTP Cookie File\n"); + for c in lines { + let bare = c.domain.trim_start_matches('.'); + let allowed = bare == "youtube.com" + || bare.ends_with(".youtube.com") + || bare == "google.com" + || bare.ends_with(".google.com"); + if !allowed { + continue; + } + let dom_out = format!(".{bare}"); + let secure = if c.secure { "TRUE" } else { "FALSE" }; + out.push_str(&format!( + "{}\tTRUE\t{}\t{}\t{}\t{}\t{}\n", + dom_out, c.path, secure, c.expiry, c.name, c.value + )); + } + out +} + +/// Build a `Cookie:` header for InnerTube. **YouTube-only** — sending +/// google.com cookies together with youtube.com breaks `account_menu` +/// (verified in scripts/auth-lab). SAPISIDHASH still uses youtube SAPISID. +fn cookie_header_from_netscape(content: &str) -> String { + let mut parts: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for line in content.lines() { + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 7 { + continue; + } + let bare = fields[0].trim_start_matches('.'); + if !bare.ends_with("youtube.com") { + continue; + } + let name = fields[5]; + let value = fields[6]; + if !seen.insert(name.to_string()) { + continue; + } + parts.push(format!("{name}={value}")); + } + parts.join("; ") +} + +fn sapisid_for_hash_from_netscape(content: &str) -> Option { + let mut yt_3p = None; + let mut yt_s = None; + for line in content.lines() { + if line.starts_with('#') || line.trim().is_empty() { + continue; + } + let fields: Vec<&str> = line.split('\t').collect(); + if fields.len() < 7 { + continue; + } + let bare = fields[0].trim_start_matches('.'); + if !bare.ends_with("youtube.com") { + continue; + } + let name = fields[5]; + let value = fields[6]; + match name { + "__Secure-3PAPISID" => yt_3p = Some(value.to_string()), + "SAPISID" => yt_s = Some(value.to_string()), + _ => {} + } + } + yt_3p.or(yt_s) +} + +fn sapisid_hash_value(sapisid: &str, origin: &str) -> Option { + let ts = time::OffsetDateTime::now_utc().unix_timestamp(); + use sha1::{Digest, Sha1}; + let mut hasher = Sha1::new(); + hasher.update(format!("{ts} {sapisid} {origin}")); + let hash = format!("{:x}", hasher.finalize()); + Some(format!("SAPISIDHASH {ts}_{hash}")) +} + +/// Whether a cookie line's domain covers `host`. Leading-dot domains and +/// `include_subdomains=TRUE` both imply subdomain coverage (Netscape +/// convention + what browser exporters usually intend). +fn cookie_domain_matches(host: &str, cookie_domain: &str, include_subdomains: bool) -> bool { + let domain = cookie_domain.trim_start_matches('.'); + let host_bare = host.trim_start_matches('.'); + if host_bare == domain { + return true; + } + let covers_subdomains = include_subdomains || cookie_domain.starts_with('.'); + covers_subdomains && host_bare.ends_with(&format!(".{domain}")) +} + +async fn register_account_from_netscape( + app: &tauri::AppHandle, + netscape: String, +) -> Result { + let lines = parse_netscape_cookies(&netscape)?; + validate_yt_auth_cookies(&lines)?; + let filtered = netscape_lines_to_text(&lines); + if filtered.lines().count() <= 1 { + return Err("no google/youtube cookies found after filtering".into()); + } + + let new_id = generate_account_id(); + let cookies_path = account_cookies_path(app, &new_id); + if let Some(dir) = cookies_path.parent() { + tokio::fs::create_dir_all(dir) + .await + .map_err(|e| format!("mkdir account dir: {e}"))?; + } + let plain = filtered.into_bytes(); + let encrypted = tokio::task::spawn_blocking(move || secure_store::encrypt(&plain)) + .await + .map_err(|e| format!("encrypt join: {e}"))? + .map_err(|e| format!("encrypt cookies: {e}"))?; + tokio::fs::write(&cookies_path, &encrypted) + .await + .map_err(|e| format!("write account cookies: {e}"))?; + + let mut idx = read_index(app).await; + let now_s = time::OffsetDateTime::now_utc().unix_timestamp(); + idx.accounts.push(Account { + id: new_id.clone(), + added_at: now_s, + ..Default::default() + }); + idx.active = Some(new_id.clone()); + write_index(app, &idx).await?; + let _ = app.emit("login-success", &new_id); + Ok(new_id) +} + fn accounts_dir(app: &tauri::AppHandle) -> PathBuf { app.path() .app_data_dir() @@ -800,26 +1020,48 @@ async fn start_login(app: tauri::AppHandle) -> Result<(), String> { .map_err(|e| e.to_string())?; let app_poll = app.clone(); - // Failure paths wipe the whole account dir (profile + jar); on - // success we keep it so the live session can be refreshed later. let cleanup_dir = account_dir.clone(); + let confirm_flag = Arc::new(AtomicBool::new(false)); + if let Ok(mut guard) = app.state::().0.lock() { + *guard = Some(confirm_flag.clone()); + } tauri::async_runtime::spawn(async move { // Set to true once we've redirected the webview to YT ourselves. // Guards against thrashing if YT auto-sign-in is slow and we // catch a Google-auth-only state on multiple ticks. let mut nudged_to_yt = false; - // Ticks spent waiting for the handshake to finish after auth - // cookies first appear (see below). - let mut full_set_grace: u8 = 0; + let mut insecure_emitted = false; + let mut login_ready_emitted = false; + let mut confirm_ticks: u32 = 0; loop { tokio::time::sleep(Duration::from_millis(1500)).await; let Some(win) = app_poll.get_webview_window("login") else { let _ = app_poll.emit("login-cancelled", ()); + if let Ok(mut guard) = app_poll.state::().0.lock() { + *guard = None; + } let _ = tokio::fs::remove_dir_all(&cleanup_dir).await; return; }; + let user_confirmed = confirm_flag.load(Ordering::Acquire); + if user_confirmed { + confirm_ticks += 1; + } + + let current_url = win.url().map(|u| u.to_string()).unwrap_or_default(); + let url_lower = current_url.to_lowercase(); + if !insecure_emitted + && (url_lower.contains("signin/rejected") + || url_lower.contains("browsernotsecure") + || url_lower.contains("couldn")) + { + let _ = app_poll.emit("login-insecure-browser", ()); + insecure_emitted = true; + let _ = win.set_title("Sign in — Google blocked this browser"); + } + let cookies = match win.cookies() { Ok(c) => c, Err(e) => { @@ -830,13 +1072,32 @@ async fn start_login(app: tauri::AppHandle) -> Result<(), String> { let has_yt_auth = cookies.iter().any(|c| { let name = c.name(); - (name == "__Secure-1PSID" || name == "SAPISID") - && c.domain() - .map(|d| d.trim_start_matches('.').ends_with("youtube.com")) - .unwrap_or(false) + let is_auth = name == "__Secure-1PSID" + || name == "__Secure-3PSID" + || name == "SAPISID" + || name == "SID"; + is_auth + && c.domain().map(|d| { + let bare = d.trim_start_matches('.'); + bare.ends_with("youtube.com") || bare.ends_with("google.com") + }).unwrap_or(false) }); + let on_music_yt = url_lower.contains("music.youtube.com"); + if !has_yt_auth { + if user_confirmed && confirm_ticks >= 20 { + let _ = app_poll.emit( + "login-failed", + "No YouTube session found. In the login window, open music.youtube.com and sign in there, then click Continue — or use Import cookies below.", + ); + let _ = win.close(); + if let Ok(mut guard) = app_poll.state::().0.lock() { + *guard = None; + } + let _ = tokio::fs::remove_dir_all(&cleanup_dir).await; + return; + } // YT cookies aren't set yet. Two ways to land here: // 1) User hasn't completed Google sign-in. Keep waiting. // 2) Google sign-in succeeded but Google parked the @@ -881,23 +1142,28 @@ async fn start_login(app: tauri::AppHandle) -> Result<(), String> { continue; } - // SAPISID shows up before YouTube finishes its handshake; - // capturing at first sight used to miss LOGIN_INFO / - // VISITOR_INFO1_LIVE / YSC. Those make our replayed traffic - // look like the browser session Google issued it to, so - // give the handshake a few ticks to complete. Capture - // anyway after ~6 s in case the cookie set changes shape. - let has_login_info = cookies.iter().any(|c| { - c.name() == "LOGIN_INFO" - && c.domain() - .map(|d| d.trim_start_matches('.').ends_with("youtube.com")) - .unwrap_or(false) - }); - if !has_login_info && full_set_grace < 4 { - full_set_grace += 1; + if !on_music_yt && !user_confirmed { + if !login_ready_emitted { + let _ = app_poll.emit("login-ready", ()); + let _ = win.set_title("Sign in — open music.youtube.com, then Continue"); + login_ready_emitted = true; + } continue; } + if user_confirmed && confirm_ticks >= 120 { + let _ = app_poll.emit( + "login-failed", + "Sign-in timed out. Try Import cookies from Chrome/Edge (export google.com + youtube.com).", + ); + let _ = win.close(); + if let Ok(mut guard) = app_poll.state::().0.lock() { + *guard = None; + } + let _ = tokio::fs::remove_dir_all(&cleanup_dir).await; + return; + } + // Same id as the persisted WebView profile created above, so // the account row and its live session profile stay paired. let new_id = account_id.clone(); @@ -963,6 +1229,9 @@ async fn start_login(app: tauri::AppHandle) -> Result<(), String> { // `accounts-changed` fires, so we never run the full reset // twice for one login flow. let _ = app_poll.emit("login-success", &new_id); + if let Ok(mut guard) = app_poll.state::().0.lock() { + *guard = None; + } let _ = win.close(); // Keep the WebView profile: it's the live session the periodic // refresh re-extracts from. Only cancel/error paths above (and @@ -1123,10 +1392,41 @@ async fn refresh_active_session(app: tauri::AppHandle) -> Result { } } +#[derive(serde::Serialize)] +struct InnertubeAuthHeaders { + #[serde(rename = "cookieHeader")] + cookie_header: String, + authorization: String, +} + +fn build_innertube_auth_from_netscape(content: &str) -> Option { + let cookie_header = cookie_header_from_netscape(content); + if cookie_header.is_empty() { + return None; + } + let sapisid = sapisid_for_hash_from_netscape(content)?; + let authorization = sapisid_hash_value(&sapisid, "https://music.youtube.com")?; + Some(InnertubeAuthHeaders { + cookie_header, + authorization, + }) +} + +async fn read_innertube_cookie_header(app: &tauri::AppHandle) -> String { + let Some(content) = read_cookies_plain(app).await else { + return String::new(); + }; + cookie_header_from_netscape(&content) +} + /// Parse a Netscape cookie jar and return a `Cookie:` header value /// containing all cookies that match the given domain (honoring the /// `include_subdomains` flag). Empty string if no jar or no matches. async fn read_cookie_header(app: &tauri::AppHandle, host: &str) -> String { + // InnerTube auth needs the full youtube jar, not host-filtered. + if host.contains("youtube") || host.contains("google") { + return read_innertube_cookie_header(app).await; + } let Some(content) = read_cookies_plain(app).await else { return String::new(); }; @@ -1135,16 +1435,12 @@ async fn read_cookie_header(app: &tauri::AppHandle, host: &str) -> String { if line.starts_with('#') || line.trim().is_empty() { continue; } - // domain \t include_subdomains \t path \t secure \t expiry \t name \t value let fields: Vec<&str> = line.split('\t').collect(); if fields.len() < 7 { continue; } - let domain = fields[0].trim_start_matches('.'); let include_sub = fields[1] == "TRUE"; - let matches = host == domain - || (include_sub && host.ends_with(&format!(".{domain}"))); - if !matches { + if !cookie_domain_matches(host, fields[0], include_sub) { continue; } parts.push(format!("{}={}", fields[5], fields[6])); @@ -1160,10 +1456,21 @@ async fn get_cookie_header( Ok(read_cookie_header(&app, &host).await) } +#[tauri::command] +async fn get_innertube_auth(app: tauri::AppHandle) -> Result { + let content = read_cookies_plain(&app) + .await + .ok_or_else(|| "not signed in".to_string())?; + build_innertube_auth_from_netscape(&content) + .ok_or_else(|| "missing SAPISID / PSID auth cookies".to_string()) +} + #[tauri::command] async fn is_logged_in(app: tauri::AppHandle) -> Result { - let header = read_cookie_header(&app, "music.youtube.com").await; - Ok(header.contains("SAPISID") || header.contains("__Secure-1PSID")) + let Some(content) = read_cookies_plain(&app).await else { + return Ok(false); + }; + Ok(build_innertube_auth_from_netscape(&content).is_some()) } /// Hard-exit the process. The window's close button hides into the tray @@ -1653,34 +1960,58 @@ async fn set_account_channel( Ok(()) } -/// Cookie header plus the active account's brand-channel page id in a -/// single call. The InnerTube client sends the page id back as the -/// `X-Goog-PageId` header. Bundling it with the cookie read (instead -/// of a second command) means a cold start can't pair fresh cookies -/// with a stale page id, or vice versa. -#[derive(Clone, Debug, serde::Serialize)] -struct AuthContext { - cookie: String, - #[serde(rename = "pageId")] - page_id: Option, +#[tauri::command] +async fn get_active_brand_id(app: tauri::AppHandle) -> Result, String> { + let idx = read_index(&app).await; + let active = idx.active.as_deref(); + Ok(idx + .accounts + .iter() + .find(|a| active == Some(a.id.as_str())) + .and_then(|a| a.page_id.clone())) } #[tauri::command] -async fn get_auth_context( +async fn import_cookies_from_text( app: tauri::AppHandle, - host: String, -) -> Result { - let cookie = read_cookie_header(&app, &host).await; - let page_id = if cookie.is_empty() { - None - } else { - let idx = read_index(&app).await; - idx.accounts - .iter() - .find(|a| idx.active.as_deref() == Some(a.id.as_str())) - .and_then(|a| a.page_id.clone()) + text: String, +) -> Result { + register_account_from_netscape(&app, text).await +} + +#[tauri::command] +async fn import_cookies_from_file(app: tauri::AppHandle) -> Result { + let path = rfd::FileDialog::new() + .add_filter("Cookie files", &["txt"]) + .add_filter("All files", &["*"]) + .set_title("Import Netscape cookie file") + .pick_file() + .ok_or_else(|| "no file selected".to_string())?; + let text = tokio::fs::read_to_string(&path) + .await + .map_err(|e| format!("read file: {e}"))?; + register_account_from_netscape(&app, text).await +} + +#[tauri::command] +fn confirm_login(state: tauri::State<'_, LoginConfirm>) -> Result<(), String> { + let guard = state + .0 + .lock() + .map_err(|e| format!("lock: {e}"))?; + let Some(flag) = guard.as_ref() else { + return Err("no login in progress".into()); }; - Ok(AuthContext { cookie, page_id }) + flag.store(true, Ordering::Release); + Ok(()) +} + +#[tauri::command] +fn cancel_login(app: tauri::AppHandle) -> Result<(), String> { + if let Some(win) = app.get_webview_window("login") { + win.close().map_err(|e| e.to_string())?; + } + Ok(()) } /// Serializes read-modify-write cycles on the active cookie jar. @@ -2994,13 +3325,19 @@ pub fn run() { .manage(RefreshGuard::default()) .manage(discord::spawn()) .manage(lastfm::LastfmState::default()) + .manage(LoginConfirm::default()) .invoke_handler(tauri::generate_handler![ ensure_ytdlp, resolve_stream_ytdlp, get_stream_base_url, start_login, + confirm_login, + cancel_login, + import_cookies_from_text, + import_cookies_from_file, get_cookie_header, - get_auth_context, + get_innertube_auth, + get_active_brand_id, merge_response_cookies, is_logged_in, refresh_active_session, diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 620c468..9e2929c 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -37,6 +37,7 @@ import { useAccountMetaBackfill, useAccountsChangedListener, useLoginSuccessListener, + useLoginPolishListener, } from "@/lib/store/accounts"; import { cn } from "@/lib/utils"; @@ -88,6 +89,7 @@ export function AppShell({ children }: { children: ReactNode }) { useWhatsNewOnUpdate(); usePremiumStatusSync(); useLoginSuccessListener(); + useLoginPolishListener(); useAccountsChangedListener(); useAccountMetaBackfill(); useGlobalShortcuts(); diff --git a/src/components/settings/account-tab.tsx b/src/components/settings/account-tab.tsx new file mode 100644 index 0000000..b0ed9a9 --- /dev/null +++ b/src/components/settings/account-tab.tsx @@ -0,0 +1,306 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { listen } from "@tauri-apps/api/event"; +import { + Loader2Icon, + LogInIcon, + UserRoundIcon, + AlertTriangleIcon, + CookieIcon, + UploadIcon, +} from "lucide-react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Group, TabPane } from "@/components/settings/primitives"; +import { fetchAccountInfo } from "@/lib/innertube/account"; + +export function AccountTab() { + return ( + + + + ); +} + +function AccountGroup() { + const [signingIn, setSigningIn] = useState(false); + const [loginReady, setLoginReady] = useState(false); + const [importing, setImporting] = useState(false); + const [cookieText, setCookieText] = useState(""); + + const loggedIn = useQuery({ + queryKey: ["auth-logged-in"], + queryFn: () => invoke("is_logged_in"), + staleTime: 30_000, + }); + + useEffect(() => { + const unlistenSuccess = listen("login-success", () => { + setSigningIn(false); + setLoginReady(false); + toast.success("Signed in"); + }); + const unlistenCancel = listen("login-cancelled", () => { + setSigningIn(false); + setLoginReady(false); + }); + const unlistenFailed = listen("login-failed", (event) => { + setSigningIn(false); + setLoginReady(false); + toast.error(event.payload, { duration: 14_000 }); + }); + const unlistenReady = listen("login-ready", () => { + setLoginReady(true); + }); + + return () => { + unlistenSuccess.then((fn) => fn()); + unlistenCancel.then((fn) => fn()); + unlistenFailed.then((fn) => fn()); + unlistenReady.then((fn) => fn()); + }; + }, []); + + const signIn = async () => { + setSigningIn(true); + try { + await invoke("start_login"); + } catch (e) { + setSigningIn(false); + toast.error(String(e)); + } + }; + + const confirmLogin = async () => { + try { + await invoke("confirm_login"); + toast.message("Finishing sign-in…", { + description: "Stay on music.youtube.com in the login window.", + }); + } catch (e) { + toast.error(String(e)); + } + }; + + const cancelLogin = async () => { + try { + await invoke("cancel_login"); + } catch { + /* window may already be closed */ + } + setSigningIn(false); + setLoginReady(false); + }; + + const importCookies = async () => { + if (!cookieText.trim()) { + toast.error("Paste Netscape-format cookies first"); + return; + } + setImporting(true); + try { + await invoke("import_cookies_from_text", { text: cookieText }); + setCookieText(""); + toast.success("Session imported"); + } catch (e) { + toast.error(String(e)); + } finally { + setImporting(false); + } + }; + + const importCookiesFromFile = async () => { + setImporting(true); + try { + await invoke("import_cookies_from_file"); + toast.success("Session imported from file"); + } catch (e) { + if (!String(e).includes("no file selected")) { + toast.error(String(e)); + } + } finally { + setImporting(false); + } + }; + + const account = useQuery({ + queryKey: ["account-info"], + queryFn: () => fetchAccountInfo(), + enabled: loggedIn.data === true, + staleTime: 5 * 60_000, + retry: false, + }); + + // Auth check or account profile still loading: avoid flashing. + if (loggedIn.isLoading || (loggedIn.data === true && account.isLoading)) { + return null; + } + + // If successfully logged in, show the signed-in account details in settings! + if (loggedIn.data === true && account.data) { + const live = account.data; + return ( + +
+ + + {live.name?.charAt(0).toUpperCase() || "?"} + + +
+ + {live.name} + + + {live.email} + +
+ +
+
+ ); + } + + return ( + +
+
+ + + + + +
+ + Not signed in + + + Sign in to unlock your library, liked songs, and + Premium-quality streams. Cookies stay on this machine. + +
+ +
+ + {loginReady && ( +
+

+ In the login window, go to music.youtube.com and + make sure you are signed in, then click Continue. YTubic will save + your session and close the window automatically. +

+
+ + +
+
+ )} + + {signingIn && !loginReady && ( + + )} + +
+ +
+
+

+ + Import session from browser +

+

+ If Google blocks the embedded sign-in window, export cookies from Chrome or Edge where you are already signed in to YouTube Music. +

+
+ +
+

+ + Security warning +

+

+ Pasted cookies are full account credentials. They stay encrypted on this PC only — never share them or paste them into untrusted sites. +

+
+ +
+

How to export:

+
    +
  1. In Chrome or Edge, sign in at music.youtube.com
  2. +
  3. Use a Netscape cookie exporter extension (e.g. "Get cookies.txt LOCALLY") for youtube.com and google.com
  4. +
  5. Paste the file contents below or choose the exported file.
  6. +
+

+ Required: cookies for both youtube.com and google.com (need SAPISID, __Secure-1PSID, or LOGIN_INFO). +

+
+ +