use anyhow::{Context, Result}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use rand::RngCore; use sha2::{Digest, Sha256}; use std::path::PathBuf; use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::{Mutex, Notify}; pub const SCHEME: &str = "tidal"; pub const DEFAULT_PORT: u16 = 8989; pub const AUTH_TIMEOUT_SECS: u64 = 300; #[derive(Debug, Clone)] pub struct PkceSession { pub verifier: String, pub challenge: String, pub state: String, pub client_unique_key: String, } pub fn new_session() -> PkceSession { let verifier = new_verifier(); PkceSession { challenge: challenge_for(&verifier), verifier, state: new_state(), client_unique_key: uuid::Uuid::new_v4().simple().to_string(), } } fn new_verifier() -> String { let mut bytes = [0u8; 64]; rand::thread_rng().fill_bytes(&mut bytes); URL_SAFE_NO_PAD.encode(bytes) } fn challenge_for(verifier: &str) -> String { let digest = Sha256::digest(verifier.as_bytes()); URL_SAFE_NO_PAD.encode(digest) } fn new_state() -> String { let mut bytes = [0u8; 16]; rand::thread_rng().fill_bytes(&mut bytes); format!("TIDAL_{}", hex_encode(bytes)) } pub fn build_authorize_url( client_id: &str, redirect_uri: &str, scope: &str, session: &PkceSession, ) -> String { let scope_param = scope.replace(' ', "+"); let mut query = url::form_urlencoded::Serializer::new(String::new()); query.append_pair("appMode", "DESKTOP"); query.append_pair("client_id", client_id); query.append_pair("client_unique_key", &session.client_unique_key); query.append_pair("code_challenge", &session.challenge); query.append_pair("code_challenge_method", "S256"); query.append_pair("lang", "en"); query.append_pair("redirect_uri", redirect_uri); query.append_pair("response_type", "code"); query.append_pair("restrictSignup", "true"); query.append_pair("state", &session.state); format!( "https://login.tidal.com/authorize?{}&scope={}", query.finish(), scope_param ) } /// Extract (code, state) from a redirect URL, a query string, or a bare code. pub fn parse_code(text: &str) -> (Option, Option) { let text = text.trim().trim_matches('"').trim_matches('\''); if text.is_empty() { return (None, None); } if text.contains("code=") { let query = match url::Url::parse(text) { Ok(url) => url.query().unwrap_or("").to_string(), Err(_) => { if let Some((_, q)) = text.split_once('?') { q.to_string() } else { text.to_string() } } }; let mut code = None; let mut state = None; for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { match k.as_ref() { "code" => code = Some(v.into_owned()), "state" => state = Some(v.into_owned()), _ => {} } } (code, state) } else { (Some(text.to_string()), None) } } fn hex_encode(bytes: [u8; 16]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } pub fn home_dir() -> PathBuf { dirs::home_dir().unwrap_or_else(|| ".".into()) } fn callback_info_path() -> PathBuf { home_dir().join(".tidal-dl.authcb.json") } fn scheme_marker_path() -> PathBuf { home_dir().join(".tidal-dl.scheme") } fn authcode_path() -> PathBuf { home_dir().join(".tidal-dl.authcode") } pub fn clear_authcode() { let _ = std::fs::remove_file(authcode_path()); } fn write_authcode(code: &str, state: Option<&str>) -> bool { let value = serde_json::json!({"code": code, "state": state}); std::fs::write(authcode_path(), value.to_string()).is_ok() } pub fn read_authcode(expected_state: Option<&str>) -> Option { let text = std::fs::read_to_string(authcode_path()).ok()?; let value: serde_json::Value = serde_json::from_str(&text).ok()?; let code = value.get("code")?.as_str()?; if code.is_empty() { return None; } if let Some(expected) = expected_state { match value.get("state").and_then(|s| s.as_str()) { Some(state) if state == expected => Some(code.to_string()), _ => None, } } else { Some(code.to_string()) } } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct CallbackInfo { port: u16, state: Option, } fn write_callback_info(port: u16, state: Option<&str>) { let info = CallbackInfo { port, state: state.map(str::to_string), }; let _ = std::fs::write( callback_info_path(), serde_json::to_string(&info).unwrap(), ); } fn read_callback_info() -> Option { let text = std::fs::read_to_string(callback_info_path()).ok()?; serde_json::from_str(&text).ok() } fn clear_callback_info() { let _ = std::fs::remove_file(callback_info_path()); } pub struct CallbackServer { port: u16, code: Arc>>, notify: Arc, _handle: tokio::task::JoinHandle<()>, } impl CallbackServer { pub async fn start(port: u16, expected_state: Option) -> Result { let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) .await .with_context(|| format!("bind loopback port {port}"))?; let bound_port = listener.local_addr().map(|a| a.port()).unwrap_or(port); let code = Arc::new(Mutex::new(None::)); let notify = Arc::new(Notify::new()); let expected = expected_state.clone(); let accept_code = code.clone(); let accept_notify = notify.clone(); let handle = tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { continue; }; let code = accept_code.clone(); let notify = accept_notify.clone(); let expected = expected.clone(); tokio::spawn(async move { serve_one(&mut socket, &code, ¬ify, expected.as_deref()).await; }); } }); write_callback_info(bound_port, expected_state.as_deref()); Ok(Self { port: bound_port, code, notify, _handle: handle, }) } pub async fn wait_for_code( &self, timeout_secs: u64, expected_state: Option<&str>, ) -> Option { let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); loop { if let Some(code) = self.code.lock().await.clone() { return Some(code); } if let Some(code) = read_authcode(expected_state) { return Some(code); } if tokio::time::Instant::now() >= deadline { return self.code.lock().await.clone(); } let remaining = deadline - tokio::time::Instant::now(); let tick = std::time::Duration::from_millis(300).min(remaining.into()); let _ = tokio::time::timeout(tick.into(), self.notify.notified()).await; } } pub fn port(&self) -> u16 { self.port } } impl Drop for CallbackServer { fn drop(&mut self) { self._handle.abort(); clear_callback_info(); } } async fn serve_one( socket: &mut tokio::net::TcpStream, code: &Arc>>, notify: &Arc, expected_state: Option<&str>, ) { let mut buf = vec![0u8; 8192]; let Ok(n) = socket.read(&mut buf).await else { return; }; let request = String::from_utf8_lossy(&buf[..n]); let path = request .lines() .next() .and_then(|line| line.split_whitespace().nth(1)) .unwrap_or("/"); let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); let mut got_code = None; let mut got_state = None; for (k, v) in url::form_urlencoded::parse(query.as_bytes()) { match k.as_ref() { "code" => got_code = Some(v.into_owned()), "state" => got_state = Some(v.into_owned()), _ => {} } } let ok = match (&got_code, expected_state) { (Some(_), None) => true, (Some(_), Some(expected)) => got_state.as_deref() == Some(expected), _ => false, }; if ok { *code.lock().await = got_code; notify.notify_waiters(); respond(socket, 200, "TIDAL login complete. You may close this tab.").await; } else { respond(socket, 400, "Login callback missing or invalid.").await; } } async fn respond(socket: &mut tokio::net::TcpStream, status: u16, message: &str) { let reason = if status == 200 { "OK" } else { "Bad Request" }; let body = format!("

