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}