viewpoint_core/browser/mod.rs
1//! Browser launching and management.
2//!
3//! This module provides the [`Browser`] type for connecting to and controlling
4//! Chromium-based browsers via the Chrome DevTools Protocol (CDP).
5//!
6//! # Connection Methods
7//!
8//! There are three ways to get a `Browser` instance:
9//!
10//! 1. **Launch a new browser** - [`Browser::launch()`] spawns a new Chromium process
11//! 2. **Connect via WebSocket URL** - [`Browser::connect()`] for direct WebSocket connection
12//! 3. **Connect via HTTP endpoint** - [`Browser::connect_over_cdp()`] discovers WebSocket URL
13//! from an HTTP endpoint like `http://localhost:9222`
14//!
15//! # Example: Launching a Browser
16//!
17//! ```no_run
18//! use viewpoint_core::Browser;
19//!
20//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
21//! let browser = Browser::launch()
22//! .headless(true)
23//! .launch()
24//! .await?;
25//!
26//! let context = browser.new_context().await?;
27//! let page = context.new_page().await?;
28//! page.goto("https://example.com").goto().await?;
29//! # Ok(())
30//! # }
31//! ```
32//!
33//! # Example: Connecting to Existing Browser (MCP-style)
34//!
35//! This is useful for MCP servers or tools that need to connect to an already-running
36//! browser instance:
37//!
38//! ```no_run
39//! use viewpoint_core::Browser;
40//! use std::time::Duration;
41//!
42//! # async fn example() -> Result<(), viewpoint_core::CoreError> {
43//! // Connect via HTTP endpoint (discovers WebSocket URL automatically)
44//! let browser = Browser::connect_over_cdp("http://localhost:9222")
45//! .timeout(Duration::from_secs(10))
46//! .connect()
47//! .await?;
48//!
49//! // Access existing browser contexts (including the default one)
50//! let contexts = browser.contexts().await?;
51//! for context in &contexts {
52//! if context.is_default() {
53//! // The default context has the browser's existing tabs
54//! let pages = context.pages().await?;
55//! println!("Found {} existing pages", pages.len());
56//! }
57//! }
58//!
59//! // You can also create new contexts in the connected browser
60//! let new_context = browser.new_context().await?;
61//! # Ok(())
62//! # }
63//! ```
64//!
65//! # Ownership Model
66//!
67//! Browsers and contexts track ownership:
68//!
69//! - **Launched browsers** (`Browser::launch()`) are "owned" - closing them terminates the process
70//! - **Connected browsers** (`connect()`, `connect_over_cdp()`) are not owned - closing only
71//! disconnects, leaving the browser process running
72//! - **Created contexts** (`new_context()`) are owned - closing disposes them
73//! - **Discovered contexts** (`contexts()`) are not owned - closing only disconnects
74
75mod connector;
76mod launcher;
77
78use std::process::Child;
79use std::sync::Arc;
80use std::time::Duration;
81
82use tokio::sync::Mutex;
83use tracing::info;
84use viewpoint_cdp::CdpConnection;
85use viewpoint_cdp::protocol::target_domain::{
86 CreateBrowserContextParams, CreateBrowserContextResult, GetBrowserContextsResult,
87};
88
89use crate::context::{
90 BrowserContext, ContextOptions, ContextOptionsBuilder, StorageState, StorageStateSource,
91};
92use crate::devices::DeviceDescriptor;
93use crate::error::BrowserError;
94
95pub use connector::ConnectOverCdpBuilder;
96pub use launcher::BrowserBuilder;
97
98/// Default timeout for browser operations.
99const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
100
101/// A browser instance connected via CDP.
102///
103/// The `Browser` struct represents a connection to a Chromium-based browser.
104/// It can be obtained by:
105///
106/// - [`Browser::launch()`] - Spawn and connect to a new browser process
107/// - [`Browser::connect()`] - Connect to an existing browser via WebSocket URL
108/// - [`Browser::connect_over_cdp()`] - Connect via HTTP endpoint (auto-discovers WebSocket)
109///
110/// # Key Methods
111///
112/// - [`new_context()`](Self::new_context) - Create a new isolated browser context
113/// - [`contexts()`](Self::contexts) - List all browser contexts (including pre-existing ones)
114/// - [`close()`](Self::close) - Close the browser connection
115///
116/// # Ownership
117///
118/// Use [`is_owned()`](Self::is_owned) to check if this browser was launched by us
119/// (vs connected to an existing process). Owned browsers are terminated when closed.
120#[derive(Debug)]
121pub struct Browser {
122 /// CDP connection to the browser.
123 connection: Arc<CdpConnection>,
124 /// Browser process (only present if we launched it).
125 process: Option<Mutex<Child>>,
126 /// Whether the browser was launched by us (vs connected to).
127 owned: bool,
128}
129
130impl Browser {
131 /// Create a browser builder for launching a new browser.
132 ///
133 /// # Example
134 ///
135 /// ```no_run
136 /// use viewpoint_core::Browser;
137 ///
138 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
139 /// let browser = Browser::launch()
140 /// .headless(true)
141 /// .launch()
142 /// .await?;
143 /// # Ok(())
144 /// # }
145 /// ```
146 pub fn launch() -> BrowserBuilder {
147 BrowserBuilder::new()
148 }
149
150 /// Connect to an already-running browser via WebSocket URL.
151 ///
152 /// # Example
153 ///
154 /// ```no_run
155 /// use viewpoint_core::Browser;
156 ///
157 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
158 /// let browser = Browser::connect("ws://localhost:9222/devtools/browser/...").await?;
159 /// # Ok(())
160 /// # }
161 /// ```
162 ///
163 /// # Errors
164 ///
165 /// Returns an error if the connection fails.
166 pub async fn connect(ws_url: &str) -> Result<Self, BrowserError> {
167 let connection = CdpConnection::connect(ws_url).await?;
168
169 Ok(Self {
170 connection: Arc::new(connection),
171 process: None,
172 owned: false,
173 })
174 }
175
176 /// Connect to an already-running browser via HTTP endpoint or WebSocket URL.
177 ///
178 /// This method supports both:
179 /// - HTTP endpoint URLs (e.g., `http://localhost:9222`) - auto-discovers WebSocket URL
180 /// - WebSocket URLs (e.g., `ws://localhost:9222/devtools/browser/...`) - direct connection
181 ///
182 /// For HTTP endpoints, the method fetches `/json/version` to discover the WebSocket URL,
183 /// similar to Playwright's `connectOverCDP`.
184 ///
185 /// # Example
186 ///
187 /// ```no_run
188 /// use viewpoint_core::Browser;
189 /// use std::time::Duration;
190 ///
191 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
192 /// // Connect via HTTP endpoint (recommended)
193 /// let browser = Browser::connect_over_cdp("http://localhost:9222")
194 /// .connect()
195 /// .await?;
196 ///
197 /// // With custom timeout and headers
198 /// let browser = Browser::connect_over_cdp("http://localhost:9222")
199 /// .timeout(Duration::from_secs(10))
200 /// .header("Authorization", "Bearer token")
201 /// .connect()
202 /// .await?;
203 ///
204 /// // Access existing browser contexts and pages
205 /// let contexts = browser.contexts().await?;
206 /// for context in contexts {
207 /// let pages = context.pages().await?;
208 /// for page in pages {
209 /// println!("Found page: {:?}", page.target_id);
210 /// }
211 /// }
212 /// # Ok(())
213 /// # }
214 /// ```
215 pub fn connect_over_cdp(endpoint_url: impl Into<String>) -> ConnectOverCdpBuilder {
216 ConnectOverCdpBuilder::new(endpoint_url)
217 }
218
219 /// Get all browser contexts.
220 ///
221 /// Returns all existing browser contexts, including:
222 /// - Contexts created via `new_context()`
223 /// - The default context (for connected browsers)
224 /// - Any pre-existing contexts (when connecting to an already-running browser)
225 ///
226 /// # Example
227 ///
228 /// ```no_run
229 /// use viewpoint_core::Browser;
230 ///
231 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
232 /// let browser = Browser::connect_over_cdp("http://localhost:9222")
233 /// .connect()
234 /// .await?;
235 ///
236 /// let contexts = browser.contexts().await?;
237 /// println!("Found {} browser contexts", contexts.len());
238 ///
239 /// // The default context (empty string ID) represents the browser's main profile
240 /// for context in &contexts {
241 /// if context.id().is_empty() {
242 /// println!("This is the default context");
243 /// }
244 /// }
245 /// # Ok(())
246 /// # }
247 /// ```
248 ///
249 /// # Errors
250 ///
251 /// Returns an error if querying contexts fails.
252 pub async fn contexts(&self) -> Result<Vec<BrowserContext>, BrowserError> {
253 info!("Getting browser contexts");
254
255 let result: GetBrowserContextsResult = self
256 .connection
257 .send_command("Target.getBrowserContexts", None::<()>, None)
258 .await?;
259
260 let mut contexts = Vec::new();
261
262 // Always include the default context (empty string ID)
263 // The default context represents the browser's main profile
264 contexts.push(BrowserContext::from_existing(
265 self.connection.clone(),
266 String::new(), // Empty string = default context
267 ));
268
269 // Add other contexts
270 for context_id in result.browser_context_ids {
271 if !context_id.is_empty() {
272 contexts.push(BrowserContext::from_existing(
273 self.connection.clone(),
274 context_id,
275 ));
276 }
277 }
278
279 info!(count = contexts.len(), "Found browser contexts");
280
281 Ok(contexts)
282 }
283
284 /// Create a browser from an existing connection and process.
285 pub(crate) fn from_connection_and_process(connection: CdpConnection, process: Child) -> Self {
286 Self {
287 connection: Arc::new(connection),
288 process: Some(Mutex::new(process)),
289 owned: true,
290 }
291 }
292
293 /// Create a new isolated browser context.
294 ///
295 /// Browser contexts are isolated environments within the browser,
296 /// similar to incognito windows. They have their own cookies,
297 /// cache, and storage.
298 ///
299 /// # Errors
300 ///
301 /// Returns an error if context creation fails.
302 pub async fn new_context(&self) -> Result<BrowserContext, BrowserError> {
303 let result: CreateBrowserContextResult = self
304 .connection
305 .send_command(
306 "Target.createBrowserContext",
307 Some(CreateBrowserContextParams::default()),
308 None,
309 )
310 .await?;
311
312 Ok(BrowserContext::new(
313 self.connection.clone(),
314 result.browser_context_id,
315 ))
316 }
317
318 /// Create a new context options builder.
319 ///
320 /// Use this to create a browser context with custom configuration.
321 ///
322 /// # Example
323 ///
324 /// ```no_run
325 /// use viewpoint_core::{Browser, Permission};
326 ///
327 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
328 /// let browser = Browser::launch().headless(true).launch().await?;
329 ///
330 /// let context = browser.new_context_builder()
331 /// .geolocation(37.7749, -122.4194)
332 /// .permissions(vec![Permission::Geolocation])
333 /// .offline(false)
334 /// .build()
335 /// .await?;
336 /// # Ok(())
337 /// # }
338 /// ```
339 pub fn new_context_builder(&self) -> NewContextBuilder<'_> {
340 NewContextBuilder::new(self)
341 }
342
343 /// Create a new isolated browser context with options.
344 ///
345 /// # Errors
346 ///
347 /// Returns an error if context creation fails.
348 pub async fn new_context_with_options(
349 &self,
350 options: ContextOptions,
351 ) -> Result<BrowserContext, BrowserError> {
352 // Load storage state if specified
353 let storage_state = match &options.storage_state {
354 Some(StorageStateSource::Path(path)) => {
355 Some(StorageState::load(path).await.map_err(|e| {
356 BrowserError::LaunchFailed(format!("Failed to load storage state: {e}"))
357 })?)
358 }
359 Some(StorageStateSource::State(state)) => Some(state.clone()),
360 None => None,
361 };
362
363 let result: CreateBrowserContextResult = self
364 .connection
365 .send_command(
366 "Target.createBrowserContext",
367 Some(CreateBrowserContextParams::default()),
368 None,
369 )
370 .await?;
371
372 let context = BrowserContext::with_options(
373 self.connection.clone(),
374 result.browser_context_id,
375 options,
376 );
377
378 // Apply options
379 context.apply_options().await?;
380
381 // Restore storage state if any
382 if let Some(state) = storage_state {
383 // Restore cookies
384 context.add_cookies(state.cookies.clone()).await?;
385
386 // Restore localStorage via init script
387 let local_storage_script = state.to_local_storage_init_script();
388 if !local_storage_script.is_empty() {
389 context.add_init_script(&local_storage_script).await?;
390 }
391
392 // Restore IndexedDB via init script
393 let indexed_db_script = state.to_indexed_db_init_script();
394 if !indexed_db_script.is_empty() {
395 context.add_init_script(&indexed_db_script).await?;
396 }
397 }
398
399 Ok(context)
400 }
401
402 /// Close the browser.
403 ///
404 /// If this browser was launched by us, the process will be terminated.
405 /// If it was connected to, only the WebSocket connection is closed.
406 ///
407 /// # Errors
408 ///
409 /// Returns an error if closing fails.
410 pub async fn close(&self) -> Result<(), BrowserError> {
411 // If we own the process, terminate it
412 if let Some(ref process) = self.process {
413 let mut child = process.lock().await;
414 let _ = child.kill();
415 }
416
417 Ok(())
418 }
419
420 /// Get a reference to the CDP connection.
421 pub fn connection(&self) -> &Arc<CdpConnection> {
422 &self.connection
423 }
424
425 /// Check if this browser was launched by us.
426 pub fn is_owned(&self) -> bool {
427 self.owned
428 }
429}
430
431/// Builder for creating a new browser context with options.
432#[derive(Debug)]
433pub struct NewContextBuilder<'a> {
434 browser: &'a Browser,
435 builder: ContextOptionsBuilder,
436}
437
438impl<'a> NewContextBuilder<'a> {
439 fn new(browser: &'a Browser) -> Self {
440 Self {
441 browser,
442 builder: ContextOptionsBuilder::new(),
443 }
444 }
445
446 /// Set storage state from a file path.
447 #[must_use]
448 pub fn storage_state_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
449 self.builder = self.builder.storage_state_path(path);
450 self
451 }
452
453 /// Set storage state from an object.
454 #[must_use]
455 pub fn storage_state(mut self, state: StorageState) -> Self {
456 self.builder = self.builder.storage_state(state);
457 self
458 }
459
460 /// Set geolocation.
461 #[must_use]
462 pub fn geolocation(mut self, latitude: f64, longitude: f64) -> Self {
463 self.builder = self.builder.geolocation(latitude, longitude);
464 self
465 }
466
467 /// Set geolocation with accuracy.
468 #[must_use]
469 pub fn geolocation_with_accuracy(
470 mut self,
471 latitude: f64,
472 longitude: f64,
473 accuracy: f64,
474 ) -> Self {
475 self.builder = self
476 .builder
477 .geolocation_with_accuracy(latitude, longitude, accuracy);
478 self
479 }
480
481 /// Grant permissions.
482 #[must_use]
483 pub fn permissions(mut self, permissions: Vec<crate::context::Permission>) -> Self {
484 self.builder = self.builder.permissions(permissions);
485 self
486 }
487
488 /// Set HTTP credentials.
489 #[must_use]
490 pub fn http_credentials(
491 mut self,
492 username: impl Into<String>,
493 password: impl Into<String>,
494 ) -> Self {
495 self.builder = self.builder.http_credentials(username, password);
496 self
497 }
498
499 /// Set extra HTTP headers.
500 #[must_use]
501 pub fn extra_http_headers(
502 mut self,
503 headers: std::collections::HashMap<String, String>,
504 ) -> Self {
505 self.builder = self.builder.extra_http_headers(headers);
506 self
507 }
508
509 /// Add an extra HTTP header.
510 #[must_use]
511 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
512 self.builder = self.builder.header(name, value);
513 self
514 }
515
516 /// Set offline mode.
517 #[must_use]
518 pub fn offline(mut self, offline: bool) -> Self {
519 self.builder = self.builder.offline(offline);
520 self
521 }
522
523 /// Set default timeout.
524 #[must_use]
525 pub fn default_timeout(mut self, timeout: Duration) -> Self {
526 self.builder = self.builder.default_timeout(timeout);
527 self
528 }
529
530 /// Set default navigation timeout.
531 #[must_use]
532 pub fn default_navigation_timeout(mut self, timeout: Duration) -> Self {
533 self.builder = self.builder.default_navigation_timeout(timeout);
534 self
535 }
536
537 /// Enable touch emulation.
538 #[must_use]
539 pub fn has_touch(mut self, has_touch: bool) -> Self {
540 self.builder = self.builder.has_touch(has_touch);
541 self
542 }
543
544 /// Set locale.
545 #[must_use]
546 pub fn locale(mut self, locale: impl Into<String>) -> Self {
547 self.builder = self.builder.locale(locale);
548 self
549 }
550
551 /// Set timezone.
552 #[must_use]
553 pub fn timezone_id(mut self, timezone_id: impl Into<String>) -> Self {
554 self.builder = self.builder.timezone_id(timezone_id);
555 self
556 }
557
558 /// Set user agent.
559 #[must_use]
560 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
561 self.builder = self.builder.user_agent(user_agent);
562 self
563 }
564
565 /// Set viewport size.
566 #[must_use]
567 pub fn viewport(mut self, width: i32, height: i32) -> Self {
568 self.builder = self.builder.viewport(width, height);
569 self
570 }
571
572 /// Set color scheme.
573 #[must_use]
574 pub fn color_scheme(mut self, color_scheme: crate::context::ColorScheme) -> Self {
575 self.builder = self.builder.color_scheme(color_scheme);
576 self
577 }
578
579 /// Set reduced motion preference.
580 #[must_use]
581 pub fn reduced_motion(mut self, reduced_motion: crate::context::ReducedMotion) -> Self {
582 self.builder = self.builder.reduced_motion(reduced_motion);
583 self
584 }
585
586 /// Set forced colors preference.
587 #[must_use]
588 pub fn forced_colors(mut self, forced_colors: crate::context::ForcedColors) -> Self {
589 self.builder = self.builder.forced_colors(forced_colors);
590 self
591 }
592
593 /// Set device scale factor (device pixel ratio).
594 #[must_use]
595 pub fn device_scale_factor(mut self, scale_factor: f64) -> Self {
596 self.builder = self.builder.device_scale_factor(scale_factor);
597 self
598 }
599
600 /// Set mobile mode.
601 #[must_use]
602 pub fn is_mobile(mut self, is_mobile: bool) -> Self {
603 self.builder = self.builder.is_mobile(is_mobile);
604 self
605 }
606
607 /// Apply a device descriptor to configure the context.
608 ///
609 /// This sets viewport, user agent, device scale factor, touch, and mobile mode
610 /// based on the device descriptor.
611 ///
612 /// # Example
613 ///
614 /// ```no_run
615 /// use viewpoint_core::{Browser, devices};
616 ///
617 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
618 /// let browser = Browser::launch().headless(true).launch().await?;
619 ///
620 /// let context = browser.new_context_builder()
621 /// .device(devices::IPHONE_13)
622 /// .build()
623 /// .await?;
624 /// # Ok(())
625 /// # }
626 /// ```
627 #[must_use]
628 pub fn device(mut self, device: DeviceDescriptor) -> Self {
629 self.builder = self.builder.device(device);
630 self
631 }
632
633 /// Enable video recording for pages in this context.
634 ///
635 /// Videos are recorded for each page and saved to the specified directory.
636 ///
637 /// # Example
638 ///
639 /// ```no_run
640 /// use viewpoint_core::{Browser, page::VideoOptions};
641 ///
642 /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
643 /// let browser = Browser::launch().headless(true).launch().await?;
644 /// let context = browser.new_context_builder()
645 /// .record_video(VideoOptions::new("./videos"))
646 /// .build()
647 /// .await?;
648 /// # Ok(())
649 /// # }
650 /// ```
651 #[must_use]
652 pub fn record_video(mut self, options: crate::page::VideoOptions) -> Self {
653 self.builder = self.builder.record_video(options);
654 self
655 }
656
657 /// Build and create the browser context.
658 ///
659 /// # Errors
660 ///
661 /// Returns an error if context creation fails.
662 pub async fn build(self) -> Result<BrowserContext, BrowserError> {
663 self.browser
664 .new_context_with_options(self.builder.build())
665 .await
666 }
667}
668
669impl Drop for Browser {
670 fn drop(&mut self) {
671 // Try to kill the process if we own it
672 if self.owned {
673 if let Some(ref process) = self.process {
674 // We can't await in drop, so we try to kill synchronously
675 if let Ok(mut guard) = process.try_lock() {
676 let _ = guard.kill();
677 }
678 }
679 }
680 }
681}