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}