use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader};
use tokio::sync::mpsc;
use tokio::time::{Instant, timeout_at};
use crate::launcher::{BrowserOptions, ensure_camoufox};
use crate::transport::{self, Child, PipeReader, PipeWriter, Spawned};
use crate::{Error, Result};
const READY_MARKER: &str = "Juggler listening";
pub struct Launched {
pub child: Child,
pub writer: PipeWriter,
pub reader: PipeReader,
pub profile_dir: PathBuf,
pub profile_is_temp: bool,
}
pub async fn launch(opts: &BrowserOptions) -> Result<Launched> {
opts.validate()?;
let exe = ensure_camoufox(opts.binary_path.as_deref()).await?;
tracing::info!(path = %exe.display(), "使用 Camoufox 可执行文件");
let (profile_dir, profile_is_temp) = prepare_profile(opts)?;
if let Some(dir) = &opts.download_path {
let _ = std::fs::create_dir_all(dir);
}
let args = build_args(opts, &profile_dir);
let envs = build_envs(opts);
tracing::debug!(?args, "Camoufox 启动参数");
let Spawned {
child,
writer,
reader,
stdout,
stderr,
} = transport::spawn(&exe, &args, &envs).await?;
wait_for_ready(stdout, stderr, opts.launch_timeout).await?;
tracing::info!("Camoufox 已就绪(Juggler 管道在线)");
Ok(Launched {
child,
writer,
reader,
profile_dir,
profile_is_temp,
})
}
fn prepare_profile(opts: &BrowserOptions) -> Result<(PathBuf, bool)> {
if let Some(dir) = &opts.user_data_dir {
std::fs::create_dir_all(dir)?;
return Ok((dir.clone(), false));
}
static COUNTER: AtomicU64 = AtomicU64::new(0);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("drission-camoufox-{}-{}-{}", std::process::id(), nanos, n));
std::fs::create_dir_all(&dir)?;
Ok((dir, true))
}
fn build_args(opts: &BrowserOptions, profile_dir: &std::path::Path) -> Vec<String> {
let mut args: Vec<String> = vec!["-no-remote".into()];
if opts.headless {
args.push("-headless".into());
} else {
args.push("-wait-for-browser".into());
args.push("-foreground".into());
}
args.push("-profile".into());
args.push(profile_dir.display().to_string());
args.push("-juggler-pipe".into());
args.extend(opts.args.iter().cloned());
args
}
fn build_envs(opts: &BrowserOptions) -> Vec<(String, String)> {
let cfg = opts.build_camou_config();
if cfg.is_empty() {
return Vec::new();
}
let json = serde_json::Value::Object(cfg).to_string();
chunk_camou_config(&json)
}
fn chunk_camou_config(json: &str) -> Vec<(String, String)> {
const MAX: usize = 2000;
let mut out = Vec::new();
let mut buf = String::new();
let mut idx = 1;
for ch in json.chars() {
if !buf.is_empty() && buf.len() + ch.len_utf8() > MAX {
out.push((format!("CAMOU_CONFIG_{idx}"), std::mem::take(&mut buf)));
idx += 1;
}
buf.push(ch);
}
if !buf.is_empty() {
out.push((format!("CAMOU_CONFIG_{idx}"), buf));
}
out
}
async fn wait_for_ready<O, E>(stdout: O, stderr: E, timeout: std::time::Duration) -> Result<()>
where
O: AsyncRead + Unpin + Send + 'static,
E: AsyncRead + Unpin + Send + 'static,
{
let (tx, mut rx) = mpsc::channel::<()>(2);
tokio::spawn(scan_stream(stdout, "out", tx.clone()));
tokio::spawn(scan_stream(stderr, "err", tx));
match timeout_at(Instant::now() + timeout, rx.recv()).await {
Ok(Some(())) => Ok(()),
Ok(None) => Err(Error::Transport(
"子进程输出在就绪前已结束(浏览器可能启动失败)".into(),
)),
Err(_) => Err(Error::Timeout(timeout)),
}
}
async fn scan_stream<R>(stream: R, tag: &'static str, tx: mpsc::Sender<()>)
where
R: AsyncRead + Unpin + Send + 'static,
{
let mut lines = BufReader::new(stream).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::debug!(target: "camoufox", "[{tag}] {line}");
if line.contains(READY_MARKER) {
let _ = tx.send(()).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chunk_roundtrips_and_bounds() {
let long = format!("{{\"k\":\"{}\",\"含中文也安全\":true}}", "a".repeat(5000));
let chunks = chunk_camou_config(&long);
assert!(chunks.len() >= 3, "5000+ 字符应被切成多块");
for (i, (name, part)) in chunks.iter().enumerate() {
assert_eq!(name, &format!("CAMOU_CONFIG_{}", i + 1));
assert!(part.chars().count() <= 2000 || part.len() <= 2000 + 4);
}
let joined: String = chunks.into_iter().map(|(_, p)| p).collect();
assert_eq!(joined, long);
}
#[test]
fn default_envs_include_humanize_and_screen() {
let envs = build_envs(&BrowserOptions::default());
assert_eq!(envs[0].0, "CAMOU_CONFIG_1");
let joined: String = envs.into_iter().map(|(_, v)| v).collect();
assert!(joined.contains("humanize"));
assert!(joined.contains("screen.width"));
}
}