chrome_cli/chrome/
launcher.rs1use std::path::{Path, PathBuf};
2use std::process::{Command, Stdio};
3use std::time::Duration;
4
5use super::ChromeError;
6use super::discovery::query_version;
7
8pub struct LaunchConfig {
10 pub executable: PathBuf,
12 pub port: u16,
14 pub headless: bool,
16 pub extra_args: Vec<String>,
18 pub user_data_dir: Option<PathBuf>,
20}
21
22pub struct ChromeProcess {
24 child: Option<std::process::Child>,
25 port: u16,
26 temp_dir: Option<TempDir>,
27}
28
29struct TempDir {
31 path: PathBuf,
32}
33
34impl Drop for TempDir {
35 fn drop(&mut self) {
36 let _ = std::fs::remove_dir_all(&self.path);
37 }
38}
39
40impl ChromeProcess {
41 #[must_use]
43 pub fn pid(&self) -> u32 {
44 self.child.as_ref().map_or(0, std::process::Child::id)
45 }
46
47 #[must_use]
49 #[allow(dead_code)]
50 pub fn port(&self) -> u16 {
51 self.port
52 }
53
54 pub fn kill(&mut self) {
56 if let Some(child) = self.child.as_mut() {
57 let _ = child.kill();
58 let _ = child.wait();
59 }
60 }
61
62 #[must_use]
66 pub fn detach(mut self) -> (u32, u16) {
67 let pid = self.pid();
68 let port = self.port;
69 self.child = None;
71 self.temp_dir = None;
73 (pid, port)
74 }
75}
76
77impl Drop for ChromeProcess {
78 fn drop(&mut self) {
79 self.kill();
80 }
81}
82
83fn random_suffix() -> String {
88 use std::io::Read;
89 let mut buf = [0u8; 8];
90 if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
91 if f.read_exact(&mut buf).is_ok() {
92 return hex_encode(&buf);
93 }
94 }
95 let pid = std::process::id();
97 let addr = &raw const buf as usize;
98 format!("{pid:x}-{addr:x}")
99}
100
101fn hex_encode(bytes: &[u8]) -> String {
102 let mut s = String::with_capacity(bytes.len() * 2);
103 for b in bytes {
104 use std::fmt::Write;
105 let _ = write!(s, "{b:02x}");
106 }
107 s
108}
109
110pub fn find_available_port() -> Result<u16, ChromeError> {
116 let listener = std::net::TcpListener::bind("127.0.0.1:0").map_err(|e| {
117 ChromeError::LaunchFailed(format!("could not bind to find a free port: {e}"))
118 })?;
119 let port = listener
120 .local_addr()
121 .map_err(|e| ChromeError::LaunchFailed(format!("could not get local address: {e}")))?
122 .port();
123 drop(listener);
124 Ok(port)
125}
126
127fn build_chrome_args(config: &LaunchConfig, data_dir: &Path) -> Vec<String> {
129 let mut args = vec![
130 format!("--remote-debugging-port={}", config.port),
131 format!("--user-data-dir={}", data_dir.display()),
132 "--no-first-run".to_string(),
133 "--no-default-browser-check".to_string(),
134 "--enable-automation".to_string(),
135 ];
136
137 if config.headless {
138 args.push("--headless=new".to_string());
139 }
140
141 for arg in &config.extra_args {
142 args.push(arg.clone());
143 }
144
145 args
146}
147
148pub async fn launch_chrome(
157 config: LaunchConfig,
158 timeout: Duration,
159) -> Result<ChromeProcess, ChromeError> {
160 let (data_dir, temp_dir) = if let Some(ref dir) = config.user_data_dir {
161 (dir.clone(), None)
162 } else {
163 let dir = std::env::temp_dir().join(format!("chrome-cli-{}", random_suffix()));
164 std::fs::create_dir_all(&dir)?;
165 let td = TempDir { path: dir.clone() };
166 (dir, Some(td))
167 };
168
169 let args = build_chrome_args(&config, &data_dir);
170
171 let mut cmd = Command::new(&config.executable);
172 for arg in &args {
173 cmd.arg(arg);
174 }
175
176 cmd.stdout(Stdio::null()).stderr(Stdio::null());
177
178 let child = cmd.spawn().map_err(|e| {
179 ChromeError::LaunchFailed(format!(
180 "failed to spawn {}: {e}",
181 config.executable.display()
182 ))
183 })?;
184
185 let mut process = ChromeProcess {
186 child: Some(child),
187 port: config.port,
188 temp_dir,
189 };
190
191 let start = tokio::time::Instant::now();
193 let poll_interval = Duration::from_millis(100);
194
195 loop {
196 if start.elapsed() > timeout {
197 process.kill();
199 return Err(ChromeError::StartupTimeout { port: config.port });
200 }
201
202 if let Some(child) = process.child.as_mut() {
204 if let Ok(Some(status)) = child.try_wait() {
205 return Err(ChromeError::LaunchFailed(format!(
206 "Chrome exited with status {status} before becoming ready"
207 )));
208 }
209 }
210
211 if query_version("127.0.0.1", config.port).await.is_ok() {
212 return Ok(process);
213 }
214
215 tokio::time::sleep(poll_interval).await;
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn find_available_port_returns_valid_port() {
225 let port = find_available_port().unwrap();
226 assert!(port > 0, "Expected a positive port number, got {port}");
227 }
228
229 fn default_launch_config(port: u16) -> LaunchConfig {
230 LaunchConfig {
231 executable: PathBuf::from("/usr/bin/chrome"),
232 port,
233 headless: false,
234 extra_args: vec![],
235 user_data_dir: None,
236 }
237 }
238
239 #[test]
240 fn automation_flag_is_included_on_launch() {
241 let config = default_launch_config(9222);
242 let data_dir = PathBuf::from("/tmp/test-data");
243 let args = build_chrome_args(&config, &data_dir);
244 assert!(
245 args.iter().any(|a| a == "--enable-automation"),
246 "Expected --enable-automation in args: {args:?}"
247 );
248 }
249
250 #[test]
251 fn headless_mode_includes_automation_flag() {
252 let mut config = default_launch_config(9222);
253 config.headless = true;
254 let data_dir = PathBuf::from("/tmp/test-data");
255 let args = build_chrome_args(&config, &data_dir);
256 assert!(
257 args.iter().any(|a| a == "--enable-automation"),
258 "Expected --enable-automation in args: {args:?}"
259 );
260 assert!(
261 args.iter().any(|a| a == "--headless=new"),
262 "Expected --headless=new in args: {args:?}"
263 );
264 }
265
266 #[test]
267 fn extra_args_do_not_conflict_with_automation_flag() {
268 let mut config = default_launch_config(9222);
269 config.extra_args = vec!["--enable-automation".to_string()];
270 let data_dir = PathBuf::from("/tmp/test-data");
271 let args = build_chrome_args(&config, &data_dir);
272 assert!(
274 args.iter().any(|a| a == "--enable-automation"),
275 "Expected --enable-automation in args: {args:?}"
276 );
277 }
278
279 #[test]
280 fn temp_dir_cleanup_on_drop() {
281 let path = std::env::temp_dir().join("chrome-cli-test-cleanup");
282 std::fs::create_dir_all(&path).unwrap();
283 assert!(path.exists());
284
285 let td = TempDir { path: path.clone() };
286 drop(td);
287
288 assert!(!path.exists(), "TempDir should have been cleaned up");
289 }
290}