use std::collections::{HashMap, HashSet};
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, Command};
use tokio::time::{sleep, timeout};
use tokio_tungstenite::tungstenite::Message;
use super::cookie::sanitize_device_id;
use super::types::BrowserAuth;
use crate::browser::locate_chromium_browser;
use crate::core::CliError;
const CDP_HOST: &str = "127.0.0.1";
const LOGIN_URL: &str = "https://suno.com/create";
const LOGIN_TIMEOUT: Duration = Duration::from_secs(300);
const POLL_INTERVAL: Duration = Duration::from_secs(2);
#[derive(Debug, Clone, Deserialize)]
struct CdpCookie {
name: String,
value: String,
domain: String,
}
#[derive(Debug, Deserialize)]
struct CdpTarget {
#[serde(rename = "type")]
target_type: String,
url: String,
#[serde(rename = "webSocketDebuggerUrl")]
web_socket_debugger_url: Option<String>,
}
#[derive(Serialize)]
struct CdpRequest<'a> {
id: u64,
method: &'a str,
params: serde_json::Value,
}
type CdpStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
struct CdpSession {
ws: CdpStream,
next_id: u64,
}
impl CdpSession {
async fn connect(ws_url: &str) -> Result<Self, CliError> {
let (ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| CliError::Config(format!("CDP ws connect: {e}")))?;
Ok(Self { ws, next_id: 0 })
}
async fn call(
&mut self,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, CliError> {
self.next_id += 1;
let id = self.next_id;
let payload = serde_json::to_string(&CdpRequest { id, method, params }).unwrap();
self.ws
.send(Message::Text(payload))
.await
.map_err(|e| CliError::Config(format!("CDP ws send {method}: {e}")))?;
loop {
let msg = timeout(Duration::from_secs(60), self.ws.next())
.await
.map_err(|_| CliError::Config(format!("CDP {method} timeout")))?
.ok_or_else(|| CliError::Config(format!("CDP {method} ws closed")))?
.map_err(|e| CliError::Config(format!("CDP {method} ws err: {e}")))?;
let text = match msg {
Message::Text(text) => text.to_string(),
Message::Binary(_) | Message::Ping(_) | Message::Pong(_) | Message::Frame(_) => {
continue;
}
Message::Close(_) => {
return Err(CliError::Config(format!("CDP {method} ws closed mid-call")));
}
};
let value: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| CliError::Config(format!("CDP {method} json: {e}")))?;
if value.get("id").and_then(|id| id.as_u64()) == Some(id) {
if let Some(err) = value.get("error") {
return Err(CliError::Config(format!("CDP {method} error: {err}")));
}
return Ok(value
.get("result")
.cloned()
.unwrap_or(serde_json::Value::Null));
}
}
}
}
pub async fn extract_interactive_browser_auth() -> Result<BrowserAuth, CliError> {
let port = allocate_cdp_port()?;
let mut child = spawn_login_browser(port).await?;
let result = async {
wait_for_cdp(port).await?;
let ws_url = find_or_create_login_tab(port).await?;
eprintln!("Complete Suno login in the opened browser window...");
wait_for_suno_auth(ws_url).await
}
.await;
if result.is_ok() {
eprintln!("Suno login captured; closing the dedicated browser window.");
}
let _ = child.start_kill();
let _ = child.wait().await;
result
}
pub fn delete_interactive_browser_profile() -> Result<(), CliError> {
let profile_dir = interactive_browser_profile_dir()?;
delete_interactive_browser_profile_at(&profile_dir)
}
async fn spawn_login_browser(port: u16) -> Result<Child, CliError> {
let browser_path = locate_chromium_browser()?;
let profile_dir = interactive_browser_profile_dir()?;
std::fs::create_dir_all(&profile_dir)?;
eprintln!(
"Opening a dedicated browser profile for Suno login. This avoids reading your default browser cookies."
);
let mut child = Command::new(&browser_path)
.arg(format!("--remote-debugging-port={port}"))
.arg(format!("--user-data-dir={}", profile_dir.display()))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-search-engine-choice-screen")
.arg("--disable-features=TranslateUI")
.arg("--window-size=1280,900")
.arg(LOGIN_URL)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
CliError::Config(format!("failed to spawn browser at {browser_path:?}: {e}"))
})?;
drain_stderr(&mut child);
Ok(child)
}
fn interactive_browser_profile_dir() -> Result<PathBuf, CliError> {
directories::ProjectDirs::from("com", "sunox", "sunox")
.map(|d| d.data_dir().join("interactive-login-browser-profile"))
.ok_or_else(|| CliError::Config("could not resolve data dir for browser profile".into()))
}
fn delete_interactive_browser_profile_at(profile_dir: &Path) -> Result<(), CliError> {
if profile_dir.exists() {
std::fs::remove_dir_all(profile_dir)?;
}
Ok(())
}
async fn wait_for_cdp(port: u16) -> Result<(), CliError> {
for _ in 0..30 {
if cdp_version(port).await.is_ok() {
return Ok(());
}
sleep(Duration::from_millis(500)).await;
}
Err(CliError::Config(
"Browser was spawned but never opened the CDP port. Check that Chrome or Edge can start normally, or set SUNO_BROWSER_PATH to a Chromium-family browser binary.".into(),
))
}
async fn cdp_version(port: u16) -> Result<serde_json::Value, CliError> {
let url = format!("http://{CDP_HOST}:{port}/json/version");
reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/version: {e}")))?
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))
}
async fn find_or_create_login_tab(port: u16) -> Result<String, CliError> {
let targets = cdp_list(port).await?;
if let Some(ws_url) = targets.into_iter().find_map(|target| {
if target.target_type == "page" && !target.url.starts_with("chrome://") {
target.web_socket_debugger_url
} else {
None
}
}) {
return Ok(ws_url);
}
let url = format!("http://{CDP_HOST}:{port}/json/new?{}", urlencode(LOGIN_URL));
let target: CdpTarget = reqwest::Client::new()
.put(&url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new: {e}")))?
.json()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new parse: {e}")))?;
target
.web_socket_debugger_url
.ok_or_else(|| CliError::Config("CDP /json/new did not return a websocket URL".into()))
}
async fn cdp_list(port: u16) -> Result<Vec<CdpTarget>, CliError> {
let url = format!("http://{CDP_HOST}:{port}/json/list");
reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/list: {e}")))?
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))
}
async fn wait_for_suno_auth(ws_url: String) -> Result<BrowserAuth, CliError> {
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
let mut session = CdpSession::connect(&ws_url).await?;
session
.call("Network.enable", serde_json::json!({}))
.await?;
session.call("Page.enable", serde_json::json!({})).await?;
session
.call(
"Page.navigate",
serde_json::json!({
"url": LOGIN_URL
}),
)
.await?;
loop {
if tokio::time::Instant::now() >= deadline {
return Err(CliError::Config(
"Timed out waiting for Suno login in the dedicated browser window.".into(),
));
}
let result = session
.call("Network.getAllCookies", serde_json::json!({}))
.await?;
let cookies: Vec<CdpCookie> =
serde_json::from_value(result.get("cookies").cloned().unwrap_or_default())
.map_err(|e| CliError::Config(format!("CDP cookie parse: {e}")))?;
if let Some(auth) = browser_auth_from_cdp_cookies(cookies) {
return Ok(auth);
}
sleep(POLL_INTERVAL).await;
}
}
fn browser_auth_from_cdp_cookies(cookies: Vec<CdpCookie>) -> Option<BrowserAuth> {
let mut selected: HashMap<String, CdpCookie> = HashMap::new();
let mut clerk_client_cookie: Option<String> = None;
let mut auth_domain_clerk: Option<String> = None;
let mut device_id: Option<String> = None;
for cookie in cookies {
if !cookie.domain.contains("suno.com") || cookie.name.is_empty() || cookie.value.is_empty()
{
continue;
}
if cookie.name == "__client" {
if cookie.domain.contains("auth.suno.com") {
auth_domain_clerk = Some(cookie.value.clone());
} else if clerk_client_cookie.is_none() {
clerk_client_cookie = Some(cookie.value.clone());
}
}
if cookie.name == "ajs_anonymous_id" && device_id.is_none() {
device_id = sanitize_device_id(&cookie.value);
}
match selected.get(&cookie.name) {
Some(existing)
if existing.domain.contains("auth.suno.com")
|| !cookie.domain.contains("auth.suno.com") => {}
_ => {
selected.insert(cookie.name.clone(), cookie);
}
}
}
let clerk_client_cookie = auth_domain_clerk.or(clerk_client_cookie)?;
let mut emitted = HashSet::new();
let mut header_parts = Vec::new();
if let Some(client_cookie) = selected.get("__client") {
header_parts.push(format!("__client={}", client_cookie.value));
emitted.insert("__client".to_string());
}
let mut rest: Vec<_> = selected
.into_values()
.filter(|cookie| !emitted.contains(&cookie.name))
.collect();
rest.sort_by(|a, b| a.name.cmp(&b.name));
header_parts.extend(
rest.into_iter()
.map(|cookie| format!("{}={}", cookie.name, cookie.value)),
);
Some(BrowserAuth {
clerk_client_cookie,
cookie_header: header_parts.join("; "),
device_id,
})
}
fn allocate_cdp_port() -> Result<u16, CliError> {
for port in 9234..9260 {
let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
if TcpListener::bind(addr).is_ok() {
return Ok(port);
}
}
Err(CliError::Config(
"could not find an available local browser debugging port".into(),
))
}
fn drain_stderr(child: &mut Child) {
if let Some(stderr) = child.stderr.take() {
let mut reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(_)) = reader.next_line().await {
}
});
}
}
fn urlencode(s: &str) -> String {
s.replace(":", "%3A").replace("/", "%2F")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn cdp_cookies_prefer_auth_domain_clerk_cookie() {
let auth = browser_auth_from_cdp_cookies(vec![
CdpCookie {
name: "__client".into(),
value: "suno-client".into(),
domain: ".suno.com".into(),
},
CdpCookie {
name: "__client".into(),
value: "auth-client".into(),
domain: "auth.suno.com".into(),
},
CdpCookie {
name: "ajs_anonymous_id".into(),
value: "%22device-123%22".into(),
domain: ".suno.com".into(),
},
])
.expect("auth");
assert_eq!(auth.clerk_client_cookie, "auth-client");
assert_eq!(auth.device_id.as_deref(), Some("device-123"));
assert!(auth.cookie_header.contains("__client=auth-client"));
assert!(!auth.cookie_header.contains("__client=suno-client"));
assert!(
auth.cookie_header
.contains("ajs_anonymous_id=%22device-123%22")
);
}
#[test]
fn cdp_cookies_ignore_non_suno_domains() {
let auth = browser_auth_from_cdp_cookies(vec![
CdpCookie {
name: "__client".into(),
value: "wrong-client".into(),
domain: "example.com".into(),
},
CdpCookie {
name: "sid".into(),
value: "session".into(),
domain: ".suno.com".into(),
},
]);
assert!(auth.is_none());
}
#[test]
fn delete_profile_dir_removes_nested_profile_files() {
let profile_dir = std::env::temp_dir().join(format!(
"sunox-profile-delete-test-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(profile_dir.join("Default")).expect("profile dir");
std::fs::write(profile_dir.join("Default").join("Cookies"), "cookie db")
.expect("cookie db");
delete_interactive_browser_profile_at(&profile_dir).expect("delete profile");
assert!(!profile_dir.exists());
}
}