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