Skip to main content

chrome_cli/chrome/
launcher.rs

1use std::path::{Path, PathBuf};
2use std::process::{Command, Stdio};
3use std::time::Duration;
4
5use super::ChromeError;
6use super::discovery::query_version;
7
8/// Configuration for launching a Chrome process.
9pub struct LaunchConfig {
10    /// Path to the Chrome executable.
11    pub executable: PathBuf,
12    /// Port for Chrome's remote debugging protocol.
13    pub port: u16,
14    /// Whether to launch in headless mode.
15    pub headless: bool,
16    /// Additional command-line arguments for Chrome.
17    pub extra_args: Vec<String>,
18    /// User data directory. If `None`, a temporary directory is created.
19    pub user_data_dir: Option<PathBuf>,
20}
21
22/// A handle to a running Chrome process.
23pub struct ChromeProcess {
24    child: Option<std::process::Child>,
25    port: u16,
26    temp_dir: Option<TempDir>,
27}
28
29/// A temporary directory that is removed on drop.
30struct 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    /// Returns the PID of the Chrome process.
42    #[must_use]
43    pub fn pid(&self) -> u32 {
44        self.child.as_ref().map_or(0, std::process::Child::id)
45    }
46
47    /// Returns the remote debugging port.
48    #[must_use]
49    #[allow(dead_code)]
50    pub fn port(&self) -> u16 {
51        self.port
52    }
53
54    /// Kill the Chrome process and clean up.
55    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    /// Detach the Chrome process so it keeps running after this handle is dropped.
63    ///
64    /// Returns `(pid, port)`. The caller is responsible for the process lifetime.
65    #[must_use]
66    pub fn detach(mut self) -> (u32, u16) {
67        let pid = self.pid();
68        let port = self.port;
69        // Take ownership to prevent Drop from killing the process
70        self.child = None;
71        // Prevent temp dir cleanup — Chrome still needs it
72        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
83/// Generate a random hex suffix for temporary directory names.
84///
85/// Reads 8 bytes from `/dev/urandom` on Unix, falling back to a PID + address
86/// combination when that is not available.
87fn 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    // Fallback: combine PID and a stack address for uniqueness
96    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
110/// Find an available TCP port on localhost.
111///
112/// # Errors
113///
114/// Returns `ChromeError::LaunchFailed` if binding fails.
115pub 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
127/// Build the Chrome command-line arguments from a launch configuration.
128fn 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
148/// Launch a Chrome process with the given configuration.
149///
150/// Polls the Chrome debug endpoint until it responds or the timeout expires.
151///
152/// # Errors
153///
154/// Returns `ChromeError::LaunchFailed` if the process cannot be spawned,
155/// or `ChromeError::StartupTimeout` if Chrome does not become ready in time.
156pub 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    // Poll until Chrome is ready or timeout
192    let start = tokio::time::Instant::now();
193    let poll_interval = Duration::from_millis(100);
194
195    loop {
196        if start.elapsed() > timeout {
197            // Kill the process since we're giving up
198            process.kill();
199            return Err(ChromeError::StartupTimeout { port: config.port });
200        }
201
202        // Check if the child has exited unexpectedly
203        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        // Should contain --enable-automation (at least once) without error
273        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}