obscura serve --user-agent <ua> only logged the value via tracing::info! and never passed it to the CDP server, so every navigation kept using the default Chrome UA string and httpbin.org/user-agent reported the default rather than the override. Plumb the option end-to-end: - BrowserContext::with_full_options(id, proxy, stealth, user_agent) initializes both BrowserContext::user_agent and the underlying ObscuraHttpClient::user_agent RwLock at construction time so the first navigation already sees the override. - CdpContext::new_with_full_options forwards the UA to the default BrowserContext. - obscura_cdp::start_with_full_options accepts the UA and passes it through cdp_processor. - main.rs Serve arm calls start_with_full_options with the parsed UA. - run_multi_worker_serve forwards --user-agent <ua> when spawning worker subprocesses so multi-worker mode honors the flag too. Existing two-arg start_with_options and three-arg with_options stay as thin shims (call the full variant with None) so older callers and the public crate API remain source-compatible. Three new unit tests in obscura-browser cover the propagation, the Chrome default fallback, and the legacy with_options path. Generated by Ora Studio Vibe coded by ousamabenyounes Co-authored-by: Ora Studio <noreply@oratelecom.net>
200 lines
7.0 KiB
Rust
200 lines
7.0 KiB
Rust
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
|
|
use obscura_browser::{BrowserContext, Page};
|
|
use obscura_js::ops::{InterceptResolution, InterceptedRequest};
|
|
use serde_json::json;
|
|
|
|
use crate::domains;
|
|
use crate::domains::fetch::FetchInterceptState;
|
|
use crate::types::{CdpEvent, CdpRequest, CdpResponse};
|
|
|
|
pub struct CdpContext {
|
|
pub pages: Vec<Page>,
|
|
pub sessions: HashMap<String, String>, // session_id -> page_id
|
|
pub pending_events: Vec<CdpEvent>,
|
|
pub default_context: Arc<BrowserContext>,
|
|
page_counter: u32,
|
|
pub preload_scripts: Vec<(String, String)>, // (identifier, source)
|
|
pub preload_counter: u32,
|
|
// World names registered via Page.createIsolatedWorld. After every
|
|
// navigation Obscura clears execution contexts (via
|
|
// Runtime.executionContextsCleared) and must re-emit a
|
|
// Runtime.executionContextCreated for each registered world, otherwise
|
|
// Playwright/Puppeteer hang waiting for their utility world to come
|
|
// back. Stored as plain Strings (not by-page) — for now we only model
|
|
// a single page in CdpContext anyway.
|
|
pub isolated_worlds: Vec<String>,
|
|
pub fetch_intercept: FetchInterceptState,
|
|
pub intercept_tx: Option<tokio::sync::mpsc::UnboundedSender<InterceptedRequest>>,
|
|
}
|
|
|
|
impl CdpContext {
|
|
pub fn new() -> Self {
|
|
Self::new_with_options(None, false)
|
|
}
|
|
|
|
pub fn new_with_proxy(proxy: Option<String>) -> Self {
|
|
Self::new_with_options(proxy, false)
|
|
}
|
|
|
|
pub fn new_with_options(proxy: Option<String>, stealth: bool) -> Self {
|
|
Self::new_with_full_options(proxy, stealth, None)
|
|
}
|
|
|
|
pub fn new_with_full_options(
|
|
proxy: Option<String>,
|
|
stealth: bool,
|
|
user_agent: Option<String>,
|
|
) -> Self {
|
|
let default_context = Arc::new(BrowserContext::with_full_options(
|
|
"default".to_string(),
|
|
proxy,
|
|
stealth,
|
|
user_agent,
|
|
));
|
|
CdpContext {
|
|
pages: Vec::new(),
|
|
sessions: HashMap::new(),
|
|
pending_events: Vec::new(),
|
|
default_context,
|
|
page_counter: 0,
|
|
preload_scripts: Vec::new(),
|
|
preload_counter: 0,
|
|
fetch_intercept: FetchInterceptState::new(),
|
|
intercept_tx: None,
|
|
isolated_worlds: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn create_page(&mut self) -> String {
|
|
self.page_counter += 1;
|
|
let page_id = format!("page-{}", self.page_counter);
|
|
let mut page = Page::new(page_id.clone(), self.default_context.clone());
|
|
page.navigate_blank();
|
|
self.pages.push(page);
|
|
page_id
|
|
}
|
|
|
|
pub fn get_page(&self, id: &str) -> Option<&Page> {
|
|
self.pages.iter().find(|p| p.id == id)
|
|
}
|
|
|
|
pub fn get_page_mut(&mut self, id: &str) -> Option<&mut Page> {
|
|
self.pages.iter_mut().find(|p| p.id == id)
|
|
}
|
|
|
|
pub fn remove_page(&mut self, id: &str) {
|
|
self.pages.retain(|p| p.id != id);
|
|
self.sessions.retain(|_, v| v != id);
|
|
}
|
|
|
|
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
|
|
let page_id = session_id
|
|
.as_ref()
|
|
.and_then(|sid| self.sessions.get(sid))?;
|
|
self.get_page(page_id)
|
|
}
|
|
|
|
pub fn get_session_page_mut(&mut self, session_id: &Option<String>) -> Option<&mut Page> {
|
|
let page_id = session_id
|
|
.as_ref()
|
|
.and_then(|sid| self.sessions.get(sid))
|
|
.cloned()?;
|
|
|
|
let target_has_js = self.pages.iter().any(|p| p.id == page_id && p.has_js());
|
|
|
|
if !target_has_js {
|
|
for page in &mut self.pages {
|
|
if page.id != page_id && page.has_js() {
|
|
page.suspend_js();
|
|
break;
|
|
}
|
|
}
|
|
if let Some(target) = self.pages.iter_mut().find(|p| p.id == page_id) {
|
|
target.resume_js();
|
|
}
|
|
}
|
|
|
|
self.get_page_mut(&page_id)
|
|
}
|
|
}
|
|
|
|
pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
|
|
let (domain, method) = match req.method.split_once('.') {
|
|
Some((d, m)) => (d, m),
|
|
None => {
|
|
return CdpResponse::error(
|
|
req.id,
|
|
-32601,
|
|
format!("Invalid method format: {}", req.method),
|
|
req.session_id.clone(),
|
|
);
|
|
}
|
|
};
|
|
|
|
let result = match domain {
|
|
"Target" => domains::target::handle(method, &req.params, ctx).await,
|
|
"Browser" => domains::browser::handle(method, &req.params).await,
|
|
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Runtime" => domains::runtime::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await,
|
|
"Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await,
|
|
// Accepted but no-op. Puppeteer's FrameManager.initialize calls
|
|
// Audits.enable on connect — refusing it breaks puppeteer.connect()
|
|
// before any user code runs.
|
|
"Emulation" | "Log" | "Performance" | "Security" | "CSS"
|
|
| "ServiceWorker" | "Inspector"
|
|
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
|
|
| "Audits" => {
|
|
Ok(json!({}))
|
|
}
|
|
_ => Err(format!("Unknown domain: {}", domain)),
|
|
};
|
|
|
|
match result {
|
|
Ok(value) => CdpResponse::success(req.id, value, req.session_id.clone()),
|
|
Err(msg) => {
|
|
tracing::warn!("CDP error for {}: {}", req.method, msg);
|
|
CdpResponse::error(req.id, -32601, msg, req.session_id.clone())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::types::CdpRequest;
|
|
|
|
fn req(method: &str) -> CdpRequest {
|
|
CdpRequest {
|
|
id: 1,
|
|
method: method.into(),
|
|
params: json!({}),
|
|
session_id: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn audits_enable_returns_empty_success() {
|
|
let mut ctx = CdpContext::new();
|
|
let resp = dispatch(&req("Audits.enable"), &mut ctx).await;
|
|
assert!(resp.error.is_none(), "Audits.enable should not error: {:?}", resp.error);
|
|
assert_eq!(resp.result, Some(json!({})));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unknown_domain_still_errors() {
|
|
let mut ctx = CdpContext::new();
|
|
let resp = dispatch(&req("DefinitelyNotADomain.enable"), &mut ctx).await;
|
|
let err = resp.error.expect("unknown domain must surface as error");
|
|
assert_eq!(err.code, -32601);
|
|
assert!(err.message.contains("Unknown domain"));
|
|
}
|
|
}
|