1use std::collections::VecDeque;
10use std::net::{SocketAddr, TcpListener as StdTcpListener};
11use std::path::PathBuf;
12use std::sync::Arc;
13use std::time::Duration;
14
15use chromiumoxide::Browser;
16use tokio::io::AsyncBufReadExt;
17use tokio::net::TcpStream;
18use tokio::process::{Child, Command};
19use tokio::sync::Mutex;
20use tokio::task::JoinHandle;
21
22use crate::host::bootstrap::{BrowserChoice, HostArgs};
23use crate::shared::error::{Error, ErrorCode};
24
25mod camoufox;
26mod chromium;
27mod lightpanda;
28mod profile_launch;
29
30const STDERR_RING_CAP: usize = 200;
32
33pub struct BrowserHandle {
36 pub ws_url: String,
37 pub family: String,
38 pub version: String,
39 pub process_id: Option<u32>,
41 pub profile_path: PathBuf,
45 pub download_dir: PathBuf,
47 pub _ephemeral_dir: Option<tempfile::TempDir>,
50 pub _profile_lock: Option<crate::sdk::profile::lock::Guard>,
52 _keepalive: BackendKeepalive,
53 pub stderr_ring: Arc<Mutex<VecDeque<String>>>,
56}
57
58enum BackendKeepalive {
62 Chromium {
63 _browser: Arc<Mutex<Browser>>,
64 handler_task: JoinHandle<()>,
65 child: Child,
66 },
67 Subprocess { child: Child },
73 None,
75}
76
77impl Drop for BackendKeepalive {
78 fn drop(&mut self) {
79 match self {
80 BackendKeepalive::Chromium {
81 handler_task,
82 child,
83 ..
84 } => {
85 handler_task.abort();
86 let _ = child.start_kill();
87 }
88 BackendKeepalive::Subprocess { child, .. } => {
89 let _ = child.start_kill();
93 }
94 BackendKeepalive::None => {}
95 }
96 }
97}
98
99impl BrowserHandle {
100 #[cfg(any(test, feature = "host"))]
103 pub fn synthetic(profile_path: PathBuf) -> Self {
104 BrowserHandle {
105 ws_url: String::new(),
106 family: "synthetic".to_string(),
107 version: String::new(),
108 process_id: None,
109 profile_path,
110 download_dir: PathBuf::new(),
111 _ephemeral_dir: None,
112 _profile_lock: None,
113 _keepalive: BackendKeepalive::None,
114 stderr_ring: Arc::new(Mutex::new(VecDeque::new())),
115 }
116 }
117
118 #[cfg(test)]
119 pub(crate) fn synthetic_ephemeral(ephemeral_dir: tempfile::TempDir) -> Self {
120 let profile_path = ephemeral_dir.path().to_path_buf();
121 BrowserHandle {
122 ws_url: String::new(),
123 family: "synthetic".to_string(),
124 version: String::new(),
125 process_id: None,
126 profile_path,
127 download_dir: PathBuf::new(),
128 _ephemeral_dir: Some(ephemeral_dir),
129 _profile_lock: None,
130 _keepalive: BackendKeepalive::None,
131 stderr_ring: Arc::new(Mutex::new(VecDeque::new())),
132 }
133 }
134}
135
136pub async fn launch(args: &HostArgs) -> Result<BrowserHandle, Error> {
137 match args.browser {
138 BrowserChoice::Lightpanda => lightpanda::launch(args).await,
139 BrowserChoice::Camoufox => camoufox::launch(args).await,
140 _ => chromium::launch(args).await,
141 }
142}
143
144fn fingerprint_seed_from_path(path: &std::path::Path) -> u32 {
151 let bytes = path.as_os_str().as_encoded_bytes();
152 let mut h: u32 = 0x811c_9dc5;
153 for b in bytes {
154 h ^= *b as u32;
155 h = h.wrapping_mul(0x0100_0193);
156 }
157 if h == 0 {
161 1
162 } else {
163 h
164 }
165}
166
167async fn ensure_download_dir(profile_dir: &std::path::Path) -> Result<PathBuf, Error> {
168 let dir = profile_dir.join("downloads");
169 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
170 Error::new(
171 ErrorCode::IoError,
172 format!("create download dir {}: {e}", dir.display()),
173 )
174 })?;
175 Ok(dir)
176}
177
178fn apply_subprocess_env(cmd: &mut Command, engine_envs: &[(String, String)]) {
202 cmd.env_clear();
203 const ALLOWLIST: &[&str] = &[
204 "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "DISPLAY",
205 ];
206 #[cfg(windows)]
213 const WINDOWS_ALLOWLIST: &[&str] = &[
214 "SYSTEMROOT",
215 "SystemDrive",
216 "windir",
217 "TEMP",
218 "TMP",
219 "APPDATA",
220 "LOCALAPPDATA",
221 "USERPROFILE",
222 "ProgramData",
223 "ProgramFiles",
224 "ProgramFiles(x86)",
225 "ProgramW6432",
226 "PATHEXT",
227 "COMSPEC",
228 "NUMBER_OF_PROCESSORS",
229 "PROCESSOR_ARCHITECTURE",
230 ];
231 for key in ALLOWLIST {
232 if let Ok(value) = std::env::var(key) {
233 cmd.env(key, value);
234 }
235 }
236 #[cfg(windows)]
237 for key in WINDOWS_ALLOWLIST {
238 if let Ok(value) = std::env::var(key) {
239 cmd.env(key, value);
240 }
241 }
242 cmd.env("MOZ_CRASHREPORTER_DISABLE", "1");
245 cmd.env("MOZ_CRASHREPORTER_NO_REPORT", "1");
246 cmd.env("NO_EM_RESTART", "1");
247 for (k, v) in engine_envs {
248 cmd.env(k, v);
249 }
250}
251
252fn new_stderr_ring(stderr: Option<tokio::process::ChildStderr>) -> Arc<Mutex<VecDeque<String>>> {
255 let ring = Arc::new(Mutex::new(VecDeque::<String>::new()));
256 if let Some(stderr) = stderr {
257 let ring_w = ring.clone();
258 tokio::spawn(async move {
259 let mut reader = tokio::io::BufReader::new(stderr).lines();
260 while let Ok(Some(line)) = reader.next_line().await {
261 let mut guard = ring_w.lock().await;
262 if guard.len() >= STDERR_RING_CAP {
263 guard.pop_front();
264 }
265 guard.push_back(line);
266 }
267 });
268 }
269 ring
270}
271
272async fn stderr_tail_summary(ring: &Arc<Mutex<VecDeque<String>>>) -> String {
273 let guard = ring.lock().await;
274 let mut summary = guard
275 .iter()
276 .rev()
277 .take(20)
278 .map(|line| crate::shared::redact::redact_userinfo_passwords(line))
279 .collect::<Vec<_>>();
280 summary.reverse();
281 let mut joined = summary.join(" | ");
282 const MAX: usize = 2000;
283 if joined.len() > MAX {
284 let start = joined.len() - MAX;
285 joined = format!("...{}", &joined[start..]);
286 }
287 joined
288}
289
290pub(crate) fn resolve_named_bin(
294 name: &str,
295 override_bin: &Option<PathBuf>,
296) -> Result<PathBuf, Error> {
297 if let Some(p) = override_bin {
298 if p.file_name().and_then(|n| n.to_str()) == Some(name) && p.exists() {
299 return Ok(p.clone());
300 }
301 }
302 for dir in [
303 "/usr/local/bin",
304 "/usr/bin",
305 "/opt/camoufox",
306 "/opt/foxbridge",
307 ] {
308 let candidate = PathBuf::from(dir).join(name);
309 if candidate.exists() {
310 return Ok(candidate);
311 }
312 }
313 if let Some(candidate) = find_on_path(name) {
314 return Ok(candidate);
315 }
316 Err(Error::new(
317 ErrorCode::BrowserLaunchFailed,
318 format!("could not find {name} binary on PATH"),
319 ))
320}
321
322fn find_on_path(name: &str) -> Option<PathBuf> {
325 let path = std::env::var_os("PATH")?;
326 std::env::split_paths(&path)
327 .map(|dir| dir.join(name))
328 .find(|candidate| candidate.exists())
329}
330
331pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
336 let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
337 let port = listener.local_addr()?.port();
338 drop(listener);
339 Ok(port)
340}
341
342pub(crate) async fn wait_for_tcp_ready(
345 target: (&str, u16),
346 timeout: Duration,
347) -> Result<(), String> {
348 let deadline = tokio::time::Instant::now() + timeout;
349 let addr: SocketAddr = format!("{}:{}", target.0, target.1)
350 .parse()
351 .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
352 loop {
353 if tokio::time::Instant::now() >= deadline {
354 return Err(format!("timed out after {timeout:?}"));
355 }
356 match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
357 Ok(Ok(_)) => return Ok(()),
358 _ => tokio::time::sleep(Duration::from_millis(50)).await,
359 }
360 }
361}
362
363fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
364 if let Some(p) = &args.browser_bin {
365 if !p.exists() {
366 return Err(Error::new(
367 ErrorCode::BrowserLaunchFailed,
368 format!("--browser-bin {} does not exist", p.display()),
369 ));
370 }
371 return Ok(resolve_chromium_wrapper_target(p));
372 }
373 let candidates: Vec<&str> = match args.browser {
374 BrowserChoice::Lightpanda => vec!["lightpanda"],
375 BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
376 BrowserChoice::ChromeShell => vec!["chrome-headless-shell"],
377 BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
378 BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
379 BrowserChoice::Brave => vec!["brave-browser", "brave"],
380 BrowserChoice::Chromium | BrowserChoice::Auto => vec![
381 "chromium",
382 "chromium-browser",
383 "google-chrome",
384 "google-chrome-stable",
385 ],
386 BrowserChoice::Camoufox => {
391 return Err(Error::new(
392 ErrorCode::InternalError,
393 "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
394 ));
395 }
396 };
397 for name in candidates {
398 for dir in [
399 "/usr/bin",
400 "/usr/local/bin",
401 "/opt/google/chrome",
402 "/Applications/Google Chrome.app/Contents/MacOS",
403 ] {
404 let p = PathBuf::from(dir).join(name);
405 if p.exists() {
406 return Ok(resolve_chromium_wrapper_target(&p));
407 }
408 }
409 if let Some(p) = find_on_path(name) {
410 return Ok(resolve_chromium_wrapper_target(&p));
411 }
412 }
413
414 #[cfg(any(target_os = "macos", target_os = "windows"))]
419 if matches!(
420 args.browser,
421 BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
422 ) {
423 let mut app_candidates: Vec<PathBuf> = Vec::new();
424 #[cfg(target_os = "macos")]
425 {
426 let mut roots = vec![PathBuf::from("/Applications")];
427 if let Ok(home) = std::env::var("HOME") {
428 roots.push(PathBuf::from(home).join("Applications"));
429 }
430 for root in roots {
431 app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
432 app_candidates.push(root.join(
433 "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
434 ));
435 app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
436 }
437 }
438 #[cfg(target_os = "windows")]
439 {
440 let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
441 .iter()
442 .filter_map(|var| std::env::var(var).ok())
443 .map(PathBuf::from)
444 .collect();
445 roots.push(PathBuf::from(r"C:\Program Files"));
446 roots.push(PathBuf::from(r"C:\Program Files (x86)"));
447 for root in roots {
448 app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
449 app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
450 }
451 }
452 for p in app_candidates {
453 if p.exists() {
454 return Ok(resolve_chromium_wrapper_target(&p));
455 }
456 }
457 }
458
459 Err(Error::new(
460 ErrorCode::BrowserLaunchFailed,
461 "no browser binary found; set --browser-bin or install chromium",
462 ))
463}
464
465fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
466 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
467 return path.to_path_buf();
468 };
469 if name != "chromium" && name != "chromium-browser" {
470 return path.to_path_buf();
471 }
472 for candidate in [
473 "/usr/lib/chromium/chromium",
474 "/usr/lib/chromium-browser/chromium-browser",
475 ] {
476 let actual = PathBuf::from(candidate);
477 if actual.exists() {
478 return actual;
479 }
480 }
481 path.to_path_buf()
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn pick_ephemeral_port_returns_usable_local_port() {
490 let port = pick_ephemeral_port().expect("pick");
491 assert!(port > 0);
492 let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
495 drop(l);
496 }
497
498 #[test]
499 fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
500 let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
501 let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
502 assert_eq!(
505 fingerprint_seed_from_path(&a),
506 fingerprint_seed_from_path(&a)
507 );
508 assert_ne!(
510 fingerprint_seed_from_path(&a),
511 fingerprint_seed_from_path(&b)
512 );
513 assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
515 }
516}