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