use obscura_browser::lifecycle::WaitUntil; use serde_json::{json, Value}; use crate::dispatch::CdpContext; use crate::types::CdpEvent; use crate::util::url_is_file_scheme; #[cfg(feature = "render")] const DEFAULT_SCREENSHOT_QUALITY: i64 = 80; #[cfg(feature = "render")] #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum ScreenshotFormat { Png, Jpeg, Webp, } #[cfg(feature = "render")] #[derive(Clone, Copy, Debug)] struct ScreenshotClip { x: f64, y: f64, width: f64, height: f64, scale: f64, } #[cfg(feature = "render")] #[derive(Clone, Copy, Debug)] struct ScreenshotOptions { format: ScreenshotFormat, quality: u8, quality_supplied: bool, clip: Option, from_surface: bool, capture_beyond_viewport: bool, optimize_for_speed: bool, } #[cfg(feature = "render")] fn screenshot_bool(params: &Value, name: &str, default: bool) -> Result { match params.get(name) { None => Ok(default), Some(Value::Bool(value)) => Ok(*value), Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")), } } #[cfg(feature = "render")] fn screenshot_number(object: &serde_json::Map, name: &str) -> Result { let value = object .get(name) .ok_or_else(|| format!("Invalid parameters: mandatory clip.{name} field missing"))? .as_f64() .ok_or_else(|| format!("Invalid parameters: clip.{name} must be a number"))?; if !value.is_finite() { return Err(format!("Invalid parameters: clip.{name} must be finite")); } Ok(value) } #[cfg(feature = "render")] fn parse_screenshot_options(params: &Value) -> Result { if !params.is_object() { return Err("Invalid parameters: expected an object".to_string()); } let format = match params.get("format") { None => ScreenshotFormat::Png, Some(Value::String(value)) => match value.as_str() { "png" => ScreenshotFormat::Png, "jpeg" => ScreenshotFormat::Jpeg, "webp" => ScreenshotFormat::Webp, _ => return Err("Invalid image format".to_string()), }, Some(_) => return Err("Invalid parameters: format must be a string".to_string()), }; let quality_supplied = params.get("quality").is_some(); let quality = match params.get("quality") { None => DEFAULT_SCREENSHOT_QUALITY, Some(value) => value .as_i64() .filter(|value| i32::try_from(*value).is_ok()) .ok_or_else(|| "Invalid parameters: quality must be an integer".to_string())?, }; // Chromium accepts an int32 outside [0, 100], but deliberately falls back // to its default quality instead of rejecting the request. let quality = if (0..=100).contains(&quality) { quality } else { DEFAULT_SCREENSHOT_QUALITY } as u8; let clip = match params.get("clip") { None => None, Some(Value::Object(object)) => { let clip = ScreenshotClip { x: screenshot_number(object, "x")?, y: screenshot_number(object, "y")?, width: screenshot_number(object, "width")?, height: screenshot_number(object, "height")?, scale: screenshot_number(object, "scale")?, }; if clip.width == 0.0 { return Err("Cannot take screenshot with 0 width.".to_string()); } if clip.height == 0.0 { return Err("Cannot take screenshot with 0 height.".to_string()); } // Chromium's current handler only checks zero, but negative sizes // and scales enter invalid gfx sizes and may stall the request. // Fail deterministically instead of risking an allocation or hang. if clip.width < 0.0 { return Err("Cannot take screenshot with negative width.".to_string()); } if clip.height < 0.0 { return Err("Cannot take screenshot with negative height.".to_string()); } if clip.scale <= 0.0 { return Err("Cannot take screenshot with non-positive scale.".to_string()); } Some(clip) } Some(_) => return Err("Invalid parameters: clip must be an object".to_string()), }; Ok(ScreenshotOptions { format, quality, quality_supplied, clip, from_surface: screenshot_bool(params, "fromSurface", true)?, capture_beyond_viewport: screenshot_bool(params, "captureBeyondViewport", false)?, optimize_for_speed: screenshot_bool(params, "optimizeForSpeed", false)?, }) } #[cfg(feature = "render")] fn screenshot_output_size(clip: ScreenshotClip) -> Result<(u32, u32), String> { // Chromium converts the CSS clip size to an integer gfx::Size before // applying scale to the requested output bitmap. let width = (clip.width.trunc() * clip.scale).round(); let height = (clip.height.trunc() * clip.scale).round(); const MAX_DIMENSION: f64 = 128.0 * 1024.0; if width <= 0.0 || height <= 0.0 { return Err("Screenshot clip is too small at the requested scale.".to_string()); } if width >= MAX_DIMENSION || height >= MAX_DIMENSION { return Err("Page is too large.".to_string()); } let pixels = width * height; // Four RGBA bytes per pixel keeps this guard below a 256 MiB surface. if !pixels.is_finite() || pixels > 64.0 * 1024.0 * 1024.0 { return Err("Screenshot bitmap is too large.".to_string()); } Ok((width as u32, height as u32)) } #[cfg(feature = "render")] fn sample_screenshot_region( source: &image::RgbaImage, clip: ScreenshotClip, relative_x: f64, relative_y: f64, ) -> Result { let (output_width, output_height) = screenshot_output_size(clip)?; let mut output = image::RgbaImage::new(output_width, output_height); let source_width = source.width() as i64; let source_height = source.height() as i64; // Sampling pixel centers gives exact copies for integral, scale=1 clips // while also handling fractional clip origins and non-integral scales. for output_y in 0..output_height { let sample_y = relative_y + (f64::from(output_y) + 0.5) * clip.height / f64::from(output_height) - 0.5; let y0 = sample_y.floor() as i64; let y1 = y0 + 1; let fy = sample_y - y0 as f64; for output_x in 0..output_width { let sample_x = relative_x + (f64::from(output_x) + 0.5) * clip.width / f64::from(output_width) - 0.5; let x0 = sample_x.floor() as i64; let x1 = x0 + 1; let fx = sample_x - x0 as f64; let pixel = |x: i64, y: i64| { source.get_pixel( x.clamp(0, source_width - 1) as u32, y.clamp(0, source_height - 1) as u32, ) }; let p00 = pixel(x0, y0); let p10 = pixel(x1, y0); let p01 = pixel(x0, y1); let p11 = pixel(x1, y1); let mut rgba = [0u8; 4]; for channel in 0..4 { let top = f64::from(p00[channel]) * (1.0 - fx) + f64::from(p10[channel]) * fx; let bottom = f64::from(p01[channel]) * (1.0 - fx) + f64::from(p11[channel]) * fx; rgba[channel] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8; } output.put_pixel(output_x, output_y, image::Rgba(rgba)); } } Ok(output) } #[cfg(feature = "render")] fn encode_screenshot( image: &image::RgbaImage, options: ScreenshotOptions, ) -> Result, String> { use image::ImageEncoder as _; let mut output = Vec::new(); match options.format { ScreenshotFormat::Png => { let encoder = if options.optimize_for_speed { image::codecs::png::PngEncoder::new_with_quality( &mut output, image::codecs::png::CompressionType::Fast, image::codecs::png::FilterType::NoFilter, ) } else { image::codecs::png::PngEncoder::new(&mut output) }; encoder .write_image( image.as_raw(), image.width(), image.height(), image::ExtendedColorType::Rgba8, ) .map_err(|error| format!("PNG screenshot encoding failed: {error}"))?; } ScreenshotFormat::Jpeg => { let rgb = image::DynamicImage::ImageRgba8(image.clone()).to_rgb8(); // image's pure-Rust JPEG encoder defines its quality range as // 1..=100; Chromium permits zero, whose effective result is the // lowest-quality encoding. let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( &mut output, options.quality.max(1), ); encoder .write_image( rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8, ) .map_err(|error| format!("JPEG screenshot encoding failed: {error}"))?; } ScreenshotFormat::Webp => { if options.quality_supplied { return Err( "WebP screenshot quality is not supported by the current lossless encoder" .to_string(), ); } image::codecs::webp::WebPEncoder::new_lossless(&mut output) .write_image( image.as_raw(), image.width(), image.height(), image::ExtendedColorType::Rgba8, ) .map_err(|error| format!("WebP screenshot encoding failed: {error}"))?; } } Ok(output) } /// Emit the post-navigation event stream into `ctx.pending_events`. Shared /// by both the in-process `do_navigate` path and the spawned path in /// `server::process_navigation`, so the recent goto-returns-Response / /// per-isolated-world fixes don't have to be duplicated. pub fn emit_navigation_events( ctx: &mut CdpContext, session_id: &Option, frame_id: &str, loader_id: &str, page_url: &str, page_id: &str, network_events: &[obscura_browser::NetworkEvent], wait_until: WaitUntil, reached_network_idle: bool, ) { let es = session_id.clone(); let ts = timestamp(); // Real Chrome uses the navigation's loaderId as the main document's // request id, and Puppeteer/Playwright identify the navigation response // via `requestId === loaderId && type === "Document"` (issue #189). let nav_request_ids: Vec = { let mut nav_seen = false; network_events.iter().map(|ev| { if !nav_seen && ev.resource_type == "Document" && ev.url == page_url { nav_seen = true; loader_id.to_string() } else { ev.request_id.clone() } }).collect() }; let nav_idx: Option = network_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 { let net_event = &network_events[idx]; let rid = &nav_request_ids[idx]; ctx.pending_events.push(CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone(), }); } // executionContextsCleared invalidates every prior context id, so a // Runtime.evaluate / callFunctionOn targeting a pre-navigation context // must be rejected (Chrome: "Cannot find context with specified id"). The // default world (id 2) and isolated worlds are re-registered below as their // executionContextCreated events are emitted. Issue #407: previously this // set was insert-only, so stale ids kept validating and grew unbounded. ctx.valid_context_ids.clear(); 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": 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() }, ]; // The default world is re-created as context id 2; re-register it. Isolated // worlds register themselves via next_isolated_context in the loop below. ctx.valid_context_ids.insert(2); let world_names: Vec = if ctx.isolated_worlds.is_empty() { vec!["__puppeteer_utility_world__24.40.0".to_string()] } else { ctx.isolated_worlds.clone() }; // Issue #192: fresh, monotonically increasing executionContextId per re-create. for world_name in &world_names { let world_ctx_id = ctx.next_isolated_context(); phase1.push(CdpEvent { method: "Runtime.executionContextCreated".into(), params: json!({"context": {"id": world_ctx_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-nav-{}-{}", page_id, world_ctx_id), "auxData": {"isDefault": false, "type": "isolated", "frameId": frame_id}}}), session_id: es.clone(), }); } phase1.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "commit", "timestamp": ts}), session_id: es.clone() }); ctx.pending_events.extend(phase1); if ctx.fetch_intercept.enabled { for (i, net_event) in network_events.iter().enumerate() { let rid = &nav_request_ids[i]; ctx.pending_events.push(CdpEvent { method: "Fetch.requestPaused".into(), params: json!({ "requestId": rid, "request": { "url": net_event.url, "method": net_event.method, "headers": net_event.headers, }, "frameId": frame_id, "resourceType": net_event.resource_type, "networkId": rid, }), session_id: es.clone(), }); } } for (i, net_event) in network_events.iter().enumerate() { let rid = &nav_request_ids[i]; if Some(i) != nav_idx { ctx.pending_events.push(CdpEvent { method: "Network.requestWillBeSent".into(), params: json!({"requestId": rid, "loaderId": loader_id, "documentURL": page_url, "request": {"url": net_event.url, "method": net_event.method, "headers": net_event.headers}, "timestamp": net_event.timestamp, "wallTime": net_event.timestamp, "initiator": {"type": "other"}, "type": net_event.resource_type, "frameId": frame_id}), session_id: es.clone(), }); } ctx.pending_events.push(CdpEvent { method: "Network.responseReceived".into(), params: json!({"requestId": rid, "loaderId": loader_id, "timestamp": net_event.timestamp, "type": net_event.resource_type, "response": {"url": net_event.url, "status": net_event.status, "statusText": "", "headers": &*net_event.response_headers, "mimeType": net_event.response_headers.get("content-type").cloned().unwrap_or_default()}, "frameId": frame_id}), session_id: es.clone(), }); ctx.pending_events.push(CdpEvent { method: "Network.loadingFinished".into(), params: json!({"requestId": rid, "timestamp": net_event.timestamp, "encodedDataLength": net_event.body_size}), session_id: es.clone(), }); } let mut phase3 = vec![ CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "DOMContentLoaded", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Page.domContentEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "load", "timestamp": ts}), session_id: es.clone() }, CdpEvent { method: "Page.loadEventFired".into(), params: json!({"timestamp": ts}), session_id: es.clone() }, ]; if reached_network_idle || matches!(wait_until, WaitUntil::Load | WaitUntil::DomContentLoaded) { let idle_ts = timestamp(); phase3.push(CdpEvent { method: "Page.lifecycleEvent".into(), params: json!({"frameId": frame_id, "loaderId": loader_id, "name": "networkIdle", "timestamp": idle_ts}), session_id: es.clone() }); } phase3.push(CdpEvent { method: "Page.frameStoppedLoading".into(), params: json!({"frameId": frame_id}), session_id: es }); ctx.pending_events.extend(phase3); // Target.targetInfoChanged: strict CDP clients (browser-use, and // Puppeteer/Playwright `page.url()` tracking) cache the TargetInfo from // attachedToTarget and only refresh it on this event. Without it they keep // reporting the pre-navigation url/title (about:blank) and never see the // loaded page. Emit it browser-level (no sessionId) with the new url/title. let (tic_title, tic_ctx) = ctx .get_page(page_id) .map(|p| (p.title.clone(), p.context.id.clone())) .unwrap_or_default(); ctx.pending_events.push(CdpEvent::new( "Target.targetInfoChanged", json!({ "targetInfo": { "targetId": page_id, "type": "page", "title": tic_title, "url": page_url, "attached": true, "canAccessOpener": false, "browserContextId": tic_ctx, } }), )); } /// Parse the `waitUntil` argument that Puppeteer/Playwright pass on /// `Page.navigate`. pub fn parse_wait_until(params: &Value) -> WaitUntil { params .get("waitUntil") .and_then(|v| { if let Some(s) = v.as_str() { Some(WaitUntil::from_str(s)) } else if let Some(arr) = v.as_array() { arr.iter() .filter_map(|item| item.as_str()) .map(WaitUntil::from_str) .max_by_key(|w| match w { WaitUntil::DomContentLoaded => 0, WaitUntil::Load => 1, WaitUntil::NetworkIdle2 => 2, WaitUntil::NetworkIdle0 => 3, }) } else { None } }) // Puppeteer and Playwright drive navigation via `Page.navigate` // without a server-side waitUntil — they wait for `Page.lifecycleEvent` // on the client side. Defaulting the server to `Load` means we run // every parser/deferred/async script on JS-heavy pages before // emitting `load`, which on sites like github.com / reddit.com // pushes nav past 25s and clients time out at 15s. Real Chrome // streams `DOMContentLoaded` as soon as the parser is done; we // batch our event emission at the end of navigation, so the // closest we can get is to default to `DomContentLoaded` and skip // the full-load wait. CLI callers that pass `--wait-until load` // (or `networkidle*`) are unaffected; they get the old behaviour. .unwrap_or(WaitUntil::DomContentLoaded) } async fn do_navigate( url: &str, params: &Value, ctx: &mut CdpContext, session_id: &Option, ) -> Result { let wait_until = parse_wait_until(params); // Block CDP-initiated file:// navigation by default. // Anyone who can reach the CDP port (default localhost, // but Docker images bind 0.0.0.0) could otherwise read // any file the obscura process can read. Opt in via // `obscura serve --allow-file-access` when local-HTML // testing is the intended workflow. let allow_file_access = ctx .get_session_page(session_id) .map(|page| page.context.allow_file_access) .unwrap_or(ctx.default_context.allow_file_access); if url_is_file_scheme(url) && !allow_file_access { return Err( "Page.navigate to file:// is disabled. Restart with `obscura serve --allow-file-access` to enable.".to_string() ); } let preload_scripts: Vec = ctx.preload_scripts.iter().map(|(_, s)| s.clone()).collect(); let (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) = { let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; let frame_id = page.frame_id.clone(); let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); // Preloads (addBinding shims, addScriptToEvaluateOnNewDocument sources) // must run BEFORE the page's own scripts (CDP contract). Hand them to // the page so navigate_single can inject them at the right point. page.set_preload_scripts(preload_scripts); let nav_method = params.get("__method").and_then(|v| v.as_str()).unwrap_or("GET"); let nav_body = params.get("__body").and_then(|v| v.as_str()).unwrap_or(""); if nav_method == "POST" && !nav_body.is_empty() { page.navigate_with_wait_post(url, wait_until, nav_method, nav_body).await.map_err(|e| e.to_string())?; } else { page.navigate_with_wait(url, wait_until).await.map_err(|e| e.to_string())?; } let reached_network_idle = page.lifecycle.is_network_idle(); // Fold in script-initiated requests (fetch/XHR/dynamic resource) so they // emit as Network events alongside static subresources (#406). page.sync_js_network_events(); let network_events: Vec<_> = page.network_events.drain(..).collect(); let page_url = page.url_string(); let page_id = page.id.clone(); (frame_id, loader_id, network_events, page_url, page_id, reached_network_idle) }; emit_navigation_events( ctx, session_id, &frame_id, &loader_id, &page_url, &page_id, &network_events, wait_until, reached_network_idle, ); Ok(json!({ "frameId": frame_id, "loaderId": loader_id, })) } pub async fn handle( method: &str, params: &Value, ctx: &mut CdpContext, session_id: &Option, ) -> Result { match method { "enable" => Ok(json!({})), "navigate" => { let url = params.get("url").and_then(|v| v.as_str()) .ok_or("url required")?; do_navigate(url, params, ctx, session_id).await } "reload" => { let current_url = ctx.get_session_page(session_id) .map(|p| p.url_string()) .unwrap_or_else(|| "about:blank".to_string()); let reload_params = json!({ "waitUntil": params.get("waitUntil").cloned().unwrap_or(json!("load")) }); do_navigate(¤t_url, &reload_params, ctx, session_id).await } "getFrameTree" => { let page = ctx.get_session_page(session_id).ok_or("No page for session")?; Ok(json!({ "frameTree": { "frame": { "id": page.frame_id, "loaderId": "initial-loader", "url": page.url_string(), "domainAndRegistry": "", "securityOrigin": page.url_string(), "mimeType": "text/html", "adFrameStatus": { "adFrameType": "none" }, }, "childFrames": [], } })) } "createIsolatedWorld" => { let (frame_id_param, world_name, page_url, page_id) = { let page = ctx.get_session_page(session_id).ok_or("No page for session")?; ( params.get("frameId").and_then(|v| v.as_str()) .unwrap_or(&page.frame_id).to_string(), params.get("worldName").and_then(|v| v.as_str()) .unwrap_or("").to_string(), page.url_string(), page.id.clone(), ) }; // Track this world so Page.navigate can re-emit a context for it // post-navigation. Without this, Playwright (and Puppeteer) // hang in any operation that uses the utility world — including // page.title() — because their utility world is gone after // Runtime.executionContextsCleared and never re-created. if !world_name.is_empty() && !ctx.isolated_worlds.contains(&world_name) { ctx.isolated_worlds.push(world_name.clone()); } // Issue #192: every isolated world emission gets a fresh id from // the monotonic counter and is registered as a valid contextId. // Reusing id 100 across navigations made Playwright's bookkeeping // diverge (it expected 101 on the second nav) and Runtime.evaluate // failed with "Cannot find context with specified id: 101". let context_id = ctx.next_isolated_context(); ctx.pending_events.push(CdpEvent { method: "Runtime.executionContextCreated".to_string(), params: json!({ "context": { "id": context_id, "origin": page_url, "name": world_name, "uniqueId": format!("ctx-isolated-{}-{}", page_id, context_id), "auxData": { "isDefault": false, "type": "isolated", "frameId": frame_id_param, } } }), session_id: session_id.clone(), }); Ok(json!({ "executionContextId": context_id })) } "setLifecycleEventsEnabled" => Ok(json!({})), "addScriptToEvaluateOnNewDocument" => { let source = params.get("source").and_then(|v| v.as_str()).unwrap_or(""); ctx.preload_counter += 1; let identifier = format!("{}", ctx.preload_counter); if !source.is_empty() { ctx.preload_scripts.push((identifier.clone(), source.to_string())); } Ok(json!({ "identifier": identifier })) } "removeScriptToEvaluateOnNewDocument" => { let identifier = params.get("identifier").and_then(|v| v.as_str()).unwrap_or(""); ctx.preload_scripts.retain(|(id, _)| id != identifier); 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" => { // Playwright calls this before every page.screenshot(). Report the // same live CSS viewport that responsive page code and paint use. let (width, height) = ctx .get_session_page(session_id) .map(|page| (page.viewport.0 as f64, page.viewport.1 as f64)) .unwrap_or((1280.0, 720.0)); let mut page_x = 0.0; let mut page_y = 0.0; let mut content_width = width; let mut content_height = height; if let Some(values) = ctx .get_session_page_mut(session_id) .map(|page| { page.evaluate( "[window.scrollX, window.scrollY, \ document.documentElement && document.documentElement.scrollWidth, \ document.documentElement && document.documentElement.scrollHeight]", ) }) .and_then(|value| value.as_array().cloned()) { page_x = values.first().and_then(Value::as_f64).unwrap_or(0.0); page_y = values.get(1).and_then(Value::as_f64).unwrap_or(0.0); content_width = values .get(2) .and_then(Value::as_f64) .filter(|value| *value > 0.0) .unwrap_or(width); content_height = values .get(3) .and_then(Value::as_f64) .filter(|value| *value > 0.0) .unwrap_or(height); } let layout_viewport = json!({ "pageX": page_x, "pageY": page_y, "clientWidth": width, "clientHeight": height, }); let visual_viewport = json!({ "offsetX": 0.0, "offsetY": 0.0, "pageX": page_x, "pageY": page_y, "clientWidth": width, "clientHeight": height, "scale": 1.0, "zoom": 1.0, }); let content_size = json!({ "x": 0.0, "y": 0.0, "width": content_width, "height": content_height, }); Ok(json!({ "layoutViewport": layout_viewport, "visualViewport": visual_viewport, "contentSize": content_size, "cssLayoutViewport": layout_viewport, "cssVisualViewport": visual_viewport, "cssContentSize": content_size, })) } "getNavigationHistory" => { let page = ctx.get_session_page(session_id).ok_or("No page for session")?; // Synthesize an entry for the current page when history is empty // (initial about:blank, never-navigated targets). Puppeteer's // goBack reads `currentIndex` and `entries[currentIndex-1]`; // an empty entries[] used to make every back/forward fail. let entries: Vec = if page.history.is_empty() { vec![json!({ "id": 0, "url": page.url_string(), "userTypedURL": page.url_string(), "title": page.title, "transitionType": "typed", })] } else { page.history.iter().enumerate().map(|(i, url)| json!({ "id": i as u64, "url": url, "userTypedURL": url, "title": if i == page.history_index { page.title.clone() } else { String::new() }, "transitionType": "typed", })).collect() }; Ok(json!({ "currentIndex": if page.history.is_empty() { 0 } else { page.history_index }, "entries": entries, })) } "navigateToHistoryEntry" => { let entry_id = params.get("entryId").and_then(|v| v.as_u64()).unwrap_or(0) as usize; let target_url = { let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; let url = page.history.get(entry_id).cloned(); if url.is_some() { page.set_history_index(entry_id); } url }; if let Some(url) = target_url { // Stash + restore history so push_history doesn't clobber // the cursor we just moved. let stash = { let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; (page.history.clone(), page.history_index) }; let (frame_id, page_id, network_events, page_url, reached_idle) = { let page = ctx.get_session_page_mut(session_id).ok_or("No page for session")?; page.navigate_with_wait(&url, WaitUntil::DomContentLoaded).await.map_err(|e| e.to_string())?; page.history = stash.0; page.history_index = stash.1; ( page.frame_id.clone(), page.id.clone(), page.network_events.drain(..).collect::>(), page.url_string(), page.lifecycle.is_network_idle(), ) }; let loader_id = format!("loader-{}", uuid::Uuid::new_v4()); emit_navigation_events( ctx, session_id, &frame_id, &loader_id, &page_url, &page_id, &network_events, WaitUntil::DomContentLoaded, reached_idle, ); } Ok(json!({})) } "resetNavigationHistory" => { if let Some(page) = ctx.get_session_page_mut(session_id) { page.history.clear(); page.history_index = 0; } Ok(json!({})) } "printToPDF" => { // Obscura has no layout/rendering engine, so PDF generation is // intentionally not implemented. Returning a distinct, descriptive // error (rather than the generic "Unknown Page method" fallback) // tells Playwright/Puppeteer/headless_chrome clients exactly why // the call failed and what to do instead. Err( "Page.printToPDF is not supported by Obscura: no layout engine. \ Use Runtime.evaluate (e.g. page.evaluate) to extract the rendered \ HTML, then render to PDF in your client (wkhtmltopdf, weasyprint, \ a separate headless Chromium pipeline, etc.)." .to_string(), ) } "captureScreenshot" => { #[cfg(feature = "render")] { let options = parse_screenshot_options(params)?; if !options.from_surface { return Err( "Page.captureScreenshot fromSurface=false is not supported: Obscura has no separate browser-window compositor surface" .to_string(), ); } if options.capture_beyond_viewport && options.clip.is_none() { return Err( "Page.captureScreenshot full-page captureBeyondViewport=true is not supported without a clip: Obscura cannot yet record an off-viewport surface without relayout or scripted scrolling" .to_string(), ); } if options.format == ScreenshotFormat::Webp && options.quality_supplied { return Err( "WebP screenshot quality is not supported by the current lossless encoder" .to_string(), ); } let page = ctx .get_session_page_mut(session_id) .ok_or("No page for session")?; let viewport = page.viewport; let scroll = if options.clip.is_some() { page.evaluate("[window.scrollX, window.scrollY]") .as_array() .map(|values| { ( values.first().and_then(Value::as_f64).unwrap_or(0.0), values.get(1).and_then(Value::as_f64).unwrap_or(0.0), ) }) .unwrap_or((0.0, 0.0)) } else { (0.0, 0.0) }; let png = page.screenshot(viewport).ok_or_else(|| { "Page.captureScreenshot failed: the page has no DOM to render".to_string() })?; // Keep the common path allocation-free and byte-for-byte // compatible with the renderer's native PNG encoder. let encoded = if options.format == ScreenshotFormat::Png && options.clip.is_none() && !options.optimize_for_speed { png } else { let source = image::load_from_memory_with_format(&png, image::ImageFormat::Png) .map_err(|error| { format!("Page.captureScreenshot could not decode renderer PNG: {error}") })? .to_rgba8(); let raster = if let Some(clip) = options.clip { let relative_x = clip.x - scroll.0; let relative_y = clip.y - scroll.1; let epsilon = 1e-6; if relative_x < -epsilon || relative_y < -epsilon || relative_x + clip.width > f64::from(source.width()) + epsilon || relative_y + clip.height > f64::from(source.height()) + epsilon { return Err(format!( "Page.captureScreenshot clip lies outside the current viewport surface; Obscura cannot yet record off-viewport pixels even with captureBeyondViewport=true (visible page rect: x={} y={} width={} height={})", scroll.0, scroll.1, viewport.0, viewport.1 )); } sample_screenshot_region( &source, clip, relative_x.max(0.0), relative_y.max(0.0), )? } else { source }; encode_screenshot(&raster, options)? }; use base64::Engine as _; let data = base64::engine::general_purpose::STANDARD.encode(encoded); Ok(json!({ "data": data })) } #[cfg(not(feature = "render"))] Err( "Page.captureScreenshot requires a build with the render feature" .to_string(), ) } "captureSnapshot" => { // A DOM/layer-tree snapshot (not a raster image). Distinct from // captureScreenshot; keep the clear error so clients fail fast. Err(format!( "Page.{method} is not supported by Obscura: no layout or paint engine. \ For visual snapshots, drive a real headless Chromium for the \ screenshot leg of your pipeline and use Obscura for the scraping leg." )) } _ => Err(format!("Unknown Page method: {}", method)), } } fn timestamp() -> f64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs_f64() } #[cfg(test)] mod tests { use super::*; use crate::dispatch::CdpContext; #[tokio::test] async fn get_layout_metrics_returns_chrome_default_viewport() { let mut ctx = CdpContext::new(); let result = handle("getLayoutMetrics", &json!({}), &mut ctx, &None) .await .expect("getLayoutMetrics should succeed without a session"); // CDP spec requires three top-level shapes; Playwright's screenshot // path reads contentSize.width/height to size the capture. Without // them the screenshot call panics with "cannot read property of // undefined". for key in [ "layoutViewport", "visualViewport", "contentSize", "cssLayoutViewport", "cssVisualViewport", "cssContentSize", ] { assert!(result.get(key).is_some(), "missing key: {key}"); } let layout = &result["layoutViewport"]; assert_eq!(layout["clientWidth"].as_f64(), Some(1280.0)); assert_eq!(layout["clientHeight"].as_f64(), Some(720.0)); let visual = &result["visualViewport"]; assert_eq!(visual["scale"].as_f64(), Some(1.0)); assert_eq!(visual["clientWidth"].as_f64(), Some(1280.0)); let content = &result["contentSize"]; assert_eq!(content["width"].as_f64(), Some(1280.0)); // Without a live page the content height falls back to the viewport. assert_eq!(content["height"].as_f64(), Some(720.0)); } #[cfg(feature = "render")] #[tokio::test] async fn cdp_metrics_and_capture_follow_the_scrolled_viewport() { let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session_id = format!("{page_id}-session"); ctx.sessions.insert(session_id.clone(), page_id); let session = Some(session_id); ctx.get_session_page_mut(&session) .expect("page") .set_viewport((100.0, 80.0)); handle( "navigate", &json!({ "url": "data:text/html,
", "waitUntil": "load", }), &mut ctx, &session, ) .await .expect("navigate"); let top = handle("captureScreenshot", &json!({}), &mut ctx, &session) .await .expect("top capture"); ctx.get_session_page_mut(&session) .expect("page") .evaluate("return (window.scrollTo(0, 80), window.scrollY)"); let metrics = handle("getLayoutMetrics", &json!({}), &mut ctx, &session) .await .expect("metrics"); assert_eq!(metrics["layoutViewport"]["pageY"].as_f64(), Some(80.0)); assert_eq!(metrics["visualViewport"]["pageY"].as_f64(), Some(80.0)); assert_eq!(metrics["contentSize"]["width"].as_f64(), Some(100.0)); assert!( metrics["contentSize"]["height"].as_f64().unwrap_or_default() >= 160.0, "contentSize must expose the scrollable document: {metrics}" ); let scrolled = handle("captureScreenshot", &json!({}), &mut ctx, &session) .await .expect("scrolled capture"); let top_data = top["data"].as_str().expect("top png"); let scrolled_data = scrolled["data"].as_str().expect("scrolled png"); assert_ne!( top_data, scrolled_data, "CDP captureScreenshot must paint the current scroll offset" ); } #[cfg(feature = "render")] fn decode_capture(result: &Value) -> (image::ImageFormat, image::RgbaImage) { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD .decode(result["data"].as_str().expect("screenshot data")) .expect("base64 screenshot"); let format = image::guess_format(&bytes).expect("image format"); let raster = image::load_from_memory(&bytes) .expect("decodable screenshot") .to_rgba8(); (format, raster) } #[cfg(feature = "render")] async fn screenshot_fixture() -> (CdpContext, Option) { let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session_id = format!("{page_id}-session"); ctx.sessions.insert(session_id.clone(), page_id); let session = Some(session_id); ctx.get_session_page_mut(&session) .expect("page") .set_viewport((100.0, 80.0)); handle( "navigate", &json!({ "url": "data:text/html,
", "waitUntil": "load", }), &mut ctx, &session, ) .await .expect("navigate screenshot fixture"); (ctx, session) } #[cfg(feature = "render")] #[tokio::test] async fn capture_screenshot_preserves_default_png_and_honors_clip_scale() { let (mut ctx, session) = screenshot_fixture().await; let native = { let page = ctx.get_session_page(&session).expect("page"); page.screenshot(page.viewport).expect("native png") }; let default = handle("captureScreenshot", &json!({}), &mut ctx, &session) .await .expect("default screenshot"); use base64::Engine as _; let default_bytes = base64::engine::general_purpose::STANDARD .decode(default["data"].as_str().expect("default data")) .expect("default base64"); assert_eq!( default_bytes, native, "default CDP path must preserve the renderer PNG bytes exactly" ); let clipped = handle( "captureScreenshot", &json!({ "captureBeyondViewport": true, "clip": {"x": 50.0, "y": 0.0, "width": 50.0, "height": 40.0, "scale": 2.0} }), &mut ctx, &session, ) .await .expect("clipped screenshot"); let (format, raster) = decode_capture(&clipped); assert_eq!(format, image::ImageFormat::Png); assert_eq!(raster.dimensions(), (100, 80)); let center = raster.get_pixel(50, 40).0; assert!( center[2] > 200 && center[0] < 50, "clip x/y must select the blue half of the live surface: {center:?}" ); ctx.get_session_page_mut(&session) .expect("page") .evaluate("window.scrollTo(0, 80)"); let page_coordinate_clip = handle( "captureScreenshot", &json!({ "clip": {"x": 0.0, "y": 80.0, "width": 20.0, "height": 20.0, "scale": 1.0} }), &mut ctx, &session, ) .await .expect("clip in page coordinates after scroll"); let (_, raster) = decode_capture(&page_coordinate_clip); let center = raster.get_pixel(10, 10).0; assert!( center[1] > 80 && center[0] < 50 && center[2] < 50, "clip coordinates must remain page-relative after scrolling: {center:?}" ); } #[cfg(feature = "render")] #[tokio::test] async fn capture_screenshot_encodes_jpeg_and_lossless_webp() { let (mut ctx, session) = screenshot_fixture().await; let jpeg = handle( "captureScreenshot", &json!({"format": "jpeg", "quality": 35}), &mut ctx, &session, ) .await .expect("jpeg screenshot"); let (format, raster) = decode_capture(&jpeg); assert_eq!(format, image::ImageFormat::Jpeg); assert_eq!(raster.dimensions(), (100, 80)); let fast_png = handle( "captureScreenshot", &json!({"optimizeForSpeed": true}), &mut ctx, &session, ) .await .expect("fast PNG screenshot"); let (format, raster) = decode_capture(&fast_png); assert_eq!(format, image::ImageFormat::Png); assert_eq!(raster.dimensions(), (100, 80)); let webp = handle( "captureScreenshot", &json!({"format": "webp"}), &mut ctx, &session, ) .await .expect("lossless webp screenshot"); let (format, raster) = decode_capture(&webp); assert_eq!(format, image::ImageFormat::WebP); assert_eq!(raster.dimensions(), (100, 80)); let error = handle( "captureScreenshot", &json!({"format": "webp", "quality": 35}), &mut ctx, &session, ) .await .expect_err("lossy WebP must not be silently faked"); assert!(error.contains("lossless encoder"), "{error}"); } #[cfg(feature = "render")] #[test] fn capture_screenshot_validates_like_chromium() { let options = parse_screenshot_options(&json!({ "format": "jpeg", "quality": -1, })) .expect("out-of-range integer quality falls back"); assert_eq!(options.quality, 80); let options = parse_screenshot_options(&json!({ "format": "jpeg", "quality": 101, })) .expect("out-of-range integer quality falls back"); assert_eq!(options.quality, 80); assert_eq!( parse_screenshot_options(&json!({"format": "gif"})).expect_err("bad format"), "Invalid image format" ); assert!(parse_screenshot_options(&json!({"format": 3})) .expect_err("format type") .contains("format must be a string")); assert!(parse_screenshot_options(&json!({"quality": 50.5})) .expect_err("quality type") .contains("quality must be an integer")); assert!(parse_screenshot_options(&json!({"fromSurface": "false"})) .expect_err("fromSurface type") .contains("fromSurface must be a boolean")); assert!(parse_screenshot_options(&json!({"captureBeyondViewport": 1})) .expect_err("captureBeyondViewport type") .contains("captureBeyondViewport must be a boolean")); assert_eq!( parse_screenshot_options(&json!({ "clip": {"x": 0, "y": 0, "width": 0, "height": 10, "scale": 1} })) .expect_err("zero width"), "Cannot take screenshot with 0 width." ); assert!(parse_screenshot_options(&json!({ "clip": {"x": 0, "y": 0, "width": 10, "height": 10} })) .expect_err("missing scale") .contains("mandatory clip.scale field missing")); } #[cfg(feature = "render")] #[tokio::test] async fn capture_screenshot_rejects_unrepresented_surfaces() { let (mut ctx, session) = screenshot_fixture().await; let beyond = handle( "captureScreenshot", &json!({"captureBeyondViewport": true}), &mut ctx, &session, ) .await .expect_err("full-page recording is unsupported"); assert!(beyond.contains("captureBeyondViewport=true"), "{beyond}"); let off_surface = handle( "captureScreenshot", &json!({"fromSurface": false}), &mut ctx, &session, ) .await .expect_err("browser-window compositor is unsupported"); assert!(off_surface.contains("fromSurface=false"), "{off_surface}"); let off_viewport = handle( "captureScreenshot", &json!({ "clip": {"x": 90, "y": 0, "width": 20, "height": 20, "scale": 1} }), &mut ctx, &session, ) .await .expect_err("partial off-viewport clip is unsupported"); assert!( off_viewport.contains("outside the current viewport surface"), "{off_viewport}" ); } #[tokio::test] async fn unknown_page_method_still_errors() { let mut ctx = CdpContext::new(); let err = handle("notARealMethod", &json!({}), &mut ctx, &None) .await .expect_err("unknown methods must surface as errors"); assert!(err.contains("Unknown Page method")); } #[tokio::test] async fn print_to_pdf_returns_descriptive_unsupported_error() { // Regression for #53: Page.printToPDF must be handled explicitly so // Playwright clients receive a descriptive error rather than the // generic "Unknown Page method" fallback. let mut ctx = CdpContext::new(); let err = handle("printToPDF", &json!({}), &mut ctx, &None) .await .expect_err("printToPDF must error until a real renderer exists"); assert!( !err.contains("Unknown Page method"), "printToPDF must NOT fall through to the catch-all: {err}" ); assert!( err.contains("not supported by Obscura"), "error must clearly state PDF is unsupported: {err}" ); // Direct user to a workaround so the message is actionable. assert!( err.to_lowercase().contains("evaluate") || err.to_lowercase().contains("html"), "error must point to a workaround: {err}" ); } /// Regression for #45: same idea as printToPDF for captureScreenshot. /// Playwright's `page.screenshot()` calls Page.captureScreenshot via CDP; /// without an explicit arm, clients see "Unknown Page method" and have /// no idea why their screenshot request failed. #[tokio::test] async fn capture_screenshot_returns_descriptive_unsupported_error() { let mut ctx = CdpContext::new(); let err = handle("captureScreenshot", &json!({}), &mut ctx, &None) .await .expect_err("captureScreenshot must error until a real paint exists"); assert!( !err.contains("Unknown Page method"), "captureScreenshot must NOT fall through to the catch-all: {err}" ); #[cfg(not(feature = "render"))] assert!( err.contains("requires a build with the render feature"), "error must clearly state screenshot needs the render feature: {err}" ); // Same for the MHTML snapshot sibling method. let err2 = handle("captureSnapshot", &json!({}), &mut ctx, &None) .await .expect_err("captureSnapshot must error until a real renderer exists"); assert!( !err2.contains("Unknown Page method"), "captureSnapshot must NOT fall through: {err2}" ); } #[tokio::test] async fn navigation_emits_target_info_changed_with_url_and_title() { // Strict CDP clients (browser-use, Puppeteer/Playwright `page.url()`) // refresh a target's url/title only on Target.targetInfoChanged. A // navigation must emit it with the post-nav url/title, otherwise those // clients stay stuck on the pre-nav about:blank. let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session_id = format!("{}-session", page_id); ctx.sessions.insert(session_id.clone(), page_id.clone()); let params = json!({ "url": "data:text/html,Hello", "waitUntil": "load", }); handle("navigate", ¶ms, &mut ctx, &Some(session_id.clone())) .await .expect("navigate should succeed"); let evt = ctx .pending_events .iter() .find(|e| e.method == "Target.targetInfoChanged") .expect("navigation must emit Target.targetInfoChanged"); // Browser-level event (no sessionId) so the root connection's // targetInfoChanged handler receives it. assert!( evt.session_id.is_none(), "targetInfoChanged must be browser-level (no sessionId)" ); let info = evt.params["targetInfo"].clone(); // The payload must carry the live post-navigation url/title and the // canAccessOpener field strict clients require on every TargetInfo. let (exp_url, exp_title) = { let page = ctx.get_page(&page_id).expect("page exists"); (page.url_string(), page.title.clone()) }; assert_eq!(info["targetId"], json!(page_id)); assert_eq!(info["type"], "page"); assert_eq!(info["url"], json!(exp_url)); assert_eq!(info["title"], json!(exp_title)); assert!( info["url"].as_str().unwrap_or_default().starts_with("data:"), "url should reflect the navigated page, got {}", info["url"] ); assert_eq!(info["canAccessOpener"], json!(false)); } }