diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index cbedb28..676e80e 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -171,6 +171,9 @@ pub struct Page { pub http_client: Arc, pub context: Arc, pub title: String, + /// CSS viewport used by responsive page JavaScript and CDP screenshots. + /// The physical `screen` fingerprint remains independent. + pub viewport: (f32, f32), /// WHATWG canonical name of the current document's character encoding /// (e.g. "UTF-8", "EUC-JP"), detected when the response body is decoded. /// Exposed to JS as `document.characterSet` and used for the URL query @@ -468,6 +471,7 @@ impl Page { http_client, context, title: String::new(), + viewport: (1280.0, 720.0), encoding: "UTF-8".to_string(), history: Vec::new(), history_index: 0, @@ -502,6 +506,23 @@ impl Page { false } + /// Update the page's CSS viewport. Calling this before navigation makes + /// responsive scripts observe it from their first instruction; calling it + /// on a live page mirrors CDP's device-metrics override surfaces. + pub fn set_viewport(&mut self, viewport: (f32, f32)) { + if !viewport.0.is_finite() + || !viewport.1.is_finite() + || viewport.0 <= 0.0 + || viewport.1 <= 0.0 + { + return; + } + self.viewport = viewport; + if let Some(js) = &mut self.js { + js.set_viewport(viewport.0 as f64, viewport.1 as f64); + } + } + async fn do_fetch(&self, url: &Url) -> Result { #[cfg(feature = "stealth")] if let Some(ref stealth) = self.stealth_client { @@ -568,6 +589,7 @@ impl Page { if let Some((lat, lon)) = env_geolocation() { rt.set_geolocation(lat, lon); } + rt.set_viewport(self.viewport.0 as f64, self.viewport.1 as f64); rt.set_cookie_jar(self.context.cookie_jar.clone()); rt.set_http_client(self.http_client.clone()); diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index 90fd2ad..5d69301 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -387,13 +387,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await, "IO" => domains::io::handle(method, &req.params, ctx).await, "Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await, + "Emulation" => domains::emulation::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" + "Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => { diff --git a/crates/obscura-cdp/src/domains/emulation.rs b/crates/obscura-cdp/src/domains/emulation.rs new file mode 100644 index 0000000..1e7799e --- /dev/null +++ b/crates/obscura-cdp/src/domains/emulation.rs @@ -0,0 +1,98 @@ +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "setDeviceMetricsOverride" => { + let width = params + .get("width") + .and_then(Value::as_f64) + .ok_or("Emulation.setDeviceMetricsOverride requires width")?; + let height = params + .get("height") + .and_then(Value::as_f64) + .ok_or("Emulation.setDeviceMetricsOverride requires height")?; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return Err( + "Emulation.setDeviceMetricsOverride requires positive finite dimensions" + .to_string(), + ); + } + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + page.set_viewport((width as f32, height as f32)); + Ok(json!({})) + } + "clearDeviceMetricsOverride" => { + let page = ctx + .get_session_page_mut(session_id) + .ok_or("No page for session")?; + page.set_viewport((1280.0, 720.0)); + Ok(json!({})) + } + // Touch emulation does not affect layout yet, but acknowledging it is + // compatible with clients that pair it with a metrics override. + "setTouchEmulationEnabled" => Ok(json!({})), + _ => Ok(json!({})), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn device_metrics_override_updates_page_and_window_viewport() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = Some("viewport-session".to_string()); + ctx.sessions + .insert(session_id.clone().unwrap(), page_id); + + handle( + "setDeviceMetricsOverride", + &json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}), + &mut ctx, + &session_id, + ) + .await + .expect("metrics override"); + + let page = ctx + .get_session_page_mut(&session_id) + .expect("page for session"); + assert_eq!(page.viewport, (1024.0, 768.0)); + assert_eq!( + page.evaluate( + "return [innerWidth, innerHeight, visualViewport.width,\ + visualViewport.height];" + ), + json!([1024, 768, 1024, 768]) + ); + } + + #[tokio::test] + async fn device_metrics_override_rejects_zero_dimensions() { + let mut ctx = CdpContext::new(); + let page_id = ctx.create_page(); + let session_id = Some("viewport-session".to_string()); + ctx.sessions + .insert(session_id.clone().unwrap(), page_id); + let error = handle( + "setDeviceMetricsOverride", + &json!({"width": 0, "height": 768}), + &mut ctx, + &session_id, + ) + .await + .expect_err("zero width must fail"); + assert!(error.contains("positive finite dimensions")); + } +} diff --git a/crates/obscura-cdp/src/domains/mod.rs b/crates/obscura-cdp/src/domains/mod.rs index 41534f7..7af96ae 100644 --- a/crates/obscura-cdp/src/domains/mod.rs +++ b/crates/obscura-cdp/src/domains/mod.rs @@ -11,3 +11,4 @@ pub mod input; pub mod storage; pub mod accessibility; pub mod lp; +pub mod emulation; diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 05cf2e5..44f8f7c 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -400,13 +400,12 @@ pub async fn handle( // 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 - // content height from document.documentElement.scrollHeight. - // Playwright calls this before every page.screenshot() and - // would otherwise fail with "Unknown Page method". - let width = 1280.0_f64; - let height = 720.0_f64; + // 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 content_height = ctx .get_session_page_mut(session_id) .map(|p| p.evaluate("document.documentElement && document.documentElement.scrollHeight")) @@ -528,7 +527,7 @@ pub async fn handle( #[cfg(feature = "render")] { let page = ctx.get_session_page(session_id).ok_or("No page for session")?; - match page.screenshot((1280.0, 720.0)) { + match page.screenshot(page.viewport) { Some(png) => { use base64::Engine as _; let data = base64::engine::general_purpose::STANDARD.encode(&png); diff --git a/crates/obscura-cdp/src/server.rs b/crates/obscura-cdp/src/server.rs index 473810d..a1c41ac 100644 --- a/crates/obscura-cdp/src/server.rs +++ b/crates/obscura-cdp/src/server.rs @@ -1227,7 +1227,7 @@ fn fast_path_response(text: &str) -> Option { "Page.enable" | "Page.setLifecycleEventsEnabled" | "Page.setInterceptFileChooserDialog" | "Runtime.runIfWaitingForDebugger" | "Runtime.discardConsoleEntries" | "Performance.enable" | "Log.enable" | "Security.enable" | - "Emulation.setDeviceMetricsOverride" | "Emulation.setTouchEmulationEnabled" | + "Emulation.setTouchEmulationEnabled" | "CSS.enable" | "Accessibility.enable" | "ServiceWorker.enable" | "Inspector.enable" | "Debugger.enable" | "Profiler.enable" | "HeapProfiler.enable" | "Overlay.enable" | "Storage.enable" | diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index ea32bf8..23c72f1 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -607,6 +607,24 @@ async fn run_fetch( allow_private_network, )); let mut page = Page::new("fetch-page".to_string(), context.clone()); + // A screenshot viewport is also the navigation viewport: responsive + // frameworks must build the DOM for the same dimensions we later paint. + // Previously page JS saw a randomized screen-sized innerWidth while the + // screenshot used these values only at the final raster step. + let screenshot_viewport = screenshot.as_ref().map(|_| { + let width = std::env::var("OBSCURA_SHOT_W") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1280.0); + let height = std::env::var("OBSCURA_SHOT_H") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(720.0); + (width, height) + }); + if let Some(viewport) = screenshot_viewport { + page.set_viewport(viewport); + } if let Some(ref ua) = user_agent { page.http_client.set_user_agent(ua).await; @@ -661,9 +679,8 @@ async fn run_fetch( // Default CSS-pixel viewport, matching the engine's innerWidth/Height. // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // capture below-the-fold content in one shot). - let shot_w = std::env::var("OBSCURA_SHOT_W").ok().and_then(|v| v.parse::().ok()).unwrap_or(1280.0); - let shot_h = std::env::var("OBSCURA_SHOT_H").ok().and_then(|v| v.parse::().ok()).unwrap_or(720.0); - match page.screenshot((shot_w, shot_h)) { + let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0)); + match page.screenshot(viewport) { Some(bytes) => std::fs::write(path, &bytes)?, None => anyhow::bail!("screenshot failed: page has no DOM to render"), } diff --git a/crates/obscura-js/js/bootstrap.js b/crates/obscura-js/js/bootstrap.js index af84adf..bcf25de 100644 --- a/crates/obscura-js/js/bootstrap.js +++ b/crates/obscura-js/js/bootstrap.js @@ -8907,10 +8907,18 @@ globalThis.__obscura_init = function() { const scr = _fp('screen'); const sw = scr[0], sh = scr[1]; + // The OS screen and the page viewport are different browser concepts. + // Keep the fingerprinted screen, but let the embedding browser provide the + // actual CSS viewport so responsive JavaScript, layout, and screenshots all + // observe the same dimensions. + const vw = Number.isFinite(globalThis.__obscura_viewport_w) && globalThis.__obscura_viewport_w > 0 + ? globalThis.__obscura_viewport_w : sw; + const vh = Number.isFinite(globalThis.__obscura_viewport_h) && globalThis.__obscura_viewport_h > 0 + ? globalThis.__obscura_viewport_h : sh - 80; globalThis.screen = new Screen(sw, sh); - globalThis.visualViewport = { width:sw, height:sh-80, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; + globalThis.visualViewport = { width:vw, height:vh, offsetLeft:0, offsetTop:0, scale:1, addEventListener(){}, removeEventListener(){} }; globalThis.devicePixelRatio = sw >= 2560 ? 2 : 1; - globalThis.innerWidth = sw; globalThis.innerHeight = sh - 80; + globalThis.innerWidth = vw; globalThis.innerHeight = vh; globalThis.outerWidth = sw; globalThis.outerHeight = sh - 40; var hwValues = globalThis.__obscura_stealth ? [4, 6, 8, 12, 16] : [2, 4, 6, 8, 12, 16]; diff --git a/crates/obscura-js/src/runtime.rs b/crates/obscura-js/src/runtime.rs index c42782d..12b5df3 100644 --- a/crates/obscura-js/src/runtime.rs +++ b/crates/obscura-js/src/runtime.rs @@ -278,6 +278,27 @@ impl ObscuraJsRuntime { ); } + /// Set the CSS viewport exposed to page JavaScript. This must run before + /// `run_page_init` for navigation-time responsive code; it may also be + /// called later by CDP emulation to update the live window surfaces. + pub fn set_viewport(&mut self, width: f64, height: f64) { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return; + } + let _ = self.runtime.execute_script( + "", + format!( + "globalThis.__obscura_viewport_w={width};\ + globalThis.__obscura_viewport_h={height};\ + globalThis.innerWidth={width};globalThis.innerHeight={height};\ + if(globalThis.visualViewport){{\ + globalThis.visualViewport.width={width};\ + globalThis.visualViewport.height={height};\ + }}", + ), + ); + } + /// Run __obscura_init() after all per-page properties (UA, platform, stealth, etc.) /// have been set. Must be called once per page setup, after all set_* methods. pub fn run_page_init(&mut self) { @@ -1788,6 +1809,25 @@ mod tests { assert_eq!(title, serde_json::json!("Test Page")); } + #[test] + fn explicit_viewport_is_distinct_from_fingerprinted_screen() { + let dom = parse_html(""); + let mut rt = ObscuraJsRuntime::new(); + rt.set_dom(dom); + rt.set_viewport(1024.0, 768.0); + rt.run_page_init(); + let result = rt + .evaluate( + "return [innerWidth, innerHeight, visualViewport.width,\ + visualViewport.height, screen.width > 0, screen.height > 0];", + ) + .unwrap(); + assert_eq!( + result, + serde_json::json!([1024, 768, 1024, 768, true, true]) + ); + } + #[test] fn test_document_url() { let mut rt = setup_runtime(""); diff --git a/crates/obscura-render/src/css.rs b/crates/obscura-render/src/css.rs index 8f71eb6..6ba1dc0 100644 --- a/crates/obscura-render/src/css.rs +++ b/crates/obscura-render/src/css.rs @@ -45,6 +45,18 @@ impl Stylesheet { /// Parse and index a set of raw CSS sources (the text of each ` +

The Progressive

+ "#, + ); + let pixmap = paint_dom(&tree, (500.0, 100.0), None).expect("pixmap"); + let mut green = false; + let mut blue = false; + let mut normal = false; + for pixel in pixmap.pixels() { + let (r, g, b) = (pixel.red(), pixel.green(), pixel.blue()); + green |= g > r.saturating_add(20) && g > b.saturating_add(10); + blue |= b > r.saturating_add(20) && b > g.saturating_add(5); + normal |= b > g.saturating_add(10) && r < 80 && g < 100; + } + assert!(normal, "surrounding heading text should retain its normal color"); + assert!(green && blue, "inline accent should contain both gradient colors"); + } + #[test] fn serializes_inline_svg_subtree() { // A sprite-style svg: a that references a in the same diff --git a/crates/obscura-render/src/style.rs b/crates/obscura-render/src/style.rs index 9c5b342..4733925 100644 --- a/crates/obscura-render/src/style.rs +++ b/crates/obscura-render/src/style.rs @@ -448,7 +448,11 @@ fn apply_value(style: &mut LayoutStyle, name: &str, value: &str) { // content-box) is an ordinary background, so clear the flag. style.background_clip_text = value.trim().eq_ignore_ascii_case("text"); } - "color" => style.color = parse_color(value), + // Blink/WebKit gradient text commonly makes the glyph fill + // transparent through this inherited property while clipping a + // background gradient to the text. We model one effective text color, + // so the vendor fill color is the paint-time color winner. + "color" | "-webkit-text-fill-color" => style.color = parse_color(value), "border-color" => style.border_color = parse_color(value), "font-size" => { // Absolute lengths resolve now; font/viewport-relative ones defer @@ -2508,6 +2512,13 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) let v = value.trim(); let lower = v.to_ascii_lowercase(); let start = lower.find("linear-gradient(")?; + // The original prefixed WebKit gradient syntax predates the standardized + // angle system: 0deg points right and positive angles turn + // counter-clockwise. Blink still accepts that syntax for compatibility. + // Convert it to our standard CSS angle (0deg up, clockwise) before paint. + let prefix = &lower[..start]; + let legacy_webkit_angle = + prefix.ends_with("-webkit-") || prefix.ends_with("-webkit-repeating-"); let open = start + "linear-gradient(".len(); // Match the closing paren for this function. let bytes = v.as_bytes(); @@ -2546,7 +2557,11 @@ fn parse_linear_gradient(value: &str) -> Option<(f32, Vec<([u8; 4], Option) let mut stop_start = 0; if first.ends_with("deg") { if let Ok(a) = first.trim_end_matches("deg").trim().parse::() { - angle = a; + angle = if legacy_webkit_angle { + (90.0 - a).rem_euclid(360.0) + } else { + a.rem_euclid(360.0) + }; } stop_start = 1; } else if first.starts_with("to ") { @@ -3123,6 +3138,25 @@ mod tests { fn background_clip_text_flag() { let s = compute_style("h1", Some("color: transparent; -webkit-background-clip: text")); assert!(s.background_clip_text); + let vendor_fill = compute_style( + "h1", + Some( + "color: black; -webkit-text-fill-color: transparent;\ + background: linear-gradient(90deg, red, blue);\ + -webkit-background-clip: text", + ), + ); + assert_eq!(vendor_fill.color, Some([0, 0, 0, 0])); + assert!(vendor_fill.background_clip_text); + assert!(vendor_fill.background_gradient.is_some()); + let legacy_angle = compute_style( + "span", + Some("background:-webkit-linear-gradient(315deg,#42d392 25%,#647eff)"), + ) + .background_gradient + .expect("prefixed gradient") + .0; + assert_eq!(legacy_angle, 135.0); let n = compute_style("h1", Some("background-clip: border-box")); assert!(!n.background_clip_text); let l = compute_style("h1", Some("background-clip: text")); diff --git a/render-repros/checks.json b/render-repros/checks.json index 7b94364..f085560 100644 --- a/render-repros/checks.json +++ b/render-repros/checks.json @@ -253,6 +253,11 @@ {"name": "unsupported identity transform still establishes containing block", "color": "#7048e8", "x": 550, "y": 280, "width": 20, "height": 20}, {"name": "positioned ancestor does not capture fixed", "color": "#a61e4d", "x": 700, "y": 40, "width": 90, "height": 60} ], + "viewport-consistency": [ + {"name": "width media query uses the screenshot viewport", "color": "#087f5b", "x": 10, "y": 10, "width": 200, "height": 80}, + {"name": "height media query uses the screenshot viewport", "color": "#1971c2", "x": 10, "y": 110, "width": 200, "height": 80}, + {"name": "page JavaScript sees the screenshot viewport", "color": "#7048e8", "x": 10, "y": 210, "width": 200, "height": 80} + ], "z-index": [ {"name": "positive layer", "color": "#008000", "width": 200, "height": 100}, {"name": "negative layer", "color": "#ffa500", "width": 260, "height": 110} diff --git a/render-repros/viewport-consistency.html b/render-repros/viewport-consistency.html new file mode 100644 index 0000000..415b0f7 --- /dev/null +++ b/render-repros/viewport-consistency.html @@ -0,0 +1,32 @@ + + +viewport consistency + +
+
+
+