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 { 1 } else { h }
161}
162
163async fn ensure_download_dir(profile_dir: &std::path::Path) -> Result<PathBuf, Error> {
164 let dir = profile_dir.join("downloads");
165 tokio::fs::create_dir_all(&dir).await.map_err(|e| {
166 Error::new(
167 ErrorCode::IoError,
168 format!("create download dir {}: {e}", dir.display()),
169 )
170 })?;
171 Ok(dir)
172}
173
174fn apply_subprocess_env(cmd: &mut Command, engine_envs: &[(String, String)]) {
198 cmd.env_clear();
199 const ALLOWLIST: &[&str] = &[
200 "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "TMPDIR", "DISPLAY",
201 ];
202 #[cfg(windows)]
209 const WINDOWS_ALLOWLIST: &[&str] = &[
210 "SYSTEMROOT",
211 "SystemDrive",
212 "windir",
213 "TEMP",
214 "TMP",
215 "APPDATA",
216 "LOCALAPPDATA",
217 "USERPROFILE",
218 "ProgramData",
219 "ProgramFiles",
220 "ProgramFiles(x86)",
221 "ProgramW6432",
222 "PATHEXT",
223 "COMSPEC",
224 "NUMBER_OF_PROCESSORS",
225 "PROCESSOR_ARCHITECTURE",
226 ];
227 for key in ALLOWLIST {
228 if let Ok(value) = std::env::var(key) {
229 cmd.env(key, value);
230 }
231 }
232 #[cfg(windows)]
233 for key in WINDOWS_ALLOWLIST {
234 if let Ok(value) = std::env::var(key) {
235 cmd.env(key, value);
236 }
237 }
238 cmd.env("MOZ_CRASHREPORTER_DISABLE", "1");
241 cmd.env("MOZ_CRASHREPORTER_NO_REPORT", "1");
242 cmd.env("NO_EM_RESTART", "1");
243 for (k, v) in engine_envs {
244 cmd.env(k, v);
245 }
246}
247
248fn new_stderr_ring(stderr: Option<tokio::process::ChildStderr>) -> Arc<Mutex<VecDeque<String>>> {
251 let ring = Arc::new(Mutex::new(VecDeque::<String>::new()));
252 if let Some(stderr) = stderr {
253 let ring_w = ring.clone();
254 tokio::spawn(async move {
255 let mut reader = tokio::io::BufReader::new(stderr).lines();
256 while let Ok(Some(line)) = reader.next_line().await {
257 let mut guard = ring_w.lock().await;
258 if guard.len() >= STDERR_RING_CAP {
259 guard.pop_front();
260 }
261 guard.push_back(line);
262 }
263 });
264 }
265 ring
266}
267
268async fn stderr_tail_summary(ring: &Arc<Mutex<VecDeque<String>>>) -> String {
269 let guard = ring.lock().await;
270 let mut summary = guard
271 .iter()
272 .rev()
273 .take(20)
274 .map(|line| crate::shared::redact::redact_userinfo_passwords(line))
275 .collect::<Vec<_>>();
276 summary.reverse();
277 let mut joined = summary.join(" | ");
278 const MAX: usize = 2000;
279 if joined.len() > MAX {
280 let start = joined.len() - MAX;
281 joined = format!("...{}", &joined[start..]);
282 }
283 joined
284}
285
286pub(crate) fn resolve_named_bin(
290 name: &str,
291 override_bin: &Option<PathBuf>,
292) -> Result<PathBuf, Error> {
293 if let Some(p) = override_bin
294 && p.file_name().and_then(|n| n.to_str()) == Some(name)
295 && p.exists()
296 {
297 return Ok(p.clone());
298 }
299 for dir in [
300 "/usr/local/bin",
301 "/usr/bin",
302 "/opt/camoufox",
303 "/opt/foxbridge",
304 ] {
305 let candidate = PathBuf::from(dir).join(name);
306 if candidate.exists() {
307 return Ok(candidate);
308 }
309 }
310 if let Some(candidate) = find_on_path(name) {
311 return Ok(candidate);
312 }
313 Err(Error::new(
314 ErrorCode::BrowserLaunchFailed,
315 format!("could not find {name} binary on PATH"),
316 ))
317}
318
319fn find_on_path(name: &str) -> Option<PathBuf> {
322 let path = std::env::var_os("PATH")?;
323 std::env::split_paths(&path)
324 .map(|dir| dir.join(name))
325 .find(|candidate| candidate.exists())
326}
327
328pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
333 let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
334 let port = listener.local_addr()?.port();
335 drop(listener);
336 Ok(port)
337}
338
339pub(crate) async fn wait_for_tcp_ready(
342 target: (&str, u16),
343 timeout: Duration,
344) -> Result<(), String> {
345 let deadline = tokio::time::Instant::now() + timeout;
346 let addr: SocketAddr = format!("{}:{}", target.0, target.1)
347 .parse()
348 .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
349 loop {
350 if tokio::time::Instant::now() >= deadline {
351 return Err(format!("timed out after {timeout:?}"));
352 }
353 match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
354 Ok(Ok(_)) => return Ok(()),
355 _ => tokio::time::sleep(Duration::from_millis(50)).await,
356 }
357 }
358}
359
360fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
361 if let Some(p) = &args.browser_bin {
362 if !p.exists() {
363 return Err(Error::new(
364 ErrorCode::BrowserLaunchFailed,
365 format!("--browser-bin {} does not exist", p.display()),
366 ));
367 }
368 return Ok(resolve_chromium_wrapper_target(p));
369 }
370 let candidates: Vec<&str> = match args.browser {
371 BrowserChoice::Lightpanda => vec!["lightpanda"],
372 BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
373 BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
374 BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
375 BrowserChoice::Brave => vec!["brave-browser", "brave"],
376 BrowserChoice::Chromium | BrowserChoice::Auto => vec![
377 "chromium",
378 "chromium-browser",
379 "google-chrome",
380 "google-chrome-stable",
381 ],
382 BrowserChoice::Camoufox => {
387 return Err(Error::new(
388 ErrorCode::InternalError,
389 "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
390 ));
391 }
392 };
393 for name in candidates {
394 for dir in [
395 "/usr/bin",
396 "/usr/local/bin",
397 "/opt/google/chrome",
398 "/Applications/Google Chrome.app/Contents/MacOS",
399 ] {
400 let p = PathBuf::from(dir).join(name);
401 if p.exists() {
402 return Ok(resolve_chromium_wrapper_target(&p));
403 }
404 }
405 if let Some(p) = find_on_path(name) {
406 return Ok(resolve_chromium_wrapper_target(&p));
407 }
408 }
409
410 #[cfg(any(target_os = "macos", target_os = "windows"))]
415 if matches!(
416 args.browser,
417 BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
418 ) {
419 let mut app_candidates: Vec<PathBuf> = Vec::new();
420 #[cfg(target_os = "macos")]
421 {
422 let mut roots = vec![PathBuf::from("/Applications")];
423 if let Ok(home) = std::env::var("HOME") {
424 roots.push(PathBuf::from(home).join("Applications"));
425 }
426 for root in roots {
427 app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
428 app_candidates.push(root.join(
429 "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
430 ));
431 app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
432 }
433 }
434 #[cfg(target_os = "windows")]
435 {
436 let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
437 .iter()
438 .filter_map(|var| std::env::var(var).ok())
439 .map(PathBuf::from)
440 .collect();
441 roots.push(PathBuf::from(r"C:\Program Files"));
442 roots.push(PathBuf::from(r"C:\Program Files (x86)"));
443 for root in roots {
444 app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
445 app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
446 }
447 }
448 for p in app_candidates {
449 if p.exists() {
450 return Ok(resolve_chromium_wrapper_target(&p));
451 }
452 }
453 }
454
455 Err(Error::new(
456 ErrorCode::BrowserLaunchFailed,
457 "no browser binary found; set --browser-bin or install chromium",
458 ))
459}
460
461fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
462 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
463 return path.to_path_buf();
464 };
465 if name != "chromium" && name != "chromium-browser" {
466 return path.to_path_buf();
467 }
468 for candidate in [
469 "/usr/lib/chromium/chromium",
470 "/usr/lib/chromium-browser/chromium-browser",
471 ] {
472 let actual = PathBuf::from(candidate);
473 if actual.exists() {
474 return actual;
475 }
476 }
477 path.to_path_buf()
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn pick_ephemeral_port_returns_usable_local_port() {
486 let port = pick_ephemeral_port().expect("pick");
487 assert!(port > 0);
488 let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
491 drop(l);
492 }
493
494 #[test]
495 fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
496 let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
497 let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
498 assert_eq!(
501 fingerprint_seed_from_path(&a),
502 fingerprint_seed_from_path(&a)
503 );
504 assert_ne!(
506 fingerprint_seed_from_path(&a),
507 fingerprint_seed_from_path(&b)
508 );
509 assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
511 }
512}