viewpoint_core/browser/
mod.rs

1//! Browser launching and management.
2
3mod launcher;
4
5use std::process::Child;
6use std::sync::Arc;
7use std::time::Duration;
8
9use viewpoint_cdp::protocol::target_domain::{CreateBrowserContextParams, CreateBrowserContextResult};
10use viewpoint_cdp::CdpConnection;
11use tokio::sync::Mutex;
12
13use crate::context::{BrowserContext, ContextOptions, ContextOptionsBuilder, StorageState, StorageStateSource};
14use crate::devices::DeviceDescriptor;
15use crate::error::BrowserError;
16
17pub use launcher::BrowserBuilder;
18
19/// Default timeout for browser operations.
20const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
21
22/// A browser instance connected via CDP.
23#[derive(Debug)]
24pub struct Browser {
25    /// CDP connection to the browser.
26    connection: Arc<CdpConnection>,
27    /// Browser process (only present if we launched it).
28    process: Option<Mutex<Child>>,
29    /// Whether the browser was launched by us (vs connected to).
30    owned: bool,
31}
32
33impl Browser {
34    /// Create a browser builder for launching a new browser.
35    ///
36    /// # Example
37    ///
38    /// ```no_run
39    /// use viewpoint_core::Browser;
40    ///
41    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
42    /// let browser = Browser::launch()
43    ///     .headless(true)
44    ///     .launch()
45    ///     .await?;
46    /// # Ok(())
47    /// # }
48    /// ```
49    pub fn launch() -> BrowserBuilder {
50        BrowserBuilder::new()
51    }
52
53    /// Connect to an already-running browser via WebSocket URL.
54    ///
55    /// # Example
56    ///
57    /// ```no_run
58    /// use viewpoint_core::Browser;
59    ///
60    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
61    /// let browser = Browser::connect("ws://localhost:9222/devtools/browser/...").await?;
62    /// # Ok(())
63    /// # }
64    /// ```
65    ///
66    /// # Errors
67    ///
68    /// Returns an error if the connection fails.
69    pub async fn connect(ws_url: &str) -> Result<Self, BrowserError> {
70        let connection = CdpConnection::connect(ws_url).await?;
71
72        Ok(Self {
73            connection: Arc::new(connection),
74            process: None,
75            owned: false,
76        })
77    }
78
79    /// Create a browser from an existing connection and process.
80    pub(crate) fn from_connection_and_process(
81        connection: CdpConnection,
82        process: Child,
83    ) -> Self {
84        Self {
85            connection: Arc::new(connection),
86            process: Some(Mutex::new(process)),
87            owned: true,
88        }
89    }
90
91    /// Create a new isolated browser context.
92    ///
93    /// Browser contexts are isolated environments within the browser,
94    /// similar to incognito windows. They have their own cookies,
95    /// cache, and storage.
96    ///
97    /// # Errors
98    ///
99    /// Returns an error if context creation fails.
100    pub async fn new_context(&self) -> Result<BrowserContext, BrowserError> {
101        let result: CreateBrowserContextResult = self
102            .connection
103            .send_command(
104                "Target.createBrowserContext",
105                Some(CreateBrowserContextParams::default()),
106                None,
107            )
108            .await?;
109
110        Ok(BrowserContext::new(
111            self.connection.clone(),
112            result.browser_context_id,
113        ))
114    }
115
116    /// Create a new context options builder.
117    ///
118    /// Use this to create a browser context with custom configuration.
119    ///
120    /// # Example
121    ///
122    /// ```no_run
123    /// use viewpoint_core::{Browser, Permission};
124    ///
125    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
126    /// let browser = Browser::launch().headless(true).launch().await?;
127    ///
128    /// let context = browser.new_context_builder()
129    ///     .geolocation(37.7749, -122.4194)
130    ///     .permissions(vec![Permission::Geolocation])
131    ///     .offline(false)
132    ///     .build()
133    ///     .await?;
134    /// # Ok(())
135    /// # }
136    /// ```
137    pub fn new_context_builder(&self) -> NewContextBuilder<'_> {
138        NewContextBuilder::new(self)
139    }
140
141    /// Create a new isolated browser context with options.
142    ///
143    /// # Errors
144    ///
145    /// Returns an error if context creation fails.
146    pub async fn new_context_with_options(
147        &self,
148        options: ContextOptions,
149    ) -> Result<BrowserContext, BrowserError> {
150        // Load storage state if specified
151        let storage_state = match &options.storage_state {
152            Some(StorageStateSource::Path(path)) => {
153                Some(StorageState::load(path).await.map_err(|e| {
154                    BrowserError::LaunchFailed(format!("Failed to load storage state: {e}"))
155                })?)
156            }
157            Some(StorageStateSource::State(state)) => Some(state.clone()),
158            None => None,
159        };
160
161        let result: CreateBrowserContextResult = self
162            .connection
163            .send_command(
164                "Target.createBrowserContext",
165                Some(CreateBrowserContextParams::default()),
166                None,
167            )
168            .await?;
169
170        let context = BrowserContext::with_options(
171            self.connection.clone(),
172            result.browser_context_id,
173            options,
174        );
175
176        // Apply options
177        context.apply_options().await?;
178
179        // Restore storage state if any
180        if let Some(state) = storage_state {
181            // Restore cookies
182            context.add_cookies(state.cookies.clone()).await?;
183
184            // Restore localStorage via init script
185            let local_storage_script = state.to_local_storage_init_script();
186            if !local_storage_script.is_empty() {
187                context.add_init_script(&local_storage_script).await?;
188            }
189
190            // Restore IndexedDB via init script
191            let indexed_db_script = state.to_indexed_db_init_script();
192            if !indexed_db_script.is_empty() {
193                context.add_init_script(&indexed_db_script).await?;
194            }
195        }
196
197        Ok(context)
198    }
199
200    /// Close the browser.
201    ///
202    /// If this browser was launched by us, the process will be terminated.
203    /// If it was connected to, only the WebSocket connection is closed.
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if closing fails.
208    pub async fn close(&self) -> Result<(), BrowserError> {
209        // If we own the process, terminate it
210        if let Some(ref process) = self.process {
211            let mut child = process.lock().await;
212            let _ = child.kill();
213        }
214
215        Ok(())
216    }
217
218    /// Get a reference to the CDP connection.
219    pub fn connection(&self) -> &Arc<CdpConnection> {
220        &self.connection
221    }
222
223    /// Check if this browser was launched by us.
224    pub fn is_owned(&self) -> bool {
225        self.owned
226    }
227}
228
229/// Builder for creating a new browser context with options.
230#[derive(Debug)]
231pub struct NewContextBuilder<'a> {
232    browser: &'a Browser,
233    builder: ContextOptionsBuilder,
234}
235
236impl<'a> NewContextBuilder<'a> {
237    fn new(browser: &'a Browser) -> Self {
238        Self {
239            browser,
240            builder: ContextOptionsBuilder::new(),
241        }
242    }
243
244    /// Set storage state from a file path.
245    #[must_use]
246    pub fn storage_state_path(mut self, path: impl Into<std::path::PathBuf>) -> Self {
247        self.builder = self.builder.storage_state_path(path);
248        self
249    }
250
251    /// Set storage state from an object.
252    #[must_use]
253    pub fn storage_state(mut self, state: StorageState) -> Self {
254        self.builder = self.builder.storage_state(state);
255        self
256    }
257
258    /// Set geolocation.
259    #[must_use]
260    pub fn geolocation(mut self, latitude: f64, longitude: f64) -> Self {
261        self.builder = self.builder.geolocation(latitude, longitude);
262        self
263    }
264
265    /// Set geolocation with accuracy.
266    #[must_use]
267    pub fn geolocation_with_accuracy(mut self, latitude: f64, longitude: f64, accuracy: f64) -> Self {
268        self.builder = self.builder.geolocation_with_accuracy(latitude, longitude, accuracy);
269        self
270    }
271
272    /// Grant permissions.
273    #[must_use]
274    pub fn permissions(mut self, permissions: Vec<crate::context::Permission>) -> Self {
275        self.builder = self.builder.permissions(permissions);
276        self
277    }
278
279    /// Set HTTP credentials.
280    #[must_use]
281    pub fn http_credentials(mut self, username: impl Into<String>, password: impl Into<String>) -> Self {
282        self.builder = self.builder.http_credentials(username, password);
283        self
284    }
285
286    /// Set extra HTTP headers.
287    #[must_use]
288    pub fn extra_http_headers(mut self, headers: std::collections::HashMap<String, String>) -> Self {
289        self.builder = self.builder.extra_http_headers(headers);
290        self
291    }
292
293    /// Add an extra HTTP header.
294    #[must_use]
295    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
296        self.builder = self.builder.header(name, value);
297        self
298    }
299
300    /// Set offline mode.
301    #[must_use]
302    pub fn offline(mut self, offline: bool) -> Self {
303        self.builder = self.builder.offline(offline);
304        self
305    }
306
307    /// Set default timeout.
308    #[must_use]
309    pub fn default_timeout(mut self, timeout: Duration) -> Self {
310        self.builder = self.builder.default_timeout(timeout);
311        self
312    }
313
314    /// Set default navigation timeout.
315    #[must_use]
316    pub fn default_navigation_timeout(mut self, timeout: Duration) -> Self {
317        self.builder = self.builder.default_navigation_timeout(timeout);
318        self
319    }
320
321    /// Enable touch emulation.
322    #[must_use]
323    pub fn has_touch(mut self, has_touch: bool) -> Self {
324        self.builder = self.builder.has_touch(has_touch);
325        self
326    }
327
328    /// Set locale.
329    #[must_use]
330    pub fn locale(mut self, locale: impl Into<String>) -> Self {
331        self.builder = self.builder.locale(locale);
332        self
333    }
334
335    /// Set timezone.
336    #[must_use]
337    pub fn timezone_id(mut self, timezone_id: impl Into<String>) -> Self {
338        self.builder = self.builder.timezone_id(timezone_id);
339        self
340    }
341
342    /// Set user agent.
343    #[must_use]
344    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
345        self.builder = self.builder.user_agent(user_agent);
346        self
347    }
348
349    /// Set viewport size.
350    #[must_use]
351    pub fn viewport(mut self, width: i32, height: i32) -> Self {
352        self.builder = self.builder.viewport(width, height);
353        self
354    }
355
356    /// Set color scheme.
357    #[must_use]
358    pub fn color_scheme(mut self, color_scheme: crate::context::ColorScheme) -> Self {
359        self.builder = self.builder.color_scheme(color_scheme);
360        self
361    }
362
363    /// Set reduced motion preference.
364    #[must_use]
365    pub fn reduced_motion(mut self, reduced_motion: crate::context::ReducedMotion) -> Self {
366        self.builder = self.builder.reduced_motion(reduced_motion);
367        self
368    }
369
370    /// Set forced colors preference.
371    #[must_use]
372    pub fn forced_colors(mut self, forced_colors: crate::context::ForcedColors) -> Self {
373        self.builder = self.builder.forced_colors(forced_colors);
374        self
375    }
376
377    /// Set device scale factor (device pixel ratio).
378    #[must_use]
379    pub fn device_scale_factor(mut self, scale_factor: f64) -> Self {
380        self.builder = self.builder.device_scale_factor(scale_factor);
381        self
382    }
383
384    /// Set mobile mode.
385    #[must_use]
386    pub fn is_mobile(mut self, is_mobile: bool) -> Self {
387        self.builder = self.builder.is_mobile(is_mobile);
388        self
389    }
390
391    /// Apply a device descriptor to configure the context.
392    ///
393    /// This sets viewport, user agent, device scale factor, touch, and mobile mode
394    /// based on the device descriptor.
395    ///
396    /// # Example
397    ///
398    /// ```no_run
399    /// use viewpoint_core::{Browser, devices};
400    ///
401    /// # async fn example() -> Result<(), viewpoint_core::CoreError> {
402    /// let browser = Browser::launch().headless(true).launch().await?;
403    ///
404    /// let context = browser.new_context_builder()
405    ///     .device(devices::IPHONE_13)
406    ///     .build()
407    ///     .await?;
408    /// # Ok(())
409    /// # }
410    /// ```
411    #[must_use]
412    pub fn device(mut self, device: DeviceDescriptor) -> Self {
413        self.builder = self.builder.device(device);
414        self
415    }
416
417    /// Enable video recording for pages in this context.
418    ///
419    /// Videos are recorded for each page and saved to the specified directory.
420    ///
421    /// # Example
422    ///
423    /// ```ignore
424    /// use viewpoint_core::{Browser, VideoOptions};
425    ///
426    /// let browser = Browser::launch().headless(true).launch().await?;
427    /// let context = browser.new_context_builder()
428    ///     .record_video(VideoOptions::new("./videos"))
429    ///     .build()
430    ///     .await?;
431    /// ```
432    #[must_use]
433    pub fn record_video(mut self, options: crate::page::VideoOptions) -> Self {
434        self.builder = self.builder.record_video(options);
435        self
436    }
437
438    /// Build and create the browser context.
439    ///
440    /// # Errors
441    ///
442    /// Returns an error if context creation fails.
443    pub async fn build(self) -> Result<BrowserContext, BrowserError> {
444        self.browser.new_context_with_options(self.builder.build()).await
445    }
446}
447
448impl Drop for Browser {
449    fn drop(&mut self) {
450        // Try to kill the process if we own it
451        if self.owned {
452            if let Some(ref process) = self.process {
453                // We can't await in drop, so we try to kill synchronously
454                if let Ok(mut guard) = process.try_lock() {
455                    let _ = guard.kill();
456                }
457            }
458        }
459    }
460}