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