browser_commander/browser/
launcher.rs1use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use chromiumoxide::browser::{Browser as CdpBrowser, BrowserConfig, HeadlessMode};
11use futures::StreamExt;
12
13use crate::browser::chromiumoxide_adapter::ChromiumoxidePage;
14use crate::browser::media::ColorScheme;
15use crate::core::constants::CHROME_ARGS;
16use crate::core::engine::{EngineAdapter, EngineType};
17
18#[derive(Debug, Clone)]
20pub struct LaunchOptions {
21 pub engine: EngineType,
23 pub user_data_dir: Option<PathBuf>,
25 pub headless: bool,
27 pub slow_mo: u64,
29 pub verbose: bool,
31 pub args: Vec<String>,
33 pub color_scheme: Option<ColorScheme>,
35 pub launch_timeout: Option<Duration>,
37 pub sandbox: bool,
44}
45
46impl Default for LaunchOptions {
47 fn default() -> Self {
48 Self {
49 engine: EngineType::Chromiumoxide,
50 user_data_dir: None,
51 headless: false,
52 slow_mo: 0,
53 verbose: false,
54 args: Vec::new(),
55 color_scheme: None,
56 launch_timeout: None,
57 sandbox: true,
58 }
59 }
60}
61
62impl LaunchOptions {
63 pub fn chromiumoxide() -> Self {
65 Self {
66 engine: EngineType::Chromiumoxide,
67 ..Default::default()
68 }
69 }
70
71 pub fn fantoccini() -> Self {
73 Self {
74 engine: EngineType::Fantoccini,
75 ..Default::default()
76 }
77 }
78
79 pub fn headless(mut self, headless: bool) -> Self {
81 self.headless = headless;
82 self
83 }
84
85 pub fn user_data_dir(mut self, dir: impl Into<PathBuf>) -> Self {
87 self.user_data_dir = Some(dir.into());
88 self
89 }
90
91 pub fn slow_mo(mut self, ms: u64) -> Self {
93 self.slow_mo = ms;
94 self
95 }
96
97 pub fn verbose(mut self, verbose: bool) -> Self {
99 self.verbose = verbose;
100 self
101 }
102
103 pub fn with_args(mut self, args: Vec<String>) -> Self {
105 self.args = args;
106 self
107 }
108
109 pub fn color_scheme(mut self, color_scheme: ColorScheme) -> Self {
111 self.color_scheme = Some(color_scheme);
112 self
113 }
114
115 pub fn launch_timeout(mut self, timeout: Duration) -> Self {
117 self.launch_timeout = Some(timeout);
118 self
119 }
120
121 pub fn sandbox(mut self, sandbox: bool) -> Self {
123 self.sandbox = sandbox;
124 self
125 }
126
127 pub fn all_chrome_args(&self) -> Vec<String> {
129 let mut all_args: Vec<String> = CHROME_ARGS.iter().map(|s| s.to_string()).collect();
130 all_args.extend(self.args.clone());
131 all_args
132 }
133
134 pub fn get_user_data_dir(&self) -> PathBuf {
136 if let Some(ref dir) = self.user_data_dir {
137 dir.clone()
138 } else {
139 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
140 home.join(".browser-commander")
141 .join(format!("{}-data", self.engine))
142 }
143 }
144}
145
146#[derive(Debug, Clone)]
148pub struct Browser {
149 pub engine: EngineType,
151 pub user_data_dir: PathBuf,
153 pub headless: bool,
155}
156
157pub struct LaunchResult {
163 pub browser: Browser,
165 pub page: Arc<dyn EngineAdapter>,
171}
172
173impl std::fmt::Debug for LaunchResult {
174 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
175 f.debug_struct("LaunchResult")
176 .field("browser", &self.browser)
177 .field("page", &"<dyn EngineAdapter>")
178 .finish()
179 }
180}
181
182pub async fn launch_browser(options: LaunchOptions) -> Result<LaunchResult, anyhow::Error> {
204 if options.verbose {
205 tracing::info!("Launching browser with {} engine...", options.engine);
206 }
207
208 let user_data_dir = options.get_user_data_dir();
209 std::fs::create_dir_all(&user_data_dir)?;
210
211 match options.engine {
212 EngineType::Chromiumoxide => launch_chromiumoxide(options, user_data_dir).await,
213 EngineType::Fantoccini => Err(anyhow::anyhow!(
214 "fantoccini engine launch is not yet implemented; \
215 connect to an existing WebDriver session or use EngineType::Chromiumoxide"
216 )),
217 }
218}
219
220async fn launch_chromiumoxide(
221 options: LaunchOptions,
222 user_data_dir: PathBuf,
223) -> Result<LaunchResult, anyhow::Error> {
224 let headless_mode = if options.headless {
225 HeadlessMode::New
226 } else {
227 HeadlessMode::False
228 };
229
230 let mut builder = BrowserConfig::builder()
231 .user_data_dir(&user_data_dir)
232 .headless_mode(headless_mode)
233 .args(options.all_chrome_args());
234
235 if !options.sandbox {
236 builder = builder.no_sandbox();
237 }
238
239 if let Some(timeout) = options.launch_timeout {
240 builder = builder.launch_timeout(timeout);
241 }
242
243 let config = builder
244 .build()
245 .map_err(|e| anyhow::anyhow!("failed to build browser config: {}", e))?;
246
247 let (browser, mut handler) = CdpBrowser::launch(config)
248 .await
249 .map_err(|e| anyhow::anyhow!("failed to launch chromium: {}", e))?;
250
251 let handler_task = tokio::spawn(async move {
256 while let Some(event) = handler.next().await {
257 if let Err(err) = event {
258 tracing::debug!(error = %err, "chromiumoxide handler event error");
259 }
260 }
261 });
262
263 let page = browser
264 .new_page("about:blank")
265 .await
266 .map_err(|e| anyhow::anyhow!("failed to open initial page: {}", e))?;
267
268 let engine = options.engine;
269 let headless = options.headless;
270 let color_scheme = options.color_scheme.clone();
271
272 let adapter = ChromiumoxidePage::new(page, browser, handler_task, user_data_dir.clone());
273
274 if let Some(ref cs) = color_scheme {
276 if let Err(err) = adapter.set_color_scheme(Some(cs)).await {
277 if options.verbose {
278 tracing::warn!(error = %err, "could not set color scheme");
279 }
280 }
281 }
282
283 if !headless {
286 if let Err(err) = adapter.bring_to_front().await {
287 if options.verbose {
288 tracing::debug!(error = %err, "bring_to_front failed");
289 }
290 }
291 }
292
293 if options.verbose {
294 tracing::info!("Browser launched with {} engine", engine);
295 }
296
297 Ok(LaunchResult {
298 browser: Browser {
299 engine,
300 user_data_dir,
301 headless,
302 },
303 page: Arc::new(adapter),
304 })
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn launch_options_default() {
313 let options = LaunchOptions::default();
314 assert_eq!(options.engine, EngineType::Chromiumoxide);
315 assert!(!options.headless);
316 assert_eq!(options.slow_mo, 0);
317 assert!(!options.verbose);
318 assert!(options.args.is_empty());
319 }
320
321 #[test]
322 fn launch_options_builder() {
323 let options = LaunchOptions::chromiumoxide()
324 .headless(true)
325 .slow_mo(100)
326 .verbose(true)
327 .with_args(vec!["--custom-arg".to_string()]);
328
329 assert_eq!(options.engine, EngineType::Chromiumoxide);
330 assert!(options.headless);
331 assert_eq!(options.slow_mo, 100);
332 assert!(options.verbose);
333 assert_eq!(options.args, vec!["--custom-arg"]);
334 }
335
336 #[test]
337 fn launch_options_fantoccini() {
338 let options = LaunchOptions::fantoccini();
339 assert_eq!(options.engine, EngineType::Fantoccini);
340 }
341
342 #[test]
343 fn all_chrome_args_includes_defaults() {
344 let options = LaunchOptions::default();
345 let args = options.all_chrome_args();
346
347 assert!(args.contains(&"--disable-infobars".to_string()));
348 assert!(args.contains(&"--no-first-run".to_string()));
349 }
350
351 #[test]
352 fn all_chrome_args_includes_custom() {
353 let options = LaunchOptions::default().with_args(vec!["--custom".to_string()]);
354 let args = options.all_chrome_args();
355
356 assert!(args.contains(&"--custom".to_string()));
357 }
358
359 #[test]
360 fn get_user_data_dir_uses_custom() {
361 let options = LaunchOptions::default().user_data_dir("/custom/path");
362 assert_eq!(options.get_user_data_dir(), PathBuf::from("/custom/path"));
363 }
364
365 #[test]
366 fn get_user_data_dir_creates_default() {
367 let options = LaunchOptions::default();
368 let dir = options.get_user_data_dir();
369 assert!(dir.to_string_lossy().contains("browser-commander"));
370 assert!(dir.to_string_lossy().contains("chromiumoxide-data"));
371 }
372
373 #[tokio::test]
374 async fn launch_fantoccini_is_unimplemented() {
375 let options = LaunchOptions::fantoccini();
376 let err = launch_browser(options).await.unwrap_err();
377 assert!(err.to_string().contains("fantoccini"));
378 }
379}