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