Skip to main content

roboticus_browser/
manager.rs

1use std::path::Path;
2use std::process::Stdio;
3
4use tokio::process::{Child, Command};
5use tracing::{debug, info};
6
7use roboticus_core::config::BrowserConfig;
8use roboticus_core::{Result, RoboticusError};
9
10pub struct BrowserManager {
11    config: BrowserConfig,
12    process: Option<Child>,
13}
14
15impl BrowserManager {
16    pub fn new(config: BrowserConfig) -> Self {
17        Self {
18            config,
19            process: None,
20        }
21    }
22
23    // SECURITY: `executable_path` comes from the server configuration file
24    // which is trusted (written by an administrator). We do not sanitize
25    // or restrict the path beyond checking that the file exists.
26    fn find_chrome_executable(&self) -> Option<String> {
27        if let Some(ref path) = self.config.executable_path
28            && Path::new(path).exists()
29        {
30            return Some(path.clone());
31        }
32
33        let candidates = [
34            "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
35            "/usr/bin/google-chrome",
36            "/usr/bin/google-chrome-stable",
37            "/usr/bin/chromium",
38            "/usr/bin/chromium-browser",
39            "/snap/bin/chromium",
40        ];
41
42        for candidate in &candidates {
43            if Path::new(candidate).exists() {
44                return Some(candidate.to_string());
45            }
46        }
47
48        None
49    }
50
51    pub async fn start(&mut self) -> Result<()> {
52        if self.process.is_some() {
53            return Ok(());
54        }
55
56        let executable = self
57            .find_chrome_executable()
58            .ok_or_else(|| RoboticusError::Tool {
59                tool: "browser".into(),
60                message: "Chrome/Chromium not found".into(),
61            })?;
62
63        let profile = self.config.profile_dir.display().to_string();
64        let mut args = vec![
65            format!("--remote-debugging-port={}", self.config.cdp_port),
66            format!("--user-data-dir={profile}"),
67            "--no-first-run".to_string(),
68            "--no-default-browser-check".to_string(),
69            "--disable-background-networking".to_string(),
70            "--disable-extensions".to_string(),
71            "--disable-plugins".to_string(),
72            "--disable-popup-blocking".to_string(),
73            "--disable-component-update".to_string(),
74        ];
75
76        if self.config.headless {
77            args.push("--headless=new".to_string());
78        }
79
80        info!(
81            executable_path = %executable,
82            port = self.config.cdp_port,
83            headless = self.config.headless,
84            "starting browser"
85        );
86
87        let child = Command::new(&executable)
88            .args(&args)
89            .stdout(Stdio::null())
90            .stderr(Stdio::null())
91            .spawn()
92            .map_err(|e| RoboticusError::Tool {
93                tool: "browser".into(),
94                message: format!("failed to start Chrome: {e}"),
95            })?;
96
97        self.process = Some(child);
98
99        // Brief grace period for the browser process to initialize its CDP
100        // listener. The caller (Browser::start in lib.rs) retries
101        // cdp.list_targets() up to 10 times with 300ms back-off, so this
102        // initial delay only needs to cover typical startup jitter.
103        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
104
105        debug!("browser process spawned, CDP listener may still be initializing");
106        Ok(())
107    }
108
109    pub async fn stop(&mut self) -> Result<()> {
110        if let Some(mut child) = self.process.take() {
111            debug!("stopping browser");
112            child.kill().await.map_err(|e| RoboticusError::Tool {
113                tool: "browser".into(),
114                message: format!("failed to stop Chrome: {e}"),
115            })?;
116        }
117        Ok(())
118    }
119
120    pub fn is_running(&self) -> bool {
121        self.process.is_some()
122    }
123
124    pub fn cdp_port(&self) -> u16 {
125        self.config.cdp_port
126    }
127}
128
129impl Drop for BrowserManager {
130    fn drop(&mut self) {
131        // best-effort: browser process cleanup during drop
132        if let Some(mut child) = self.process.take() {
133            let _ = child.start_kill();
134        }
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn manager_defaults() {
144        let mgr = BrowserManager::new(BrowserConfig::default());
145        assert!(!mgr.is_running());
146        assert_eq!(mgr.cdp_port(), 9222);
147    }
148
149    #[test]
150    fn find_chrome_with_explicit_path() {
151        let config = BrowserConfig {
152            executable_path: Some("/usr/bin/false".into()),
153            ..Default::default()
154        };
155        let mgr = BrowserManager::new(config);
156        let found = mgr.find_chrome_executable();
157        assert!(found.is_some());
158        assert_eq!(found.unwrap(), "/usr/bin/false");
159    }
160
161    #[test]
162    fn find_chrome_explicit_nonexistent() {
163        let config = BrowserConfig {
164            executable_path: Some("/nonexistent/chrome".into()),
165            ..Default::default()
166        };
167        let mgr = BrowserManager::new(config);
168        let found = mgr.find_chrome_executable();
169        if let Some(path) = found {
170            assert!(Path::new(&path).exists());
171        }
172    }
173
174    #[test]
175    fn custom_cdp_port() {
176        let config = BrowserConfig {
177            cdp_port: 9333,
178            ..Default::default()
179        };
180        let mgr = BrowserManager::new(config);
181        assert_eq!(mgr.cdp_port(), 9333);
182    }
183
184    #[test]
185    fn is_running_false_initially() {
186        let mgr = BrowserManager::new(BrowserConfig::default());
187        assert!(!mgr.is_running());
188    }
189
190    #[tokio::test]
191    async fn stop_when_not_started_is_ok() {
192        let mut mgr = BrowserManager::new(BrowserConfig::default());
193        let result = mgr.stop().await;
194        assert!(result.is_ok());
195    }
196
197    #[tokio::test]
198    async fn start_with_nonexistent_executable_and_no_system_chrome() {
199        // Use a config with a nonexistent path and hope system chrome doesn't exist either.
200        // If system chrome DOES exist, this test will actually try to start it,
201        // so we use a config where the explicit path is bogus but fall through
202        // to candidates that might not exist.
203        let config = BrowserConfig {
204            executable_path: Some("/nonexistent/path/to/chrome_12345".into()),
205            ..Default::default()
206        };
207        let mut mgr = BrowserManager::new(config);
208        let result = mgr.start().await;
209
210        // If no system Chrome exists, this returns an error.
211        // If system Chrome exists, it returns Ok. Either way the test validates the code path.
212        if let Err(e) = result {
213            let err_str = e.to_string();
214            assert!(
215                err_str.contains("Chrome") || err_str.contains("not found"),
216                "unexpected error: {err_str}"
217            );
218        }
219    }
220
221    #[tokio::test]
222    async fn start_already_running_returns_ok() {
223        // Simulate a running process by starting a harmless short-lived process
224        // via the manager's internal mechanism.
225        // We'll use /bin/sleep as the "chrome" executable.
226        let config = BrowserConfig {
227            executable_path: Some("/bin/sleep".into()),
228            ..Default::default()
229        };
230        let mut mgr = BrowserManager::new(config);
231
232        // Manually set process to simulate "already running"
233        let child = Command::new("/bin/sleep")
234            .arg("10")
235            .stdout(Stdio::null())
236            .stderr(Stdio::null())
237            .spawn()
238            .unwrap();
239        mgr.process = Some(child);
240
241        assert!(mgr.is_running());
242
243        // start() should return Ok immediately without spawning a second process
244        let result = mgr.start().await;
245        assert!(result.is_ok());
246        assert!(mgr.is_running());
247
248        // Cleanup
249        mgr.stop().await.unwrap();
250    }
251
252    #[tokio::test]
253    async fn stop_kills_process() {
254        let child = Command::new("/bin/sleep")
255            .arg("60")
256            .stdout(Stdio::null())
257            .stderr(Stdio::null())
258            .spawn()
259            .unwrap();
260
261        let mut mgr = BrowserManager::new(BrowserConfig::default());
262        mgr.process = Some(child);
263        assert!(mgr.is_running());
264
265        let result = mgr.stop().await;
266        assert!(result.is_ok());
267        assert!(!mgr.is_running());
268    }
269
270    #[test]
271    fn drop_kills_process() {
272        let mut child = std::process::Command::new("/bin/sleep")
273            .arg("60")
274            .stdout(Stdio::null())
275            .stderr(Stdio::null())
276            .spawn()
277            .unwrap();
278        let pid = child.id();
279        // Kill immediately — we only need the pid for the test, not a running process.
280        let _ = child.kill();
281        let _ = child.wait();
282
283        // Wrap in tokio Child for the manager
284        // Actually, manager uses tokio::process::Child. Let's use a runtime for this.
285        let rt = tokio::runtime::Runtime::new().unwrap();
286        rt.block_on(async {
287            let tokio_child = Command::new("/bin/sleep")
288                .arg("60")
289                .stdout(Stdio::null())
290                .stderr(Stdio::null())
291                .spawn()
292                .unwrap();
293
294            let mut mgr = BrowserManager::new(BrowserConfig::default());
295            mgr.process = Some(tokio_child);
296            assert!(mgr.is_running());
297            // Drop mgr - should kill the process
298            drop(mgr);
299        });
300
301        // Kill the std child too
302        let _ = std::process::Command::new("kill")
303            .arg(pid.to_string())
304            .status();
305    }
306
307    #[test]
308    fn find_chrome_with_no_explicit_path_and_no_candidates() {
309        // When executable_path is None and no candidate paths exist,
310        // find_chrome_executable should return None.
311        // On a Mac with Chrome installed, this will still find Chrome.
312        // We just verify the method doesn't panic.
313        let config = BrowserConfig {
314            executable_path: None,
315            ..Default::default()
316        };
317        let mgr = BrowserManager::new(config);
318        let found = mgr.find_chrome_executable();
319        // found may be Some or None depending on system; just ensure no panic
320        if let Some(ref path) = found {
321            assert!(Path::new(path).exists());
322        }
323    }
324
325    #[tokio::test]
326    async fn start_with_invalid_executable() {
327        // Use a file that exists but is not executable as a Chrome binary
328        // /dev/null exists but cannot be executed as a process
329        let config = BrowserConfig {
330            executable_path: Some("/dev/null".into()),
331            ..Default::default()
332        };
333        let mut mgr = BrowserManager::new(config);
334        let result = mgr.start().await;
335        assert!(result.is_err());
336        let err_str = result.unwrap_err().to_string();
337        assert!(
338            err_str.contains("failed to start Chrome") || err_str.contains("browser"),
339            "unexpected error: {err_str}"
340        );
341    }
342}