browser_control/launch/
mod.rs1use std::path::PathBuf;
5
6use anyhow::Result;
7
8use crate::detect::{Engine, Installed};
9
10pub mod chromium;
11pub mod download_prefs;
12pub mod firefox;
13
14#[derive(Debug, Clone)]
15pub struct LaunchOpts {
16 pub headless: bool,
17 pub profile_dir: PathBuf,
18 pub download_dir: Option<PathBuf>,
22}
23
24#[derive(Debug)]
25pub struct LaunchedHandle {
26 pub pid: u32,
27 pub port: u16,
28 pub endpoint: String,
30 pub engine: Engine,
31 pub profile_dir: PathBuf,
32 pub(crate) child: Option<tokio::process::Child>,
36}
37
38impl LaunchedHandle {
39 pub fn forget(mut self) -> u32 {
43 let pid = self.pid;
44 if let Some(child) = self.child.take() {
45 drop(child);
48 }
49 pid
50 }
51
52 pub async fn kill(mut self) -> Result<()> {
54 if let Some(mut c) = self.child.take() {
55 let _ = c.kill().await;
56 }
57 Ok(())
58 }
59}
60
61pub fn allocate_free_port() -> Result<u16> {
63 let l = std::net::TcpListener::bind("127.0.0.1:0")?;
64 Ok(l.local_addr()?.port())
65}
66
67pub async fn launch(installed: &Installed, opts: LaunchOpts) -> Result<LaunchedHandle> {
69 if installed.kind.is_chromium() {
70 chromium::launch(installed, opts).await
71 } else {
72 firefox::launch(installed, opts).await
73 }
74}
75
76pub(crate) fn configure_session_detachment(cmd: &mut tokio::process::Command) {
94 #[cfg(unix)]
95 {
96 unsafe {
102 cmd.pre_exec(|| {
103 if libc::setsid() == -1 {
104 return Err(std::io::Error::last_os_error());
105 }
106 Ok(())
107 });
108 }
109 }
110 #[cfg(not(unix))]
111 {
112 let _ = cmd;
113 }
114}
115
116pub(crate) fn background_after_launch(pid: u32) {
122 #[cfg(target_os = "macos")]
123 {
124 let script = format!(
125 "tell application \"System Events\" to set visible of first application process whose unix id is {pid} to false"
126 );
127 match std::process::Command::new("osascript")
128 .arg("-e")
129 .arg(script)
130 .status()
131 {
132 Ok(status) if status.success() => {}
133 Ok(status) => {
134 tracing::warn!(
135 target = "launch",
136 pid,
137 %status,
138 "failed to hide launched browser process"
139 );
140 }
141 Err(err) => {
142 tracing::warn!(
143 target = "launch",
144 pid,
145 error = %err,
146 "failed to run osascript to hide launched browser process"
147 );
148 }
149 }
150 }
151 #[cfg(not(target_os = "macos"))]
152 {
153 let _ = pid;
154 }
155}
156
157pub(crate) async fn wait_for_endpoint(
164 port: u16,
165 child: &mut tokio::process::Child,
166 log_path: &std::path::Path,
167) -> Result<String> {
168 use anyhow::{bail, Context};
169 use std::time::Duration;
170
171 let client = reqwest::Client::builder()
172 .timeout(Duration::from_millis(500))
173 .build()
174 .context("building reqwest client")?;
175 let url = format!("http://127.0.0.1:{port}/json/version");
176
177 let deadline = std::time::Instant::now() + Duration::from_secs(15);
178 loop {
179 if let Some(status) = child.try_wait().context("polling child status")? {
180 let log = std::fs::read_to_string(log_path).unwrap_or_default();
181 bail!(
182 "browser process exited before endpoint came up (status: {status}); \
183 log ({}):\n{}",
184 log_path.display(),
185 log
186 );
187 }
188
189 if let Ok(resp) = client.get(&url).send().await {
190 if resp.status().is_success() {
191 if let Ok(json) = resp.json::<serde_json::Value>().await {
192 if let Some(ws) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
193 return Ok(ws.to_string());
194 }
195 }
196 }
197 }
198
199 if std::time::Instant::now() >= deadline {
200 let _ = child.start_kill();
201 bail!(
202 "timed out waiting for browser endpoint on port {port}; see log at {}",
203 log_path.display()
204 );
205 }
206 tokio::time::sleep(Duration::from_millis(50)).await;
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use crate::detect::{Engine, Installed, Kind};
214 use tempfile::TempDir;
215
216 fn build_fake_browser() -> std::path::PathBuf {
217 let status = std::process::Command::new(env!("CARGO"))
218 .args(["build", "--example", "fake_browser", "--quiet"])
219 .status()
220 .expect("invoke cargo build");
221 assert!(status.success(), "failed to build fake_browser example");
222 let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
223 p.push("target");
224 p.push("debug");
225 p.push("examples");
226 #[cfg(windows)]
227 p.push("fake_browser.exe");
228 #[cfg(not(windows))]
229 p.push("fake_browser");
230 assert!(
231 p.exists(),
232 "fake_browser binary not found at {}",
233 p.display()
234 );
235 p
236 }
237
238 #[tokio::test]
239 async fn allocate_free_port_returns_nonzero() {
240 let p = allocate_free_port().unwrap();
241 assert!(p > 0);
242 }
243
244 #[tokio::test]
245 async fn chromium_launch_against_fake() {
246 let exe = build_fake_browser();
247 let tmp = TempDir::new().unwrap();
248 let installed = Installed {
249 kind: Kind::Chrome,
250 executable: exe,
251 version: "fake".into(),
252 engine: Engine::Cdp,
253 };
254 let opts = LaunchOpts {
255 headless: true,
256 profile_dir: tmp.path().join("profile"),
257 download_dir: None,
258 };
259 let h = launch(&installed, opts).await.expect("launch chromium");
260 assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
261 assert!(h.port > 0);
262 assert_eq!(h.engine, Engine::Cdp);
263 h.kill().await.unwrap();
264 }
265
266 #[tokio::test]
267 async fn firefox_launch_against_fake() {
268 let exe = build_fake_browser();
269 let tmp = TempDir::new().unwrap();
270 let installed = Installed {
271 kind: Kind::Firefox,
272 executable: exe,
273 version: "fake".into(),
274 engine: Engine::Bidi,
275 };
276 let opts = LaunchOpts {
277 headless: true,
278 profile_dir: tmp.path().join("profile"),
279 download_dir: None,
280 };
281 let h = launch(&installed, opts).await.expect("launch firefox");
282 assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
283 assert!(h.port > 0);
284 assert_eq!(h.engine, Engine::Bidi);
285 h.kill().await.unwrap();
286 }
287
288 #[tokio::test]
289 async fn launch_fails_when_process_exits_immediately() {
290 #[cfg(unix)]
293 {
294 let tmp = TempDir::new().unwrap();
295 let installed = Installed {
296 kind: Kind::Chrome,
297 executable: std::path::PathBuf::from("/usr/bin/true"),
298 version: "fake".into(),
299 engine: Engine::Cdp,
300 };
301 let opts = LaunchOpts {
302 headless: true,
303 profile_dir: tmp.path().join("profile"),
304 download_dir: None,
305 };
306 let err = launch(&installed, opts).await.unwrap_err();
307 let msg = format!("{err:#}");
308 assert!(
309 msg.contains("exited") || msg.contains("timed out"),
310 "unexpected error: {msg}"
311 );
312 }
313 }
314}