use crate::error::Result;
use crate::protocol::{BrowserContext, BrowserType, Page};
use crate::server::channel::Channel;
use crate::server::channel_owner::{ChannelOwner, ChannelOwnerImpl, ParentOrConnection};
use crate::server::connection::ConnectionExt;
use serde::Deserialize;
use serde_json::Value;
use std::any::Any;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
type DisconnectedHandlerFuture = Pin<Box<dyn Future<Output = Result<()>> + Send>>;
type DisconnectedHandler = Arc<dyn Fn() -> DisconnectedHandlerFuture + Send + Sync>;
#[derive(Debug, Default, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BindOptions {
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_dir: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<u16>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct BindResult {
pub endpoint: String,
}
#[derive(Debug, Default, Clone)]
pub struct StartTracingOptions {
pub page: Option<Page>,
pub screenshots: Option<bool>,
pub categories: Option<Vec<String>>,
}
#[derive(Clone)]
pub struct Browser {
base: ChannelOwnerImpl,
version: String,
name: String,
is_connected: Arc<AtomicBool>,
disconnected_handlers: Arc<Mutex<Vec<DisconnectedHandler>>>,
}
impl Browser {
pub fn new(
parent: Arc<dyn ChannelOwner>,
type_name: String,
guid: Arc<str>,
initializer: Value,
) -> Result<Self> {
let base = ChannelOwnerImpl::new(
ParentOrConnection::Parent(parent),
type_name,
guid,
initializer.clone(),
);
let version = initializer["version"]
.as_str()
.ok_or_else(|| {
crate::error::Error::ProtocolError(
"Browser initializer missing 'version' field".to_string(),
)
})?
.to_string();
let name = initializer["name"]
.as_str()
.ok_or_else(|| {
crate::error::Error::ProtocolError(
"Browser initializer missing 'name' field".to_string(),
)
})?
.to_string();
Ok(Self {
base,
version,
name,
is_connected: Arc::new(AtomicBool::new(true)),
disconnected_handlers: Arc::new(Mutex::new(Vec::new())),
})
}
pub fn version(&self) -> &str {
&self.version
}
pub fn name(&self) -> &str {
&self.name
}
pub fn is_connected(&self) -> bool {
self.is_connected.load(Ordering::SeqCst)
}
fn channel(&self) -> &Channel {
self.base.channel()
}
#[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
pub async fn new_context(&self) -> Result<BrowserContext> {
#[derive(Deserialize)]
struct NewContextResponse {
context: GuidRef,
}
#[derive(Deserialize)]
struct GuidRef {
#[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
guid: Arc<str>,
}
let response: NewContextResponse = self
.channel()
.send("newContext", serde_json::json!({}))
.await?;
let context: BrowserContext = self
.connection()
.get_typed::<BrowserContext>(&response.context.guid)
.await?;
let selectors = self.connection().selectors();
if let Err(e) = selectors.add_context(context.channel().clone()).await {
tracing::warn!("Failed to register BrowserContext with Selectors: {}", e);
}
Ok(context)
}
#[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
pub async fn new_context_with_options(
&self,
mut options: crate::protocol::BrowserContextOptions,
) -> Result<BrowserContext> {
#[derive(Deserialize)]
struct NewContextResponse {
context: GuidRef,
}
#[derive(Deserialize)]
struct GuidRef {
#[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
guid: Arc<str>,
}
if let Some(path) = &options.storage_state_path {
let file_content = tokio::fs::read_to_string(path).await.map_err(|e| {
crate::error::Error::ProtocolError(format!(
"Failed to read storage state file '{}': {}",
path, e
))
})?;
let storage_state: crate::protocol::StorageState = serde_json::from_str(&file_content)
.map_err(|e| {
crate::error::Error::ProtocolError(format!(
"Failed to parse storage state file '{}': {}",
path, e
))
})?;
options.storage_state = Some(storage_state);
options.storage_state_path = None; }
let options_json = serde_json::to_value(options).map_err(|e| {
crate::error::Error::ProtocolError(format!(
"Failed to serialize context options: {}",
e
))
})?;
let response: NewContextResponse = self.channel().send("newContext", options_json).await?;
let context: BrowserContext = self
.connection()
.get_typed::<BrowserContext>(&response.context.guid)
.await?;
let selectors = self.connection().selectors();
if let Err(e) = selectors.add_context(context.channel().clone()).await {
tracing::warn!("Failed to register BrowserContext with Selectors: {}", e);
}
Ok(context)
}
#[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
pub async fn new_page(&self) -> Result<Page> {
let context = self.new_context().await?;
context.new_page().await
}
pub fn contexts(&self) -> Vec<BrowserContext> {
let my_guid = self.guid();
self.connection()
.all_objects_sync()
.into_iter()
.filter_map(|obj| {
let ctx = obj.as_any().downcast_ref::<BrowserContext>()?.clone();
let parent_guid = ctx.parent().map(|p| p.guid().to_string());
if parent_guid.as_deref() == Some(my_guid) {
Some(ctx)
} else {
None
}
})
.collect()
}
pub fn browser_type(&self) -> BrowserType {
self.base
.parent()
.expect("Browser always has a BrowserType parent")
.as_any()
.downcast_ref::<BrowserType>()
.expect("Browser parent is always a BrowserType")
.clone()
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
pub async fn new_browser_cdp_session(&self) -> Result<crate::protocol::CDPSession> {
#[derive(Deserialize)]
struct Response {
session: GuidRef,
}
#[derive(Deserialize)]
struct GuidRef {
#[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
guid: Arc<str>,
}
let response: Response = self
.channel()
.send("newBrowserCDPSession", serde_json::json!({}))
.await?;
self.connection()
.get_typed::<crate::protocol::CDPSession>(&response.session.guid)
.await
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
pub async fn on_disconnected<F, Fut>(&self, handler: F) -> Result<()>
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<()>> + Send + 'static,
{
let handler = Arc::new(move || -> DisconnectedHandlerFuture { Box::pin(handler()) });
self.disconnected_handlers.lock().unwrap().push(handler);
Ok(())
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name, title = %title))]
pub async fn bind(&self, title: &str, options: Option<BindOptions>) -> Result<BindResult> {
let mut params = serde_json::to_value(options.unwrap_or_default())
.unwrap_or_else(|_| serde_json::json!({}));
params["title"] = serde_json::json!(title);
let result: BindResult = self.channel().send("startServer", params).await?;
Ok(result)
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
pub async fn unbind(&self) -> Result<()> {
self.channel()
.send_no_result("stopServer", serde_json::json!({}))
.await
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name))]
pub async fn start_tracing(&self, options: Option<StartTracingOptions>) -> Result<()> {
#[derive(serde::Serialize)]
struct StartTracingParams {
#[serde(skip_serializing_if = "Option::is_none")]
page: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
screenshots: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
categories: Option<Vec<String>>,
}
let opts = options.unwrap_or_default();
let page_ref = opts
.page
.as_ref()
.map(|p| serde_json::json!({ "guid": p.guid() }));
let params = StartTracingParams {
page: page_ref,
screenshots: opts.screenshots,
categories: opts.categories,
};
self.channel()
.send_no_result(
"startTracing",
serde_json::to_value(params).map_err(|e| {
crate::error::Error::ProtocolError(format!(
"serialize startTracing params: {e}"
))
})?,
)
.await
}
#[tracing::instrument(level = "debug", skip_all, fields(name = %self.name, bytes_len = tracing::field::Empty))]
pub async fn stop_tracing(&self) -> Result<Vec<u8>> {
#[derive(Deserialize)]
struct StopTracingResponse {
artifact: ArtifactRef,
}
#[derive(Deserialize)]
struct ArtifactRef {
#[serde(deserialize_with = "crate::server::connection::deserialize_arc_str")]
guid: Arc<str>,
}
let response: StopTracingResponse = self
.channel()
.send("stopTracing", serde_json::json!({}))
.await?;
let artifact: crate::protocol::artifact::Artifact = self
.connection()
.get_typed::<crate::protocol::artifact::Artifact>(&response.artifact.guid)
.await?;
let tmp_path = std::env::temp_dir().join(format!(
"playwright-trace-{}.json",
response.artifact.guid.replace('@', "-")
));
let tmp_str = tmp_path
.to_str()
.ok_or_else(|| {
crate::error::Error::ProtocolError(
"Temporary path contains non-UTF-8 characters".to_string(),
)
})?
.to_string();
artifact.save_as(&tmp_str).await?;
let bytes = tokio::fs::read(&tmp_path).await.map_err(|e| {
crate::error::Error::ProtocolError(format!(
"Failed to read tracing artifact from '{}': {}",
tmp_str, e
))
})?;
let _ = tokio::fs::remove_file(&tmp_path).await;
tracing::Span::current().record("bytes_len", bytes.len());
Ok(bytes)
}
#[tracing::instrument(level = "info", skip_all, fields(name = %self.name))]
pub async fn close(&self) -> Result<()> {
let result = self
.channel()
.send_no_result("close", serde_json::json!({}))
.await;
#[cfg(windows)]
{
let is_ci = std::env::var("CI").is_ok() || std::env::var("GITHUB_ACTIONS").is_ok();
if is_ci {
tracing::debug!("[playwright-rust] Adding Windows CI browser cleanup delay");
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
}
result
}
}
impl ChannelOwner for Browser {
fn guid(&self) -> &str {
self.base.guid()
}
fn type_name(&self) -> &str {
self.base.type_name()
}
fn parent(&self) -> Option<Arc<dyn ChannelOwner>> {
self.base.parent()
}
fn connection(&self) -> Arc<dyn crate::server::connection::ConnectionLike> {
self.base.connection()
}
fn initializer(&self) -> &Value {
self.base.initializer()
}
fn channel(&self) -> &Channel {
self.base.channel()
}
fn dispose(&self, reason: crate::server::channel_owner::DisposeReason) {
if self
.is_connected
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
let handlers = self.disconnected_handlers.lock().unwrap().clone();
tokio::spawn(async move {
for handler in handlers {
if let Err(e) = handler().await {
tracing::warn!("Browser disconnected handler error (from dispose): {}", e);
}
}
});
}
self.base.dispose(reason)
}
fn adopt(&self, child: Arc<dyn ChannelOwner>) {
self.base.adopt(child)
}
fn add_child(&self, guid: Arc<str>, child: Arc<dyn ChannelOwner>) {
self.base.add_child(guid, child)
}
fn remove_child(&self, guid: &str) {
self.base.remove_child(guid)
}
fn on_event(&self, method: &str, params: Value) {
if method == "disconnected" {
if self
.is_connected
.compare_exchange(true, false, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
let handlers = self.disconnected_handlers.lock().unwrap().clone();
tokio::spawn(async move {
for handler in handlers {
if let Err(e) = handler().await {
tracing::warn!("Browser disconnected handler error: {}", e);
}
}
});
}
}
self.base.on_event(method, params)
}
fn was_collected(&self) -> bool {
self.base.was_collected()
}
fn as_any(&self) -> &dyn Any {
self
}
}
impl std::fmt::Debug for Browser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Browser")
.field("guid", &self.guid())
.field("name", &self.name)
.field("version", &self.version)
.finish()
}
}