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}