use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine;
use rustc_hash::FxHashMap;
use serde_json::json;
use tokio::sync::RwLock;
use super::element::BidiElement;
use super::input;
use super::session::BidiSession;
use super::types::EvaluateResult;
use crate::backend::{
AnyElement, AxNodeData, AxProperty, CookieData, FrameInfo, ImageFormat, MetricData, NavLifecycle, ScreenshotOpts,
};
use crate::console_message::{ConsoleMessage, ConsoleMessageLocation};
use crate::error::{FerriError, Result};
use crate::events::{EventEmitter, PageEvent};
use crate::network::{
self, BodyFn, RawHeadersFn, RemoteAddr, Request as NetworkRequest, RequestInit, Response, ResponseInit,
SecurityDetails,
};
use crate::state::DialogEvent;
fn bidi_remote_value_to_backing(arg: &serde_json::Value) -> crate::js_handle::JSHandleBacking {
let ty = arg.get("type").and_then(|v| v.as_str()).unwrap_or("");
if ty == "node" {
if let Some(shared_id) = arg.get("sharedId").and_then(|v| v.as_str()) {
let handle = arg
.get("handle")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
return crate::js_handle::JSHandleBacking::Remote(crate::js_handle::HandleRemote::Bidi {
shared_id: shared_id.to_string(),
handle,
});
}
}
if matches!(
ty,
"object" | "array" | "map" | "set" | "function" | "error" | "promise" | "symbol" | "window" | "weakmap" | "weakset"
) {
if let Some(h) = arg.get("handle").and_then(|v| v.as_str()) {
return crate::js_handle::JSHandleBacking::Remote(crate::js_handle::HandleRemote::Bidi {
shared_id: String::new(),
handle: Some(h.to_string()),
});
}
}
let serialized = match ty {
"undefined" => crate::protocol::SerializedValue::Special(crate::protocol::SpecialValue::Undefined),
"null" => crate::protocol::SerializedValue::Special(crate::protocol::SpecialValue::Null),
"bigint" => {
let s = arg
.get("value")
.and_then(|v| v.as_str())
.map_or_else(String::new, std::string::ToString::to_string);
crate::protocol::SerializedValue::BigInt(s)
},
_ => {
let value = arg.get("value").cloned().unwrap_or(serde_json::Value::Null);
let mut ctx = crate::protocol::SerializationContext::default();
crate::protocol::SerializedValue::from_json(&value, &mut ctx)
},
};
crate::js_handle::JSHandleBacking::Value(serialized)
}
fn bidi_stack_trace_to_location(
stack: Option<&serde_json::Value>,
_source: Option<&serde_json::Value>,
) -> ConsoleMessageLocation {
let Some(frame) = stack
.and_then(|s| s.get("callFrames"))
.and_then(|v| v.as_array())
.and_then(|frames| frames.first())
else {
return ConsoleMessageLocation {
url: String::new(),
line_number: 1,
column_number: 1,
};
};
ConsoleMessageLocation {
url: frame.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
line_number: frame
.get("lineNumber")
.and_then(serde_json::Value::as_u64)
.map_or(1, u64_to_u32_saturating),
column_number: frame
.get("columnNumber")
.and_then(serde_json::Value::as_u64)
.map_or(1, u64_to_u32_saturating),
}
}
fn u64_to_u32_saturating(n: u64) -> u32 {
u32::try_from(n).unwrap_or(u32::MAX)
}
fn split_error_text(text: &str) -> (String, String) {
if let Some(idx) = text.find(": ") {
(text[..idx].to_string(), text[idx + 2..].to_string())
} else {
(String::new(), text.to_string())
}
}
fn build_bidi_stack(text: &str, stack: Option<&serde_json::Value>) -> String {
use std::fmt::Write as _;
let mut out = text.to_string();
let Some(frames) = stack.and_then(|s| s.get("callFrames")).and_then(|v| v.as_array()) else {
return out;
};
for frame in frames {
let url = frame.get("url").and_then(|v| v.as_str()).unwrap_or("");
let line = frame.get("lineNumber").and_then(serde_json::Value::as_u64).unwrap_or(0) + 1;
let col = frame
.get("columnNumber")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
+ 1;
let function_name = frame.get("functionName").and_then(|v| v.as_str()).unwrap_or("");
out.push('\n');
if function_name.is_empty() {
let _ = write!(out, " at {url}:{line}:{col}");
} else {
let _ = write!(out, " at {function_name} ({url}:{line}:{col})");
}
}
out
}
fn f64_to_u64_saturating(n: f64) -> u64 {
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss
)]
let clamped = if !n.is_finite() || n < 0.0 {
0_u64
} else if n >= u64::MAX as f64 {
u64::MAX
} else {
n as u64
};
clamped
}
#[derive(Clone)]
pub struct BidiPage {
pub(crate) session: Arc<BidiSession>,
pub(crate) context_id: Arc<str>,
pub events: EventEmitter,
routes: Arc<RwLock<Vec<crate::route::RegisteredRoute>>>,
intercept_ids: Arc<RwLock<Vec<String>>>,
closed: Arc<AtomicBool>,
preload_scripts: Arc<RwLock<FxHashMap<String, String>>>,
exposed_fns: Arc<RwLock<FxHashMap<String, crate::events::ExposedFn>>>,
injected_script: Arc<InjectedScriptManager>,
nav_request_slot: crate::network::NavRequestSlot,
pub dialog_manager: crate::dialog::DialogManager,
pub file_chooser_manager: crate::file_chooser::FileChooserManager,
pub download_manager: crate::download::DownloadManager,
pub downloads_dir: Arc<tempfile::TempDir>,
pub page_backref: crate::backend::PageBackref,
pub(crate) frame_cache: Arc<std::sync::Mutex<crate::frame_cache::FrameCache>>,
pub(crate) frame_listener_started: Arc<AtomicBool>,
pub(crate) route_listener_started: Arc<AtomicBool>,
pub(crate) handle_realms: Arc<std::sync::Mutex<FxHashMap<String, Arc<str>>>>,
}
pub struct InjectedScriptManager {
injected: std::sync::Mutex<rustc_hash::FxHashSet<String>>,
}
impl InjectedScriptManager {
fn new() -> Self {
Self {
injected: std::sync::Mutex::new(rustc_hash::FxHashSet::default()),
}
}
fn reset(&self) {
if let Ok(mut s) = self.injected.lock() {
s.clear();
}
}
fn reset_context(&self, ctx: &str) {
if ctx.is_empty() {
return;
}
if let Ok(mut s) = self.injected.lock() {
s.remove(ctx);
}
}
async fn ensure(&self, page: &BidiPage) -> Result<()> {
let ctx = page.context_id.clone();
self.ensure_in(page, &ctx).await
}
async fn ensure_in(&self, page: &BidiPage, ctx: &str) -> Result<()> {
if let Ok(s) = self.injected.lock() {
if s.contains(ctx) {
return Ok(());
}
}
let full_check_js = crate::selectors::build_lazy_inject_js();
page
.cmd(
"script.evaluate",
json!({
"expression": full_check_js,
"target": {"context": ctx},
"awaitPromise": true,
"resultOwnership": "none"
}),
)
.await?;
if let Ok(mut s) = self.injected.lock() {
s.insert(ctx.to_string());
}
Ok(())
}
}
impl BidiPage {
pub(crate) fn create(session: Arc<BidiSession>, context_id: String) -> Result<Self> {
let downloads_dir = tempfile::Builder::new()
.prefix("ferridriver-downloads-")
.tempdir()
.map_err(|e| FerriError::backend(format!("downloads tempdir: {e}")))?;
Ok(Self {
session,
context_id: Arc::from(context_id),
events: EventEmitter::new(),
routes: Arc::new(RwLock::new(Vec::new())),
intercept_ids: Arc::new(RwLock::new(Vec::new())),
closed: Arc::new(AtomicBool::new(false)),
preload_scripts: Arc::new(RwLock::new(FxHashMap::default())),
exposed_fns: Arc::new(RwLock::new(FxHashMap::default())),
injected_script: Arc::new(InjectedScriptManager::new()),
nav_request_slot: crate::network::NavRequestSlot::new(),
dialog_manager: crate::dialog::DialogManager::new(),
file_chooser_manager: crate::file_chooser::FileChooserManager::new(),
download_manager: crate::download::DownloadManager::new(),
downloads_dir: Arc::new(downloads_dir),
page_backref: crate::backend::PageBackref::new(),
frame_cache: Arc::new(std::sync::Mutex::new(crate::frame_cache::FrameCache::default())),
frame_listener_started: Arc::new(AtomicBool::new(false)),
route_listener_started: Arc::new(AtomicBool::new(false)),
handle_realms: Arc::new(std::sync::Mutex::new(FxHashMap::default())),
})
}
fn remember_handle_realm(&self, key: &str, ctx: &str) {
if key.is_empty() || ctx == &*self.context_id {
return;
}
if let Ok(mut map) = self.handle_realms.lock() {
map.insert(key.to_string(), Arc::from(ctx));
}
}
fn handle_realm(&self, key: &str) -> Option<Arc<str>> {
if key.is_empty() {
return None;
}
self.handle_realms.lock().ok().and_then(|map| map.get(key).cloned())
}
fn forget_handle_realm(&self, key: &str) {
if key.is_empty() {
return;
}
if let Ok(mut map) = self.handle_realms.lock() {
map.remove(key);
}
}
fn clear_handle_realms(&self) {
if let Ok(mut map) = self.handle_realms.lock() {
map.clear();
}
}
async fn cmd(&self, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
self.session.transport.send_command(method, params).await
}
pub(crate) fn is_retryable_context_error(err: &str) -> bool {
err.contains("DiscardedBrowsingContextError")
|| err.contains("BrowsingContext does no longer exist")
|| err.contains("BiDi error 'no such frame'")
|| err.contains("BiDi error 'no such window'")
}
pub async fn wait_until_ready(&self) -> Result<()> {
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
match self
.cmd(
"script.evaluate",
json!({
"expression": "document.readyState",
"target": {"context": &*self.context_id},
"awaitPromise": true,
"resultOwnership": "none"
}),
)
.await
{
Ok(_) => return Ok(()),
Err(err) if Self::is_retryable_context_error(&err.to_string()) && tokio::time::Instant::now() < deadline => {
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
},
Err(err) => return Err(err),
}
}
}
fn lifecycle_to_wait(lifecycle: NavLifecycle) -> &'static str {
match lifecycle {
NavLifecycle::Commit => "none",
NavLifecycle::DomContentLoaded => "interactive",
NavLifecycle::Load => "complete",
}
}
async fn eval_internal(&self, expression: &str, context: &str) -> Result<Option<serde_json::Value>> {
let result = self
.cmd(
"script.evaluate",
json!({
"expression": expression,
"target": {"context": context},
"awaitPromise": true,
"resultOwnership": "none"
}),
)
.await?;
let eval_result: EvaluateResult =
serde_json::from_value(result).map_err(|e| FerriError::Backend(format!("BiDi evaluate parse: {e}")))?;
match eval_result {
EvaluateResult::Success { result } => Ok(result.to_json()),
EvaluateResult::Exception { exception_details } => {
Err(FerriError::evaluation(format!("JS error: {}", exception_details.text)))
},
}
}
pub async fn get_frame_tree(&self) -> Result<Vec<FrameInfo>> {
let result = self
.cmd("browsingContext.getTree", json!({"root": &*self.context_id}))
.await?;
let contexts = result
.get("contexts")
.and_then(|v| v.as_array())
.ok_or_else(|| FerriError::protocol("browsingContext.getTree", "missing contexts"))?;
let mut frames = Vec::new();
for ctx in contexts {
collect_frames(ctx, None, &mut frames);
}
let child_indices: Vec<usize> = frames
.iter()
.enumerate()
.filter(|(_, f)| f.parent_frame_id.is_some() && f.name.is_empty())
.map(|(i, _)| i)
.collect();
if !child_indices.is_empty() {
let futs: Vec<_> = child_indices
.iter()
.map(|&i| self.eval_internal("window.name", &frames[i].frame_id))
.collect();
let results = futures::future::join_all(futs).await;
for (idx, result) in child_indices.into_iter().zip(results) {
if let Ok(Some(val)) = result {
if let Some(name) = val.as_str() {
frames[idx].name = name.to_string();
}
}
}
}
Ok(frames)
}
pub async fn evaluate_in_frame(&self, expression: &str, frame_id: &str) -> Result<Option<serde_json::Value>> {
if frame_id != &*self.context_id {
self.wait_context_ready(frame_id).await?;
self.ensure_engine_injected_in(frame_id).await?;
}
self.eval_internal(expression, frame_id).await
}
pub async fn goto(
&self,
url: &str,
lifecycle: NavLifecycle,
timeout_ms: u64,
referer: Option<&str>,
) -> Result<Option<Response>> {
self.injected_script.reset();
self.clear_handle_realms();
self.nav_request_slot.clear();
let had_referer = referer.is_some();
if let Some(r) = referer {
let _ = self
.cmd(
"network.setExtraHeaders",
json!({
"headers": [{ "name": "Referer", "value": { "type": "string", "value": r } }],
"contexts": [&*self.context_id],
}),
)
.await;
}
let wait = Self::lifecycle_to_wait(lifecycle);
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
self.cmd(
"browsingContext.navigate",
json!({
"context": &*self.context_id,
"url": url,
"wait": wait
}),
),
)
.await;
if had_referer {
let _ = self
.cmd(
"network.setExtraHeaders",
json!({ "headers": [], "contexts": [&*self.context_id] }),
)
.await;
}
match result {
Ok(Ok(_)) => Ok(self.await_nav_response().await),
Ok(Err(e)) => Err(e),
Err(_) => Err(FerriError::timeout(format!("navigating to {url}"), timeout_ms)),
}
}
async fn await_nav_response(&self) -> Option<Response> {
let req = self.nav_request_slot.get()?;
req.response().await.ok().flatten()
}
pub async fn wait_for_navigation(&self) -> Result<()> {
let mut rx = self.session.transport.subscribe_events();
let ctx = self.context_id.clone();
let timeout = tokio::time::timeout(std::time::Duration::from_secs(30), async move {
while let Ok(event) = rx.recv().await {
if event.method == "browsingContext.load" {
if let Some(c) = event.params.get("context").and_then(|v| v.as_str()) {
if c == &*ctx {
return Ok(());
}
}
}
}
Err(FerriError::backend("Event channel closed"))
});
match timeout.await {
Ok(result) => result,
Err(_) => Err(FerriError::timeout("wait_for_navigation", 30_000)),
}
}
pub async fn reload(&self, lifecycle: NavLifecycle, timeout_ms: u64) -> Result<Option<Response>> {
self.injected_script.reset();
self.clear_handle_realms();
self.nav_request_slot.clear();
let wait = Self::lifecycle_to_wait(lifecycle);
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
self.cmd(
"browsingContext.reload",
json!({
"context": &*self.context_id,
"wait": wait
}),
),
)
.await;
match result {
Ok(Ok(_)) => Ok(self.await_nav_response().await),
Ok(Err(e)) => Err(e),
Err(_) => Err(FerriError::timeout("reloading", timeout_ms)),
}
}
pub async fn go_back(&self, _lifecycle: NavLifecycle, timeout_ms: u64) -> Result<Option<Response>> {
self.nav_request_slot.clear();
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
self.cmd(
"browsingContext.traverseHistory",
json!({
"context": &*self.context_id,
"delta": -1
}),
),
)
.await;
match result {
Ok(Ok(_)) => Ok(self.await_nav_response().await),
Ok(Err(e)) => Err(e),
Err(_) => Err(FerriError::timeout("go_back", timeout_ms)),
}
}
pub async fn go_forward(&self, _lifecycle: NavLifecycle, timeout_ms: u64) -> Result<Option<Response>> {
self.nav_request_slot.clear();
let result = tokio::time::timeout(
std::time::Duration::from_millis(timeout_ms),
self.cmd(
"browsingContext.traverseHistory",
json!({
"context": &*self.context_id,
"delta": 1
}),
),
)
.await;
match result {
Ok(Ok(_)) => Ok(self.await_nav_response().await),
Ok(Err(e)) => Err(e),
Err(_) => Err(FerriError::timeout("go_forward", timeout_ms)),
}
}
pub async fn url(&self) -> Result<Option<String>> {
self
.eval_internal("location.href", &self.context_id)
.await
.map(|v| v.and_then(|val| val.as_str().map(String::from)))
}
pub async fn title(&self) -> Result<Option<String>> {
self
.eval_internal("document.title", &self.context_id)
.await
.map(|v| v.and_then(|val| val.as_str().map(String::from)))
}
pub async fn injected_script(&self) -> Result<String> {
self.ensure_engine_injected().await?;
Ok("window.__fd".to_string())
}
pub async fn ensure_engine_injected(&self) -> Result<()> {
self.injected_script.ensure(self).await
}
pub async fn ensure_engine_injected_in(&self, ctx: &str) -> Result<()> {
self.injected_script.ensure_in(self, ctx).await
}
async fn wait_context_ready(&self, ctx: &str) -> Result<()> {
if let Ok(Some(v)) = self.eval_internal("document.readyState", ctx).await {
if let Some(s) = v.as_str() {
if s == "interactive" || s == "complete" {
return Ok(());
}
}
}
let mut rx = self.session.transport.subscribe_events();
let target = ctx.to_string();
let waited = tokio::time::timeout(std::time::Duration::from_secs(30), async move {
while let Ok(event) = rx.recv().await {
if matches!(
event.method.as_str(),
"browsingContext.domContentLoaded" | "browsingContext.load"
) && event.params.get("context").and_then(|v| v.as_str()) == Some(target.as_str())
{
return;
}
}
})
.await;
let _ = waited;
Ok(())
}
pub async fn evaluate(&self, expression: &str) -> Result<Option<serde_json::Value>> {
self.eval_internal(expression, &self.context_id).await
}
pub async fn find_element(&self, selector: &str) -> Result<AnyElement> {
self.ensure_engine_injected().await?;
let sel_js = crate::selectors::build_selone_js(selector, "window.__fd", false)?;
self
.evaluate_to_element(&sel_js, None)
.await
.map_err(|_| FerriError::invalid_selector(selector, "no element found"))
}
pub async fn evaluate_to_element(&self, js: &str, frame_id: Option<&str>) -> Result<AnyElement> {
let is_function = js.trim_start().starts_with("function") || js.trim_start().starts_with('(');
let target_ctx: &str = frame_id.unwrap_or(&self.context_id);
if let Some(fid) = frame_id {
if fid != &*self.context_id {
self.wait_context_ready(fid).await?;
self.ensure_engine_injected_in(fid).await?;
}
}
let result = if is_function {
self
.cmd(
"script.callFunction",
json!({
"functionDeclaration": js,
"target": {"context": target_ctx},
"awaitPromise": true,
"resultOwnership": "root"
}),
)
.await?
} else {
self
.cmd(
"script.evaluate",
json!({
"expression": js,
"target": {"context": target_ctx},
"awaitPromise": true,
"resultOwnership": "root"
}),
)
.await?
};
let eval_result: EvaluateResult = serde_json::from_value(result)
.map_err(|e| FerriError::protocol("script.callFunction", format!("BiDi evaluate_to_element parse: {e}")))?;
match eval_result {
EvaluateResult::Success { result: remote_val } => {
let shared_ref = remote_val.as_shared_reference().ok_or_else(|| {
FerriError::protocol("script.callFunction", "evaluate_to_element: result is not a DOM node")
})?;
let owning_ctx: Arc<str> = match frame_id {
Some(fid) => Arc::from(fid),
None => self.context_id.clone(),
};
self.remember_handle_realm(&shared_ref.shared_id, &owning_ctx);
Ok(AnyElement::Bidi(BidiElement::new(
self.session.clone(),
owning_ctx,
shared_ref.shared_id,
)))
},
EvaluateResult::Exception { exception_details } => Err(FerriError::evaluation(format!(
"JS error in evaluate_to_element: {}",
exception_details.text
))),
}
}
pub async fn content(&self) -> Result<String> {
let result = self
.eval_internal("document.documentElement.outerHTML", &self.context_id)
.await?;
Ok(result.and_then(|v| v.as_str().map(String::from)).unwrap_or_default())
}
pub async fn set_content(&self, html: &str) -> Result<()> {
self
.cmd(
"script.callFunction",
json!({
"functionDeclaration": "(html) => { document.open(); document.write(html); document.close(); }",
"target": {"context": &*self.context_id},
"arguments": [{"type": "string", "value": html}],
"awaitPromise": false,
"resultOwnership": "none"
}),
)
.await?;
Ok(())
}
pub async fn screenshot(&self, opts: ScreenshotOpts) -> Result<Vec<u8>> {
if opts.omit_background {
return Err(FerriError::unsupported(
"BiDi/Firefox does not support `omitBackground` screenshots — no BiDi command exposes the transparent-background override.",
));
}
let style_installed = self.screenshot_install_style(&opts).await?;
let mask_installed = self.screenshot_install_mask(&opts).await?;
let params = self.screenshot_build_params(&opts);
let result = self.cmd("browsingContext.captureScreenshot", params).await;
if style_installed {
let _ = self
.eval_bidi_function(&format!(
"() => {{ {}; }}",
crate::backend::screenshot_js::uninstall_style_js()
))
.await;
}
if mask_installed {
let _ = self
.eval_bidi_function(&format!(
"() => {{ {}; }}",
crate::backend::screenshot_js::uninstall_mask_js()
))
.await;
}
let data_str = result?
.get("data")
.and_then(|v| v.as_str().map(String::from))
.ok_or_else(|| FerriError::backend("Screenshot: missing data"))?;
base64::engine::general_purpose::STANDARD
.decode(data_str)
.map_err(|e| FerriError::Backend(format!("Screenshot base64 decode: {e}")))
}
async fn eval_bidi_function(&self, function_declaration: &str) -> Result<()> {
self
.cmd(
"script.callFunction",
json!({
"functionDeclaration": function_declaration,
"target": {"context": &*self.context_id},
"awaitPromise": false,
"resultOwnership": "none",
}),
)
.await
.map(|_| ())
}
async fn screenshot_install_style(&self, opts: &ScreenshotOpts) -> Result<bool> {
let css = crate::backend::screenshot_js::build_css(opts);
if css.is_empty() {
return Ok(false);
}
let install = format!("() => {{ {}; }}", crate::backend::screenshot_js::install_style_js(&css));
self.eval_bidi_function(&install).await.map(|()| true)
}
async fn screenshot_install_mask(&self, opts: &ScreenshotOpts) -> Result<bool> {
if let Some(js) = crate::backend::screenshot_js::install_mask_js(opts) {
let wrapped = format!("() => {{ {js}; }}");
self.eval_bidi_function(&wrapped).await.map(|()| true)
} else {
Ok(false)
}
}
fn screenshot_build_params(&self, opts: &ScreenshotOpts) -> serde_json::Value {
let format_type = match opts.format {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Webp => "image/webp",
};
let quality = opts
.quality
.map(|q| f64::from(i32::try_from(q.clamp(0, 100)).unwrap_or(100)) / 100.0);
let origin = if opts.full_page { "document" } else { "viewport" };
let mut params = json!({
"context": &*self.context_id,
"origin": origin,
"format": { "type": format_type }
});
if let Some(q) = quality {
params["format"]["quality"] = json!(q);
}
if let Some(rect) = opts.clip {
params["clip"] = json!({
"type": "box",
"x": rect.x,
"y": rect.y,
"width": rect.width,
"height": rect.height,
});
}
params
}
pub async fn screenshot_element(&self, selector: &str, format: ImageFormat) -> Result<Vec<u8>> {
let elem = self.find_element(selector).await?;
let shared_id = match &elem {
AnyElement::Bidi(e) => &e.shared_id,
_ => {
return Err(FerriError::backend(
"screenshot_element: non-BiDi element on BiDi backend",
));
},
};
let format_type = match format {
ImageFormat::Png => "image/png",
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Webp => "image/webp",
};
let result = self
.cmd(
"browsingContext.captureScreenshot",
json!({
"context": &*self.context_id,
"format": {"type": format_type},
"clip": {"type": "element", "element": {"sharedId": shared_id}}
}),
)
.await?;
let data_str = result
.get("data")
.and_then(|v| v.as_str())
.ok_or_else(|| FerriError::backend("Screenshot: missing data"))?;
base64::engine::general_purpose::STANDARD
.decode(data_str)
.map_err(|e| FerriError::Backend(format!("Screenshot base64 decode: {e}")))
}
pub async fn accessibility_tree(&self) -> Result<Vec<AxNodeData>> {
self.accessibility_tree_with_depth(-1).await
}
pub async fn accessibility_tree_with_depth(&self, max_depth: i32) -> Result<Vec<AxNodeData>> {
let fd = self.injected_script().await?;
self
.eval_internal(crate::selectors::AX_SUPPORT_JS, &self.context_id)
.await?;
let result = self
.eval_internal(
&format!("JSON.stringify({fd}.accessibilityTree({max_depth}))"),
&self.context_id,
)
.await?;
let json_str = result
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "[]".into());
let arr: Vec<serde_json::Value> = serde_json::from_str(&json_str)
.map_err(|e| FerriError::protocol("script.evaluate", format!("accessibility_tree parse: {e}")))?;
let mut nodes = Vec::with_capacity(arr.len());
for item in &arr {
let mut properties = Vec::new();
if let Some(checked) = item.get("checked").and_then(|v| v.as_str()) {
if !checked.is_empty() {
properties.push(AxProperty {
name: "checked".into(),
value: Some(serde_json::Value::String(checked.into())),
});
}
}
if item
.get("disabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
properties.push(AxProperty {
name: "disabled".into(),
value: Some(serde_json::Value::Bool(true)),
});
}
if item
.get("readonly")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
properties.push(AxProperty {
name: "readonly".into(),
value: Some(serde_json::Value::Bool(true)),
});
}
let level = item.get("level").and_then(serde_json::Value::as_i64).unwrap_or(0);
if level > 0 {
properties.push(AxProperty {
name: "level".into(),
value: Some(serde_json::json!(level)),
});
}
if let Some(expanded) = item.get("expanded").and_then(|v| v.as_str()) {
if !expanded.is_empty() {
properties.push(AxProperty {
name: "expanded".into(),
value: Some(serde_json::Value::String(expanded.into())),
});
}
}
if item
.get("required")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
properties.push(AxProperty {
name: "required".into(),
value: Some(serde_json::Value::Bool(true)),
});
}
if let Some(url) = item.get("url").and_then(|v| v.as_str()) {
if !url.is_empty() {
properties.push(AxProperty {
name: "url".into(),
value: Some(serde_json::Value::String(url.into())),
});
}
}
nodes.push(AxNodeData {
node_id: item.get("nodeId").and_then(|v| v.as_str()).unwrap_or("").to_string(),
parent_id: item.get("parentId").and_then(|v| v.as_str()).map(String::from),
backend_dom_node_id: item.get("backendId").and_then(serde_json::Value::as_i64),
ignored: item
.get("ignored")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
role: item.get("role").and_then(|v| v.as_str()).map(String::from),
name: item.get("name").and_then(|v| v.as_str()).map(String::from),
description: item.get("description").and_then(|v| v.as_str()).map(String::from),
properties,
});
}
Ok(nodes)
}
pub async fn click_at(&self, x: f64, y: f64) -> Result<()> {
self
.cmd("input.performActions", input::click(&self.context_id, x, y))
.await?;
Ok(())
}
pub async fn click_at_opts(&self, x: f64, y: f64, button: &str, click_count: u32) -> Result<()> {
let btn = input::button_name_to_id(button);
self
.cmd(
"input.performActions",
input::click_button(&self.context_id, x, y, btn, click_count),
)
.await?;
Ok(())
}
pub async fn click_at_with(&self, x: f64, y: f64, args: &super::super::BackendClickArgs) -> Result<()> {
self
.cmd(
"input.performActions",
input::click_with_args(&self.context_id, x, y, args),
)
.await?;
Ok(())
}
pub async fn hover_at_with(&self, x: f64, y: f64, args: &super::super::BackendHoverArgs) -> Result<()> {
self
.cmd(
"input.performActions",
input::hover_with_args(&self.context_id, x, y, *args),
)
.await?;
Ok(())
}
#[allow(clippy::unused_async, clippy::unused_self)]
pub async fn tap_at_with(&self, _x: f64, _y: f64, _args: &super::super::BackendTapArgs) -> Result<()> {
Err(FerriError::unsupported(
"tap is not available on the BiDi backend — WebDriver BiDi's input.performActions \
pointerType has no 'touch' value in the stable spec (Playwright's own BiDi backend leaves \
Touchscreen unimplemented for the same reason). Use the cdp-pipe or cdp-raw backend for tap.",
))
}
pub async fn press_modifiers(&self, mods: &[crate::options::Modifier]) -> Result<()> {
if mods.is_empty() {
return Ok(());
}
self
.cmd("input.performActions", input::modifiers_down(&self.context_id, mods))
.await?;
Ok(())
}
pub async fn release_modifiers(&self, mods: &[crate::options::Modifier]) -> Result<()> {
if mods.is_empty() {
return Ok(());
}
self
.cmd("input.performActions", input::modifiers_up(&self.context_id, mods))
.await?;
Ok(())
}
pub async fn move_mouse(&self, x: f64, y: f64) -> Result<()> {
self
.cmd("input.performActions", input::pointer_move(&self.context_id, x, y))
.await?;
Ok(())
}
pub async fn move_mouse_smooth(&self, from_x: f64, from_y: f64, to_x: f64, to_y: f64, steps: u32) -> Result<()> {
self
.cmd(
"input.performActions",
input::pointer_move_smooth(&self.context_id, from_x, from_y, to_x, to_y, steps),
)
.await?;
Ok(())
}
pub async fn mouse_wheel(&self, delta_x: f64, delta_y: f64) -> Result<()> {
self
.cmd(
"input.performActions",
input::wheel_scroll(&self.context_id, delta_x, delta_y),
)
.await?;
Ok(())
}
pub async fn mouse_down(&self, x: f64, y: f64, button: &str) -> Result<()> {
let btn = input::button_name_to_id(button);
self
.cmd("input.performActions", input::mouse_down(&self.context_id, x, y, btn))
.await?;
Ok(())
}
pub async fn mouse_up(&self, x: f64, y: f64, button: &str) -> Result<()> {
let btn = input::button_name_to_id(button);
self
.cmd("input.performActions", input::mouse_up(&self.context_id, x, y, btn))
.await?;
Ok(())
}
pub async fn click_and_drag(&self, from: (f64, f64), to: (f64, f64), steps: u32) -> Result<()> {
self
.cmd(
"input.performActions",
input::click_and_drag(&self.context_id, from, to, steps),
)
.await?;
Ok(())
}
pub async fn type_str(&self, text: &str) -> Result<()> {
self
.cmd("input.performActions", input::type_text(&self.context_id, text))
.await?;
Ok(())
}
pub async fn key_down(&self, key: &str) -> Result<()> {
self
.cmd("input.performActions", input::key_down(&self.context_id, key))
.await?;
Ok(())
}
pub async fn key_up(&self, key: &str) -> Result<()> {
self
.cmd("input.performActions", input::key_up(&self.context_id, key))
.await?;
Ok(())
}
pub async fn press_key(&self, key: &str) -> Result<()> {
self
.cmd("input.performActions", input::press_key(&self.context_id, key))
.await?;
Ok(())
}
pub async fn get_cookies(&self) -> Result<Vec<CookieData>> {
let result = self
.cmd(
"storage.getCookies",
json!({
"partition": {"type": "context", "context": &*self.context_id}
}),
)
.await?;
let cookies = result
.get("cookies")
.and_then(|v| v.as_array())
.ok_or_else(|| FerriError::protocol("storage.getCookies", "missing cookies array"))?;
let mut out = Vec::with_capacity(cookies.len());
for c in cookies {
out.push(parse_bidi_cookie(c));
}
Ok(out)
}
pub async fn set_cookie(&self, cookie: CookieData) -> Result<()> {
let (mut domain, mut path) = (cookie.domain.clone(), cookie.path.clone());
if let Some(u) = &cookie.url
&& let Ok(parsed) = reqwest::Url::parse(u)
{
if domain.is_empty() {
domain = parsed.host_str().unwrap_or("").to_string();
}
if path.is_empty() {
let p = parsed.path();
path = if p.is_empty() { "/".to_string() } else { p.to_string() };
}
}
let mut cookie_obj = json!({
"name": cookie.name,
"value": {"type": "string", "value": cookie.value},
"domain": domain,
"path": path
});
if cookie.secure {
cookie_obj["secure"] = json!(true);
}
if cookie.http_only {
cookie_obj["httpOnly"] = json!(true);
}
if let Some(expires) = cookie.expires {
let rounded = expires.round();
if rounded.is_finite() && rounded >= 0.0 {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&format!("{rounded:.0}")) {
cookie_obj["expiry"] = v;
}
}
}
if let Some(ref ss) = cookie.same_site {
cookie_obj["sameSite"] = json!(ss.as_str().to_lowercase());
}
self
.cmd(
"storage.setCookie",
json!({
"cookie": cookie_obj,
"partition": {"type": "context", "context": &*self.context_id}
}),
)
.await?;
Ok(())
}
pub async fn delete_cookie(&self, name: &str, domain: Option<&str>) -> Result<()> {
let mut filter = json!({"name": name});
if let Some(d) = domain {
filter["domain"] = json!(d);
}
self
.cmd(
"storage.deleteCookies",
json!({
"filter": filter,
"partition": {"type": "context", "context": &*self.context_id}
}),
)
.await?;
Ok(())
}
pub async fn clear_cookies(&self) -> Result<()> {
self
.cmd(
"storage.deleteCookies",
json!({
"partition": {"type": "context", "context": &*self.context_id}
}),
)
.await?;
Ok(())
}
#[allow(clippy::too_many_lines)]
pub async fn apply_context_options(&self, opts: &crate::options::BrowserContextOptions) -> Result<()> {
use futures::future::OptionFuture;
let viewport_fut: OptionFuture<_> = opts
.resolved_viewport()
.map(|vp| async move { self.emulate_viewport(&vp).await })
.into();
let media_fut: OptionFuture<_> = opts
.any_media_override()
.then(|| {
let m = opts.as_emulate_media();
async move { self.emulate_media(&m).await }
})
.into();
let ua_fut: OptionFuture<_> = opts
.user_agent
.as_deref()
.map(|ua| async move {
self
.cmd(
"emulation.setUserAgentOverride",
json!({"contexts": [&*self.context_id], "value": ua}),
)
.await
.map(|_| ())
})
.into();
let locale_fut: OptionFuture<_> = opts
.locale
.as_deref()
.map(|l| async move {
self
.cmd(
"emulation.setLocaleOverride",
json!({"contexts": [&*self.context_id], "locale": l}),
)
.await
.map(|_| ())
})
.into();
let tz_fut: OptionFuture<_> = opts
.timezone_id
.as_deref()
.map(|tz| async move {
self
.cmd(
"emulation.setTimezoneOverride",
json!({"contexts": [&*self.context_id], "timezone": tz}),
)
.await
.map(|_| ())
})
.into();
let js_fut: OptionFuture<_> = opts
.java_script_enabled
.map(|v| async move {
self
.cmd(
"emulation.setScriptingEnabled",
json!({"contexts": [&*self.context_id], "enabled": v}),
)
.await
.map(|_| ())
})
.into();
let dl_fut: OptionFuture<_> = opts
.accept_downloads
.map(|accept| async move {
let dl = if accept {
json!({ "type": "allowed", "destinationFolder": "" })
} else {
json!({ "type": "denied" })
};
self
.cmd("browser.setDownloadBehavior", json!({"downloadBehavior": dl}))
.await
.map(|_| ())
})
.into();
let headers_fut: OptionFuture<_> = opts
.extra_http_headers
.as_ref()
.map(|h| async move { self.set_extra_http_headers(h).await })
.into();
let geo_fut: OptionFuture<_> = opts
.geolocation
.map(|g| async move {
self
.cmd(
"emulation.setGeolocationOverride",
json!({
"contexts": [&*self.context_id],
"coordinates": {"latitude": g.latitude, "longitude": g.longitude, "accuracy": g.accuracy},
}),
)
.await
.map(|_| ())
})
.into();
let offline_fut: OptionFuture<_> = opts
.offline
.map(|o| async move {
self
.cmd(
"emulation.setNetworkConditions",
json!({
"contexts": [&*self.context_id],
"offline": o, "latency": 0.0, "downloadThroughput": -1.0, "uploadThroughput": -1.0,
}),
)
.await
.map(|_| ())
})
.into();
let sw_fut: OptionFuture<_> = opts
.service_workers
.map(|p| async move {
if matches!(p, crate::options::ServiceWorkerPolicy::Block) {
self
.add_init_script(
"if(navigator.serviceWorker){navigator.serviceWorker.register=()=>Promise.reject(new Error('Service workers blocked'))}",
)
.await
.map(|_| ())
} else {
Ok(())
}
})
.into();
let (r_vp, r_ua, r_loc, r_tz, r_js, r_dl, r_hdr, r_med, r_geo, r_off, r_sw) = tokio::join!(
viewport_fut,
ua_fut,
locale_fut,
tz_fut,
js_fut,
dl_fut,
headers_fut,
media_fut,
geo_fut,
offline_fut,
sw_fut,
);
let mut errs: Vec<String> = Vec::new();
for (label, r) in [
("viewport", r_vp),
("userAgent", r_ua),
("locale", r_loc),
("timezoneId", r_tz),
("javaScriptEnabled", r_js),
("acceptDownloads", r_dl),
("extraHTTPHeaders", r_hdr),
("media (colorScheme/reducedMotion/forcedColors/contrast)", r_med),
("geolocation", r_geo),
("offline", r_off),
("serviceWorkers", r_sw),
] {
if let Some(Err(e)) = r {
errs.push(format!("{label}: {e}"));
}
}
for (label, present) in [
("bypassCSP", opts.bypass_csp.is_some()),
("ignoreHTTPSErrors", opts.ignore_https_errors.is_some()),
("httpCredentials", opts.http_credentials.is_some()),
("screen", opts.screen.is_some()),
("permissions", opts.permissions.is_some()),
] {
if present {
errs.push(format!(
"{label}: BiDi/Firefox backend does not yet support this context option"
));
}
}
if errs.is_empty() {
Ok(())
} else {
Err(FerriError::Backend(errs.join("; ")))
}
}
pub async fn set_http_credentials(&self, _creds: Option<crate::options::HttpCredentials>) -> Result<()> {
tokio::task::yield_now().await;
Err(FerriError::unsupported(
"BrowserContext.setHTTPCredentials is not supported on the bidi/Firefox backend: no network auth-challenge command exists",
))
}
pub async fn emulate_viewport(&self, config: &crate::options::ViewportConfig) -> Result<()> {
let mut params = json!({
"context": &*self.context_id,
"viewport": {
"width": config.width,
"height": config.height
}
});
if config.device_scale_factor > 0.0 {
params["devicePixelRatio"] = json!(config.device_scale_factor);
}
self.cmd("browsingContext.setViewport", params).await?;
Ok(())
}
pub async fn emulate_media(&self, opts: &crate::options::EmulateMediaOptions) -> Result<()> {
use crate::options::MediaOverride;
if opts.media.is_specified() {
return Err(FerriError::unsupported(
"BiDi/Firefox does not support `media` emulation — no BiDi protocol command exists for it",
));
}
if opts.reduced_motion.is_specified() {
return Err(FerriError::unsupported(
"BiDi/Firefox does not support `reducedMotion` emulation — no BiDi protocol command exists for it",
));
}
if opts.forced_colors.is_specified() {
return Err(FerriError::unsupported(
"BiDi/Firefox does not support `forcedColors` emulation — no BiDi protocol command exists for it",
));
}
if opts.contrast.is_specified() {
return Err(FerriError::unsupported(
"BiDi/Firefox does not support `contrast` emulation — no BiDi protocol command exists for it",
));
}
match &opts.color_scheme {
MediaOverride::Unchanged => {},
MediaOverride::Disabled => {
self
.cmd(
"emulation.setForcedColorsModeThemeOverride",
json!({ "contexts": [&*self.context_id], "theme": serde_json::Value::Null }),
)
.await?;
},
MediaOverride::Set(cs) => {
let theme: serde_json::Value = match cs.as_str() {
"dark" => json!("dark"),
"light" => json!("light"),
_ => serde_json::Value::Null,
};
self
.cmd(
"emulation.setForcedColorsModeThemeOverride",
json!({ "contexts": [&*self.context_id], "theme": theme }),
)
.await?;
},
}
Ok(())
}
pub async fn set_extra_http_headers(&self, headers: &FxHashMap<String, String>) -> Result<()> {
let header_list: Vec<serde_json::Value> = headers
.iter()
.map(|(k, v)| {
json!({
"name": k,
"value": {"type": "string", "value": v}
})
})
.collect();
self
.cmd(
"network.setExtraHeaders",
json!({
"contexts": [&*self.context_id],
"headers": header_list
}),
)
.await?;
Ok(())
}
pub fn reset_permissions(&self) -> impl std::future::Future<Output = Result<()>> {
let _ = &self.context_id;
std::future::ready(Err(FerriError::unsupported(
"Permissions API not available in BiDi backend",
)))
}
pub fn start_tracing(&self) -> impl std::future::Future<Output = Result<()>> {
let _ = &self.context_id;
std::future::ready(Err(FerriError::unsupported("Tracing not supported on BiDi backend")))
}
pub fn stop_tracing(&self) -> impl std::future::Future<Output = Result<()>> {
let _ = &self.context_id;
std::future::ready(Err(FerriError::unsupported("Tracing not supported on BiDi backend")))
}
pub fn metrics(&self) -> impl std::future::Future<Output = Result<Vec<MetricData>>> {
let _ = &self.context_id;
std::future::ready(Err(FerriError::unsupported(
"Performance metrics not supported on BiDi backend",
)))
}
pub async fn resolve_backend_node(&self, backend_node_id: i64, _ref_id: &str) -> Result<AnyElement> {
self.find_element(&format!("[data-fdref='{backend_node_id}']")).await
}
#[allow(clippy::too_many_lines)]
pub fn attach_listeners(
&self,
console_log: Arc<RwLock<Vec<ConsoleMessage>>>,
network_log: Arc<RwLock<Vec<NetworkRequest>>>,
dialog_log: Arc<RwLock<Vec<DialogEvent>>>,
) {
let _ = self.dialog_manager.register_emitter_bridge(self.events.clone());
let _ = self.file_chooser_manager.register_emitter_bridge(self.events.clone());
let _ = self.download_manager.register_emitter_bridge(self.events.clone());
{
let session = self.session.clone();
let downloads_dir = self.downloads_dir.clone();
tokio::spawn(async move {
let params = serde_json::json!({
"downloadBehavior": {
"type": "allowed",
"destinationFolder": downloads_dir.path().to_string_lossy(),
},
});
let _ = session
.transport
.send_command("browser.setDownloadBehavior", params)
.await;
});
}
let mut rx = self.session.transport.subscribe_events();
let ctx = self.context_id.clone();
let session = self.session.clone();
let dialog_manager = self.dialog_manager.clone();
let file_chooser_manager = self.file_chooser_manager.clone();
let download_manager = self.download_manager.clone();
let downloads_dir = self.downloads_dir.clone();
let page_backref = self.page_backref.clone();
let closed = self.closed.clone();
let emitter = self.events.clone();
let injected_script = self.injected_script.clone();
let exposed_fns = self.exposed_fns.clone();
let exposed_session = self.session.clone();
let exposed_ctx = self.context_id.clone();
let tracker = Arc::new(BidiNetworkTracker::new(
self.session.clone(),
self.nav_request_slot.clone(),
));
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
let event_ctx = event.params.get("context").and_then(|v| v.as_str()).unwrap_or("");
let event_parent = event.params.get("parent").and_then(|v| v.as_str()).unwrap_or("");
let child_of_this = !event_parent.is_empty() && event_parent == &*ctx;
if event_ctx != &*ctx && !event_ctx.is_empty() && !child_of_this {
continue;
}
if event.method == "browsingContext.contextCreated" && child_of_this {
let frame_id = event_ctx.to_string();
let url = event
.params
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let parent_id = (*ctx).to_string();
emitter.emit(PageEvent::FrameAttached(crate::backend::FrameInfo {
frame_id: frame_id.clone(),
parent_frame_id: Some(parent_id.clone()),
name: String::new(),
url: url.clone(),
}));
let session_for_refresh = session.clone();
let emitter_for_refresh = emitter.clone();
let parent_for_refresh = parent_id.clone();
let child_for_refresh = frame_id.clone();
tokio::spawn(async move {
let result = session_for_refresh
.transport
.send_command(
"browsingContext.getTree",
json!({"root": &parent_for_refresh, "maxDepth": 1}),
)
.await;
let Ok(tree) = result else { return };
let Some(contexts) = tree.get("contexts").and_then(|v| v.as_array()) else {
return;
};
let Some(children) = contexts
.first()
.and_then(|p| p.get("children"))
.and_then(|v| v.as_array())
else {
return;
};
for child in children {
let cid = child.get("context").and_then(|v| v.as_str()).unwrap_or("");
if cid != child_for_refresh {
continue;
}
let cname = child.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let curl = child.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
emitter_for_refresh.emit(PageEvent::FrameNavigated(crate::backend::FrameInfo {
frame_id: cid.to_string(),
parent_frame_id: Some(parent_for_refresh.clone()),
name: cname,
url: curl,
}));
break;
}
});
continue;
}
match event.method.as_str() {
"browsingContext.navigationStarted"
| "browsingContext.fragmentNavigated"
| "browsingContext.domContentLoaded"
| "browsingContext.load" => {
injected_script.reset_context(event_ctx);
},
"log.entryAdded" => {
let entry_type = event.params.get("type").and_then(|v| v.as_str()).unwrap_or("");
let level = event.params.get("level").and_then(|v| v.as_str()).unwrap_or("");
if entry_type == "javascript" && level == "error" {
let text = event
.params
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let (name, message) = split_error_text(&text);
let stack = build_bidi_stack(&text, event.params.get("stackTrace"));
let details = crate::web_error::ErrorDetails { name, message, stack };
let web_err = match page_backref.upgrade() {
Some(page) => crate::web_error::WebError::new(&page, details),
None => crate::web_error::WebError::new_detached(details),
};
emitter.emit(PageEvent::PageError(web_err));
continue;
}
if entry_type != "console" {
continue;
}
if let Some(text_arg) = event
.params
.get("args")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|a| a.get("value"))
.and_then(|v| v.as_str())
{
if text_arg.starts_with(r#"{"__ferri_call":"#) {
if let Ok(payload) = serde_json::from_str::<serde_json::Value>(text_arg) {
let fn_name = payload
.get("__ferri_call")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let id = payload.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let args: Vec<serde_json::Value> = payload
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let maybe_fn = exposed_fns.read().await.get(&fn_name).cloned();
if let Some(callback) = maybe_fn {
let result = callback(args).await;
let result_js = serde_json::to_string(&result).unwrap_or_else(|_| "null".into());
let escaped_id = id.replace('\\', r"\\").replace('\'', r"\'");
let resolve_js = format!(
"(() => {{ const f = window.__ferri_exposed && window.__ferri_exposed['{escaped_id}']; if (f) {{ delete window.__ferri_exposed['{escaped_id}']; f({result_js}); }} }})()"
);
let _ = exposed_session
.transport
.send_command(
"script.callFunction",
json!({
"functionDeclaration": format!("() => {{ {resolve_js} }}"),
"target": {"context": &*exposed_ctx},
"awaitPromise": false,
"resultOwnership": "none"
}),
)
.await;
}
continue;
}
}
}
let Some(page) = page_backref.upgrade() else {
continue;
};
let method = event.params.get("method").and_then(|v| v.as_str()).unwrap_or("log");
let type_str = if method == "warn" { "warning" } else { method };
let text = if matches!(method, "timeLog" | "timeEnd") {
event
.params
.get("text")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
} else {
None
};
let args_json = event
.params
.get("args")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let mut args: Vec<crate::js_handle::JSHandle> = Vec::with_capacity(args_json.len());
for arg in &args_json {
let backing = bidi_remote_value_to_backing(arg);
let is_node = arg.get("type").and_then(|v| v.as_str()) == Some("node");
args.push(crate::js_handle::JSHandle::from_backing(page.clone(), backing, is_node));
}
let location = bidi_stack_trace_to_location(event.params.get("stackTrace"), event.params.get("source"));
let timestamp = event
.params
.get("timestamp")
.and_then(serde_json::Value::as_f64)
.map_or(0, f64_to_u64_saturating);
let msg = ConsoleMessage::new(&page, type_str, text, args, location, timestamp);
console_log.write().await.push(msg.clone());
emitter.emit(PageEvent::Console(msg));
},
"network.beforeRequestSent" => {
tracker
.on_before_request_sent(&event.params, &emitter, &network_log)
.await;
},
"network.responseStarted" => {
tracker.on_response_started(&event.params, &emitter).await;
},
"network.responseCompleted" => {
tracker.on_response_completed(&event.params, &emitter).await;
},
"network.fetchError" => {
tracker.on_fetch_error(&event.params, &emitter).await;
},
"browsingContext.userPromptOpened" => {
let prompt_type_str = event
.params
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("alert")
.to_string();
let message = event
.params
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let default_value = event
.params
.get("defaultValue")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let dialog_type = crate::dialog::DialogType::parse(&prompt_type_str);
let responder_session = session.clone();
let responder_ctx = ctx.clone();
let responder: crate::dialog::DialogResponder = Arc::new(move |response| {
let session = responder_session.clone();
let ctx = responder_ctx.clone();
Box::pin(async move {
let accept = matches!(response, crate::dialog::DialogResponse::Accept { .. });
let mut handle_params = json!({
"context": &*ctx,
"accept": accept,
});
if let crate::dialog::DialogResponse::Accept { prompt_text: Some(t) } = response {
handle_params["userText"] = json!(t);
}
session
.transport
.send_command("browsingContext.handleUserPrompt", handle_params)
.await
.map(|_| ())
})
});
let dialog = crate::dialog::Dialog::new_with_manager(
dialog_type,
message.clone(),
default_value,
responder,
Some(dialog_manager.clone()),
page_backref.weak(),
);
dialog_manager.did_open(dialog);
dialog_log.write().await.push(DialogEvent {
dialog_type: prompt_type_str,
message,
action: "dispatched".to_string(),
});
},
"browsingContext.contextDestroyed" => {
closed.store(true, Ordering::Relaxed);
emitter.emit(PageEvent::Close);
},
"input.fileDialogOpened" => {
let shared_id = event
.params
.get("element")
.and_then(|e| e.get("sharedId"))
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let is_multiple = event
.params
.get("multiple")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let Some(shared_id) = shared_id else {
continue;
};
let manager_clone = file_chooser_manager.clone();
let backref_clone = page_backref.clone();
let ctx_clone = ctx.clone();
let session_clone = session.clone();
tokio::spawn(async move {
let Some(page) = backref_clone.upgrade() else {
return;
};
let element =
crate::backend::AnyElement::Bidi(super::BidiElement::new(session_clone, ctx_clone, shared_id));
let Ok(handle) = crate::element_handle::ElementHandle::from_any_element(page.clone(), element).await
else {
return;
};
let chooser = crate::file_chooser::FileChooser::new(handle, is_multiple);
manager_clone.did_open(&chooser);
});
},
"browsingContext.downloadWillBegin" => {
let Some(navigation) = event
.params
.get("navigation")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
else {
continue;
};
let url = event
.params
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let suggested = event
.params
.get("suggestedFilename")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let Some(page) = page_backref.upgrade() else {
continue;
};
let canceler: crate::download::DownloadCanceler = Arc::new(|| {
Box::pin(async {
Err(crate::error::FerriError::Unsupported(
"Download.cancel is not supported on the BiDi backend: Firefox's BiDi implementation has no browser.cancelDownload command and Playwright's own BiDi backend leaves cancelDownload as a no-op (see bidiBrowser.ts::cancelDownload)".into(),
))
})
});
let download = crate::download::Download::new(
&page,
navigation,
url,
suggested,
downloads_dir.path().to_path_buf(),
canceler,
);
download_manager.did_open(&download);
},
"browsingContext.downloadEnd" => {
let Some(navigation) = event.params.get("navigation").and_then(|v| v.as_str()) else {
continue;
};
let status = event
.params
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("complete");
if let Some(d) = download_manager.take_for_guid(navigation) {
if status == "canceled" {
d.report_finished(None, Some("canceled".to_string()));
} else {
let filepath = event
.params
.get("filepath")
.and_then(|v| v.as_str())
.map(std::path::PathBuf::from);
d.report_finished(filepath, None);
}
}
},
_ => {},
}
}
});
}
pub async fn pdf(&self, opts: crate::options::PdfOptions) -> Result<Vec<u8>> {
let mut paper_width = 8.5_f64;
let mut paper_height = 11.0_f64;
if let Some(ref format) = opts.format {
if let Some((w, h)) = crate::options::pdf_paper_format_size(format) {
paper_width = w;
paper_height = h;
} else {
return Err(FerriError::invalid_argument(
"format",
format!("unknown paper format: {format}"),
));
}
} else {
if let Some(ref w) = opts.width {
paper_width = w.to_inches();
}
if let Some(ref h) = opts.height {
paper_height = h.to_inches();
}
}
let margin = opts.margin.unwrap_or_default();
let page_ranges: Option<Vec<String>> = opts
.page_ranges
.as_deref()
.filter(|s| !s.is_empty())
.map(|s| s.split(',').map(|r| r.trim().to_string()).collect());
let mut params = json!({
"context": &*self.context_id,
"background": opts.print_background.unwrap_or(false),
"margin": {
"bottom": margin.bottom.as_ref().map_or(0.0, crate::options::PdfSize::to_inches),
"left": margin.left.as_ref().map_or(0.0, crate::options::PdfSize::to_inches),
"right": margin.right.as_ref().map_or(0.0, crate::options::PdfSize::to_inches),
"top": margin.top.as_ref().map_or(0.0, crate::options::PdfSize::to_inches),
},
"orientation": if opts.landscape.unwrap_or(false) { "landscape" } else { "portrait" },
"page": { "width": paper_width, "height": paper_height },
"scale": opts.scale.unwrap_or(1.0),
});
if let Some(ranges) = page_ranges {
params["pageRanges"] = serde_json::Value::Array(ranges.into_iter().map(serde_json::Value::String).collect());
}
let result = self.cmd("browsingContext.print", params).await?;
let data_str = result
.get("data")
.and_then(|v| v.as_str())
.ok_or_else(|| FerriError::backend("PDF: missing data"))?;
base64::engine::general_purpose::STANDARD
.decode(data_str)
.map_err(|e| FerriError::Backend(format!("PDF base64 decode: {e}")))
}
pub fn start_screencast(
&self,
quality: u8,
_max_width: u32,
_max_height: u32,
) -> impl std::future::Future<Output = Result<tokio::sync::mpsc::UnboundedReceiver<(Vec<u8>, f64)>>> {
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
let session = self.session.clone();
let ctx_id = self.context_id.clone();
let closed = self.closed.clone();
let mut event_rx = self.session.transport.subscribe_events();
let event_notify = Arc::new(tokio::sync::Notify::new());
let event_notify2 = event_notify.clone();
let event_ctx = self.context_id.clone();
tokio::spawn(async move {
while let Ok(event) = event_rx.recv().await {
let is_relevant = matches!(
event.method.as_str(),
"browsingContext.load" | "browsingContext.domContentLoaded" | "browsingContext.navigationCommitted"
);
if is_relevant {
if let Some(c) = event.params.get("context").and_then(|v| v.as_str()) {
if c == &*event_ctx {
event_notify2.notify_one();
}
}
}
}
});
tokio::spawn(async move {
let target_interval = std::time::Duration::from_millis(66); let capture_params = json!({
"context": &*ctx_id,
"format": {"type": "image/jpeg", "quality": f64::from(quality) / 100.0},
"origin": "viewport"
});
loop {
if closed.load(Ordering::Relaxed) {
break;
}
let frame_start = tokio::time::Instant::now();
let result = session
.transport
.send_command("browsingContext.captureScreenshot", capture_params.clone())
.await;
if let Ok(result) = result {
if let Some(data_str) = result.get("data").and_then(|v| v.as_str()) {
if let Ok(jpeg_bytes) = base64::engine::general_purpose::STANDARD.decode(data_str) {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
if tx.send((jpeg_bytes, ts)).is_err() {
break;
}
}
}
} else {
break;
}
let elapsed = frame_start.elapsed();
if elapsed < target_interval {
let remaining = target_interval.checked_sub(elapsed).unwrap_or_default();
tokio::select! {
() = tokio::time::sleep(remaining) => {},
() = event_notify.notified() => {},
}
}
}
});
std::future::ready(Ok(rx))
}
pub fn stop_screencast(&self) -> impl std::future::Future<Output = Result<()>> {
let _ = &self.context_id;
std::future::ready(Ok(()))
}
pub async fn set_file_input(&self, selector: &str, paths: &[String]) -> Result<()> {
let elem = self.find_element(selector).await?;
let shared_id = match &elem {
AnyElement::Bidi(e) => e.shared_id.clone(),
_ => return Err(FerriError::backend("set_file_input: non-BiDi element on BiDi backend")),
};
self
.cmd(
"input.setFiles",
json!({
"context": &*self.context_id,
"element": {"sharedId": shared_id},
"files": paths
}),
)
.await?;
Ok(())
}
pub async fn route(
&self,
matcher: crate::url_matcher::UrlMatcher,
handler: crate::route::RouteHandler,
times: Option<u32>,
) -> Result<()> {
let needs_intercept = self.intercept_ids.read().await.is_empty();
if needs_intercept {
let result = self
.cmd(
"network.addIntercept",
json!({
"phases": ["beforeRequestSent"],
"contexts": [&*self.context_id]
}),
)
.await?;
let intercept_id = result
.get("intercept")
.and_then(|v| v.as_str())
.ok_or_else(|| FerriError::protocol("network.addIntercept", "missing intercept id"))?
.to_string();
self.intercept_ids.write().await.push(intercept_id);
}
self.start_route_listener();
self
.routes
.write()
.await
.push(crate::route::RegisteredRoute::new(matcher, handler, times));
Ok(())
}
fn start_route_listener(&self) {
if self
.route_listener_started
.swap(true, std::sync::atomic::Ordering::SeqCst)
{
return;
}
let mut rx = self.session.transport.subscribe_events();
let ctx = self.context_id.clone();
let session = self.session.clone();
let routes = self.routes.clone();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
if event.method != "network.beforeRequestSent" {
continue;
}
let event_ctx = event.params.get("context").and_then(|v| v.as_str()).unwrap_or("");
if event_ctx != &*ctx {
continue;
}
let is_blocked = event
.params
.get("isBlocked")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !is_blocked {
continue;
}
let req_obj = event.params.get("request");
let request_id = req_obj
.and_then(|v| v.get("request"))
.and_then(|v| v.as_str())
.unwrap_or("");
let url = req_obj
.and_then(|v| v.get("url"))
.and_then(|v| v.as_str())
.unwrap_or("");
let matched_handler = {
let mut guard = routes.write().await;
crate::route::take_matching_handler(&mut guard, url)
};
if let Some(handler) = matched_handler {
let method = req_obj
.and_then(|r| r.get("method"))
.and_then(|v| v.as_str())
.unwrap_or("GET");
let headers: FxHashMap<String, String> = req_obj
.and_then(|r| r.get("headers"))
.map(parse_bidi_headers)
.unwrap_or_default();
let orig_method = method.to_string();
let orig_headers = headers.clone();
let intercepted = crate::route::InterceptedRequest {
request_id: request_id.to_string(),
url: url.to_string(),
method: method.to_string(),
headers,
post_data: None,
resource_type: String::new(),
};
let (tx, action_rx) = tokio::sync::oneshot::channel();
let route = crate::route::Route::new(intercepted, tx);
handler(route);
let action = action_rx.await.unwrap_or(crate::route::RouteAction::Continue(
crate::route::ContinueOverrides::default(),
));
execute_bidi_route_action(&session.transport, request_id, action, &orig_method, &orig_headers).await;
} else {
let _ = session
.transport
.send_command("network.continueRequest", json!({"request": request_id}))
.await;
}
}
});
}
pub async fn unroute(&self, matcher: &crate::url_matcher::UrlMatcher) -> Result<()> {
let mut routes = self.routes.write().await;
routes.retain(|r| !r.matcher.equivalent(matcher));
if routes.is_empty() {
let mut ids = self.intercept_ids.write().await;
for id in ids.drain(..) {
let _ = self.cmd("network.removeIntercept", json!({"intercept": id})).await;
}
}
Ok(())
}
pub async fn unroute_all(&self, _behavior: crate::options::UnrouteBehavior) -> Result<()> {
self.routes.write().await.clear();
let mut ids = self.intercept_ids.write().await;
for id in ids.drain(..) {
let _ = self.cmd("network.removeIntercept", json!({"intercept": id})).await;
}
Ok(())
}
pub async fn close_page(&self, opts: crate::options::PageCloseOptions) -> Result<()> {
self
.cmd(
"browsingContext.close",
json!({
"context": &*self.context_id,
"promptUnload": opts.run_before_unload.unwrap_or(false),
}),
)
.await?;
self.closed.store(true, Ordering::Relaxed);
Ok(())
}
#[must_use]
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Relaxed)
}
pub async fn add_init_script(&self, source: &str) -> Result<String> {
let wrapped = format!("() => {{ {source} }}");
let result = self
.cmd(
"script.addPreloadScript",
json!({
"functionDeclaration": wrapped,
"contexts": [&*self.context_id]
}),
)
.await?;
let bidi_id = result
.get("script")
.and_then(|v| v.as_str())
.ok_or_else(|| FerriError::protocol("script.addPreloadScript", "missing script id"))?
.to_string();
let our_id = format!("init-{}", self.preload_scripts.read().await.len());
self.preload_scripts.write().await.insert(our_id.clone(), bidi_id);
Ok(our_id)
}
pub async fn remove_init_script(&self, identifier: &str) -> Result<()> {
let bidi_id = self
.preload_scripts
.write()
.await
.remove(identifier)
.ok_or_else(|| FerriError::invalid_argument("identifier", format!("init script '{identifier}' not found")))?;
self
.cmd("script.removePreloadScript", json!({"script": bidi_id}))
.await?;
Ok(())
}
pub(crate) fn element_from_shared_id(&self, shared_id: String) -> super::BidiElement {
super::BidiElement::new(self.session.clone(), self.context_id.clone(), shared_id)
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
pub async fn call_utility_evaluate(
&self,
fn_source: &str,
args: &[crate::protocol::SerializedValue],
handles: &[crate::protocol::HandleId],
frame_id: Option<&str>,
is_function: Option<bool>,
return_by_value: bool,
) -> Result<crate::js_handle::EvaluateResult> {
use crate::js_handle::{EvaluateResult as FdEvalResult, HandleRemote};
use crate::protocol::HandleId;
use serde_json::json;
self.ensure_engine_injected().await?;
let resolved_ctx: Option<Arc<str>> = if frame_id.is_some() {
None
} else {
handles.iter().find_map(|h| match h {
HandleId::Bidi { shared_id, handle } => handle
.as_deref()
.and_then(|hh| self.handle_realm(hh))
.or_else(|| self.handle_realm(shared_id)),
_ => None,
})
};
let target_ctx: &str = frame_id.or(resolved_ctx.as_deref()).unwrap_or(&self.context_id);
if target_ctx != &*self.context_id {
self.wait_context_ready(target_ctx).await?;
self.ensure_engine_injected_in(target_ctx).await?;
}
let args_json = serde_json::to_string(args)?;
let count = args.len();
let is_fn_local = match is_function {
Some(true) => json!({"type": "boolean", "value": true}),
Some(false) => json!({"type": "boolean", "value": false}),
None => json!({"type": "undefined"}),
};
let mut arguments = vec![
is_fn_local,
json!({"type": "boolean", "value": return_by_value}),
json!({"type": "string", "value": fn_source}),
json!({"type": "number", "value": count}),
json!({"type": "string", "value": args_json}),
];
for handle in handles {
match handle {
HandleId::Bidi { shared_id, handle } => {
if let Some(h) = handle {
arguments.push(json!({"type": "handle", "handle": h}));
} else if !shared_id.is_empty() {
arguments.push(json!({"type": "sharedReference", "sharedId": shared_id}));
} else {
return Err(FerriError::invalid_argument(
"handles",
"BiDi handle carries neither sharedId nor handle",
));
}
},
_ => {
return Err(FerriError::invalid_argument(
"handles",
"call_utility_evaluate: non-BiDi handle in arg.handles on BiDi backend",
));
},
}
}
let params = json!({
"functionDeclaration": crate::backend::cdp::UTILITY_EVAL_WRAPPER,
"target": {"context": target_ctx},
"arguments": arguments,
"awaitPromise": true,
"resultOwnership": if return_by_value { "none" } else { "root" },
});
let response = self.cmd("script.callFunction", params).await?;
let eval_result: super::types::EvaluateResult = serde_json::from_value(response)
.map_err(|e| FerriError::Backend(format!("BiDi call_utility_evaluate parse: {e}")))?;
match eval_result {
super::types::EvaluateResult::Exception { exception_details } => Err(FerriError::evaluation(format!(
"Evaluation error: {}",
exception_details.text
))),
super::types::EvaluateResult::Success { result } => {
if return_by_value {
let inner_json: serde_json::Value = match result {
super::types::RemoteValue::String { value } => {
let s = value.as_str().unwrap_or("null").to_string();
serde_json::from_str(&s).map_err(|e| FerriError::Backend(format!("BiDi parse utility result: {e}")))?
},
super::types::RemoteValue::Null | super::types::RemoteValue::Undefined => {
return Ok(FdEvalResult::Value(crate::protocol::SerializedValue::Special(
crate::protocol::SpecialValue::Undefined,
)));
},
other => {
return Err(FerriError::Backend(format!(
"BiDi call_utility_evaluate: wrapper returned non-string in returnByValue mode: {other:?}"
)));
},
};
let parsed: crate::protocol::SerializedValue = serde_json::from_value(inner_json)
.map_err(|e| FerriError::Backend(format!("BiDi parse SerializedValue: {e}")))?;
Ok(FdEvalResult::Value(parsed))
} else {
if let Some(shared) = result.as_shared_reference() {
self.remember_handle_realm(&shared.shared_id, target_ctx);
if let Some(h) = shared.handle.as_deref() {
self.remember_handle_realm(h, target_ctx);
}
Ok(FdEvalResult::Handle(
crate::js_handle::JSHandleBacking::Remote(HandleRemote::Bidi {
shared_id: shared.shared_id,
handle: shared.handle,
}),
true,
))
} else {
let non_node_handle = match &result {
super::types::RemoteValue::Array { handle, .. }
| super::types::RemoteValue::Object { handle, .. }
| super::types::RemoteValue::Map { handle, .. }
| super::types::RemoteValue::Set { handle, .. }
| super::types::RemoteValue::Function { handle }
| super::types::RemoteValue::Error { handle }
| super::types::RemoteValue::Promise { handle }
| super::types::RemoteValue::Symbol { handle } => handle.clone(),
_ => None,
};
if let Some(h) = non_node_handle {
self.remember_handle_realm(&h, target_ctx);
Ok(FdEvalResult::Handle(
crate::js_handle::JSHandleBacking::Remote(HandleRemote::Bidi {
shared_id: String::new(),
handle: Some(h),
}),
false,
))
} else {
let as_json = result.to_json().unwrap_or(serde_json::Value::Null);
let serialized = match &result {
super::types::RemoteValue::Undefined => {
crate::protocol::SerializedValue::Special(crate::protocol::SpecialValue::Undefined)
},
super::types::RemoteValue::Null => {
crate::protocol::SerializedValue::Special(crate::protocol::SpecialValue::Null)
},
super::types::RemoteValue::BigInt { value } => {
let s = value
.as_str()
.map_or_else(|| value.to_string(), std::string::ToString::to_string);
crate::protocol::SerializedValue::BigInt(s)
},
_ => {
let mut ctx = crate::protocol::SerializationContext::default();
crate::protocol::SerializedValue::from_json(&as_json, &mut ctx)
},
};
Ok(FdEvalResult::Handle(
crate::js_handle::JSHandleBacking::Value(serialized),
false,
))
}
}
}
},
}
}
pub async fn release_handle(&self, shared_id: &str, handle: Option<&str>) -> Result<()> {
let handle_str = handle.unwrap_or(shared_id);
let ctx = handle
.and_then(|h| self.handle_realm(h))
.or_else(|| self.handle_realm(shared_id))
.unwrap_or_else(|| self.context_id.clone());
let result = self
.cmd(
"script.disown",
json!({
"handles": [handle_str],
"target": {"context": &*ctx},
}),
)
.await
.map(|_| ());
self.forget_handle_realm(shared_id);
if let Some(h) = handle {
self.forget_handle_realm(h);
}
result
}
pub async fn expose_function(&self, name: &str, func: crate::events::ExposedFn) -> Result<()> {
let js = format!(
r"() => {{
window['{name}'] = (...args) => {{
return new Promise((resolve) => {{
const id = Math.random().toString(36);
window.__ferri_exposed = window.__ferri_exposed || {{}};
window.__ferri_exposed[id] = resolve;
console.log(JSON.stringify({{__ferri_call: '{name}', id, args}}));
}});
}};
}}"
);
self
.cmd(
"script.addPreloadScript",
json!({
"functionDeclaration": js,
"contexts": [&*self.context_id]
}),
)
.await?;
let _ = self
.cmd(
"script.callFunction",
json!({
"functionDeclaration": js,
"target": {"context": &*self.context_id},
"awaitPromise": false,
"resultOwnership": "none"
}),
)
.await;
self.exposed_fns.write().await.insert(name.to_string(), func);
Ok(())
}
pub async fn remove_exposed_function(&self, name: &str) -> Result<()> {
self.exposed_fns.write().await.remove(name);
let js = format!("delete window['{name}']");
let _ = self.evaluate(&js).await;
Ok(())
}
}
fn collect_frames(ctx: &serde_json::Value, parent_id: Option<&str>, frames: &mut Vec<FrameInfo>) {
let context_id = ctx.get("context").and_then(|v| v.as_str()).unwrap_or("");
let url = ctx.get("url").and_then(|v| v.as_str()).unwrap_or("");
frames.push(FrameInfo {
frame_id: context_id.to_string(),
parent_frame_id: parent_id.map(String::from),
name: String::new(),
url: url.to_string(),
});
if let Some(children) = ctx.get("children").and_then(|v| v.as_array()) {
for child in children {
collect_frames(child, Some(context_id), frames);
}
}
}
struct BidiNetworkTracker {
session: Arc<super::session::BidiSession>,
requests: tokio::sync::Mutex<FxHashMap<String, NetworkRequest>>,
responses: tokio::sync::Mutex<FxHashMap<String, Response>>,
nav_request_slot: crate::network::NavRequestSlot,
}
impl BidiNetworkTracker {
fn new(session: Arc<super::session::BidiSession>, nav_request_slot: crate::network::NavRequestSlot) -> Self {
Self {
session,
requests: tokio::sync::Mutex::new(FxHashMap::default()),
responses: tokio::sync::Mutex::new(FxHashMap::default()),
nav_request_slot,
}
}
async fn on_before_request_sent(
self: &Arc<Self>,
params: &serde_json::Value,
emitter: &EventEmitter,
network_log: &Arc<RwLock<Vec<NetworkRequest>>>,
) {
let Some(req) = params.get("request") else {
return;
};
let id = req.get("request").and_then(|v| v.as_str()).unwrap_or("").to_string();
if id.is_empty() {
return;
}
let url = req.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
let method = req.get("method").and_then(|v| v.as_str()).unwrap_or("GET").to_string();
let headers = req.get("headers").map(parse_bidi_headers).unwrap_or_default();
let resource_type = params
.get("initiator")
.and_then(|i| i.get("type"))
.and_then(|v| v.as_str())
.map_or("", |t| match t {
"parser" => "Document",
"script" => "Script",
"preflight" => "Preflight",
other => other,
})
.to_string();
let post_data = req
.get("body")
.and_then(|b| b.get("value"))
.and_then(|v| v.as_str())
.map(|s| s.as_bytes().to_vec());
let frame_id = params
.get("context")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string);
let is_navigation_request = params.get("navigation").and_then(|v| v.as_str()).is_some();
let redirected_from = if params
.get("redirectCount")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
> 0
{
let mut requests = self.requests.lock().await;
requests.remove(&id)
} else {
None
};
let new_request = network::Request::new(RequestInit {
id: id.clone(),
url,
method,
resource_type,
is_navigation_request,
post_data,
headers,
frame_id,
redirected_from,
timing: None,
raw_headers_fn: None,
});
self.requests.lock().await.insert(id.clone(), new_request.clone());
if new_request.is_navigation_request() {
self.nav_request_slot.set(new_request.clone());
}
network_log.write().await.push(new_request.clone());
emitter.emit(PageEvent::Request(new_request));
}
async fn on_response_started(self: &Arc<Self>, params: &serde_json::Value, emitter: &EventEmitter) {
let Some(request_id) = params
.get("request")
.and_then(|r| r.get("request"))
.and_then(|v| v.as_str())
else {
return;
};
let request_id = request_id.to_string();
let Some(req) = self.requests.lock().await.get(&request_id).cloned() else {
return;
};
let Some(resp) = params.get("response") else {
return;
};
let response = self.build_response(req.clone(), resp, &request_id);
self.responses.lock().await.insert(request_id, response.clone());
req.set_response(&response).await;
emitter.emit(PageEvent::Response(response));
}
async fn on_response_completed(self: &Arc<Self>, params: &serde_json::Value, emitter: &EventEmitter) {
let Some(request_id) = params
.get("request")
.and_then(|r| r.get("request"))
.and_then(|v| v.as_str())
else {
return;
};
let request_id = request_id.to_string();
let Some(req) = self.requests.lock().await.get(&request_id).cloned() else {
return;
};
if req.existing_response().await.is_none() {
if let Some(resp) = params.get("response") {
let response = self.build_response(req.clone(), resp, &request_id);
self.responses.lock().await.insert(request_id.clone(), response.clone());
req.set_response(&response).await;
emitter.emit(PageEvent::Response(response));
}
}
if let Some(resp) = self.responses.lock().await.get(&request_id).cloned() {
resp.finish_success().await;
}
emitter.emit(PageEvent::RequestFinished(req));
}
async fn on_fetch_error(self: &Arc<Self>, params: &serde_json::Value, emitter: &EventEmitter) {
let Some(request_id) = params
.get("request")
.and_then(|r| r.get("request"))
.and_then(|v| v.as_str())
else {
return;
};
let request_id = request_id.to_string();
let Some(req) = self.requests.lock().await.get(&request_id).cloned() else {
return;
};
let error_text = params
.get("errorText")
.and_then(|v| v.as_str())
.unwrap_or("net::ERR_FAILED")
.to_string();
req.set_failure(error_text.clone());
if let Some(resp) = self.responses.lock().await.get(&request_id).cloned() {
resp.finish_failure(error_text).await;
}
emitter.emit(PageEvent::RequestFailed(req));
}
fn build_response(self: &Arc<Self>, request: NetworkRequest, resp: &serde_json::Value, request_id: &str) -> Response {
let url = resp.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string();
let status = resp.get("status").and_then(serde_json::Value::as_i64).unwrap_or(0);
let status_text = resp
.get("statusText")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let from_service_worker = resp
.get("fromCache")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let headers = resp.get("headers").map(parse_bidi_headers).unwrap_or_default();
let body_fn = self.make_body_fn(request_id);
let raw_headers_fn = self.make_raw_headers_fn(request_id);
Response::new(ResponseInit {
request,
url,
status,
status_text,
from_service_worker,
http_version: None,
headers,
remote_addr: parse_bidi_remote_addr(resp),
security_details: parse_bidi_security_details(resp),
body_fn: Some(body_fn),
raw_headers_fn: Some(raw_headers_fn),
})
}
fn make_body_fn(self: &Arc<Self>, request_id: &str) -> BodyFn {
let session = self.session.clone();
let request_id = request_id.to_string();
Arc::new(move || {
let session = session.clone();
let request_id = request_id.clone();
Box::pin(async move {
let resp = session
.transport
.send_command(
"network.getData",
json!({"request": request_id, "dataType": "response"}),
)
.await
.map_err(|e| {
let msg = e.to_string();
if msg.contains("no such network data") {
crate::error::FerriError::Unsupported(
"Response body unavailable on BiDi without network interception (Firefox discards bytes after response)".into(),
)
} else {
crate::error::FerriError::Protocol {
method: "network.getData".into(),
message: msg,
}
}
})?;
let bytes = resp.get("bytes").and_then(|b| b.get("value")).and_then(|v| v.as_str());
let data = bytes.unwrap_or("");
base64::engine::general_purpose::STANDARD
.decode(data)
.map_err(|e| crate::error::FerriError::Backend(format!("base64 decode: {e}")))
})
})
}
fn make_raw_headers_fn(self: &Arc<Self>, request_id: &str) -> RawHeadersFn {
let tracker = self.clone();
let request_id = request_id.to_string();
Arc::new(move || {
let tracker = tracker.clone();
let request_id = request_id.clone();
Box::pin(async move {
if let Some(resp) = tracker.responses.lock().await.get(&request_id) {
return Ok(resp.headers_array().await);
}
Ok(Vec::new())
})
})
}
}
fn parse_bidi_remote_addr(_resp: &serde_json::Value) -> Option<RemoteAddr> {
None
}
fn parse_bidi_security_details(resp: &serde_json::Value) -> Option<SecurityDetails> {
resp
.get("securityDetails")
.and_then(|s| s.as_object())
.map(|obj| SecurityDetails {
protocol: obj.get("protocol").and_then(|v| v.as_str()).map(String::from),
subject_name: obj.get("subjectName").and_then(|v| v.as_str()).map(String::from),
issuer: obj.get("issuer").and_then(|v| v.as_str()).map(String::from),
valid_from: obj.get("validFrom").and_then(serde_json::Value::as_f64),
valid_to: obj.get("validTo").and_then(serde_json::Value::as_f64),
})
}
fn parse_bidi_headers(headers_val: &serde_json::Value) -> FxHashMap<String, String> {
headers_val
.as_array()
.map(|arr| {
arr
.iter()
.filter_map(|entry| {
let name = entry.get("name")?.as_str()?;
let value = entry
.get("value")
.and_then(|v| v.get("value"))
.and_then(|v| v.as_str())
.unwrap_or("");
Some((name.to_string(), value.to_string()))
})
.collect()
})
.unwrap_or_default()
}
async fn execute_bidi_route_action(
transport: &super::transport::BidiTransport,
request_id: &str,
action: crate::route::RouteAction,
orig_method: &str,
orig_headers: &FxHashMap<String, String>,
) {
match action {
crate::route::RouteAction::Fulfill(resp) => {
let body_b64 = base64::engine::general_purpose::STANDARD.encode(&resp.body);
let mut hdrs: Vec<serde_json::Value> = resp
.headers
.iter()
.map(|(k, v)| json!({"name": k, "value": {"type": "string", "value": v}}))
.collect();
if let Some(ct) = &resp.content_type {
if !hdrs
.iter()
.any(|h| h.get("name").and_then(|n| n.as_str()) == Some("content-type"))
{
hdrs.push(json!({"name": "content-type", "value": {"type": "string", "value": ct}}));
}
}
let _ = transport
.send_command(
"network.provideResponse",
json!({
"request": request_id,
"statusCode": resp.status,
"reasonPhrase": crate::route::status_text(resp.status),
"headers": hdrs,
"body": {"type": "base64", "value": body_b64},
}),
)
.await;
},
crate::route::RouteAction::Continue(overrides) => {
if let Some(target_url) = &overrides.url {
let method = overrides.method.as_deref().unwrap_or(orig_method);
let mut merged: Vec<(String, String)> = orig_headers.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
if let Some(over) = &overrides.headers {
merged.retain(|(k, _)| !over.iter().any(|(ok, _)| ok.eq_ignore_ascii_case(k)));
merged.extend(over.iter().cloned());
}
bidi_continue_via_fetch(
transport,
request_id,
target_url,
method,
&merged,
overrides.post_data.as_deref(),
)
.await;
} else {
let mut params = json!({"request": request_id});
if let Some(method) = &overrides.method {
params["method"] = serde_json::Value::String(method.clone());
}
if let Some(headers) = &overrides.headers {
let hdrs: Vec<serde_json::Value> = headers
.iter()
.map(|(k, v)| json!({"name": k, "value": {"type": "string", "value": v}}))
.collect();
params["headers"] = serde_json::Value::Array(hdrs);
}
if let Some(post_data) = &overrides.post_data {
let encoded = base64::engine::general_purpose::STANDARD.encode(post_data);
params["body"] = json!({"type": "base64", "value": encoded});
}
let _ = transport.send_command("network.continueRequest", params).await;
}
},
crate::route::RouteAction::Abort(_reason) => {
let _ = transport
.send_command("network.failRequest", json!({"request": request_id}))
.await;
},
}
}
async fn bidi_continue_via_fetch(
transport: &super::transport::BidiTransport,
request_id: &str,
url: &str,
method: &str,
headers: &[(String, String)],
body: Option<&[u8]>,
) {
match fetch_for_route(url, method, headers, body).await {
Ok((status, resp_headers, resp_body)) => {
let body_b64 = base64::engine::general_purpose::STANDARD.encode(&resp_body);
let hdrs: Vec<serde_json::Value> = resp_headers
.iter()
.map(|(k, v)| json!({"name": k, "value": {"type": "string", "value": v}}))
.collect();
let _ = transport
.send_command(
"network.provideResponse",
json!({
"request": request_id,
"statusCode": status,
"reasonPhrase": crate::route::status_text(i32::from(status)),
"headers": hdrs,
"body": {"type": "base64", "value": body_b64},
}),
)
.await;
},
Err(e) => {
tracing::warn!("bidi route url-override fetch failed ({e}); continuing request unmodified");
let _ = transport
.send_command("network.continueRequest", json!({"request": request_id}))
.await;
},
}
}
async fn fetch_for_route(
url: &str,
method: &str,
headers: &[(String, String)],
body: Option<&[u8]>,
) -> Result<(u16, Vec<(String, String)>, Vec<u8>)> {
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
let client = reqwest::Client::builder()
.build()
.map_err(|e| FerriError::protocol("route url override", format!("http client: {e}")))?;
let m = reqwest::Method::from_bytes(method.as_bytes())
.map_err(|e| FerriError::protocol("route url override", format!("bad method {method:?}: {e}")))?;
let mut hmap = HeaderMap::new();
for (k, v) in headers {
if matches!(
k.to_ascii_lowercase().as_str(),
"host" | "content-length" | "connection" | "accept-encoding"
) {
continue;
}
if let (Ok(name), Ok(val)) = (HeaderName::from_bytes(k.as_bytes()), HeaderValue::from_str(v)) {
hmap.insert(name, val);
}
}
let mut req = client.request(m, url).headers(hmap);
if let Some(b) = body {
req = req.body(b.to_vec());
}
let resp = req
.send()
.await
.map_err(|e| FerriError::protocol("route url override", format!("request to {url} failed: {e}")))?;
let status = resp.status().as_u16();
let resp_headers: Vec<(String, String)> = resp
.headers()
.iter()
.filter(|(k, _)| {
!matches!(
k.as_str().to_ascii_lowercase().as_str(),
"content-length" | "transfer-encoding" | "connection"
)
})
.filter_map(|(k, v)| v.to_str().ok().map(|s| (k.as_str().to_string(), s.to_string())))
.collect();
let bytes = resp
.bytes()
.await
.map_err(|e| FerriError::protocol("route url override", format!("body read failed: {e}")))?
.to_vec();
Ok((status, resp_headers, bytes))
}
fn parse_bidi_cookie(c: &serde_json::Value) -> CookieData {
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let value = c
.get("value")
.and_then(|v| {
v.get("value").and_then(|inner| inner.as_str()).or_else(|| v.as_str())
})
.unwrap_or("")
.to_string();
let domain = c.get("domain").and_then(|v| v.as_str()).unwrap_or("").to_string();
let path = c.get("path").and_then(|v| v.as_str()).unwrap_or("/").to_string();
let secure = c.get("secure").and_then(serde_json::Value::as_bool).unwrap_or(false);
let http_only = c.get("httpOnly").and_then(serde_json::Value::as_bool).unwrap_or(false);
let expires = c.get("expiry").and_then(serde_json::Value::as_f64);
let same_site = c.get("sameSite").and_then(|v| v.as_str()).and_then(|s| match s {
"strict" => Some(crate::backend::SameSite::Strict),
"lax" => Some(crate::backend::SameSite::Lax),
"none" => Some(crate::backend::SameSite::None),
_ => None,
});
CookieData {
name,
value,
domain,
path,
secure,
http_only,
expires,
same_site,
url: None,
}
}