{message}

"); let response = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len() ); let _ = socket.write_all(response.as_bytes()).await; } /// Forward a captured redirect URL/code to the waiting login session. pub async fn deliver_callback(url: &str) -> bool { let (code, state) = parse_code(url); let Some(code) = code else { return false }; write_authcode(&code, state.as_deref()); let port = std::env::var("TIDAL_CALLBACK_PORT") .ok() .and_then(|s| s.parse().ok()) .or_else(|| read_callback_info().map(|info| info.port)) .unwrap_or(DEFAULT_PORT); let mut target = format!("http://127.0.0.1:{port}/callback?code={}", url_encode(&code)); if let Some(state) = state { target.push_str(&format!("&state={}", url_encode(&state))); } let _ = reqwest::get(&target).await; true } fn url_encode(s: &str) -> String { url::form_urlencoded::byte_serialize(s.as_bytes()).collect() } pub fn open_browser(url: &str) -> bool { if is_termux() { if let Ok(opener) = which("termux-open-url") { return std::process::Command::new(opener) .arg(url) .spawn() .is_ok(); } return false; } open::that(url).is_ok() } fn is_termux() -> bool { std::env::var("PREFIX").map(|p| p.contains("com.termux")).unwrap_or(false) || std::env::var("ANDROID_ROOT").is_ok() || which("termux-open-url").is_ok() } fn which(name: &str) -> Result { let paths = std::env::var("PATH").map_err(|_| ())?; for dir in std::env::split_paths(&paths) { let candidate = dir.join(name); if candidate.is_file() { return Ok(candidate.to_string_lossy().into_owned()); } } Err(()) } pub fn register_scheme(force: bool) -> bool { let marker = scheme_marker_path(); if !force && marker.exists() { return true; } let ok = do_register().unwrap_or(false); let _ = std::fs::write(&marker, format!("{}\n", std::env::consts::OS)); ok } #[cfg(target_os = "linux")] fn do_register() -> Result { use std::process::Command; let apps_dir = home_dir().join(".local/share/applications"); std::fs::create_dir_all(&apps_dir)?; let exe = std::env::current_exe()?.to_string_lossy().into_owned(); let exec_line = format!("\"{exe}\" --auth-callback \"%u\""); let content = format!( "[Desktop Entry]\nName=tidal-dl\nComment=TIDAL login callback handler\nExec={exec_line}\nType=Application\nTerminal=false\nNoDisplay=true\nMimeType=x-scheme-handler/{SCHEME};\n" ); std::fs::write(apps_dir.join("tidal-dl.desktop"), content)?; let _ = Command::new("xdg-mime") .args(["default", "tidal-dl.desktop", "x-scheme-handler/tidal"]) .status(); let _ = Command::new("update-desktop-database") .arg(&apps_dir) .status(); Ok(true) } #[cfg(target_os = "macos")] fn do_register() -> Result { use std::os::unix::fs::PermissionsExt; use std::process::Command; const INFO_PLIST: &str = r#" CFBundleIdentifiercom.tidal-dl.scheme CFBundleNametidal-dl-scheme CFBundleExecutablerun CFBundleURLTypes CFBundleURLNamecom.tidal-dl.scheme CFBundleURLSchemestidal "#; let exe = std::env::current_exe()?.to_string_lossy().into_owned(); let app = home_dir().join("Applications/tidal-dl-scheme.app"); let macos_dir = app.join("Contents/MacOS"); std::fs::create_dir_all(&macos_dir)?; std::fs::write(app.join("Contents/Info.plist"), INFO_PLIST)?; let run = macos_dir.join("run"); std::fs::write(&run, format!("#!/bin/sh\n\"{exe}\" --auth-callback \"$1\"\n"))?; std::fs::set_permissions(&run, std::fs::Permissions::from_mode(0o755))?; const LSREGISTER: &str = "/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister"; if std::path::Path::new(LSREGISTER).exists() { let _ = Command::new(LSREGISTER).args(["-R", "-f"]).arg(&app).status(); } Ok(true) } #[cfg(target_os = "windows")] fn do_register() -> Result { Ok(false) } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] fn do_register() -> Result { Ok(false) } #[cfg(test)] mod tests { use super::*; #[test] fn parse_code_handles_redirect_and_bare_code() { let (code, state) = parse_code("tidal://login/auth?code=abc&state=TIDAL_xyz"); assert_eq!(code.as_deref(), Some("abc")); assert_eq!(state.as_deref(), Some("TIDAL_xyz")); let (code, _) = parse_code("abc123"); assert_eq!(code.as_deref(), Some("abc123")); let (code, _) = parse_code(""); assert!(code.is_none()); } #[test] fn authorize_url_carries_pkce_params() { let session = new_session(); let url = build_authorize_url("cid", "tidal://login/auth", "r_usr w_usr", &session); assert!(url.starts_with("https://login.tidal.com/authorize?")); assert!(url.contains("code_challenge=")); assert!(url.contains("code_challenge_method=S256")); assert!(url.contains("scope=r_usr+w_usr")); } }