use std::process::Stdio;
use std::sync::OnceLock;
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::sync::Mutex;
use tokio::time::{sleep, timeout};
use tokio_tungstenite::tungstenite::Message;
use crate::auth::AuthState;
use crate::errors::CliError;
const SUNO_HCAPTCHA_SITEKEY: &str = "d65453de-3f1a-4aac-9366-a0f06e52b2ce";
const CDP_PORT: u16 = 9233;
const CDP_HOST: &str = "127.0.0.1";
static CHROME: OnceLock<Mutex<Option<Child>>> = OnceLock::new();
fn chrome_slot() -> &'static Mutex<Option<Child>> {
CHROME.get_or_init(|| Mutex::new(None))
}
pub async fn solve(auth: &AuthState) -> Result<String, CliError> {
ensure_chrome_running().await?;
let target = find_or_create_suno_tab().await?;
let token = render_and_execute(&target.web_socket_debugger_url, auth).await?;
Ok(token)
}
async fn ensure_chrome_running() -> Result<(), CliError> {
if cdp_version().await.is_ok() {
return Ok(());
}
let chrome_path = locate_chrome()?;
let profile_dir = directories::ProjectDirs::from("com", "suno-cli", "suno-cli")
.map(|d| d.data_dir().join("chrome-profile"))
.ok_or_else(|| CliError::Config("could not resolve data dir for chrome profile".into()))?;
std::fs::create_dir_all(&profile_dir)?;
eprintln!("Launching headless Chrome for captcha solver (one-time per session)...");
let mut child = Command::new(&chrome_path)
.arg(format!("--remote-debugging-port={CDP_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-position=-32000,-32000")
.arg("--window-size=1,1")
.arg("--silent-launch")
.arg("about:blank")
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| CliError::Config(format!("failed to spawn Chrome at {chrome_path:?}: {e}")))?;
drain_stderr(&mut child);
{
let mut slot = chrome_slot().lock().await;
*slot = Some(child);
}
for _ in 0..20 {
sleep(Duration::from_millis(500)).await;
if cdp_version().await.is_ok() {
return Ok(());
}
}
Err(CliError::Config(
"Chrome was spawned but never opened the CDP port. Check that Chrome can start normally, or set SUNO_CHROME_PATH to a Chrome/Chromium binary.".into(),
))
}
fn locate_chrome() -> Result<String, CliError> {
if let Ok(path) = std::env::var("SUNO_CHROME_PATH")
&& !path.trim().is_empty()
{
if std::path::Path::new(&path).exists() {
return Ok(path);
}
return Err(CliError::Config(format!(
"SUNO_CHROME_PATH points to a missing file: {path}"
)));
}
let candidates: &[&str] = if cfg!(target_os = "macos") {
&[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
]
} else if cfg!(target_os = "linux") {
&[
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
]
} else {
&[
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
]
};
for c in candidates {
if std::path::Path::new(c).exists() {
return Ok(c.to_string());
}
}
Err(CliError::Config(
"Could not find a Chrome/Chromium binary. Install Google Chrome or set SUNO_CHROME_PATH."
.into(),
))
}
#[derive(Debug, Deserialize)]
struct Target {
#[serde(rename = "type")]
target_type: String,
url: String,
#[serde(rename = "webSocketDebuggerUrl")]
web_socket_debugger_url: String,
}
async fn cdp_version() -> Result<serde_json::Value, CliError> {
let url = format!("http://{CDP_HOST}:{CDP_PORT}/json/version");
let resp = reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(2))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/version: {e}")))?;
let v: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))?;
Ok(v)
}
async fn cdp_list() -> Result<Vec<Target>, CliError> {
let url = format!("http://{CDP_HOST}:{CDP_PORT}/json/list");
let resp = reqwest::Client::new()
.get(&url)
.timeout(Duration::from_secs(5))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/list: {e}")))?;
let list: Vec<Target> = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP json parse: {e}")))?;
Ok(list)
}
async fn find_or_create_suno_tab() -> Result<Target, CliError> {
let targets = cdp_list().await?;
if let Some(t) = targets
.into_iter()
.find(|t| t.target_type == "page" && t.url.contains("suno.com"))
{
return Ok(t);
}
let url = format!(
"http://{CDP_HOST}:{CDP_PORT}/json/new?{}",
urlencode("https://suno.com/create")
);
let resp = reqwest::Client::new()
.put(&url)
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new: {e}")))?;
let t: Target = resp
.json()
.await
.map_err(|e| CliError::Config(format!("CDP /json/new parse: {e}")))?;
sleep(Duration::from_millis(800)).await;
Ok(t)
}
fn urlencode(s: &str) -> String {
s.replace(":", "%3A").replace("/", "%2F")
}
#[derive(Serialize)]
struct CdpReq<'a> {
id: u64,
method: &'a str,
params: serde_json::Value,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct CdpCookie {
name: String,
value: String,
domain: String,
path: String,
secure: bool,
http_only: bool,
same_site: &'static str,
}
type CdpStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
async fn cdp_call(
ws: &mut CdpStream,
id: u64,
method: &str,
params: serde_json::Value,
) -> Result<serde_json::Value, CliError> {
let req = CdpReq { id, method, params };
let payload = serde_json::to_string(&req).unwrap();
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), 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(t) => t.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 v: serde_json::Value = serde_json::from_str(&text)
.map_err(|e| CliError::Config(format!("CDP {method} json: {e}")))?;
if v.get("id").and_then(|x| x.as_u64()) == Some(id) {
if let Some(err) = v.get("error") {
return Err(CliError::Config(format!("CDP {method} error: {err}")));
}
return Ok(v.get("result").cloned().unwrap_or(serde_json::Value::Null));
}
}
}
async fn render_and_execute(ws_url: &str, auth: &AuthState) -> Result<String, CliError> {
let (mut ws, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| CliError::Config(format!("CDP ws connect: {e}")))?;
let mut next_id: u64 = 0;
let mut next = || -> u64 {
next_id += 1;
next_id
};
cdp_call(&mut ws, next(), "Network.enable", serde_json::json!({})).await?;
cdp_call(&mut ws, next(), "Page.enable", serde_json::json!({})).await?;
cdp_call(&mut ws, next(), "Runtime.enable", serde_json::json!({})).await?;
let cookies = extract_cookies(auth)?;
if !cookies.is_empty() {
cdp_call(
&mut ws,
next(),
"Network.setCookies",
serde_json::json!({ "cookies": cookies }),
)
.await?;
}
let page_url = cdp_call(
&mut ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": "location.href",
"returnByValue": true,
}),
)
.await?;
let needs_nav = page_url
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.map(|s| !s.contains("suno.com/create"))
.unwrap_or(true);
if needs_nav {
cdp_call(
&mut ws,
next(),
"Page.navigate",
serde_json::json!({ "url": "https://suno.com/create" }),
)
.await?;
let mut ready = false;
for _ in 0..30 {
sleep(Duration::from_secs(1)).await;
let probe = cdp_call(
&mut ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": "typeof hcaptcha !== 'undefined' && !!hcaptcha.render",
"returnByValue": true,
}),
)
.await?;
if probe
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_bool())
.unwrap_or(false)
{
ready = true;
break;
}
}
if !ready {
return Err(CliError::Config(
"hcaptcha never finished loading on suno.com/create".into(),
));
}
sleep(Duration::from_secs(2)).await;
}
let solve_js = format!(
r#"
(async () => {{
try {{
const div = document.createElement('div');
div.style.cssText = 'position:fixed;top:-9999px;left:-9999px;';
document.body.appendChild(div);
const id = hcaptcha.render(div, {{
sitekey: '{SUNO_HCAPTCHA_SITEKEY}',
size: 'invisible',
sentry: false,
endpoint: 'https://hcaptcha-endpoint-prod.suno.com',
assethost: 'https://hcaptcha-assets-prod.suno.com',
imghost: 'https://hcaptcha-imgs-prod.suno.com',
reportapi: 'https://hcaptcha-reportapi-prod.suno.com',
}});
const r = await hcaptcha.execute(id, {{ async: true }});
return (r && r.response) ? r.response : '';
}} catch (e) {{
return 'ERR:' + String(e);
}}
}})()
"#
);
let result = cdp_call(
&mut ws,
next(),
"Runtime.evaluate",
serde_json::json!({
"expression": solve_js,
"awaitPromise": true,
"returnByValue": true,
}),
)
.await?;
let token = result
.get("result")
.and_then(|r| r.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if token.is_empty() {
return Err(CliError::Config("hcaptcha returned empty token".into()));
}
if token.starts_with("ERR:") {
return Err(CliError::Config(format!("hcaptcha solver: {token}")));
}
Ok(token)
}
fn extract_cookies(auth: &AuthState) -> Result<Vec<CdpCookie>, CliError> {
if let Some(cookie_header) = auth.cookie.as_deref().filter(|c| !c.trim().is_empty()) {
return Ok(cookies_from_header(cookie_header));
}
let domains: Vec<String> = vec![
"suno.com".into(),
"auth.suno.com".into(),
".suno.com".into(),
];
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
let raw_cookies = [
rookie::chrome(Some(domains.clone())),
rookie::arc(Some(domains.clone())),
rookie::brave(Some(domains.clone())),
rookie::firefox(Some(domains.clone())),
rookie::edge(Some(domains)),
]
.into_iter()
.find_map(|result| match result {
Ok(cookies) if cookies.iter().any(|c| c.domain.contains("suno.com")) => Some(cookies),
_ => None,
})
.ok_or_else(|| {
CliError::Config(
"could not read Suno cookies from Chrome, Arc, Brave, Firefox, or Edge".into(),
)
})?;
for c in raw_cookies {
if !c.domain.contains("suno.com") {
continue;
}
let key = (c.name.clone(), c.domain.clone());
if !seen.insert(key) {
continue;
}
out.push(CdpCookie {
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
secure: c.secure,
http_only: c.http_only,
same_site: "Lax",
});
}
Ok(out)
}
fn cookies_from_header(cookie_header: &str) -> Vec<CdpCookie> {
cookie_header
.split(';')
.filter_map(|part| {
let (name, value) = part.trim().split_once('=')?;
let name = name.trim();
if name.is_empty() {
return None;
}
let domain = if name == "__client" {
"auth.suno.com"
} else {
".suno.com"
};
Some(CdpCookie {
name: name.to_string(),
value: value.trim().to_string(),
domain: domain.to_string(),
path: "/".to_string(),
secure: true,
http_only: name == "__client",
same_site: "Lax",
})
})
.collect()
}
#[allow(dead_code)]
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 {
}
});
}
}