diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 6e2c098..1735562 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -984,6 +984,10 @@ impl Page { PageError::NetworkError(e.to_string()) })?; + // Store binary main resources (images, PDFs, octet-stream) base64 so + // Network.getResponseBody returns intact bytes. A UTF-8-lossy text store + // corrupts them (issue #340). Text-like types stay as text. + let main_is_binary = !is_text_like_content_type(response.content_type()); self.record_network_event_with_body( url.as_str(), "GET", @@ -991,7 +995,7 @@ impl Page { response.status, &response.headers, &response.body, - false, + main_is_binary, ); if !response.redirected_from.is_empty() { @@ -1469,6 +1473,23 @@ impl Page { }) } + /// Make the body stored under `from_id` also retrievable under `to_id`. + /// The main navigation resource is stored under its internal request id, but + /// the CDP layer reports it to clients with the navigation's loaderId as the + /// requestId (Chrome's `requestId === loaderId` convention). Without this + /// alias, `Network.getResponseBody(loaderId)` misses and a client navigating + /// straight to an image or other resource cannot read the main-response body + /// (issue #340). + pub fn alias_response_body(&mut self, from_id: &str, to_id: &str) { + if from_id == to_id || self.response_bodies.contains_key(to_id) { + return; + } + if let Some(body) = self.response_bodies.get(from_id).cloned() { + self.response_bodies.insert(to_id.to_string(), body); + self.response_body_order.push_back(to_id.to_string()); + } + } + pub fn clear_response_bodies(&mut self) { self.response_bodies.clear(); self.response_body_order.clear(); @@ -1678,6 +1699,29 @@ impl From for PageError { } } +/// Whether a Content-Type is text-like and can be stored/returned as a UTF-8 +/// string. Everything else (images, PDF, fonts, octet-stream) is binary and must +/// be base64-encoded so Network.getResponseBody returns intact bytes. +fn is_text_like_content_type(content_type: Option<&str>) -> bool { + let ct = match content_type { + Some(c) => c.split(';').next().unwrap_or(c).trim().to_ascii_lowercase(), + // No Content-Type: assume text (matches the HTML-parse default). + None => return true, + }; + if ct.is_empty() { + return true; + } + ct.starts_with("text/") + || ct == "application/json" + || ct == "application/xml" + || ct == "application/xhtml+xml" + || ct == "application/javascript" + || ct == "application/ecmascript" + || ct == "image/svg+xml" + || ct.ends_with("+json") + || ct.ends_with("+xml") +} + fn response_body_entry_limit() -> usize { std::env::var("OBSCURA_NETWORK_BODY_BUFFER_ENTRIES") .ok() diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 62fbafc..dd84b5e 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -41,6 +41,26 @@ pub fn emit_navigation_events( .iter() .position(|ev| ev.resource_type == "Document" && ev.url == page_url); + // The main resource's body is stored under its internal request id, but the + // client sees it as `loader_id` (the requestId we report above). Alias it so + // Network.getResponseBody(loaderId) resolves, which is the only way a client + // navigating straight to an image/PDF/other resource can read the main body + // (issue #340). Also read the real Content-Type so frameNavigated reports the + // actual mime instead of a hardcoded text/html. + let mut nav_mime = "text/html".to_string(); + if let Some(idx) = nav_idx { + let internal_id = &network_events[idx].request_id; + if let Some(ct) = network_events[idx].response_headers.get("content-type") { + // Strip any `; charset=...` parameter; frameNavigated wants the essence. + nav_mime = ct.split(';').next().unwrap_or(ct).trim().to_string(); + } + if internal_id != loader_id { + if let Some(page) = ctx.get_page_mut(page_id) { + page.alias_response_body(internal_id, loader_id); + } + } + } + // Playwright needs `Network.requestWillBeSent` for the main document to // arrive BEFORE `Page.frameNavigated` (issue #190). if let Some(idx) = nav_idx { @@ -56,7 +76,7 @@ pub fn emit_navigation_events( let mut phase1 = vec![ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "init", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextsCleared".into(), params: json!({}), session_id: es.clone() }, - CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": "text/html", "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, + CdpEvent { method: "Page.frameNavigated".into(), params: json!({"frame": {"id": frame_id, "loaderId": loader_id, "url": page_url, "domainAndRegistry": "", "securityOrigin": page_url, "mimeType": nav_mime, "adFrameStatus": {"adFrameType": "none"}}, "type": "Navigation"}), session_id: es.clone() }, CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": 2, "origin": page_url, "name": "", "uniqueId": format!("ctx-nav-{}", page_id), "auxData": {"isDefault": true, "type": "default", "frameId": frame_id}}}), session_id: es.clone() }, ]; let world_names: Vec = if ctx.isolated_worlds.is_empty() { @@ -359,6 +379,9 @@ pub async fn handle( Ok(json!({})) } "setInterceptFileChooserDialog" => Ok(json!({})), + // Obscura does not download files to disk, so there is no behavior to + // configure; ack it so clients that set it do not warn (issue #340). + "setDownloadBehavior" => Ok(json!({})), "getLayoutMetrics" => { // Obscura has no visual layout engine, so we return a fixed // 1280x720 viewport (Chrome's default) and try to derive the