Skip to main content

firefox_webdriver/browser/
window.rs

1//! Browser window management and control.
2//!
3//! Each [`Window`] owns:
4//! - One Firefox process (child process)
5//! - Reference to shared ConnectionPool
6//! - One profile directory (temporary or persistent)
7//!
8//! # Example
9//!
10//! ```no_run
11//! use firefox_webdriver::Driver;
12//!
13//! # async fn example() -> firefox_webdriver::Result<()> {
14//! let driver = Driver::builder()
15//!     .binary("/usr/bin/firefox")
16//!     .extension("./extension")
17//!     .build()
18//!     .await?;
19//!
20//! let window = driver.window()
21//!     .headless()
22//!     .window_size(1920, 1080)
23//!     .spawn()
24//!     .await?;
25//!
26//! let tab = window.tab();
27//! tab.goto("https://example.com").await?;
28//!
29//! window.close().await?;
30//! # Ok(())
31//! # }
32//! ```
33
34// ============================================================================
35// Imports
36// ============================================================================
37
38use std::fmt;
39use std::path::PathBuf;
40use std::sync::Arc;
41
42use parking_lot::Mutex;
43use rustc_hash::FxHashMap;
44use serde_json::Value;
45use tokio::process::Child;
46use tracing::{debug, info};
47use uuid::Uuid;
48
49use crate::driver::{Driver, FirefoxOptions, Profile};
50use crate::error::{Error, Result};
51use crate::identifiers::{FrameId, InterceptId, SessionId, TabId};
52use crate::protocol::{
53    BrowsingContextCommand, Command, ProxyCommand, Request, Response, SessionCommand,
54};
55use crate::transport::ConnectionPool;
56
57use super::Tab;
58use super::proxy::ProxyConfig;
59
60// ============================================================================
61// ProcessGuard
62// ============================================================================
63
64/// Guards a child process and ensures it is killed when dropped.
65struct ProcessGuard {
66    /// The child process handle.
67    child: Option<Child>,
68    /// Process ID for logging.
69    pid: u32,
70}
71
72impl ProcessGuard {
73    /// Creates a new process guard.
74    fn new(child: Child) -> Self {
75        let pid = child.id().unwrap_or(0);
76        debug!(pid, "Process guard created");
77        Self {
78            child: Some(child),
79            pid,
80        }
81    }
82
83    /// Returns the process ID.
84    #[inline]
85    fn pid(&self) -> u32 {
86        self.pid
87    }
88}
89
90impl Drop for ProcessGuard {
91    fn drop(&mut self) {
92        if let Some(mut child) = self.child.take()
93            && let Err(e) = child.start_kill()
94        {
95            debug!(pid = self.pid, error = %e, "Failed to send kill signal in Drop");
96        }
97    }
98}
99
100// ============================================================================
101// Types
102// ============================================================================
103
104/// Internal shared state for a window.
105pub(crate) struct WindowInner {
106    /// Unique identifier for this window.
107    pub uuid: Uuid,
108    /// Session ID.
109    pub session_id: SessionId,
110    /// Protected process handle.
111    process: Mutex<ProcessGuard>,
112    /// Connection pool (shared with Driver and other Windows).
113    pub pool: Arc<ConnectionPool>,
114    /// Profile directory.
115    #[allow(dead_code)]
116    profile: Profile,
117    /// All tabs in this window.
118    tabs: Mutex<FxHashMap<TabId, Tab>>,
119    /// The initial tab created when Firefox opens.
120    pub initial_tab_id: TabId,
121    /// Mapping from InterceptId to handler key for targeted removal.
122    pub intercept_handlers: Mutex<FxHashMap<InterceptId, String>>,
123}
124
125// ============================================================================
126// Window
127// ============================================================================
128
129/// A handle to a Firefox browser window.
130///
131/// The window owns a Firefox process and profile, and holds a reference
132/// to the shared ConnectionPool for WebSocket communication.
133/// When dropped, the process is automatically killed.
134///
135/// # Example
136///
137/// ```no_run
138/// # use firefox_webdriver::Driver;
139/// # async fn example() -> firefox_webdriver::Result<()> {
140/// # let driver = Driver::builder().binary("/usr/bin/firefox").extension("./ext").build().await?;
141/// let window = driver.window().headless().spawn().await?;
142///
143/// // Get the initial tab
144/// let tab = window.tab();
145///
146/// // Create a new tab
147/// let new_tab = window.new_tab().await?;
148///
149/// // Close the window
150/// window.close().await?;
151/// # Ok(())
152/// # }
153/// ```
154#[derive(Clone)]
155pub struct Window {
156    /// Shared inner state.
157    pub(crate) inner: Arc<WindowInner>,
158}
159
160// ============================================================================
161// Window - Display
162// ============================================================================
163
164impl fmt::Debug for Window {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        f.debug_struct("Window")
167            .field("uuid", &self.inner.uuid)
168            .field("session_id", &self.inner.session_id)
169            .field("port", &self.inner.pool.port())
170            .finish_non_exhaustive()
171    }
172}
173
174// ============================================================================
175// Window - Constructor
176// ============================================================================
177
178impl Window {
179    /// Creates a new window handle.
180    pub(crate) fn new(
181        pool: Arc<ConnectionPool>,
182        process: Child,
183        profile: Profile,
184        session_id: SessionId,
185        initial_tab_id: TabId,
186    ) -> Self {
187        let uuid = Uuid::new_v4();
188        let initial_tab = Tab::new(initial_tab_id, FrameId::main(), session_id, None);
189        let mut tabs = FxHashMap::default();
190        tabs.insert(initial_tab_id, initial_tab);
191
192        debug!(
193            uuid = %uuid,
194            session_id = %session_id,
195            tab_id = %initial_tab_id,
196            port = pool.port(),
197            "Window created"
198        );
199
200        Self {
201            inner: Arc::new(WindowInner {
202                uuid,
203                session_id,
204                process: Mutex::new(ProcessGuard::new(process)),
205                pool,
206                profile,
207                tabs: Mutex::new(tabs),
208                initial_tab_id,
209                intercept_handlers: Mutex::new(FxHashMap::default()),
210            }),
211        }
212    }
213}
214
215// ============================================================================
216// Window - Accessors
217// ============================================================================
218
219impl Window {
220    /// Returns the session ID.
221    #[inline]
222    #[must_use]
223    pub fn session_id(&self) -> SessionId {
224        self.inner.session_id
225    }
226
227    /// Returns the Rust-side unique UUID.
228    #[inline]
229    #[must_use]
230    pub fn uuid(&self) -> &Uuid {
231        &self.inner.uuid
232    }
233
234    /// Returns the WebSocket port (shared across all windows).
235    #[inline]
236    #[must_use]
237    pub fn port(&self) -> u16 {
238        self.inner.pool.port()
239    }
240
241    /// Returns the Firefox process ID.
242    #[inline]
243    #[must_use]
244    pub fn pid(&self) -> u32 {
245        self.inner.process.lock().pid()
246    }
247}
248
249// ============================================================================
250// Window - Lifecycle
251// ============================================================================
252
253impl Window {
254    /// Closes the window and kills the Firefox process.
255    ///
256    /// # Errors
257    ///
258    /// Returns an error if the process cannot be killed.
259    pub async fn close(&self) -> Result<()> {
260        debug!(uuid = %self.inner.uuid, "Closing window");
261
262        // Remove from pool first
263        self.inner.pool.remove(self.inner.session_id);
264
265        // Take the child process out of the guard inside a sync block,
266        // so we don't hold the parking_lot::Mutex across an .await point.
267        let child = {
268            let mut guard = self.inner.process.lock();
269            guard.child.take()
270        };
271
272        // Now await outside the lock
273        if let Some(mut child) = child {
274            let pid = child.id().unwrap_or(0);
275            debug!(pid, "Killing Firefox process");
276            if let Err(e) = child.kill().await {
277                debug!(pid, error = %e, "Failed to kill process");
278            }
279            if let Err(e) = child.wait().await {
280                debug!(pid, error = %e, "Failed to wait for process");
281            }
282            info!(pid, "Process terminated");
283        }
284
285        info!(uuid = %self.inner.uuid, "Window closed");
286        Ok(())
287    }
288}
289
290// ============================================================================
291// Window - Tab Management
292// ============================================================================
293
294impl Window {
295    /// Returns the initial tab for this window.
296    #[must_use]
297    pub fn tab(&self) -> Tab {
298        Tab::new(
299            self.inner.initial_tab_id,
300            FrameId::main(),
301            self.inner.session_id,
302            Some(self.clone()),
303        )
304    }
305
306    /// Creates a new tab in this window.
307    ///
308    /// # Errors
309    ///
310    /// Returns an error if tab creation fails.
311    pub async fn new_tab(&self) -> Result<Tab> {
312        let command = Command::BrowsingContext(BrowsingContextCommand::NewTab);
313        let response = self.send_command(command).await?;
314
315        let tab_id_u32 = response
316            .result
317            .as_ref()
318            .and_then(|v| v.get("tabId"))
319            .and_then(|v| v.as_u64())
320            .ok_or_else(|| Error::protocol("Expected tabId in NewTab response"))?;
321
322        let new_tab_id = TabId::new(tab_id_u32 as u32)
323            .ok_or_else(|| Error::protocol("Invalid tabId in NewTab response"))?;
324
325        let tab = Tab::new(
326            new_tab_id,
327            FrameId::main(),
328            self.inner.session_id,
329            Some(self.clone()),
330        );
331
332        self.inner.tabs.lock().insert(new_tab_id, tab.clone());
333        debug!(session_id = %self.inner.session_id, tab_id = %new_tab_id, "New tab created");
334        Ok(tab)
335    }
336
337    /// Returns the number of tabs in this window.
338    #[inline]
339    #[must_use]
340    pub fn tab_count(&self) -> usize {
341        self.inner.tabs.lock().len()
342    }
343
344    /// Steals logs from extension (returns and clears).
345    ///
346    /// Useful for debugging extension issues.
347    pub async fn steal_logs(&self) -> Result<Vec<Value>> {
348        let command = Command::Session(SessionCommand::StealLogs);
349        let response = self.send_command(command).await?;
350        let logs = response
351            .result
352            .as_ref()
353            .and_then(|v| v.get("logs"))
354            .and_then(|v| v.as_array())
355            .cloned()
356            .unwrap_or_default();
357        Ok(logs)
358    }
359}
360
361// ============================================================================
362// Window - Proxy
363// ============================================================================
364
365impl Window {
366    /// Sets a proxy for all tabs in this window.
367    ///
368    /// Window-level proxy applies to all tabs unless overridden by tab-level proxy.
369    ///
370    /// # Example
371    ///
372    /// ```ignore
373    /// use firefox_webdriver::ProxyConfig;
374    ///
375    /// // HTTP proxy for all tabs
376    /// window.set_proxy(ProxyConfig::http("proxy.example.com", 8080)).await?;
377    ///
378    /// // SOCKS5 proxy with auth
379    /// window.set_proxy(
380    ///     ProxyConfig::socks5("proxy.example.com", 1080)
381    ///         .with_credentials("user", "pass")
382    ///         .with_proxy_dns(true)
383    /// ).await?;
384    /// ```
385    pub async fn set_proxy(&self, config: ProxyConfig) -> Result<()> {
386        debug!(
387            session_id = %self.inner.session_id,
388            proxy_type = %config.proxy_type.as_str(),
389            host = %config.host,
390            port = config.port,
391            "Setting window proxy"
392        );
393
394        let command = Command::Proxy(ProxyCommand::SetWindowProxy {
395            proxy_type: config.proxy_type.as_str().to_string(),
396            host: config.host,
397            port: config.port,
398            username: config.username,
399            password: config.password,
400            proxy_dns: config.proxy_dns,
401        });
402
403        self.send_command(command).await?;
404        Ok(())
405    }
406
407    /// Clears the proxy for this window.
408    ///
409    /// After clearing, all tabs use direct connection (unless they have tab-level proxy).
410    pub async fn clear_proxy(&self) -> Result<()> {
411        debug!(session_id = %self.inner.session_id, "Clearing window proxy");
412        let command = Command::Proxy(ProxyCommand::ClearWindowProxy);
413        self.send_command(command).await?;
414        Ok(())
415    }
416}
417
418// ============================================================================
419// Window - Internal
420// ============================================================================
421
422impl Window {
423    /// Sends a command via the connection pool and waits for the response.
424    pub(crate) async fn send_command(&self, command: Command) -> Result<Response> {
425        let request = Request::new(self.inner.initial_tab_id, FrameId::main(), command);
426        self.inner.pool.send(self.inner.session_id, request).await
427    }
428}
429
430// ============================================================================
431// WindowBuilder
432// ============================================================================
433
434/// Builder for spawning browser windows.
435///
436/// # Example
437///
438/// ```no_run
439/// # use firefox_webdriver::Driver;
440/// # async fn example() -> firefox_webdriver::Result<()> {
441/// # let driver = Driver::builder().binary("/usr/bin/firefox").extension("./ext").build().await?;
442/// let window = driver.window()
443///     .headless()
444///     .window_size(1920, 1080)
445///     .profile("./my_profile")
446///     .spawn()
447///     .await?;
448/// # Ok(())
449/// # }
450/// ```
451pub struct WindowBuilder<'a> {
452    /// Reference to the driver.
453    driver: &'a Driver,
454    /// Firefox launch options.
455    options: FirefoxOptions,
456    /// Optional custom profile path.
457    profile: Option<PathBuf>,
458}
459
460// ============================================================================
461// WindowBuilder - Implementation
462// ============================================================================
463
464impl<'a> WindowBuilder<'a> {
465    /// Creates a new window builder.
466    pub(crate) fn new(driver: &'a Driver) -> Self {
467        Self {
468            driver,
469            options: FirefoxOptions::new(),
470            profile: None,
471        }
472    }
473
474    /// Enables headless mode.
475    ///
476    /// Firefox runs without a visible window.
477    #[must_use]
478    pub fn headless(mut self) -> Self {
479        self.options = self.options.with_headless();
480        self
481    }
482
483    /// Sets the window size.
484    ///
485    /// # Arguments
486    ///
487    /// * `width` - Window width in pixels
488    /// * `height` - Window height in pixels
489    #[must_use]
490    pub fn window_size(mut self, width: u32, height: u32) -> Self {
491        self.options = self.options.with_window_size(width, height);
492        self
493    }
494
495    /// Uses a custom profile directory.
496    ///
497    /// # Arguments
498    ///
499    /// * `path` - Path to profile directory
500    #[must_use]
501    pub fn profile(mut self, path: impl Into<PathBuf>) -> Self {
502        self.profile = Some(path.into());
503        self
504    }
505
506    /// Spawns the window.
507    ///
508    /// # Errors
509    ///
510    /// Returns an error if window creation fails.
511    pub async fn spawn(self) -> Result<Window> {
512        self.driver.spawn_window(self.options, self.profile).await
513    }
514}
515
516// ============================================================================
517// Tests
518// ============================================================================
519
520#[cfg(test)]
521mod tests {
522    use super::Window;
523
524    #[test]
525    fn test_window_is_clone() {
526        fn assert_clone<T: Clone>() {}
527        assert_clone::<Window>();
528    }
529
530    #[test]
531    fn test_window_is_debug() {
532        fn assert_debug<T: std::fmt::Debug>() {}
533        assert_debug::<Window>();
534    }
535}