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 Ok(path) = std::env::var("PATH") {
290 for dir in path.split(':') {
291 let candidate = PathBuf::from(dir).join(name);
292 if candidate.exists() {
293 return Ok(candidate);
294 }
295 }
296 }
297 Err(Error::new(
298 ErrorCode::BrowserLaunchFailed,
299 format!("could not find {name} binary on PATH"),
300 ))
301}
302
303pub(crate) fn pick_ephemeral_port() -> std::io::Result<u16> {
308 let listener = StdTcpListener::bind(("127.0.0.1", 0))?;
309 let port = listener.local_addr()?.port();
310 drop(listener);
311 Ok(port)
312}
313
314pub(crate) async fn wait_for_tcp_ready(
317 target: (&str, u16),
318 timeout: Duration,
319) -> Result<(), String> {
320 let deadline = tokio::time::Instant::now() + timeout;
321 let addr: SocketAddr = format!("{}:{}", target.0, target.1)
322 .parse()
323 .map_err(|e| format!("parse {}:{}: {e}", target.0, target.1))?;
324 loop {
325 if tokio::time::Instant::now() >= deadline {
326 return Err(format!("timed out after {timeout:?}"));
327 }
328 match tokio::time::timeout(Duration::from_millis(200), TcpStream::connect(addr)).await {
329 Ok(Ok(_)) => return Ok(()),
330 _ => tokio::time::sleep(Duration::from_millis(50)).await,
331 }
332 }
333}
334
335fn resolve_browser_bin(args: &HostArgs) -> Result<PathBuf, Error> {
336 if let Some(p) = &args.browser_bin {
337 if !p.exists() {
338 return Err(Error::new(
339 ErrorCode::BrowserLaunchFailed,
340 format!("--browser-bin {} does not exist", p.display()),
341 ));
342 }
343 return Ok(resolve_chromium_wrapper_target(p));
344 }
345 let candidates: Vec<&str> = match args.browser {
346 BrowserChoice::Lightpanda => vec!["lightpanda"],
347 BrowserChoice::Chrome => vec!["google-chrome", "google-chrome-stable", "chrome"],
348 BrowserChoice::ChromeShell => vec!["chrome-headless-shell"],
349 BrowserChoice::FingerprintChromium => vec!["fingerprint-chromium"],
350 BrowserChoice::Edge => vec!["microsoft-edge", "edge"],
351 BrowserChoice::Brave => vec!["brave-browser", "brave"],
352 BrowserChoice::Chromium | BrowserChoice::Auto => vec![
353 "chromium",
354 "chromium-browser",
355 "google-chrome",
356 "google-chrome-stable",
357 ],
358 BrowserChoice::Camoufox => {
363 return Err(Error::new(
364 ErrorCode::InternalError,
365 "resolve_browser_bin invoked for camoufox; should route through launch_camoufox",
366 ));
367 }
368 };
369 for name in candidates {
370 for dir in [
371 "/usr/bin",
372 "/usr/local/bin",
373 "/opt/google/chrome",
374 "/Applications/Google Chrome.app/Contents/MacOS",
375 ] {
376 let p = PathBuf::from(dir).join(name);
377 if p.exists() {
378 return Ok(resolve_chromium_wrapper_target(&p));
379 }
380 }
381 if let Ok(path) = std::env::var("PATH") {
383 for dir in path.split(':') {
384 let p = PathBuf::from(dir).join(name);
385 if p.exists() {
386 return Ok(resolve_chromium_wrapper_target(&p));
387 }
388 }
389 }
390 }
391
392 #[cfg(any(target_os = "macos", target_os = "windows"))]
397 if matches!(
398 args.browser,
399 BrowserChoice::Auto | BrowserChoice::Chromium | BrowserChoice::Chrome
400 ) {
401 let mut app_candidates: Vec<PathBuf> = Vec::new();
402 #[cfg(target_os = "macos")]
403 {
404 let mut roots = vec![PathBuf::from("/Applications")];
405 if let Ok(home) = std::env::var("HOME") {
406 roots.push(PathBuf::from(home).join("Applications"));
407 }
408 for root in roots {
409 app_candidates.push(root.join("Google Chrome.app/Contents/MacOS/Google Chrome"));
410 app_candidates.push(root.join(
411 "Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
412 ));
413 app_candidates.push(root.join("Chromium.app/Contents/MacOS/Chromium"));
414 }
415 }
416 #[cfg(target_os = "windows")]
417 {
418 let mut roots: Vec<PathBuf> = ["ProgramFiles", "ProgramFiles(x86)", "LOCALAPPDATA"]
419 .iter()
420 .filter_map(|var| std::env::var(var).ok())
421 .map(PathBuf::from)
422 .collect();
423 roots.push(PathBuf::from(r"C:\Program Files"));
424 roots.push(PathBuf::from(r"C:\Program Files (x86)"));
425 for root in roots {
426 app_candidates.push(root.join(r"Google\Chrome\Application\chrome.exe"));
427 app_candidates.push(root.join(r"Chromium\Application\chrome.exe"));
428 }
429 }
430 for p in app_candidates {
431 if p.exists() {
432 return Ok(resolve_chromium_wrapper_target(&p));
433 }
434 }
435 }
436
437 Err(Error::new(
438 ErrorCode::BrowserLaunchFailed,
439 "no browser binary found; set --browser-bin or install chromium",
440 ))
441}
442
443fn resolve_chromium_wrapper_target(path: &std::path::Path) -> PathBuf {
444 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
445 return path.to_path_buf();
446 };
447 if name != "chromium" && name != "chromium-browser" {
448 return path.to_path_buf();
449 }
450 for candidate in [
451 "/usr/lib/chromium/chromium",
452 "/usr/lib/chromium-browser/chromium-browser",
453 ] {
454 let actual = PathBuf::from(candidate);
455 if actual.exists() {
456 return actual;
457 }
458 }
459 path.to_path_buf()
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465
466 #[test]
467 fn pick_ephemeral_port_returns_usable_local_port() {
468 let port = pick_ephemeral_port().expect("pick");
469 assert!(port > 0);
470 let l = StdTcpListener::bind(("127.0.0.1", port)).expect("rebind");
473 drop(l);
474 }
475
476 #[test]
477 fn fingerprint_seed_is_stable_per_path_and_distinct_across_paths() {
478 let a = std::path::PathBuf::from("/var/lib/afhttp/profiles/work");
479 let b = std::path::PathBuf::from("/var/lib/afhttp/profiles/other");
480 assert_eq!(
483 fingerprint_seed_from_path(&a),
484 fingerprint_seed_from_path(&a)
485 );
486 assert_ne!(
488 fingerprint_seed_from_path(&a),
489 fingerprint_seed_from_path(&b)
490 );
491 assert_ne!(fingerprint_seed_from_path(std::path::Path::new("")), 0);
493 }
494}