use crate::backend::{AnyPage, CookieData};
use crate::error::Result;
use crate::network::Request;
use crate::page::Page;
use crate::state::SessionKey;
use arc_swap::ArcSwap;
use rustc_hash::FxHashMap as HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, serde::Serialize)]
pub struct DialogEvent {
pub dialog_type: String,
pub message: String,
pub action: String,
}
pub struct BrowserContext {
pub pages: Vec<AnyPage>,
pub active_page_idx: usize,
pub ref_map: Arc<ArcSwap<HashMap<String, i64>>>,
pub console_log: Arc<RwLock<Vec<crate::console_message::ConsoleMessage>>>,
pub network_log: Arc<RwLock<Vec<Request>>>,
pub dialog_log: Arc<RwLock<Vec<DialogEvent>>>,
name: String,
pub cdp_context_id: Option<String>,
}
impl BrowserContext {
pub(crate) fn new(name: String) -> Self {
Self {
pages: Vec::new(),
active_page_idx: 0,
ref_map: Arc::new(ArcSwap::from_pointee(HashMap::default())),
console_log: Arc::new(RwLock::new(Vec::new())),
network_log: Arc::new(RwLock::new(Vec::new())),
dialog_log: Arc::new(RwLock::new(Vec::new())),
name,
cdp_context_id: None,
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn active_page(&self) -> Option<&AnyPage> {
self.pages.get(self.active_page_idx)
}
pub async fn cookies(&self) -> Result<Vec<CookieData>> {
if let Some(page) = self.active_page() {
page.get_cookies().await
} else {
Ok(Vec::new())
}
}
pub async fn add_cookies(&self, cookies: Vec<CookieData>) -> Result<()> {
let page = self.active_page().ok_or(crate::error::FerriError::NotConnected)?;
for cookie in cookies {
page.set_cookie(cookie).await?;
}
Ok(())
}
pub async fn clear_cookies(&self) -> Result<()> {
if let Some(page) = self.active_page() {
page.clear_cookies().await?;
}
Ok(())
}
pub async fn delete_cookie(&self, name: &str, domain: Option<&str>) -> Result<()> {
let cookies = self.cookies().await?;
if let Some(page) = self.active_page() {
page.clear_cookies().await?;
for cookie in cookies {
let name_matches = cookie.name == name;
let domain_matches = domain.is_none_or(|d| cookie.domain == d);
if !(name_matches && domain_matches) {
page.set_cookie(cookie).await?;
}
}
}
Ok(())
}
pub async fn console_messages(
&self,
level: Option<&str>,
limit: usize,
) -> Vec<crate::console_message::ConsoleMessage> {
let msgs = self.console_log.read().await;
msgs
.iter()
.filter(|m| level.is_none_or(|l| l == "all" || m.type_str() == l))
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
pub async fn network_requests(&self, limit: usize) -> Vec<Request> {
let reqs = self.network_log.read().await;
reqs
.iter()
.rev()
.take(limit)
.cloned()
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect()
}
pub async fn dialog_messages(&self, limit: usize) -> Vec<DialogEvent> {
let msgs = self.dialog_log.read().await;
let start = msgs.len().saturating_sub(limit);
msgs[start..].to_vec()
}
}
use crate::state::BrowserState;
#[derive(Clone)]
pub struct ContextRef {
pub(crate) state: Arc<RwLock<BrowserState>>,
pub(crate) name: Arc<str>,
pub(crate) key: SessionKey,
default_timeout_ms: Arc<std::sync::atomic::AtomicU64>,
default_navigation_timeout_ms: Arc<std::sync::atomic::AtomicU64>,
events: crate::events::ContextEventEmitter,
browser: Option<crate::Browser>,
closed: Arc<std::sync::atomic::AtomicBool>,
}
impl ContextRef {
pub fn new(state: Arc<RwLock<BrowserState>>, name: String) -> Self {
let key = SessionKey::parse(&name);
let (events, closed) = match state.try_read() {
Ok(s) => (
s.get_or_create_context_events(&key.to_composite()),
s.get_or_create_context_closed(&key.to_composite()),
),
Err(_) => (
crate::events::ContextEventEmitter::new(),
Arc::new(std::sync::atomic::AtomicBool::new(false)),
),
};
Self {
state,
name: Arc::from(name),
key,
default_timeout_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
default_navigation_timeout_ms: Arc::new(std::sync::atomic::AtomicU64::new(0)),
events,
browser: None,
closed,
}
}
#[must_use]
pub(crate) fn with_browser(mut self, browser: crate::Browser) -> Self {
self.browser = Some(browser);
self
}
#[must_use]
pub fn browser(&self) -> Option<&crate::Browser> {
self.browser.as_ref()
}
#[must_use]
pub fn is_closed(&self) -> bool {
if self.closed.load(std::sync::atomic::Ordering::SeqCst) {
return true;
}
self.browser.as_ref().is_some_and(|b| !b.is_connected())
}
#[must_use]
pub fn events(&self) -> &crate::events::ContextEventEmitter {
&self.events
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub async fn new_page(&self) -> Result<Arc<Page>> {
{
let mut state = self.state.write().await;
Box::pin(state.ensure_instance(&self.key.instance)).await?;
}
let ctx_opts = {
let state = self.state.read().await;
state.get_context_options(&self.key.to_composite())
};
let resolved_viewport = match &ctx_opts {
Some(opts) => match opts.viewport {
crate::options::ViewportOption::Null => None,
crate::options::ViewportOption::Default | crate::options::ViewportOption::Size { .. } => {
opts.resolved_viewport()
},
},
None => None,
};
let plan = {
let state = self.state.read().await;
state.page_open_plan(&self.key)?
};
let effective_viewport = if ctx_opts
.as_ref()
.is_some_and(|o| o.viewport != crate::options::ViewportOption::Default)
{
resolved_viewport
} else {
plan.viewport.clone()
};
let (any_page, browser_context_id) = if &*self.key.context == "default" {
(
Box::pin(plan.browser.new_page(
"about:blank",
plan.browser_context_id.as_deref(),
effective_viewport.as_ref(),
))
.await?,
None,
)
} else if let Some(existing_ctx_id) = plan.browser_context_id.clone() {
(
Box::pin(
plan
.browser
.new_page("about:blank", Some(&existing_ctx_id), effective_viewport.as_ref()),
)
.await?,
Some(existing_ctx_id),
)
} else {
let ctx_id = plan.browser.new_context(ctx_opts.as_ref()).await?;
let page = Box::pin(
plan
.browser
.new_page("about:blank", Some(&ctx_id), effective_viewport.as_ref()),
)
.await?;
(page, Some(ctx_id))
};
{
let mut state = self.state.write().await;
state.register_opened_page(&self.key, any_page.clone(), browser_context_id)?;
}
let page = Page::with_context(any_page, self.clone());
if let Some(opts) = ctx_opts.as_ref() {
apply_context_options(&page, opts).await?;
if let Some(ref storage) = opts.storage_state {
let should_hydrate = {
let state = self.state.read().await;
state.claim_storage_state_hydration(&self.key.to_composite())
};
if should_hydrate {
let state_value = match storage {
crate::options::StorageStateInput::Inline(v) => v.clone(),
crate::options::StorageStateInput::Path(p) => {
let text = std::fs::read_to_string(p)
.map_err(|e| crate::error::FerriError::Backend(format!("storageState: read {}: {e}", p.display())))?;
serde_json::from_str(&text).map_err(|e| {
crate::error::FerriError::Backend(format!("storageState: parse JSON from {}: {e}", p.display()))
})?
},
};
page.set_storage_state(&state_value).await?;
}
}
}
let record_opts = {
let state = self.state.read().await;
state.get_record_video(&self.key.to_composite())
};
if let Some(opts) = record_opts {
start_video_recording(&page, &opts);
}
self.apply_context_bindings(page.inner()).await?;
Ok(page)
}
async fn apply_context_bindings(&self, page: &AnyPage) -> Result<()> {
let composite = self.key.to_composite();
let bindings = {
let bindings_handle = self.state.read().await.context_bindings_handle();
let guard = bindings_handle.read().await;
guard
.get(&composite)
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>())
.unwrap_or_default()
};
for (name, binding) in bindings {
let fn_for_page = bind_source(binding, composite.clone(), page);
page.expose_function(&name, fn_for_page).await?;
}
Ok(())
}
pub async fn pages(&self) -> Result<Vec<Arc<Page>>> {
let inner_pages = {
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
ctx.pages.clone()
};
let mut pages = Vec::with_capacity(inner_pages.len());
for inner in inner_pages {
pages.push(Page::with_context(inner, self.clone()));
}
Ok(pages)
}
pub async fn cookies(&self) -> Result<Vec<CookieData>> {
let page = {
let state = self.state.read().await;
state.context(&self.name)?.active_page().cloned()
};
if let Some(page) = page {
page.get_cookies().await
} else {
Ok(Vec::new())
}
}
pub async fn storage_state(
&self,
opts: Option<crate::options::StorageStateOptions>,
) -> Result<crate::options::StorageState> {
const COLLECT_JS: &str = r"JSON.stringify({
origin: location.origin,
localStorage: Object.entries(localStorage).map(([name, value]) => ({ name, value }))
})";
let cookies = self.cookies().await?;
let pages = self.pages().await?;
let mut origins: Vec<crate::options::OriginState> = Vec::new();
let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
for page in &pages {
let Ok(Some(raw)) = page.inner.evaluate(COLLECT_JS).await else {
continue;
};
let parsed: Option<crate::options::OriginState> = raw
.as_str()
.and_then(|s| serde_json::from_str::<crate::options::OriginState>(s).ok());
let Some(state) = parsed else { continue };
if state.origin.is_empty() || state.origin == "null" || state.local_storage.is_empty() {
continue;
}
if seen.insert(state.origin.clone()) {
origins.push(state);
}
}
let state = crate::options::StorageState { cookies, origins };
if let Some(opts) = opts {
if let Some(path) = opts.path {
let json = serde_json::to_string_pretty(&state)
.map_err(|e| crate::error::FerriError::Backend(format!("storageState: serialize JSON: {e}")))?;
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::FerriError::Backend(format!("storageState: mkdir {}: {e}", parent.display()))
})?;
}
}
std::fs::write(&path, json)
.map_err(|e| crate::error::FerriError::Backend(format!("storageState: write {}: {e}", path.display())))?;
}
}
Ok(state)
}
pub async fn add_cookies(&self, cookies: Vec<CookieData>) -> Result<()> {
let page = {
let state = self.state.read().await;
state.context(&self.name)?.active_page().cloned()
}
.ok_or(crate::error::FerriError::NotConnected)?;
for cookie in cookies {
page.set_cookie(cookie).await?;
}
Ok(())
}
pub async fn clear_cookies(&self) -> Result<()> {
let page = {
let state = self.state.read().await;
state.context(&self.name)?.active_page().cloned()
};
if let Some(page) = page {
page.clear_cookies().await?;
}
Ok(())
}
pub async fn clear_cookies_filtered(&self, options: &crate::backend::ClearCookieOptions) -> Result<()> {
if options.name.is_none() && options.domain.is_none() && options.path.is_none() {
return self.clear_cookies().await;
}
let page = {
let state = self.state.read().await;
state.context(&self.name)?.active_page().cloned()
};
if let Some(page) = page {
let cookies = page.get_cookies().await?;
page.clear_cookies().await?;
for c in cookies {
let name_match = options.name.as_ref().is_none_or(|n| &c.name == n);
let domain_match = options.domain.as_ref().is_none_or(|d| &c.domain == d);
let path_match = options.path.as_ref().is_none_or(|p| &c.path == p);
if !(name_match && domain_match && path_match) {
page.set_cookie(c).await?;
}
}
}
Ok(())
}
pub async fn delete_cookie(&self, name: &str, domain: Option<&str>) -> Result<()> {
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
ctx.delete_cookie(name, domain).await
}
pub fn set_default_timeout(&self, ms: u64) {
self.default_timeout_ms.store(ms, std::sync::atomic::Ordering::Relaxed);
}
pub fn set_default_navigation_timeout(&self, ms: u64) {
self
.default_navigation_timeout_ms
.store(ms, std::sync::atomic::Ordering::Relaxed);
}
#[must_use]
pub fn default_timeout(&self) -> u64 {
self.default_timeout_ms.load(std::sync::atomic::Ordering::Relaxed)
}
#[must_use]
pub fn default_navigation_timeout(&self) -> u64 {
self
.default_navigation_timeout_ms
.load(std::sync::atomic::Ordering::Relaxed)
}
async fn mutate_options<F>(&self, f: F) -> Result<()>
where
F: FnOnce(&mut crate::options::BrowserContextOptions),
{
let composite = self.key.to_composite();
let updated = {
let state = self.state.read().await;
let mut opts = state.get_context_options(&composite).unwrap_or_default();
f(&mut opts);
state.set_context_options(&composite, opts.clone());
opts
};
let pages = Box::pin(self.pages()).await?;
for page in pages {
Box::pin(page.apply_context_options(&updated)).await?;
}
Ok(())
}
pub async fn grant_permissions(&self, permissions: &[String], _origin: Option<&str>) -> Result<()> {
let perms = permissions.to_vec();
self.mutate_options(|o| o.permissions = Some(perms)).await
}
pub async fn clear_permissions(&self) -> Result<()> {
let pages = self.pages().await?;
for page in &pages {
page.inner().reset_permissions().await?;
}
self.mutate_options(|o| o.permissions = None).await
}
pub async fn close(&self) -> Result<()> {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
let mut state = self.state.write().await;
let persistent = state.persistent_context;
state.remove_context(&self.name).await;
if persistent {
state.shutdown().await;
}
Ok(())
}
#[must_use]
pub fn state(&self) -> &Arc<RwLock<BrowserState>> {
&self.state
}
pub async fn set_record_video(&self, opts: crate::options::RecordVideoOptions) -> Result<()> {
let composite = self.key.to_composite();
let state = self.state.read().await;
state.set_record_video(&composite, opts.clone());
let mut bag = state.get_context_options(&composite).unwrap_or_default();
bag.record_video = Some(opts);
state.set_context_options(&composite, bag);
Ok(())
}
pub fn on(&self, event_name: &str, callback: crate::events::ContextEventCallback) -> crate::events::ListenerId {
self.events.on(event_name, callback)
}
pub fn once(&self, event_name: &str, callback: crate::events::ContextEventCallback) -> crate::events::ListenerId {
self.events.once(event_name, callback)
}
pub fn off(&self, id: crate::events::ListenerId) {
self.events.off(id);
}
pub async fn wait_for_event(&self, event_name: &str, timeout_ms: u64) -> Result<crate::events::ContextEvent> {
self.events.wait_for_event(event_name, timeout_ms).await
}
pub async fn add_init_script(
&self,
script: crate::options::InitScriptSource,
arg: Option<serde_json::Value>,
) -> Result<crate::disposable::Disposable> {
let source = crate::options::evaluation_script(script, arg.as_ref())?;
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
let mut undo = Vec::with_capacity(ctx.pages.len());
for page in &ctx.pages {
let id = page.add_init_script(&source).await?;
undo.push((page.clone(), id));
}
drop(state);
Ok(crate::disposable::Disposable::new(move || async move {
for (page, id) in undo {
page.remove_init_script(&id).await?;
}
Ok(())
}))
}
pub async fn set_geolocation(&self, lat: f64, lng: f64, accuracy: f64) -> Result<()> {
self
.mutate_options(|o| {
o.geolocation = Some(crate::options::Geolocation {
latitude: lat,
longitude: lng,
accuracy,
});
})
.await
}
pub async fn set_extra_http_headers(&self, headers: &rustc_hash::FxHashMap<String, String>) -> Result<()> {
let headers = headers.clone();
self.mutate_options(|o| o.extra_http_headers = Some(headers)).await
}
pub async fn set_offline(&self, offline: bool) -> Result<()> {
self.mutate_options(|o| o.offline = Some(offline)).await
}
pub async fn set_http_credentials(&self, credentials: Option<crate::options::HttpCredentials>) -> Result<()> {
let composite = self.key.to_composite();
{
let state = self.state.read().await;
let mut opts = state.get_context_options(&composite).unwrap_or_default();
opts.http_credentials.clone_from(&credentials);
state.set_context_options(&composite, opts);
}
let pages = self.pages().await?;
for page in pages {
page.set_http_credentials(credentials.clone()).await?;
}
Ok(())
}
pub async fn route(
&self,
matcher: crate::url_matcher::UrlMatcher,
handler: crate::route::RouteHandler,
times: Option<u32>,
) -> Result<crate::disposable::Disposable> {
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
let mut undo = Vec::with_capacity(ctx.pages.len());
for page in &ctx.pages {
page.route(matcher.clone(), handler.clone(), times).await?;
undo.push(page.clone());
}
drop(state);
Ok(crate::disposable::Disposable::new(move || async move {
for page in undo {
page.unroute(&matcher).await?;
}
Ok(())
}))
}
pub async fn route_from_har(&self, path: &std::path::Path, options: crate::har::RouteFromHarOptions) -> Result<()> {
let handler = crate::har::route_handler_from_file(path, options.not_found)?;
let matcher = options.url.unwrap_or_else(crate::url_matcher::UrlMatcher::any);
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
for page in &ctx.pages {
page.route(matcher.clone(), handler.clone(), None).await?;
}
Ok(())
}
pub async fn unroute(&self, matcher: &crate::url_matcher::UrlMatcher) -> Result<()> {
let state = self.state.read().await;
let ctx = state.context(&self.name)?;
for page in &ctx.pages {
page.unroute(matcher).await?;
}
Ok(())
}
pub async fn expose_binding(
&self,
name: &str,
callback: crate::events::ExposedBinding,
) -> Result<crate::disposable::Disposable> {
{
let bindings = self.state.read().await.context_bindings_handle();
let mut guard = bindings.write().await;
guard
.entry(self.key.to_composite())
.or_default()
.insert(name.to_string(), callback.clone());
}
let pages = {
let state = self.state.read().await;
state.context(&self.name).map(|c| c.pages.clone()).unwrap_or_default()
};
let composite = self.key.to_composite();
for page in &pages {
let fn_for_page = bind_source(callback.clone(), composite.clone(), page);
page.expose_function(name, fn_for_page).await?;
}
Ok(crate::disposable::Disposable::new({
let this = self.clone();
let name = name.to_string();
move || async move {
let _ = this.remove_exposed_binding(&name).await;
Ok(())
}
}))
}
pub async fn expose_function(
&self,
name: &str,
callback: crate::events::ExposedFn,
) -> Result<crate::disposable::Disposable> {
let binding: crate::events::ExposedBinding = Arc::new(move |_source, args| callback(args));
self.expose_binding(name, binding).await
}
pub async fn remove_exposed_binding(&self, name: &str) -> Result<()> {
{
let bindings = self.state.read().await.context_bindings_handle();
let mut guard = bindings.write().await;
if let Some(map) = guard.get_mut(&self.key.to_composite()) {
map.remove(name);
}
}
let pages = {
let state = self.state.read().await;
state.context(&self.name).map(|c| c.pages.clone()).unwrap_or_default()
};
for page in &pages {
page.remove_exposed_function(name).await?;
}
Ok(())
}
}
fn bind_source(
binding: crate::events::ExposedBinding,
context_key: String,
page: &AnyPage,
) -> crate::events::ExposedFn {
let frame_id = page.peek_main_frame_id().unwrap_or_default();
Arc::new(move |args| {
let source = crate::events::BindingSource {
context: context_key.clone(),
page: frame_id.clone(),
frame: frame_id.clone(),
};
binding(source, args)
})
}
async fn apply_context_options(page: &Arc<Page>, opts: &crate::options::BrowserContextOptions) -> Result<()> {
Box::pin(page.apply_context_options(opts)).await
}
fn start_video_recording(page: &Arc<Page>, opts: &crate::options::RecordVideoOptions) {
static VIDEO_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let (video, sink) = crate::video::Video::new();
page.attach_video(Arc::new(video));
let size = opts.size.unwrap_or_default();
let width = size.width & !1;
let height = size.height & !1;
let millis = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis());
let id = VIDEO_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let filename = format!("{millis}-{id}.{}", crate::video::video_extension());
let output_path = opts.dir.join(filename);
if let Err(e) = std::fs::create_dir_all(&opts.dir) {
sink.finish_err(crate::FerriError::backend(format!(
"failed to create recordVideo.dir {}: {e}",
opts.dir.display()
)));
return;
}
let page_for_task = page.clone();
tokio::spawn(async move {
let handle = match crate::video::start_recording(&page_for_task, output_path.clone(), width, height, 90).await {
Ok(h) => h,
Err(e) => {
sink.finish_err(crate::FerriError::backend(format!("start_recording: {e}")));
return;
},
};
while !page_for_task.is_closed() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
match handle.stop(&page_for_task).await {
Ok(path) => sink.finish_ok(path),
Err(e) => sink.finish_err(crate::FerriError::backend(format!("stop recording: {e}"))),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[tokio::test]
async fn expose_function_wrapper_discards_source() {
let seen = Arc::new(AtomicUsize::new(0));
let seen_cb = seen.clone();
let inner: crate::events::ExposedFn = Arc::new(move |args: Vec<serde_json::Value>| {
seen_cb.store(args.len(), Ordering::SeqCst);
Box::pin(async move { serde_json::json!(args.iter().filter_map(serde_json::Value::as_i64).sum::<i64>()) })
});
let binding: crate::events::ExposedBinding = Arc::new(move |_source, args| inner(args));
let source = crate::events::BindingSource {
context: "inst:ctx".into(),
page: "frame-1".into(),
frame: "frame-1".into(),
};
let out = binding(source, vec![serde_json::json!(20), serde_json::json!(22)]).await;
assert_eq!(seen.load(Ordering::SeqCst), 2);
assert_eq!(out, serde_json::json!(42));
}
#[tokio::test]
async fn disposable_runs_once() {
let count = Arc::new(AtomicUsize::new(0));
let c = count.clone();
let d = crate::disposable::Disposable::new(move || {
let c = c.clone();
async move {
c.fetch_add(1, Ordering::SeqCst);
Ok(())
}
});
d.dispose().await.expect("dispose");
d.dispose().await.expect("dispose");
assert_eq!(count.load(Ordering::SeqCst), 1);
}
}