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 context_builder;
77mod launcher;
78
79use std::process::Child;
80use std::sync::Arc;
81use std::time::Duration;
82
83use tokio::sync::Mutex;
84use tracing::info;
85use viewpoint_cdp::CdpConnection;
86use viewpoint_cdp::protocol::target_domain::{
87    CreateBrowserContextParams, CreateBrowserContextResult, GetBrowserContextsResult,
88};
89
90use crate::context::{
91    BrowserContext, ContextOptions, StorageState, StorageStateSource,
92};
93use crate::error::BrowserError;
94
95pub use connector::ConnectOverCdpBuilder;
96pub use context_builder::NewContextBuilder;
97pub use launcher::BrowserBuilder;
98
99/// Default timeout for browser operations.
100const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
101
102/// A browser instance connected via CDP.
103///
104/// The `Browser` struct represents a connection to a Chromium-based browser.
105/// It can be obtained by:
106///
107/// - [`Browser::launch()`] - Spawn and connect to a new browser process
108/// - [`Browser::connect()`] - Connect to an existing browser via WebSocket URL
109/// - [`Browser::connect_over_cdp()`] - Connect via HTTP endpoint (auto-discovers WebSocket)
110///
111/// # Key Methods
112///
113/// - [`new_context()`](Self::new_context) - Create a new isolated browser context
114/// - [`contexts()`](Self::contexts) - List all browser contexts (including pre-existing ones)
115/// - [`close()`](Self::close) - Close the browser connection
116///
117/// # Ownership
118///
119/// Use [`is_owned()`](Self::is_owned) to check if this browser was launched by us
120/// (vs connected to an existing process). Owned browsers are terminated when closed.
121#[derive(Debug)]
122pub struct Browser {
123    /// CDP connection to the browser.
124    connection: Arc<CdpConnection>,
125    /// Browser process (only present if we launched it).
126    process: Option<Mutex<Child>>,
127    /// Whether the browser was launched by us (vs connected to).
128    owned: bool,
129}
130
131impl Browser {
132    /// Create a browser builder for launching a new browser.
133    ///
134    /// # Example
135    ///
136    /// ```no_run
137    /// use viewpoint_core::Browser;
138    ///
139    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
140    /// let browser = Browser::launch()
141    ///     .headless(true)
142    ///     .launch()
143    ///     .await?;
144    /// # Ok(())
145    /// # }
146    /// ```
147    pub fn launch() -> BrowserBuilder {
148        BrowserBuilder::new()
149    }
150
151    /// Connect to an already-running browser via WebSocket URL.
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// use viewpoint_core::Browser;
157    ///
158    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
159    /// let browser = Browser::connect("ws://localhost:9222/devtools/browser/...").await?;
160    /// # Ok(())
161    /// # }
162    /// ```
163    ///
164    /// # Errors
165    ///
166    /// Returns an error if the connection fails.
167    pub async fn connect(ws_url: &str) -> Result<Self, BrowserError> {
168        let connection = CdpConnection::connect(ws_url).await?;
169
170        Ok(Self {
171            connection: Arc::new(connection),
172            process: None,
173            owned: false,
174        })
175    }
176
177    /// Connect to an already-running browser via HTTP endpoint or WebSocket URL.
178    ///
179    /// This method supports both:
180    /// - HTTP endpoint URLs (e.g., `http://localhost:9222`) - auto-discovers WebSocket URL
181    /// - WebSocket URLs (e.g., `ws://localhost:9222/devtools/browser/...`) - direct connection
182    ///
183    /// For HTTP endpoints, the method fetches `/json/version` to discover the WebSocket URL,
184    /// similar to Playwright's `connectOverCDP`.
185    ///
186    /// # Example
187    ///
188    /// ```no_run
189    /// use viewpoint_core::Browser;
190    /// use std::time::Duration;
191    ///
192    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
193    /// // Connect via HTTP endpoint (recommended)
194    /// let browser = Browser::connect_over_cdp("http://localhost:9222")
195    ///     .connect()
196    ///     .await?;
197    ///
198    /// // With custom timeout and headers
199    /// let browser = Browser::connect_over_cdp("http://localhost:9222")
200    ///     .timeout(Duration::from_secs(10))
201    ///     .header("Authorization", "Bearer token")
202    ///     .connect()
203    ///     .await?;
204    ///
205    /// // Access existing browser contexts and pages
206    /// let contexts = browser.contexts().await?;
207    /// for context in contexts {
208    ///     let pages = context.pages().await?;
209    ///     for page in pages {
210    ///         println!("Found page: {:?}", page.target_id);
211    ///     }
212    /// }
213    /// # Ok(())
214    /// # }
215    /// ```
216    pub fn connect_over_cdp(endpoint_url: impl Into<String>) -> ConnectOverCdpBuilder {
217        ConnectOverCdpBuilder::new(endpoint_url)
218    }
219
220    /// Get all browser contexts.
221    ///
222    /// Returns all existing browser contexts, including:
223    /// - Contexts created via `new_context()`
224    /// - The default context (for connected browsers)
225    /// - Any pre-existing contexts (when connecting to an already-running browser)
226    ///
227    /// # Example
228    ///
229    /// ```no_run
230    /// use viewpoint_core::Browser;
231    ///
232    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
233    /// let browser = Browser::connect_over_cdp("http://localhost:9222")
234    ///     .connect()
235    ///     .await?;
236    ///
237    /// let contexts = browser.contexts().await?;
238    /// println!("Found {} browser contexts", contexts.len());
239    ///
240    /// // The default context (empty string ID) represents the browser's main profile
241    /// for context in &contexts {
242    ///     if context.id().is_empty() {
243    ///         println!("This is the default context");
244    ///     }
245    /// }
246    /// # Ok(())
247    /// # }
248    /// ```
249    ///
250    /// # Errors
251    ///
252    /// Returns an error if querying contexts fails.
253    pub async fn contexts(&self) -> Result<Vec<BrowserContext>, BrowserError> {
254        info!("Getting browser contexts");
255
256        let result: GetBrowserContextsResult = self
257            .connection
258            .send_command("Target.getBrowserContexts", None::<()>, None)
259            .await?;
260
261        let mut contexts = Vec::new();
262
263        // Always include the default context (empty string ID)
264        // The default context represents the browser's main profile
265        contexts.push(BrowserContext::from_existing(
266            self.connection.clone(),
267            String::new(), // Empty string = default context
268        ));
269
270        // Add other contexts
271        for context_id in result.browser_context_ids {
272            if !context_id.is_empty() {
273                contexts.push(BrowserContext::from_existing(
274                    self.connection.clone(),
275                    context_id,
276                ));
277            }
278        }
279
280        info!(count = contexts.len(), "Found browser contexts");
281
282        Ok(contexts)
283    }
284
285    /// Create a browser from an existing connection and process.
286    pub(crate) fn from_connection_and_process(connection: CdpConnection, process: Child) -> Self {
287        Self {
288            connection: Arc::new(connection),
289            process: Some(Mutex::new(process)),
290            owned: true,
291        }
292    }
293
294    /// Create a new isolated browser context.
295    ///
296    /// Browser contexts are isolated environments within the browser,
297    /// similar to incognito windows. They have their own cookies,
298    /// cache, and storage.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if context creation fails.
303    pub async fn new_context(&self) -> Result<BrowserContext, BrowserError> {
304        let result: CreateBrowserContextResult = self
305            .connection
306            .send_command(
307                "Target.createBrowserContext",
308                Some(CreateBrowserContextParams::default()),
309                None,
310            )
311            .await?;
312
313        Ok(BrowserContext::new(
314            self.connection.clone(),
315            result.browser_context_id,
316        ))
317    }
318
319    /// Create a new context options builder.
320    ///
321    /// Use this to create a browser context with custom configuration.
322    ///
323    /// # Example
324    ///
325    /// ```no_run
326    /// use viewpoint_core::{Browser, Permission};
327    ///
328    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
329    /// let browser = Browser::launch().headless(true).launch().await?;
330    ///
331    /// let context = browser.new_context_builder()
332    ///     .geolocation(37.7749, -122.4194)
333    ///     .permissions(vec![Permission::Geolocation])
334    ///     .offline(false)
335    ///     .build()
336    ///     .await?;
337    /// # Ok(())
338    /// # }
339    /// ```
340    pub fn new_context_builder(&self) -> NewContextBuilder<'_> {
341        NewContextBuilder::new(self)
342    }
343
344    /// Create a new isolated browser context with options.
345    ///
346    /// # Errors
347    ///
348    /// Returns an error if context creation fails.
349    pub async fn new_context_with_options(
350        &self,
351        options: ContextOptions,
352    ) -> Result<BrowserContext, BrowserError> {
353        // Load storage state if specified
354        let storage_state = match &options.storage_state {
355            Some(StorageStateSource::Path(path)) => {
356                Some(StorageState::load(path).await.map_err(|e| {
357                    BrowserError::LaunchFailed(format!("Failed to load storage state: {e}"))
358                })?)
359            }
360            Some(StorageStateSource::State(state)) => Some(state.clone()),
361            None => None,
362        };
363
364        let result: CreateBrowserContextResult = self
365            .connection
366            .send_command(
367                "Target.createBrowserContext",
368                Some(CreateBrowserContextParams::default()),
369                None,
370            )
371            .await?;
372
373        let context = BrowserContext::with_options(
374            self.connection.clone(),
375            result.browser_context_id,
376            options,
377        );
378
379        // Apply options
380        context.apply_options().await?;
381
382        // Restore storage state if any
383        if let Some(state) = storage_state {
384            // Restore cookies
385            context.add_cookies(state.cookies.clone()).await?;
386
387            // Restore localStorage via init script
388            let local_storage_script = state.to_local_storage_init_script();
389            if !local_storage_script.is_empty() {
390                context.add_init_script(&local_storage_script).await?;
391            }
392
393            // Restore IndexedDB via init script
394            let indexed_db_script = state.to_indexed_db_init_script();
395            if !indexed_db_script.is_empty() {
396                context.add_init_script(&indexed_db_script).await?;
397            }
398        }
399
400        Ok(context)
401    }
402
403    /// Close the browser.
404    ///
405    /// If this browser was launched by us, the process will be terminated.
406    /// If it was connected to, only the WebSocket connection is closed.
407    ///
408    /// # Errors
409    ///
410    /// Returns an error if closing fails.
411    pub async fn close(&self) -> Result<(), BrowserError> {
412        // If we own the process, terminate it
413        if let Some(ref process) = self.process {
414            let mut child = process.lock().await;
415            let _ = child.kill();
416        }
417
418        Ok(())
419    }
420
421    /// Get a reference to the CDP connection.
422    pub fn connection(&self) -> &Arc<CdpConnection> {
423        &self.connection
424    }
425
426    /// Check if this browser was launched by us.
427    pub fn is_owned(&self) -> bool {
428        self.owned
429    }
430}
431
432impl Drop for Browser {
433    fn drop(&mut self) {
434        // Try to kill the process if we own it
435        if self.owned {
436            if let Some(ref process) = self.process {
437                // We can't await in drop, so we try to kill synchronously
438                if let Ok(mut guard) = process.try_lock() {
439                    let _ = guard.kill();
440                }
441            }
442        }
443    }
444}