Improve modern rendering and output parity

This commit is contained in:
SGavrl committed 2026-08-03 19:46:24 +00:00
1 parent f428aa37fa
commit c8a5477296
25 files changed
+12878 -5046

No files matched your search

Generated
+17
View File
@@ -1870,6 +1870,7 @@ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
"futures", "futures",
"image",
"obscura-dom", "obscura-dom",
"obscura-js", "obscura-js",
"obscura-net", "obscura-net",
@@ -1957,6 +1958,7 @@ dependencies = [
"obscura-net", "obscura-net",
"obscura-render", "obscura-render",
"pbkdf2", "pbkdf2",
"psl",
"reqwest", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
@@ -2358,6 +2360,21 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "psl"
version = "2.1.223"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0dedad316e05de220cbf3fd8bfb69f3df8755dde2d7d479211827b595168a93"
dependencies = [
"psl-types",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]] [[package]]
name = "pxfm" name = "pxfm"
version = "0.1.29" version = "0.1.29"
+2 -1
View File
@@ -6,7 +6,7 @@ edition.workspace = true
[features] [features]
default = [] default = []
stealth = ["obscura-net/stealth", "obscura-js/stealth"] stealth = ["obscura-net/stealth", "obscura-js/stealth"]
render = ["obscura-js/render"] render = ["obscura-js/render", "dep:image"]
[dependencies] [dependencies]
obscura-dom = { workspace = true } obscura-dom = { workspace = true }
@@ -20,3 +20,4 @@ thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
image = { version = "0.25", default-features = false, features = ["jpeg", "png"], optional = true }
+8 -2
View File
@@ -1,12 +1,18 @@
pub mod page;
pub mod context; pub mod context;
pub mod lifecycle; pub mod lifecycle;
pub mod page;
#[cfg(feature = "render")]
pub mod pdf;
pub mod profiles; pub mod profiles;
pub use page::{NetworkEvent, Page, PageError};
pub use context::BrowserContext; pub use context::BrowserContext;
pub use lifecycle::{LifecycleState, WaitUntil}; pub use lifecycle::{LifecycleState, WaitUntil};
pub use obscura_js::HTML_TO_MARKDOWN_JS; pub use obscura_js::HTML_TO_MARKDOWN_JS;
#[cfg(feature = "render")]
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
pub use page::{NetworkEvent, Page, PageError};
#[cfg(feature = "render")]
pub use pdf::{RasterPdfError, RasterPdfOptions};
// Re-exported so the embeddable `obscura` crate (which depends on obscura-browser, // Re-exported so the embeddable `obscura` crate (which depends on obscura-browser,
// not obscura-js) can surface the interception channel types. // not obscura-js) can surface the interception channel types.
pub use obscura_js::ops::{InterceptResolution, InterceptedRequest}; pub use obscura_js::ops::{InterceptResolution, InterceptedRequest};
File diff suppressed because it is too large. Load diff
+556
View File
@@ -0,0 +1,556 @@
//! Bounded raster-backed PDF export over the retained document-space painter.
//!
//! This deliberately does not claim CSS paged-media support. It preserves the
//! screen layout, fits the full document width into the printable area, and
//! slices that immutable layout vertically across PDF pages.
use std::io;
use image::ImageEncoder as _;
use obscura_js::CaptureRegion;
use crate::Page;
const POINTS_PER_INCH: f32 = 72.0;
const MAX_PAPER_INCHES: f32 = 200.0;
const MAX_PDF_PAGES: usize = 250;
const MAX_PDF_PAGE_PIXELS: u64 = 16 * 1024 * 1024;
const MAX_PDF_TOTAL_RASTER_PIXELS: u64 = 64 * 1024 * 1024;
const MAX_PDF_OUTPUT_BYTES: usize = 64 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct RasterPdfOptions {
pub landscape: bool,
pub paper_width_in: f32,
pub paper_height_in: f32,
pub margin_top_in: f32,
pub margin_bottom_in: f32,
pub margin_left_in: f32,
pub margin_right_in: f32,
}
impl Default for RasterPdfOptions {
fn default() -> Self {
Self {
landscape: false,
paper_width_in: 8.5,
paper_height_in: 11.0,
// CDP's defaults are one centimetre.
margin_top_in: 0.3937,
margin_bottom_in: 0.3937,
margin_left_in: 0.3937,
margin_right_in: 0.3937,
}
}
}
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum RasterPdfError {
#[error("PDF paper dimensions must be finite and between 0 and 200 inches")]
InvalidPaperSize,
#[error("PDF margins must be finite, non-negative, and leave a printable area")]
InvalidMargins,
#[error("the page has no retained renderable document")]
NoRenderableDocument,
#[error("PDF pagination would exceed the {0}-page safety limit")]
TooManyPages(usize),
#[error("PDF raster work would exceed the bounded page or document pixel budget")]
RasterWorkLimitExceeded,
#[error("document-space PDF capture failed: {0}")]
CaptureFailed(String),
#[error("PDF raster image decoding failed: {0}")]
ImageDecode(String),
#[error("PDF JPEG encoding failed: {0}")]
ImageEncode(String),
#[error("encoded PDF would exceed the 64 MiB safety limit")]
OutputLimitExceeded,
}
#[derive(Debug)]
struct RasterPage {
rgb: image::RgbImage,
draw_width_pt: f32,
draw_height_pt: f32,
#[cfg(test)]
_lifetime_probe: Option<std::rc::Rc<()>>,
}
#[derive(Clone, Copy, Debug)]
struct PaginationPlan {
points_per_css_pixel: f32,
css_page_height: f32,
page_count: usize,
}
impl RasterPdfOptions {
fn page_geometry(self) -> Result<(f32, f32, f32, f32, f32, f32), RasterPdfError> {
let values = [self.paper_width_in, self.paper_height_in];
if values
.iter()
.any(|value| !value.is_finite() || *value <= 0.0 || *value > MAX_PAPER_INCHES)
{
return Err(RasterPdfError::InvalidPaperSize);
}
let margins = [
self.margin_top_in,
self.margin_bottom_in,
self.margin_left_in,
self.margin_right_in,
];
if margins
.iter()
.any(|value| !value.is_finite() || *value < 0.0)
{
return Err(RasterPdfError::InvalidMargins);
}
let (paper_width_in, paper_height_in) = if self.landscape {
(self.paper_height_in, self.paper_width_in)
} else {
(self.paper_width_in, self.paper_height_in)
};
let page_width = paper_width_in * POINTS_PER_INCH;
let page_height = paper_height_in * POINTS_PER_INCH;
let left = self.margin_left_in * POINTS_PER_INCH;
let bottom = self.margin_bottom_in * POINTS_PER_INCH;
let printable_width =
page_width - (self.margin_left_in + self.margin_right_in) * POINTS_PER_INCH;
let printable_height =
page_height - (self.margin_top_in + self.margin_bottom_in) * POINTS_PER_INCH;
if printable_width <= 0.0 || printable_height <= 0.0 {
return Err(RasterPdfError::InvalidMargins);
}
Ok((
page_width,
page_height,
printable_width,
printable_height,
left,
bottom,
))
}
}
fn pagination_plan(
content_width: f32,
content_height: f32,
printable_width: f32,
printable_height: f32,
) -> Result<PaginationPlan, RasterPdfError> {
let points_per_css_pixel = printable_width / content_width;
let css_page_height = printable_height / points_per_css_pixel;
if !points_per_css_pixel.is_finite()
|| points_per_css_pixel <= 0.0
|| !css_page_height.is_finite()
|| css_page_height <= 0.0
{
return Err(RasterPdfError::RasterWorkLimitExceeded);
}
let page_count_value = (content_height / css_page_height).ceil().max(1.0);
if !page_count_value.is_finite() || page_count_value > MAX_PDF_PAGES as f32 {
return Err(RasterPdfError::TooManyPages(MAX_PDF_PAGES));
}
let page_count = page_count_value as usize;
let pixel_width = content_width.ceil();
if !pixel_width.is_finite()
|| pixel_width <= 0.0
|| pixel_width > obscura_js::MAX_CAPTURE_DIMENSION as f32
{
return Err(RasterPdfError::RasterWorkLimitExceeded);
}
let pixel_width = pixel_width as u64;
let mut total_pixels = 0u64;
for page_index in 0..page_count {
let y = page_index as f32 * css_page_height;
let slice_height = (content_height - y).min(css_page_height).ceil();
if !slice_height.is_finite()
|| slice_height <= 0.0
|| slice_height > obscura_js::MAX_CAPTURE_DIMENSION as f32
{
return Err(RasterPdfError::RasterWorkLimitExceeded);
}
let page_pixels = pixel_width
.checked_mul(slice_height as u64)
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
if page_pixels > MAX_PDF_PAGE_PIXELS {
return Err(RasterPdfError::RasterWorkLimitExceeded);
}
total_pixels = total_pixels
.checked_add(page_pixels)
.ok_or(RasterPdfError::RasterWorkLimitExceeded)?;
if total_pixels > MAX_PDF_TOTAL_RASTER_PIXELS {
return Err(RasterPdfError::RasterWorkLimitExceeded);
}
}
Ok(PaginationPlan {
points_per_css_pixel,
css_page_height,
page_count,
})
}
impl Page {
/// Export the current screen layout as a paginated raster PDF.
///
/// The full document width is scaled uniformly into the printable width;
/// vertical slices become pages. This does not reflow into `@media print`
/// or implement CSS paged media, headers, footers, or page ranges.
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
let (page_width, page_height, printable_width, printable_height, left, bottom) =
options.page_geometry()?;
let js = self
.js
.as_ref()
.ok_or(RasterPdfError::NoRenderableDocument)?;
let (content_width, content_height) = js
.prepared_content_size()
.ok_or(RasterPdfError::NoRenderableDocument)?;
if !content_width.is_finite()
|| !content_height.is_finite()
|| content_width <= 0.0
|| content_height <= 0.0
{
return Err(RasterPdfError::NoRenderableDocument);
}
let plan = pagination_plan(
content_width,
content_height,
printable_width,
printable_height,
)?;
encode_pdf_pages(
plan.page_count,
page_width,
page_height,
left,
bottom,
printable_height,
|page_index| {
let y = page_index as f32 * plan.css_page_height;
let slice_height = (content_height - y).min(plan.css_page_height);
let png = js
.screenshot_prepared_region(CaptureRegion::new(
0.0,
y,
content_width,
slice_height,
1.0,
))
.map_err(|error| RasterPdfError::CaptureFailed(format!("{error:?}")))?;
let decoded = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
.map_err(|error| RasterPdfError::ImageDecode(error.to_string()))?;
// The document capture is already a complete PNG allocation. Drop
// it before converting the decoded pixels and, below, encoding the
// JPEG directly into the final PDF buffer. At no point do we retain
// PNGs or JPEGs for earlier pages.
drop(png);
let rgb = decoded.into_rgb8();
Ok(RasterPage {
rgb,
draw_width_pt: printable_width,
draw_height_pt: slice_height * plan.points_per_css_pixel,
#[cfg(test)]
_lifetime_probe: None,
})
},
)
}
}
fn encode_pdf_pages(
page_count: usize,
page_width: f32,
page_height: f32,
left: f32,
bottom: f32,
printable_height: f32,
mut page_source: impl FnMut(usize) -> Result<RasterPage, RasterPdfError>,
) -> Result<Vec<u8>, RasterPdfError> {
let object_count = 2usize
.checked_add(
page_count
.checked_mul(3)
.ok_or(RasterPdfError::OutputLimitExceeded)?,
)
.ok_or(RasterPdfError::OutputLimitExceeded)?;
let mut writer = PdfWriter::new(object_count, MAX_PDF_OUTPUT_BYTES)?;
writer.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")?;
let kids = (0..page_count)
.map(|index| format!("{} 0 R", 3 + index * 3))
.collect::<Vec<_>>()
.join(" ");
let pages_dictionary = format!("<< /Type /Pages /Count {page_count} /Kids [{kids}] >>");
writer.write_object(2, pages_dictionary.as_bytes())?;
for index in 0..page_count {
let page = page_source(index)?;
let page_id = 3 + index * 3;
let content_id = page_id + 1;
let image_id = page_id + 2;
let page_dictionary = format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {page_width:.3} {page_height:.3}] /Resources << /XObject << /Im0 {image_id} 0 R >> >> /Contents {content_id} 0 R >>"
);
writer.write_object(page_id, page_dictionary.as_bytes())?;
let draw_y = bottom + printable_height - page.draw_height_pt;
let commands = format!(
"q\n{:.3} 0 0 {:.3} {:.3} {:.3} cm\n/Im0 Do\nQ\n",
page.draw_width_pt, page.draw_height_pt, left, draw_y,
);
let content = format!(
"<< /Length {} >>\nstream\n{}endstream",
commands.len(),
commands
);
writer.write_object(content_id, content.as_bytes())?;
writer.write_rgb_image(image_id, &page.rgb)?;
// `page`, including its decoded RGB raster, is dropped here before
// the next page is captured. Only the bounded final PDF survives.
}
writer.finish()
}
struct PdfWriter {
output: Vec<u8>,
offsets: Vec<usize>,
limit: usize,
limit_exceeded: bool,
}
impl PdfWriter {
fn new(object_count: usize, limit: usize) -> Result<Self, RasterPdfError> {
let mut writer = Self {
output: Vec::new(),
offsets: vec![0usize; object_count + 1],
limit,
limit_exceeded: false,
};
writer.append(b"%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")?;
Ok(writer)
}
fn append(&mut self, bytes: &[u8]) -> Result<(), RasterPdfError> {
let new_len = self
.output
.len()
.checked_add(bytes.len())
.ok_or(RasterPdfError::OutputLimitExceeded)?;
if new_len > self.limit {
self.limit_exceeded = true;
return Err(RasterPdfError::OutputLimitExceeded);
}
self.output.extend_from_slice(bytes);
Ok(())
}
fn write_object(&mut self, id: usize, body: &[u8]) -> Result<(), RasterPdfError> {
self.offsets[id] = self.output.len();
self.append(format!("{id} 0 obj\n").as_bytes())?;
self.append(body)?;
self.append(b"\nendobj\n")
}
fn write_rgb_image(&mut self, id: usize, rgb: &image::RgbImage) -> Result<(), RasterPdfError> {
self.offsets[id] = self.output.len();
self.append(format!(
"{id} 0 obj\n<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode /Length ",
rgb.width(), rgb.height(),
).as_bytes())?;
// Encode into the final PDF rather than building a second JPEG Vec.
// A fixed-width decimal token lets us patch /Length after encoding.
const LENGTH_DIGITS: usize = 20;
let length_offset = self.output.len();
self.append(b"00000000000000000000 >>\nstream\n")?;
let stream_offset = self.output.len();
let encode_result = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut *self, 90)
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
);
if let Err(error) = encode_result {
return if self.limit_exceeded {
Err(RasterPdfError::OutputLimitExceeded)
} else {
Err(RasterPdfError::ImageEncode(error.to_string()))
};
}
let stream_len = self.output.len() - stream_offset;
let length = format!("{stream_len:0LENGTH_DIGITS$}");
if length.len() != LENGTH_DIGITS {
return Err(RasterPdfError::OutputLimitExceeded);
}
self.output[length_offset..length_offset + LENGTH_DIGITS]
.copy_from_slice(length.as_bytes());
self.append(b"\nendstream\nendobj\n")
}
fn finish(mut self) -> Result<Vec<u8>, RasterPdfError> {
let object_count = self.offsets.len() - 1;
let xref_offset = self.output.len();
self.append(format!("xref\n0 {}\n0000000000 65535 f \n", object_count + 1).as_bytes())?;
for index in 1..self.offsets.len() {
let offset = self.offsets[index];
self.append(format!("{offset:010} 00000 n \n").as_bytes())?;
}
self.append(
format!(
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n",
object_count + 1,
)
.as_bytes(),
)?;
Ok(self.output)
}
}
impl io::Write for PdfWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.append(bytes)
.map(|()| bytes.len())
.map_err(|error| io::Error::new(io::ErrorKind::Other, error.to_string()))
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn options_reject_impossible_media_boxes() {
let mut options = RasterPdfOptions::default();
options.paper_width_in = 0.0;
assert_eq!(
options.page_geometry(),
Err(RasterPdfError::InvalidPaperSize)
);
let mut options = RasterPdfOptions::default();
options.margin_left_in = 5.0;
options.margin_right_in = 5.0;
assert_eq!(options.page_geometry(), Err(RasterPdfError::InvalidMargins));
}
#[test]
fn pagination_preflight_bounds_pages_and_raster_work() {
let (_, _, printable_width, printable_height, _, _) =
RasterPdfOptions::default().page_geometry().unwrap();
let ordinary = pagination_plan(1280.0, 10_000.0, printable_width, printable_height)
.expect("an ordinary multi-page document stays inside the budget");
assert!(ordinary.page_count > 1);
assert_eq!(
pagination_plan(5_000.0, 5_000.0, printable_width, printable_height).unwrap_err(),
RasterPdfError::RasterWorkLimitExceeded,
"one excessively large raster page must fail before capture"
);
assert_eq!(
pagination_plan(1_000.0, 70_000.0, printable_width, printable_height).unwrap_err(),
RasterPdfError::RasterWorkLimitExceeded,
"many individually valid pages must still respect a total work budget"
);
assert_eq!(
pagination_plan(1_000.0, 400_000.0, printable_width, printable_height).unwrap_err(),
RasterPdfError::TooManyPages(MAX_PDF_PAGES),
);
}
#[test]
fn writer_emits_xref_and_one_image_per_page() {
let pdf = encode_pdf_pages(1, 612.0, 792.0, 36.0, 36.0, 720.0, |_| {
Ok(RasterPage {
rgb: image::RgbImage::from_pixel(2, 3, image::Rgb([10, 20, 30])),
draw_width_pt: 100.0,
draw_height_pt: 150.0,
_lifetime_probe: None,
})
})
.unwrap();
assert!(pdf.starts_with(b"%PDF-1.4"));
assert!(pdf.ends_with(b"%%EOF\n"));
let text = String::from_utf8_lossy(&pdf);
assert!(text.contains("/Count 1"));
assert!(text.contains("/Subtype /Image"));
assert!(text.contains("xref\n0 6"));
let startxref = text
.rsplit_once("startxref\n")
.unwrap()
.1
.lines()
.next()
.unwrap()
.parse::<usize>()
.unwrap();
assert!(pdf[startxref..].starts_with(b"xref\n"));
let object_one_offset = text
.split("xref\n0 6\n")
.nth(1)
.unwrap()
.lines()
.nth(1)
.unwrap()[..10]
.parse::<usize>()
.unwrap();
assert!(pdf[object_one_offset..].starts_with(b"1 0 obj\n"));
}
#[test]
fn page_rasters_are_released_before_capturing_the_next_page() {
let previous = std::cell::RefCell::new(None::<std::rc::Weak<()>>);
let pdf = encode_pdf_pages(4, 612.0, 792.0, 36.0, 36.0, 720.0, |index| {
if let Some(previous) = previous.borrow().as_ref() {
assert!(
previous.upgrade().is_none(),
"page {index} was requested while the prior raster was still retained"
);
}
let probe = std::rc::Rc::new(());
*previous.borrow_mut() = Some(std::rc::Rc::downgrade(&probe));
Ok(RasterPage {
rgb: image::RgbImage::from_pixel(8, 8, image::Rgb([index as u8, 0, 0])),
draw_width_pt: 100.0,
draw_height_pt: 100.0,
_lifetime_probe: Some(probe),
})
})
.unwrap();
assert_eq!(
String::from_utf8_lossy(&pdf)
.matches("/Subtype /Image")
.count(),
4
);
}
#[test]
fn writer_enforces_the_output_limit_while_encoding_the_image_stream() {
let mut writer = PdfWriter::new(5, 600).unwrap();
writer
.write_object(1, b"<< /Type /Catalog /Pages 2 0 R >>")
.unwrap();
writer
.write_object(2, b"<< /Type /Pages /Count 1 /Kids [3 0 R] >>")
.unwrap();
let noisy = image::RgbImage::from_fn(128, 128, |x, y| {
image::Rgb([
x.wrapping_mul(37) as u8,
y.wrapping_mul(53) as u8,
x.wrapping_add(y).wrapping_mul(71) as u8,
])
});
assert_eq!(
writer.write_rgb_image(5, &noisy),
Err(RasterPdfError::OutputLimitExceeded)
);
assert!(writer.output.len() <= 600);
}
}
+103 -52
View File
@@ -11,7 +11,10 @@ use crate::types::{CdpEvent, CdpRequest, CdpResponse};
#[cfg(feature = "render")] #[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum ScreencastFormat { Png, Jpeg } pub(crate) enum ScreencastFormat {
Png,
Jpeg,
}
#[cfg(feature = "render")] #[cfg(feature = "render")]
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
@@ -257,10 +260,15 @@ impl CdpContext {
self.pages.retain(|p| p.id != id); self.pages.retain(|p| p.id != id);
#[cfg(feature = "render")] #[cfg(feature = "render")]
{ {
let removed: Vec<String> = self.sessions.iter() let removed: Vec<String> = self
.sessions
.iter()
.filter(|(_, page_id)| page_id.as_str() == id) .filter(|(_, page_id)| page_id.as_str() == id)
.map(|(session_id, _)| session_id.clone()).collect(); .map(|(session_id, _)| session_id.clone())
for session_id in removed { self.screencasts.remove(&session_id); } .collect();
for session_id in removed {
self.screencasts.remove(&session_id);
}
} }
self.sessions.retain(|_, v| v != id); self.sessions.retain(|_, v| v != id);
} }
@@ -273,9 +281,7 @@ impl CdpContext {
} }
pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> { pub fn get_session_page(&self, session_id: &Option<String>) -> Option<&Page> {
let page_id = session_id let page_id = session_id.as_ref().and_then(|sid| self.sessions.get(sid))?;
.as_ref()
.and_then(|sid| self.sessions.get(sid))?;
self.get_page(page_id) self.get_page(page_id)
} }
@@ -311,38 +317,67 @@ impl CdpContext {
/// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and /// reads. `get_session_page_mut` triggers `suspend_js`/`resume_js` and
/// must stay behind the lock. /// must stay behind the lock.
fn is_v8_free_method(method: &str) -> bool { fn is_v8_free_method(method: &str) -> bool {
matches!(method, matches!(
"Target.getTargets" | "Target.setDiscoverTargets" method,
| "Target.attachToTarget" | "Target.attachToBrowserTarget" "Target.getTargets"
| "Target.setAutoAttach" | "Target.setDiscoverTargets"
| "Target.getBrowserContexts" | "Target.createBrowserContext" | "Target.attachToTarget"
| "Target.disposeBrowserContext" | "Target.getTargetInfo" | "Target.attachToBrowserTarget"
| "Target.detachFromTarget" | "Target.activateTarget" | "Target.setAutoAttach"
| "Browser.getVersion" | "Browser.close" | "Browser.getWindowForTarget" | "Target.getBrowserContexts"
| "Browser.setDownloadBehavior" | "Browser.getWindowBounds" | "Browser.setWindowBounds" | "Target.createBrowserContext"
| "Page.enable" | "Page.disable" | "Page.getFrameTree" | "Target.disposeBrowserContext"
| "Page.setDownloadBehavior" | "Target.getTargetInfo"
| "Page.setLifecycleEventsEnabled" | "Target.detachFromTarget"
| "Page.addScriptToEvaluateOnNewDocument" | "Page.removeScriptToEvaluateOnNewDocument" | "Target.activateTarget"
| "Page.setInterceptFileChooserDialog" | "Page.getNavigationHistory" | "Browser.getVersion"
| "Page.resetNavigationHistory" | "Page.printToPDF" | "Browser.close"
| "Page.captureSnapshot" | "Browser.getWindowForTarget"
| "Page.stopScreencast" | "Page.screencastFrameAck" | "Browser.setDownloadBehavior"
| "Page.createIsolatedWorld" | "Browser.getWindowBounds"
| "Runtime.enable" | "Runtime.disable" | "Browser.setWindowBounds"
| "Runtime.runIfWaitingForDebugger" | "Runtime.getExceptionDetails" | "Page.enable"
| "Runtime.discardConsoleEntries" | "Page.disable"
| "Network.enable" | "Network.disable" | "Network.setCacheDisabled" | "Page.getFrameTree"
| "Network.setRequestInterception" | "Network.setBlockedURLs" | "Page.setDownloadBehavior"
| "Network.setExtraHTTPHeaders" | "Page.setLifecycleEventsEnabled"
| "Network.setUserAgentOverride" | "Page.addScriptToEvaluateOnNewDocument"
| "Network.getCookies" | "Network.getAllCookies" | "Page.removeScriptToEvaluateOnNewDocument"
| "Network.setCookie" | "Network.setCookies" | "Page.setInterceptFileChooserDialog"
| "Network.deleteCookies" | "Network.clearBrowserCookies" | "Page.getNavigationHistory"
| "Network.getResponseBody" | "Page.resetNavigationHistory"
| "Fetch.continueRequest" | "Fetch.fulfillRequest" | "Page.captureSnapshot"
| "Fetch.failRequest" | "Fetch.getResponseBody" | "Page.stopScreencast"
| "Storage.getCookies" | "Storage.setCookies" | "Storage.deleteCookies" | "Page.screencastFrameAck"
| "Page.createIsolatedWorld"
| "Runtime.enable"
| "Runtime.disable"
| "Runtime.runIfWaitingForDebugger"
| "Runtime.getExceptionDetails"
| "Runtime.discardConsoleEntries"
| "Network.enable"
| "Network.disable"
| "Network.setCacheDisabled"
| "Network.setRequestInterception"
| "Network.setBlockedURLs"
| "Network.setExtraHTTPHeaders"
| "Network.setUserAgentOverride"
| "Network.getCookies"
| "Network.getAllCookies"
| "Network.setCookie"
| "Network.setCookies"
| "Network.deleteCookies"
| "Network.clearBrowserCookies"
| "Network.getResponseBody"
| "Fetch.continueRequest"
| "Fetch.fulfillRequest"
| "Fetch.failRequest"
| "Fetch.getResponseBody"
| "IO.read"
| "IO.close"
| "Storage.getCookies"
| "Storage.setCookies"
| "Storage.deleteCookies"
) )
} }
@@ -401,7 +436,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
} else { } else {
ctx.get_session_page(&req.session_id) ctx.get_session_page(&req.session_id)
.and_then(|p| p.isolate_handle()) .and_then(|p| p.isolate_handle())
.map(|h| obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms))) .map(|h| {
obscura_js::cdp_watchdog::arm(h, std::time::Duration::from_millis(cmd_budget_ms))
})
}; };
let (domain, method) = match req.method.split_once('.') { let (domain, method) = match req.method.split_once('.') {
@@ -421,7 +458,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
"Browser" => domains::browser::handle(method, &req.params).await, "Browser" => domains::browser::handle(method, &req.params).await,
"Page" => domains::page::handle(method, &req.params, ctx, &req.session_id).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, "DOM" => domains::dom::handle(method, &req.params, ctx, &req.session_id).await,
"DOMSnapshot" => domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await, "DOMSnapshot" => {
domains::domsnapshot::handle(method, &req.params, ctx, &req.session_id).await
}
"Runtime" => domains::runtime::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, "Network" => domains::network::handle(method, &req.params, ctx, &req.session_id).await,
"Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await, "Fetch" => domains::fetch::handle(method, &req.params, ctx, &req.session_id).await,
@@ -430,16 +469,14 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse {
"Emulation" => domains::emulation::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, "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await,
"LP" => domains::lp::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, "Accessibility" => {
domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await
}
// Accepted but no-op. Puppeteer's FrameManager.initialize calls // Accepted but no-op. Puppeteer's FrameManager.initialize calls
// Audits.enable on connect — refusing it breaks puppeteer.connect() // Audits.enable on connect — refusing it breaks puppeteer.connect()
// before any user code runs. // before any user code runs.
"Log" | "Performance" | "Security" | "CSS" "Log" | "Performance" | "Security" | "CSS" | "ServiceWorker" | "Inspector" | "Debugger"
| "ServiceWorker" | "Inspector" | "Profiler" | "HeapProfiler" | "Overlay" | "Audits" => Ok(json!({})),
| "Debugger" | "Profiler" | "HeapProfiler" | "Overlay"
| "Audits" => {
Ok(json!({}))
}
_ => Err(format!("Unknown domain: {}", domain)), _ => Err(format!("Unknown domain: {}", domain)),
}; };
@@ -599,7 +636,11 @@ mod tests {
async fn audits_enable_returns_empty_success() { async fn audits_enable_returns_empty_success() {
let mut ctx = CdpContext::new(); let mut ctx = CdpContext::new();
let resp = dispatch(&req("Audits.enable"), &mut ctx).await; let resp = dispatch(&req("Audits.enable"), &mut ctx).await;
assert!(resp.error.is_none(), "Audits.enable should not error: {:?}", resp.error); assert!(
resp.error.is_none(),
"Audits.enable should not error: {:?}",
resp.error
);
assert_eq!(resp.result, Some(json!({}))); assert_eq!(resp.result, Some(json!({})));
} }
@@ -631,7 +672,11 @@ mod tests {
}; };
let resp = dispatch(&outer, &mut ctx).await; let resp = dispatch(&outer, &mut ctx).await;
assert!(resp.error.is_none(), "wrapper must succeed: {:?}", resp.error); assert!(
resp.error.is_none(),
"wrapper must succeed: {:?}",
resp.error
);
assert_eq!(resp.id, 99); assert_eq!(resp.id, 99);
assert_eq!(resp.result, Some(json!({}))); assert_eq!(resp.result, Some(json!({})));
@@ -647,8 +692,14 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap(); let parsed: serde_json::Value = serde_json::from_str(inner_msg).unwrap();
assert_eq!(parsed["id"], 42); assert_eq!(parsed["id"], 42);
// Browser.getVersion returns a populated result object, not an error. // Browser.getVersion returns a populated result object, not an error.
assert!(parsed.get("result").is_some(), "inner response carries result"); assert!(
assert!(parsed.get("error").is_none(), "inner response is not an error"); parsed.get("result").is_some(),
"inner response carries result"
);
assert!(
parsed.get("error").is_none(),
"inner response is not an error"
);
} }
#[tokio::test] #[tokio::test]
+122 -23
View File
@@ -2,6 +2,22 @@ use serde_json::{json, Value};
use crate::dispatch::CdpContext; use crate::dispatch::CdpContext;
const MAX_DEVICE_METRIC_DIMENSION: i64 = 10_000_000;
const DEFAULT_VIEWPORT: (f32, f32) = (1280.0, 720.0);
fn metric_dimension(params: &Value, name: &str) -> Result<u32, String> {
let value = params
.get(name)
.and_then(Value::as_i64)
.ok_or_else(|| format!("Emulation.setDeviceMetricsOverride requires integer {name}"))?;
if !(0..=MAX_DEVICE_METRIC_DIMENSION).contains(&value) {
return Err(format!(
"Emulation.setDeviceMetricsOverride {name} must be between 0 and {MAX_DEVICE_METRIC_DIMENSION}"
));
}
Ok(value as u32)
}
pub async fn handle( pub async fn handle(
method: &str, method: &str,
params: &Value, params: &Value,
@@ -10,31 +26,42 @@ pub async fn handle(
) -> Result<Value, String> { ) -> Result<Value, String> {
match method { match method {
"setDeviceMetricsOverride" => { "setDeviceMetricsOverride" => {
let width = params let width = metric_dimension(params, "width")?;
.get("width") let height = metric_dimension(params, "height")?;
let device_scale_factor = params
.get("deviceScaleFactor")
.and_then(Value::as_f64) .and_then(Value::as_f64)
.ok_or("Emulation.setDeviceMetricsOverride requires width")?; .ok_or("Emulation.setDeviceMetricsOverride requires deviceScaleFactor")?;
let height = params if !device_scale_factor.is_finite() || device_scale_factor < 0.0 {
.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( return Err(
"Emulation.setDeviceMetricsOverride requires positive finite dimensions" "Emulation.setDeviceMetricsOverride requires a non-negative finite deviceScaleFactor"
.to_string(), .to_string(),
); );
} }
let page = ctx let page = ctx
.get_session_page_mut(session_id) .get_session_page_mut(session_id)
.ok_or("No page for session")?; .ok_or("No page for session")?;
page.set_viewport((width as f32, height as f32)); page.set_viewport((
if width == 0 {
DEFAULT_VIEWPORT.0
} else {
width as f32
},
if height == 0 {
DEFAULT_VIEWPORT.1
} else {
height as f32
},
));
page.set_device_scale_factor(device_scale_factor as f32);
Ok(json!({})) Ok(json!({}))
} }
"clearDeviceMetricsOverride" => { "clearDeviceMetricsOverride" => {
let page = ctx let page = ctx
.get_session_page_mut(session_id) .get_session_page_mut(session_id)
.ok_or("No page for session")?; .ok_or("No page for session")?;
page.set_viewport((1280.0, 720.0)); page.set_viewport(DEFAULT_VIEWPORT);
page.set_device_scale_factor(1.0);
Ok(json!({})) Ok(json!({}))
} }
// Touch emulation does not affect layout yet, but acknowledging it is // Touch emulation does not affect layout yet, but acknowledging it is
@@ -53,12 +80,11 @@ mod tests {
let mut ctx = CdpContext::new(); let mut ctx = CdpContext::new();
let page_id = ctx.create_page(); let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string()); let session_id = Some("viewport-session".to_string());
ctx.sessions ctx.sessions.insert(session_id.clone().unwrap(), page_id);
.insert(session_id.clone().unwrap(), page_id);
handle( handle(
"setDeviceMetricsOverride", "setDeviceMetricsOverride",
&json!({"width": 1024, "height": 768, "deviceScaleFactor": 1}), &json!({"width": 1024, "height": 768, "deviceScaleFactor": 2}),
&mut ctx, &mut ctx,
&session_id, &session_id,
) )
@@ -69,30 +95,103 @@ mod tests {
.get_session_page_mut(&session_id) .get_session_page_mut(&session_id)
.expect("page for session"); .expect("page for session");
assert_eq!(page.viewport, (1024.0, 768.0)); assert_eq!(page.viewport, (1024.0, 768.0));
assert_eq!(page.device_scale_factor, 2.0);
assert_eq!( assert_eq!(
page.evaluate( page.evaluate(
"return [innerWidth, innerHeight, visualViewport.width,\ "return [innerWidth, innerHeight, visualViewport.width,\
visualViewport.height];" visualViewport.height, devicePixelRatio];"
), ),
json!([1024, 768, 1024, 768]) json!([1024, 768, 1024, 768, 2])
); );
} }
#[tokio::test] #[tokio::test]
async fn device_metrics_override_rejects_zero_dimensions() { async fn device_metrics_override_rejects_fractional_and_out_of_range_dimensions() {
let mut ctx = CdpContext::new(); let mut ctx = CdpContext::new();
let page_id = ctx.create_page(); let page_id = ctx.create_page();
let session_id = Some("viewport-session".to_string()); let session_id = Some("viewport-session".to_string());
ctx.sessions ctx.sessions.insert(session_id.clone().unwrap(), page_id);
.insert(session_id.clone().unwrap(), page_id); for params in [
let error = handle( json!({"width": 0.5, "height": 768, "deviceScaleFactor": 1}),
json!({"width": 10_000_001, "height": 768, "deviceScaleFactor": 1}),
json!({"width": -1, "height": 768, "deviceScaleFactor": 1}),
] {
assert!(
handle("setDeviceMetricsOverride", &params, &mut ctx, &session_id)
.await
.is_err(),
"must reject {params}"
);
}
}
#[tokio::test]
async fn zero_dimensions_disable_size_override() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("zero-size-session".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride", "setDeviceMetricsOverride",
&json!({"width": 0, "height": 768}), &json!({"width": 0, "height": 0, "deviceScaleFactor": 0}),
&mut ctx, &mut ctx,
&session_id, &session_id,
) )
.await .await
.expect_err("zero width must fail"); .expect("zero disables the overrides");
assert!(error.contains("positive finite dimensions"));
let page = ctx.get_session_page_mut(&session_id).unwrap();
assert_eq!(page.viewport, DEFAULT_VIEWPORT);
assert_eq!(page.device_scale_factor, 1.0);
}
#[tokio::test]
async fn device_scale_factor_zero_disables_override_and_clear_restores_defaults() {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = Some("scale-session".to_string());
ctx.sessions.insert(session_id.clone().unwrap(), page_id);
handle(
"setDeviceMetricsOverride",
&json!({"width": 640, "height": 480, "deviceScaleFactor": 3}),
&mut ctx,
&session_id,
)
.await
.unwrap();
assert_eq!(
ctx.get_session_page(&session_id)
.unwrap()
.device_scale_factor,
3.0
);
handle(
"setDeviceMetricsOverride",
&json!({"width": 640, "height": 480, "deviceScaleFactor": 0}),
&mut ctx,
&session_id,
)
.await
.expect("zero disables the scale override");
assert_eq!(
ctx.get_session_page(&session_id)
.unwrap()
.device_scale_factor,
1.0
);
handle(
"clearDeviceMetricsOverride",
&json!({}),
&mut ctx,
&session_id,
)
.await
.unwrap();
let page = ctx.get_session_page(&session_id).unwrap();
assert_eq!(page.viewport, (1280.0, 720.0));
assert_eq!(page.device_scale_factor, 1.0);
} }
} }
+17 -7
View File
@@ -115,10 +115,19 @@ pub async fn handle(
if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) { if let Some(paused) = ctx.fetch_intercept.paused.remove(request_id) {
let _ = paused.resolver.send(FetchResolution::Continue { let _ = paused.resolver.send(FetchResolution::Continue {
url: params.get("url").and_then(|v| v.as_str()).map(|s| s.to_string()), url: params
method: params.get("method").and_then(|v| v.as_str()).map(|s| s.to_string()), .get("url")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
method: params
.get("method")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
headers: None, headers: None,
post_data: params.get("postData").and_then(|v| v.as_str()).map(|s| s.to_string()), post_data: params
.get("postData")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
}); });
} }
Ok(json!({})) Ok(json!({}))
@@ -178,9 +187,7 @@ pub async fn handle(
} }
Ok(json!({})) Ok(json!({}))
} }
"getResponseBody" => { "getResponseBody" => Ok(json!({ "body": "", "base64Encoded": false })),
Ok(json!({ "body": "", "base64Encoded": false }))
}
"takeResponseBodyAsStream" => { "takeResponseBodyAsStream" => {
// Hand the client a streaming handle for a large response body so it // Hand the client a streaming handle for a large response body so it
// can pull it in chunks via IO.read and free it with IO.close, // can pull it in chunks via IO.read and free it with IO.close,
@@ -206,7 +213,10 @@ pub async fn handle(
format!("Fetch.takeResponseBodyAsStream: no cached body for {request_id}") format!("Fetch.takeResponseBodyAsStream: no cached body for {request_id}")
})?; })?;
let handle = ctx.io_streams.insert(bytes); let handle = ctx
.io_streams
.insert(bytes)
.map_err(|error| format!("Fetch.takeResponseBodyAsStream: {error}"))?;
Ok(json!({ "stream": handle })) Ok(json!({ "stream": handle }))
} }
_ => Err(format!("Unknown Fetch method: {}", method)), _ => Err(format!("Unknown Fetch method: {}", method)),
+150 -50
View File
@@ -9,6 +9,7 @@ use crate::dispatch::CdpContext;
// order of magnitude; keeping chunks bounded is the point of streaming (issue // order of magnitude; keeping chunks bounded is the point of streaming (issue
// #360), so we never return the whole body in one IO.read. // #360), so we never return the whole body in one IO.read.
const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB const DEFAULT_CHUNK: usize = 1 << 20; // 1 MiB
const MAX_READ_CHUNK: usize = 4 << 20; // 4 MiB
fn io_stream_max_entries() -> usize { fn io_stream_max_entries() -> usize {
std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES") std::env::var("OBSCURA_IO_STREAM_MAX_ENTRIES")
@@ -35,48 +36,90 @@ fn io_stream_max_bytes() -> usize {
/// memory stays bounded regardless of client behavior. Reading an evicted /// memory stays bounded regardless of client behavior. Reading an evicted
/// handle fails cleanly (the client re-takes or gives up), which is the right /// handle fails cleanly (the client re-takes or gives up), which is the right
/// trade against an OOM. /// trade against an OOM.
#[derive(Default)]
pub struct IoStreamStore { pub struct IoStreamStore {
streams: HashMap<String, (Vec<u8>, usize)>, streams: HashMap<String, (Vec<u8>, usize)>,
order: VecDeque<String>, order: VecDeque<String>,
total_bytes: usize, total_bytes: usize,
counter: u64, counter: u64,
max_entries: usize,
max_bytes: usize,
}
impl Default for IoStreamStore {
fn default() -> Self {
Self::with_limits(io_stream_max_entries(), io_stream_max_bytes())
}
} }
impl IoStreamStore { impl IoStreamStore {
/// Store a body and return its handle, evicting the oldest streams if this fn with_limits(max_entries: usize, max_bytes: usize) -> Self {
/// pushes the store past its entry or byte cap. Self {
pub fn insert(&mut self, bytes: Vec<u8>) -> String { streams: HashMap::new(),
let handle = format!("stream-{}", self.counter); order: VecDeque::new(),
self.counter += 1; total_bytes: 0,
self.total_bytes += bytes.len(); counter: 0,
self.streams.insert(handle.clone(), (bytes, 0)); max_entries: max_entries.max(1),
self.order.push_back(handle.clone()); max_bytes,
}
}
let max_entries = io_stream_max_entries().max(1); /// Store a body and return its handle, evicting the oldest streams if this
let max_bytes = io_stream_max_bytes(); /// would push the store past its entry or byte cap. A single body larger
// Evict oldest streams while over either cap. Never evict the stream we /// than the byte cap is rejected rather than becoming an unbounded
// just inserted (it sits at the back of `order`); a lone oversized body /// exception to the store's memory contract.
// is kept, since the client explicitly asked to stream it. pub fn insert(&mut self, bytes: Vec<u8>) -> Result<String, String> {
while self.order.len() > 1 if bytes.len() > self.max_bytes {
&& (self.order.len() > max_entries || self.total_bytes > max_bytes) return Err(format!(
"IO stream body is {} bytes, exceeding the {}-byte per-context limit",
bytes.len(),
self.max_bytes,
));
}
while !self.order.is_empty()
&& (self.order.len() >= self.max_entries
|| self
.total_bytes
.checked_add(bytes.len())
.is_none_or(|total| total > self.max_bytes))
{ {
if let Some(oldest) = self.order.pop_front() { if let Some(oldest) = self.order.pop_front() {
if let Some((b, _)) = self.streams.remove(&oldest) { if let Some((body, _)) = self.streams.remove(&oldest) {
self.total_bytes -= b.len(); self.total_bytes = self.total_bytes.saturating_sub(body.len());
} }
} }
} }
handle
let handle = format!("stream-{}", self.counter);
self.counter = self
.counter
.checked_add(1)
.ok_or("IO stream handle space exhausted")?;
self.total_bytes = self
.total_bytes
.checked_add(bytes.len())
.ok_or("IO stream byte accounting overflow")?;
self.streams.insert(handle.clone(), (bytes, 0));
self.order.push_back(handle.clone());
Ok(handle)
} }
/// Read up to `size` bytes from the stream, advancing its cursor. Returns /// Read up to `size` bytes from the stream, advancing its cursor. Returns
/// the base64 chunk and whether EOF was reached, or None for an unknown or /// the base64 chunk and whether EOF was reached, or None for an unknown or
/// already-freed handle. /// already-freed handle.
pub fn read(&mut self, handle: &str, size: usize) -> Option<(String, bool)> { pub fn read(
&mut self,
handle: &str,
offset: Option<usize>,
size: usize,
) -> Option<(String, bool)> {
let (bytes, cursor) = self.streams.get_mut(handle)?; let (bytes, cursor) = self.streams.get_mut(handle)?;
if let Some(offset) = offset {
*cursor = offset.min(bytes.len());
}
let size = size.min(MAX_READ_CHUNK);
let start = (*cursor).min(bytes.len()); let start = (*cursor).min(bytes.len());
let end = start.saturating_add(size.max(1)).min(bytes.len()); let end = start.saturating_add(size).min(bytes.len());
let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]); let data = base64::engine::general_purpose::STANDARD.encode(&bytes[start..end]);
*cursor = end; *cursor = end;
Some((data, end >= bytes.len())) Some((data, end >= bytes.len()))
@@ -103,13 +146,29 @@ pub async fn handle(method: &str, params: &Value, ctx: &mut CdpContext) -> Resul
.ok_or("IO.read requires handle")?; .ok_or("IO.read requires handle")?;
let size = params let size = params
.get("size") .get("size")
.and_then(|v| v.as_u64()) .map(|value| {
.map(|s| s as usize) value
.as_i64()
.filter(|size| *size >= 0)
.and_then(|size| usize::try_from(size).ok())
.ok_or("IO.read size must be a non-negative integer")
})
.transpose()?
.unwrap_or(DEFAULT_CHUNK); .unwrap_or(DEFAULT_CHUNK);
let offset = params
.get("offset")
.map(|value| {
value
.as_i64()
.filter(|offset| *offset >= 0)
.and_then(|offset| usize::try_from(offset).ok())
.ok_or("IO.read offset must be a non-negative integer")
})
.transpose()?;
let (data, eof) = ctx let (data, eof) = ctx
.io_streams .io_streams
.read(handle, size) .read(handle, offset, size)
.ok_or_else(|| format!("IO.read: unknown handle {handle}"))?; .ok_or_else(|| format!("IO.read: unknown handle {handle}"))?;
Ok(json!({ "data": data, "eof": eof, "base64Encoded": true })) Ok(json!({ "data": data, "eof": eof, "base64Encoded": true }))
@@ -136,48 +195,89 @@ mod tests {
#[test] #[test]
fn reads_chunks_then_frees() { fn reads_chunks_then_frees() {
let mut store = IoStreamStore::default(); let mut store = IoStreamStore::with_limits(4, 1024);
let h = store.insert(b"hello".to_vec()); let h = store.insert(b"hello".to_vec()).unwrap();
let (d1, eof1) = store.read(&h, 3).unwrap(); let (d1, eof1) = store.read(&h, None, 3).unwrap();
assert_eq!(decode(&d1), b"hel"); assert_eq!(decode(&d1), b"hel");
assert!(!eof1); assert!(!eof1);
let (d2, eof2) = store.read(&h, 3).unwrap(); let (d2, eof2) = store.read(&h, None, 3).unwrap();
assert_eq!(decode(&d2), b"lo"); assert_eq!(decode(&d2), b"lo");
assert!(eof2); assert!(eof2);
store.remove(&h); store.remove(&h);
assert!(store.read(&h, 3).is_none()); assert!(store.read(&h, None, 3).is_none());
}
#[test]
fn read_offset_seeks_and_zero_size_does_not_advance() {
let mut store = IoStreamStore::with_limits(2, 1024);
let handle = store.insert(b"abcdef".to_vec()).unwrap();
let (empty, eof) = store.read(&handle, Some(1), 0).unwrap();
assert_eq!(decode(&empty), b"");
assert!(!eof);
let (middle, eof) = store.read(&handle, None, 2).unwrap();
assert_eq!(decode(&middle), b"bc");
assert!(!eof);
let (tail, eof) = store.read(&handle, Some(4), 10).unwrap();
assert_eq!(decode(&tail), b"ef");
assert!(eof);
}
#[tokio::test]
async fn read_rejects_negative_or_non_integer_ranges() {
let mut ctx = CdpContext::new();
let handle_id = ctx.io_streams.insert(b"data".to_vec()).unwrap();
for params in [
json!({"handle": handle_id.clone(), "offset": -1}),
json!({"handle": handle_id.clone(), "size": -1}),
json!({"handle": handle_id, "offset": 1.5}),
] {
assert!(handle("read", &params, &mut ctx).await.is_err(), "{params}");
}
} }
#[test] #[test]
fn evicts_oldest_over_entry_cap() { fn evicts_oldest_over_entry_cap() {
std::env::set_var("OBSCURA_IO_STREAM_MAX_ENTRIES", "3"); let mut store = IoStreamStore::with_limits(3, 1024);
let mut store = IoStreamStore::default(); let h0 = store.insert(vec![0]).unwrap();
let h0 = store.insert(vec![0]); let h1 = store.insert(vec![1]).unwrap();
let h1 = store.insert(vec![1]); let _h2 = store.insert(vec![2]).unwrap();
let _h2 = store.insert(vec![2]); let h3 = store.insert(vec![3]).unwrap(); // 4th entry, cap 3 -> h0 evicted
let h3 = store.insert(vec![3]); // 4th entry, cap 3 -> h0 evicted
std::env::remove_var("OBSCURA_IO_STREAM_MAX_ENTRIES");
assert!(store.read(&h0, 10).is_none(), "oldest stream should be evicted"); assert!(
assert!(store.read(&h1, 10).is_some()); store.read(&h0, None, 10).is_none(),
assert!(store.read(&h3, 10).is_some()); "oldest stream should be evicted"
);
assert!(store.read(&h1, None, 10).is_some());
assert!(store.read(&h3, None, 10).is_some());
} }
#[test] #[test]
fn evicts_over_byte_cap_but_keeps_newest() { fn evicts_over_byte_cap_and_rejects_oversized_body() {
std::env::set_var("OBSCURA_IO_STREAM_MAX_BYTES", "10"); let mut store = IoStreamStore::with_limits(4, 10);
let mut store = IoStreamStore::default(); let h0 = store.insert(vec![0u8; 8]).unwrap();
let h0 = store.insert(vec![0u8; 8]); let h1 = store.insert(vec![1u8; 8]).unwrap(); // 16 > 10 -> h0 evicted
let h1 = store.insert(vec![1u8; 8]); // 16 > 10 -> h0 evicted let error = store
// A single body larger than the cap is still kept (client asked for it). .insert(vec![2u8; 100])
let big = store.insert(vec![2u8; 100]); .expect_err("a single body cannot bypass the context byte cap");
std::env::remove_var("OBSCURA_IO_STREAM_MAX_BYTES");
assert!(store.read(&h0, 100).is_none()); assert!(store.read(&h0, None, 100).is_none());
assert!(store.read(&big, 200).is_some(), "the just-inserted body is never evicted"); assert!(store.read(&h1, None, 100).is_some());
let _ = h1; assert!(error.contains("exceeding"), "{error}");
}
#[test]
fn requested_read_size_is_capped() {
let mut store = IoStreamStore::with_limits(2, MAX_READ_CHUNK * 2);
let handle = store.insert(vec![7u8; MAX_READ_CHUNK + 17]).unwrap();
let (first, eof) = store.read(&handle, None, usize::MAX).unwrap();
assert_eq!(decode(&first).len(), MAX_READ_CHUNK);
assert!(!eof);
let (second, eof) = store.read(&handle, None, usize::MAX).unwrap();
assert_eq!(decode(&second).len(), 17);
assert!(eof);
} }
} }
+11 -10
View File
@@ -1,14 +1,15 @@
pub mod target; pub mod accessibility;
pub mod browser; pub mod browser;
pub mod page;
pub mod dom; pub mod dom;
pub mod domsnapshot; pub mod domsnapshot;
pub mod runtime;
pub mod network;
pub mod fetch;
pub mod io;
pub mod input;
pub mod storage;
pub mod accessibility;
pub mod lp;
pub mod emulation; pub mod emulation;
pub mod fetch;
pub mod input;
pub mod io;
pub mod lp;
pub mod network;
pub mod page;
pub mod pdf;
pub mod runtime;
pub mod storage;
pub mod target;
File diff suppressed because it is too large. Load diff
+361
View File
@@ -0,0 +1,361 @@
#[cfg(feature = "render")]
use serde_json::json;
use serde_json::Value;
use crate::dispatch::CdpContext;
#[cfg(feature = "render")]
const MAX_BASE64_PDF_BYTES: usize = 64 * 1024 * 1024;
#[cfg(feature = "render")]
fn base64_encoded_len(raw_len: usize) -> Option<usize> {
raw_len.checked_add(2)?.checked_div(3)?.checked_mul(4)
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum PdfTransferMode {
ReturnAsBase64,
ReturnAsStream,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions,
transfer_mode: PdfTransferMode,
requested_print_background: bool,
requested_tagged_pdf: bool,
}
#[cfg(feature = "render")]
fn number(params: &Value, name: &str, default: f32) -> Result<f32, String> {
match params.get(name) {
None => Ok(default),
Some(value) => value
.as_f64()
.filter(|value| value.is_finite())
.map(|value| value as f32)
.ok_or_else(|| format!("Invalid parameters: {name} must be a finite number")),
}
}
#[cfg(feature = "render")]
fn boolean(params: &Value, name: &str, default: bool) -> Result<bool, String> {
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 parse_options(params: &Value) -> Result<ParsedPdfOptions, String> {
if !params.is_object() {
return Err("Invalid parameters: expected an object".to_string());
}
let unsupported_true = [
("displayHeaderFooter", "headers and footers"),
("preferCSSPageSize", "CSS @page sizing"),
("generateDocumentOutline", "document outlines"),
];
for (name, capability) in unsupported_true {
if boolean(params, name, false)? {
return Err(format!(
"Page.printToPDF does not yet support {capability} ({name}=true)"
));
}
}
// Current Puppeteer and Playwright always send their false default. The
// raster paginator cannot suppress backgrounds without a second paint
// mode, so accept the protocol shape and disclose the limitation in the
// extension capability fields returned with the result.
let requested_print_background = boolean(params, "printBackground", false)?;
// Puppeteer currently sends true by default. Raster PDFs have no semantic
// structure to tag, but rejecting the request makes the standard client
// unusable. Accept and truthfully report `taggedPdf:false` in the response.
let requested_tagged_pdf = boolean(params, "generateTaggedPDF", false)?;
let scale = number(params, "scale", 1.0)?;
if (scale - 1.0).abs() > f32::EPSILON {
return Err(
"Page.printToPDF scale is not supported by the raster paginator; only scale=1 is accepted"
.to_string(),
);
}
if let Some(value) = params.get("pageRanges") {
let ranges = value
.as_str()
.ok_or("Invalid parameters: pageRanges must be a string")?;
if !ranges.trim().is_empty() {
return Err("Page.printToPDF pageRanges are not yet supported".to_string());
}
}
for name in ["headerTemplate", "footerTemplate"] {
if let Some(value) = params.get(name) {
let template = value
.as_str()
.ok_or_else(|| format!("Invalid parameters: {name} must be a string"))?;
if !template.is_empty() {
return Err(format!("Page.printToPDF {name} is not yet supported"));
}
}
}
let transfer_mode = match params.get("transferMode") {
None => PdfTransferMode::ReturnAsBase64,
Some(Value::String(value)) => match value.as_str() {
"ReturnAsBase64" => PdfTransferMode::ReturnAsBase64,
"ReturnAsStream" => PdfTransferMode::ReturnAsStream,
_ => return Err("Invalid parameters: unknown transferMode".into()),
},
Some(_) => return Err("Invalid parameters: transferMode must be a string".into()),
};
let defaults = obscura_browser::RasterPdfOptions::default();
Ok(ParsedPdfOptions {
raster: obscura_browser::RasterPdfOptions {
landscape: boolean(params, "landscape", defaults.landscape)?,
paper_width_in: number(params, "paperWidth", defaults.paper_width_in)?,
paper_height_in: number(params, "paperHeight", defaults.paper_height_in)?,
margin_top_in: number(params, "marginTop", defaults.margin_top_in)?,
margin_bottom_in: number(params, "marginBottom", defaults.margin_bottom_in)?,
margin_left_in: number(params, "marginLeft", defaults.margin_left_in)?,
margin_right_in: number(params, "marginRight", defaults.margin_right_in)?,
},
transfer_mode,
requested_print_background,
requested_tagged_pdf,
})
}
pub async fn print_to_pdf(
params: &Value,
ctx: &mut CdpContext,
session_id: &Option<String>,
) -> Result<Value, String> {
#[cfg(feature = "render")]
{
let options = parse_options(params)?;
let deadline_ms = std::env::var("OBSCURA_RENDER_RESOURCE_DEADLINE_MS")
.ok()
.and_then(|value| value.parse::<u64>().ok())
.unwrap_or(3_000);
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
let _ = page.prepare_screenshot_resources(deadline_ms).await;
let pdf = page
.raster_pdf(options.raster)
.map_err(|error| error.to_string())?;
let mut response = json!({
"obscuraPrintMode": "screen-raster",
"obscuraPrintBackground": true,
"obscuraRequestedPrintBackground": options.requested_print_background,
"obscuraTaggedPDF": false,
"obscuraRequestedTaggedPDF": options.requested_tagged_pdf,
"obscuraCapabilities": {
"cssPagedMedia": false,
"honorsPrintBackground": false,
"taggedPdf": false,
},
});
let mut ignored_options = Vec::new();
if !options.requested_print_background {
ignored_options.push("printBackground");
}
if options.requested_tagged_pdf {
ignored_options.push("generateTaggedPDF");
}
response["obscuraIgnoredOptions"] = json!(ignored_options);
match options.transfer_mode {
PdfTransferMode::ReturnAsBase64 => {
use base64::Engine as _;
let encoded_len = base64_encoded_len(pdf.len())
.ok_or("Page.printToPDF base64 response size overflow")?;
if encoded_len > MAX_BASE64_PDF_BYTES {
return Err(format!(
"Page.printToPDF base64 response would be {encoded_len} bytes, exceeding the {MAX_BASE64_PDF_BYTES}-byte response limit; use transferMode=ReturnAsStream"
));
}
response["data"] =
Value::String(base64::engine::general_purpose::STANDARD.encode(pdf));
}
PdfTransferMode::ReturnAsStream => {
let handle = ctx
.io_streams
.insert(pdf)
.map_err(|error| format!("Page.printToPDF could not open stream: {error}"))?;
response["data"] = Value::String(String::new());
response["stream"] = Value::String(handle);
}
}
Ok(response)
}
#[cfg(not(feature = "render"))]
{
let _ = (params, ctx, session_id);
Err("Page.printToPDF requires a build with the render feature".to_string())
}
}
#[cfg(all(test, feature = "render"))]
mod tests {
use super::*;
use crate::domains::page;
#[test]
fn parser_accepts_standard_client_defaults_and_rejects_unrepresented_features() {
let standard = parse_options(&json!({
"transferMode": "ReturnAsStream",
"displayHeaderFooter": false,
"headerTemplate": "",
"footerTemplate": "",
"printBackground": false,
"scale": 1,
"pageRanges": "",
"preferCSSPageSize": false,
"generateTaggedPDF": true,
"generateDocumentOutline": false,
}))
.expect("current Puppeteer defaults must be accepted");
assert_eq!(standard.transfer_mode, PdfTransferMode::ReturnAsStream);
assert!(!standard.requested_print_background);
assert!(standard.requested_tagged_pdf);
for params in [
json!({"displayHeaderFooter": true}),
json!({"preferCSSPageSize": true}),
json!({"scale": 0.5}),
json!({"pageRanges": "2-3"}),
json!({"headerTemplate": "<span>title</span>"}),
json!({"generateDocumentOutline": true}),
] {
assert!(parse_options(&params).is_err(), "must reject {params}");
}
}
#[test]
fn base64_size_preflight_is_exact_and_overflow_safe() {
assert_eq!(base64_encoded_len(0), Some(0));
assert_eq!(base64_encoded_len(1), Some(4));
assert_eq!(base64_encoded_len(2), Some(4));
assert_eq!(base64_encoded_len(3), Some(4));
assert_eq!(base64_encoded_len(4), Some(8));
assert_eq!(base64_encoded_len(usize::MAX), None);
let largest_raw = (MAX_BASE64_PDF_BYTES / 4) * 3;
assert_eq!(base64_encoded_len(largest_raw), Some(MAX_BASE64_PDF_BYTES));
assert!(base64_encoded_len(largest_raw + 1).unwrap() > MAX_BASE64_PDF_BYTES);
}
#[tokio::test]
async fn print_to_pdf_returns_paginated_pdf_with_requested_media_box() {
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)
.unwrap()
.set_viewport((100.0, 80.0));
page::handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:400px;background:linear-gradient(red,blue)'></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
).await.expect("navigate fixture");
let response = print_to_pdf(
&json!({
"paperWidth": 4.0, "paperHeight": 6.0,
"marginTop": 0.5, "marginBottom": 0.5,
"marginLeft": 0.5, "marginRight": 0.5,
"printBackground": true,
}),
&mut ctx,
&session,
)
.await
.expect("raster PDF");
assert_eq!(response["obscuraPrintMode"], "screen-raster");
assert_eq!(response["obscuraPrintBackground"], true);
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(response["data"].as_str().unwrap())
.unwrap();
assert!(bytes.starts_with(b"%PDF-1.4"));
assert!(bytes.ends_with(b"%%EOF\n"));
let text = String::from_utf8_lossy(&bytes);
assert!(text.contains("/MediaBox [0 0 288.000 432.000]"));
assert!(
text.matches("/Subtype /Image").count() >= 2,
"400px tall fixture should paginate at this printable aspect ratio"
);
let streamed = page::handle(
"printToPDF",
&json!({
"transferMode": "ReturnAsStream",
"landscape": false,
"displayHeaderFooter": false,
"headerTemplate": "",
"footerTemplate": "",
"printBackground": false,
"scale": 1,
"paperWidth": 8.5,
"paperHeight": 11,
"marginTop": 0,
"marginBottom": 0,
"marginLeft": 0,
"marginRight": 0,
"pageRanges": "",
"preferCSSPageSize": false,
"generateTaggedPDF": true,
"generateDocumentOutline": false,
}),
&mut ctx,
&session,
)
.await
.expect("Puppeteer/Playwright-shaped stream request");
assert_eq!(streamed["data"], "");
assert_eq!(streamed["obscuraPrintBackground"], true);
assert_eq!(streamed["obscuraRequestedPrintBackground"], false);
assert_eq!(streamed["obscuraTaggedPDF"], false);
assert_eq!(streamed["obscuraRequestedTaggedPDF"], true);
assert_eq!(
streamed["obscuraIgnoredOptions"],
json!(["printBackground", "generateTaggedPDF"])
);
let handle = streamed["stream"].as_str().expect("protocol stream handle");
let mut streamed_bytes = Vec::new();
loop {
let chunk = crate::domains::io::handle(
"read",
&json!({"handle": handle, "size": 1024}),
&mut ctx,
)
.await
.expect("read PDF stream");
streamed_bytes.extend(
base64::engine::general_purpose::STANDARD
.decode(chunk["data"].as_str().unwrap())
.unwrap(),
);
if chunk["eof"] == true {
break;
}
}
assert!(streamed_bytes.starts_with(b"%PDF-1.4"));
assert!(streamed_bytes.ends_with(b"%%EOF\n"));
crate::domains::io::handle("close", &json!({"handle": handle}), &mut ctx)
.await
.expect("close PDF stream");
assert!(
crate::domains::io::handle("read", &json!({"handle": handle}), &mut ctx,)
.await
.is_err(),
"closed PDF handles must release their buffer"
);
}
}
+379 -186
View File
@@ -129,8 +129,11 @@ enum Command {
#[arg(long)] #[arg(long)]
selector: Option<String>, selector: Option<String>,
#[arg(long, default_value_t = 5)] /// Maximum adaptive post-load settle time in seconds. When supplied
wait: u64, /// explicitly, this is a fixed delay; the default is a 5-second cap
/// that returns once the page is quiescent.
#[arg(long)]
wait: Option<u64>,
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))] #[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u64).range(1..))]
timeout: u64, timeout: u64,
@@ -194,10 +197,8 @@ enum Command {
#[arg(long)] #[arg(long)]
user_agent: Option<String>, user_agent: Option<String>,
}, },
} }
#[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)] #[derive(Clone, Debug, clap::ValueEnum, PartialEq, Eq)]
enum DumpFormat { enum DumpFormat {
Html, Html,
@@ -221,7 +222,8 @@ enum DumpFormat {
} }
fn print_banner(port: u16) { fn print_banner(port: u16) {
println!(r#" println!(
r#"
____ _ ____ _
/ __ \| | / __ \| |
| | | | |__ ___ ___ _ _ _ __ __ _ | | | | |__ ___ ___ _ _ _ __ __ _
@@ -231,7 +233,10 @@ fn print_banner(port: u16) {
Headless Browser v{} Headless Browser v{}
CDP server: ws://127.0.0.1:{}/devtools/browser CDP server: ws://127.0.0.1:{}/devtools/browser
"#, env!("OBSCURA_BUILD_VERSION"), port); "#,
env!("OBSCURA_BUILD_VERSION"),
port
);
} }
fn select_log_filter(verbose: bool, quiet: bool) -> &'static str { fn select_log_filter(verbose: bool, quiet: bool) -> &'static str {
@@ -282,9 +287,11 @@ fn normalize_v8_flags(raw: Option<&str>) -> Option<String> {
/// footprint. Together they cut RSS ~18% on heavy pages (ycombinator.com /// footprint. Together they cut RSS ~18% on heavy pages (ycombinator.com
/// 173 MB -> 140 MB) at no measurable speed cost (V8 still JITs hot paths). /// 173 MB -> 140 MB) at no measurable speed cost (V8 still JITs hot paths).
#[cfg(target_pointer_width = "64")] #[cfg(target_pointer_width = "64")]
const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size"; const DEFAULT_V8_FLAGS: &str =
"--max-old-space-size=4096 --max-semi-space-size=4 --optimize-for-size";
#[cfg(not(target_pointer_width = "64"))] #[cfg(not(target_pointer_width = "64"))]
const DEFAULT_V8_FLAGS: &str = "--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size"; const DEFAULT_V8_FLAGS: &str =
"--max-old-space-size=1024 --max-semi-space-size=4 --optimize-for-size";
fn effective_v8_flags(user: Option<&str>) -> String { fn effective_v8_flags(user: Option<&str>) -> String {
match normalize_v8_flags(user) { match normalize_v8_flags(user) {
@@ -305,10 +312,17 @@ async fn main() -> anyhow::Result<()> {
// TZ from the host is respected. // TZ from the host is respected.
// SAFETY: runs before any V8 isolate or worker thread starts, so the env is // SAFETY: runs before any V8 isolate or worker thread starts, so the env is
// effectively single threaded here. // effectively single threaded here.
if let Some(tz) = std::env::var("OBSCURA_TIMEZONE").ok().filter(|s| !s.trim().is_empty()) { if let Some(tz) = std::env::var("OBSCURA_TIMEZONE")
unsafe { std::env::set_var("TZ", tz); } .ok()
.filter(|s| !s.trim().is_empty())
{
unsafe {
std::env::set_var("TZ", tz);
}
} else if std::env::var_os("TZ").is_none() { } else if std::env::var_os("TZ").is_none() {
unsafe { std::env::set_var("TZ", "Europe/Berlin"); } unsafe {
std::env::set_var("TZ", "Europe/Berlin");
}
} }
let quiet = is_quiet_command(&args.command); let quiet = is_quiet_command(&args.command);
@@ -333,19 +347,34 @@ async fn main() -> anyhow::Result<()> {
// SAFETY: set_var is unsafe in newer rustc; this runs before any // SAFETY: set_var is unsafe in newer rustc; this runs before any
// spawned thread inspects the env, so it's effectively single // spawned thread inspects the env, so it's effectively single
// threaded at this point. // threaded at this point.
unsafe { std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1"); } unsafe {
std::env::set_var("OBSCURA_ALLOW_PRIVATE_NETWORK", "1");
}
} }
let global_proxy = args.proxy.clone(); let global_proxy = args.proxy.clone();
let stealth = args.stealth; let stealth = args.stealth;
match args.command { match args.command {
Some(Command::Serve { port, host, proxy, user_agent, workers, max_connections, allow_file_access, storage_dir, quiet: _ }) => { Some(Command::Serve {
port,
host,
proxy,
user_agent,
workers,
max_connections,
allow_file_access,
storage_dir,
quiet: _,
}) => {
// Fall back to OBSCURA_PROXY so a proxy can be supplied without // Fall back to OBSCURA_PROXY so a proxy can be supplied without
// putting credentials on the command line. The multi-worker load // putting credentials on the command line. The multi-worker load
// balancer passes the proxy to each worker this way (issue #366). // balancer passes the proxy to each worker this way (issue #366).
let proxy = merge_proxy(global_proxy.clone(), proxy) let proxy = merge_proxy(global_proxy.clone(), proxy).or_else(|| {
.or_else(|| std::env::var("OBSCURA_PROXY").ok().filter(|s| !s.is_empty())); std::env::var("OBSCURA_PROXY")
.ok()
.filter(|s| !s.is_empty())
});
print_banner(port); print_banner(port);
if let Some(ref dir) = storage_dir { if let Some(ref dir) = storage_dir {
tracing::info!("Storage dir: {}", dir.display()); tracing::info!("Storage dir: {}", dir.display());
@@ -370,12 +399,35 @@ async fn main() -> anyhow::Result<()> {
run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?; run_multi_worker_serve(port, host, workers, proxy, stealth, user_agent).await?;
} else { } else {
obscura_cdp::start_with_serve_options_and_limit( obscura_cdp::start_with_serve_options_and_limit(
port, &host, proxy, stealth, user_agent, allow_file_access, storage_dir, port,
args.allow_private_network, max_connections, &host,
).await?; proxy,
stealth,
user_agent,
allow_file_access,
storage_dir,
args.allow_private_network,
max_connections,
)
.await?;
} }
} }
Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, eval, output, quiet, storage_dir, file, concurrency, screenshot }) => { Some(Command::Fetch {
url,
dump,
selector,
wait,
timeout,
wait_until,
user_agent,
eval,
output,
quiet,
storage_dir,
file,
concurrency,
screenshot,
}) => {
if let Some(file) = file { if let Some(file) = file {
if url.is_some() { if url.is_some() {
anyhow::bail!("Pass URLs via a positional argument or --file, not both."); anyhow::bail!("Pass URLs via a positional argument or --file, not both.");
@@ -392,18 +444,71 @@ async fn main() -> anyhow::Result<()> {
), ),
} }
let urls = read_urls_from_file(&file)?; let urls = read_urls_from_file(&file)?;
run_batch_fetch(urls, concurrency.get(), timeout, user_agent, global_proxy, output, quiet).await?; run_batch_fetch(
urls,
concurrency.get(),
timeout,
user_agent,
global_proxy,
output,
quiet,
)
.await?;
} else { } else {
let url = url.ok_or_else(|| { let url = url.ok_or_else(|| {
anyhow::anyhow!("No URL provided. Pass a URL, or a list of URLs with --file <path>.") anyhow::anyhow!(
"No URL provided. Pass a URL, or a list of URLs with --file <path>."
)
})?; })?;
run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network, screenshot).await?; let wait_is_fixed = wait.is_some();
run_fetch(
&url,
dump,
selector,
wait.unwrap_or(5),
wait_is_fixed,
timeout,
&wait_until,
user_agent,
stealth,
eval,
output,
quiet,
global_proxy,
storage_dir,
args.allow_private_network,
screenshot,
)
.await?;
} }
} }
Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => { Some(Command::Scrape {
run_parallel_scrape(urls, eval, concurrency.get(), &format, timeout, quiet, global_proxy, stealth).await?; urls,
eval,
concurrency,
format,
timeout,
quiet,
}) => {
run_parallel_scrape(
urls,
eval,
concurrency.get(),
&format,
timeout,
quiet,
global_proxy,
stealth,
)
.await?;
} }
Some(Command::Mcp { http, host, port, proxy, user_agent }) => { Some(Command::Mcp {
http,
host,
port,
proxy,
user_agent,
}) => {
let mcp_proxy = merge_proxy(global_proxy.clone(), proxy); let mcp_proxy = merge_proxy(global_proxy.clone(), proxy);
if http { if http {
obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?; obscura_mcp::http::run(host, port, mcp_proxy, user_agent, stealth).await?;
@@ -431,8 +536,8 @@ async fn run_multi_worker_serve(
stealth: bool, stealth: bool,
user_agent: Option<String>, user_agent: Option<String>,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
use tokio::net::TcpListener;
use tokio::io::AsyncWriteExt as _; use tokio::io::AsyncWriteExt as _;
use tokio::net::TcpListener;
let exe = std::env::current_exe()?; let exe = std::env::current_exe()?;
let mut children = Vec::new(); let mut children = Vec::new();
@@ -514,11 +619,8 @@ async fn run_multi_worker_serve(
return; return;
} }
}; };
let _ = tokio::io::copy_bidirectional( let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream)
&mut client, .await;
&mut worker_stream,
)
.await;
}); });
} }
Err(e) => { Err(e) => {
@@ -526,9 +628,7 @@ async fn run_multi_worker_serve(
tokio::spawn(async move { tokio::spawn(async move {
let mut s = client_stream; let mut s = client_stream;
let _ = s let _ = s
.write_all( .write_all(b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n")
b"HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n",
)
.await; .await;
let _ = s.shutdown().await; let _ = s.shutdown().await;
}); });
@@ -543,8 +643,7 @@ async fn run_multi_worker_serve(
match tokio::net::TcpStream::connect(&worker_addr).await { match tokio::net::TcpStream::connect(&worker_addr).await {
Ok(mut worker_stream) => { Ok(mut worker_stream) => {
let mut client = client_stream; let mut client = client_stream;
let _ = let _ = tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
tokio::io::copy_bidirectional(&mut client, &mut worker_stream).await;
} }
Err(e) => { Err(e) => {
tracing::warn!("worker {} unreachable: {}", worker_addr, e); tracing::warn!("worker {} unreachable: {}", worker_addr, e);
@@ -559,11 +658,21 @@ async fn run_multi_worker_serve(
} }
} }
async fn settle_page(page: &mut Page, wait_secs: u64, fixed: bool) {
let wait_ms = wait_secs.saturating_mul(1000);
if fixed {
page.settle_for_duration(wait_ms).await;
} else {
page.settle(wait_ms).await;
}
}
async fn run_fetch( async fn run_fetch(
url_str: &str, url_str: &str,
dump: Option<DumpFormat>, dump: Option<DumpFormat>,
selector: Option<String>, selector: Option<String>,
wait_secs: u64, wait_secs: u64,
wait_is_fixed: bool,
timeout_secs: u64, timeout_secs: u64,
wait_until: &str, wait_until: &str,
user_agent: Option<String>, user_agent: Option<String>,
@@ -587,13 +696,7 @@ async fn run_fetch(
// payloads (images, fonts, …) and any non-HTML resource where parsing the // payloads (images, fonts, …) and any non-HTML resource where parsing the
// body through the DOM/JS layer would corrupt or discard data. // body through the DOM/JS layer would corrupt or discard data.
if dump == DumpFormat::Original { if dump == DumpFormat::Original {
let bytes = fetch_original_bytes( let bytes = fetch_original_bytes(url_str, proxy, user_agent.clone(), timeout_secs).await?;
url_str,
proxy,
user_agent.clone(),
timeout_secs,
)
.await?;
write_or_print_bytes(&bytes, output.as_ref()).await?; write_or_print_bytes(&bytes, output.as_ref()).await?;
return Ok(()); return Ok(());
} }
@@ -643,8 +746,7 @@ async fn run_fetch(
// absent. // absent.
let eval_at_capture_boundary = screenshot.is_some() let eval_at_capture_boundary = screenshot.is_some()
&& eval.is_some() && eval.is_some()
&& std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE") && std::env::var("OBSCURA_SHOT_EVAL_AT_CAPTURE").is_ok_and(|value| value == "1");
.is_ok_and(|value| value == "1");
let controlled_scroll_request = screenshot.as_ref().and_then(|_| { let controlled_scroll_request = screenshot.as_ref().and_then(|_| {
let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?; let raw_y = std::env::var("OBSCURA_SHOT_SCROLL_Y").ok()?;
let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") { let x = match std::env::var("OBSCURA_SHOT_SCROLL_X") {
@@ -673,9 +775,7 @@ async fn run_fetch(
{ {
let settle_passes = if eval_at_capture_boundary { let settle_passes = if eval_at_capture_boundary {
1 + u64::from(controlled_scroll_request.is_some()) 1 + u64::from(controlled_scroll_request.is_some())
} else if eval.is_some() } else if eval.is_some() && (screenshot.is_some() || selector.is_some() || dump_specified) {
&& (screenshot.is_some() || selector.is_some() || dump_specified)
{
2 2
} else { } else {
1 1
@@ -687,13 +787,23 @@ async fn run_fetch(
); );
std::thread::spawn(move || { std::thread::spawn(move || {
std::thread::sleep(hard); std::thread::sleep(hard);
eprintln!("obscura: hard timeout exceeded ({}s); forcing exit", hard.as_secs()); eprintln!(
"obscura: hard timeout exceeded ({}s); forcing exit",
hard.as_secs()
);
std::process::exit(124); std::process::exit(124);
}); });
} }
match timeout(Duration::from_secs(timeout_secs), page.navigate_with_wait(url_str, wait_condition)).await { match timeout(
Ok(result) => result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?, Duration::from_secs(timeout_secs),
page.navigate_with_wait(url_str, wait_condition),
)
.await
{
Ok(result) => {
result.map_err(|e| anyhow::anyhow!("Failed to navigate to {}: {}", url_str, e))?
}
Err(_) => anyhow::bail!( Err(_) => anyhow::bail!(
"Timed out navigating to {} after {}s", "Timed out navigating to {} after {}s",
url_str, url_str,
@@ -709,7 +819,7 @@ async fn run_fetch(
// and completion callbacks (e.g. testharness's add_completion_callback) run // and completion callbacks (e.g. testharness's add_completion_callback) run
// before we read the page. Returns early once the loop is idle, so static // before we read the page. Returns early once the loop is idle, so static
// pages stay fast. // pages stay fast.
page.settle(wait_secs.saturating_mul(1000)).await; settle_page(&mut page, wait_secs, wait_is_fixed).await;
let mut deferred_eval_output = None; let mut deferred_eval_output = None;
let initial_controlled_scroll = if eval_at_capture_boundary { let initial_controlled_scroll = if eval_at_capture_boundary {
@@ -731,7 +841,7 @@ async fn run_fetch(
None None
}; };
if initial_controlled_scroll.is_some() { if initial_controlled_scroll.is_some() {
page.settle(wait_secs.saturating_mul(1000)).await; settle_page(&mut page, wait_secs, wait_is_fixed).await;
} }
if !eval_at_capture_boundary { if !eval_at_capture_boundary {
@@ -764,7 +874,7 @@ async fn run_fetch(
// listener) that writes the DOM. Drive the event loop again so that // listener) that writes the DOM. Drive the event loop again so that
// work completes, then fall through to selector/capture/dump instead // work completes, then fall through to selector/capture/dump instead
// of returning the still-pending eval value (issue #248). // of returning the still-pending eval value (issue #248).
page.settle(wait_secs.saturating_mul(1000)).await; settle_page(&mut page, wait_secs, wait_is_fixed).await;
} }
} }
@@ -785,7 +895,9 @@ async fn run_fetch(
.ok() .ok()
.and_then(|value| value.parse::<u64>().ok()) .and_then(|value| value.parse::<u64>().ok())
.unwrap_or(3_000); .unwrap_or(3_000);
let _ = page.prepare_screenshot_resources(resource_deadline_ms).await; let _ = page
.prepare_screenshot_resources(resource_deadline_ms)
.await;
// Default CSS-pixel viewport, matching the engine's innerWidth/Height. // Default CSS-pixel viewport, matching the engine's innerWidth/Height.
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to // OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
// capture below-the-fold content in one shot). // capture below-the-fold content in one shot).
@@ -795,8 +907,8 @@ async fn run_fetch(
// render resources during prepare/paint, so sampling first would // render resources during prepare/paint, so sampling first would
// compare pre-paint Obscura state with post-load Chromium state. // compare pre-paint Obscura state with post-load Chromium state.
// Keep this private opt-in out of ordinary CLI screenshots. // Keep this private opt-in out of ordinary CLI screenshots.
let warmup_capture = std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP") let warmup_capture =
.is_ok_and(|value| value == "1"); std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1");
if warmup_capture { if warmup_capture {
if page.screenshot(viewport).is_none() { if page.screenshot(viewport).is_none() {
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render"); anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
@@ -836,9 +948,8 @@ async fn run_fetch(
}); });
if eval_at_capture_boundary { if eval_at_capture_boundary {
if let Some(ref expr) = eval { if let Some(ref expr) = eval {
deferred_eval_output = Some( deferred_eval_output =
page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)) Some(page.evaluate_with_timeout(expr, Duration::from_secs(timeout_secs)));
);
} }
} }
let capture_state = deferred_eval_output.as_ref().map(|_| { let capture_state = deferred_eval_output.as_ref().map(|_| {
@@ -896,7 +1007,11 @@ async fn run_fetch(
); );
} }
if !quiet { if !quiet {
eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)); eprintln!(
"Screenshot written: {} ({} bytes)",
path.display(),
std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)
);
} }
context.save_cookies(); context.save_cookies();
return Ok(()); return Ok(());
@@ -958,7 +1073,11 @@ async fn fetch_original_bytes(
user_agent: Option<String>, user_agent: Option<String>,
timeout_secs: u64, timeout_secs: u64,
) -> anyhow::Result<Vec<u8>> { ) -> anyhow::Result<Vec<u8>> {
Ok(fetch_original_response(url_str, proxy, user_agent, timeout_secs).await?.body) Ok(
fetch_original_response(url_str, proxy, user_agent, timeout_secs)
.await?
.body,
)
} }
/// Read newline-delimited URLs from `path` (or stdin when `path` is `-`). /// Read newline-delimited URLs from `path` (or stdin when `path` is `-`).
@@ -1024,9 +1143,13 @@ async fn run_batch_fetch(
handles.push(tokio::spawn(async move { handles.push(tokio::spawn(async move {
let _permit = sem.acquire().await.unwrap(); let _permit = sem.acquire().await.unwrap();
let task_start = Instant::now(); let task_start = Instant::now();
let result = let result = fetch_original_response(
fetch_original_response(&url, (*proxy).clone(), (*user_agent).clone(), timeout_secs) &url,
.await; (*proxy).clone(),
(*user_agent).clone(),
timeout_secs,
)
.await;
let elapsed_ms = task_start.elapsed().as_millis(); let elapsed_ms = task_start.elapsed().as_millis();
let line = match result { let line = match result {
@@ -1097,7 +1220,10 @@ async fn run_batch_fetch(
Ok(()) Ok(())
} }
async fn write_or_print(content: String, output: Option<&std::path::PathBuf>) -> anyhow::Result<()> { async fn write_or_print(
content: String,
output: Option<&std::path::PathBuf>,
) -> anyhow::Result<()> {
if let Some(path) = output { if let Some(path) = output {
tokio::fs::write(path, content) tokio::fs::write(path, content)
.await .await
@@ -1135,9 +1261,9 @@ async fn write_or_print_bytes(
async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool { async fn wait_for_selector(page: &mut Page, selector: &str, timeout_secs: u64) -> bool {
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs); let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(timeout_secs);
loop { loop {
let found = page.with_dom(|dom| { let found = page
dom.query_selector(selector).ok().flatten().is_some() .with_dom(|dom| dom.query_selector(selector).ok().flatten().is_some())
}).unwrap_or(false); .unwrap_or(false);
if found { if found {
return true; return true;
@@ -1176,7 +1302,8 @@ fn dump_html(page: &Page) -> String {
let doc = dom.document(); let doc = dom.document();
dom.inner_html(doc) dom.inner_html(doc)
} }
}).unwrap_or_default() })
.unwrap_or_default()
} }
fn dump_text(page: &mut Page) -> String { fn dump_text(page: &mut Page) -> String {
@@ -1187,7 +1314,8 @@ fn dump_text(page: &mut Page) -> String {
} else { } else {
String::new() String::new()
} }
}).unwrap_or_default() })
.unwrap_or_default()
} }
fn dump_markdown(page: &mut Page) -> String { fn dump_markdown(page: &mut Page) -> String {
@@ -1249,18 +1377,47 @@ fn extract_readable_text(dom: &obscura_dom::DomTree, node_id: obscura_dom::NodeI
// Boilerplate elements rarely contain content the user wants to // Boilerplate elements rarely contain content the user wants to
// scrape — strip them so `--dump text` returns the article body // scrape — strip them so `--dump text` returns the article body
// instead of menus, footers, and cookie banners. // instead of menus, footers, and cookie banners.
if matches!(tag, "script" | "style" | "nav" | "header" | "footer" | "aside") { if matches!(
tag,
"script" | "style" | "nav" | "header" | "footer" | "aside"
) {
continue; continue;
} }
let is_block = matches!( let is_block = matches!(
tag, tag,
"div" | "p" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" "div"
| "li" | "tr" | "br" | "hr" | "blockquote" | "pre" | "p"
| "section" | "article" | "header" | "footer" | "nav" | "h1"
| "main" | "aside" | "figure" | "figcaption" | "table" | "h2"
| "thead" | "tbody" | "tfoot" | "dl" | "dt" | "dd" | "h3"
| "ul" | "ol" | "h4"
| "h5"
| "h6"
| "li"
| "tr"
| "br"
| "hr"
| "blockquote"
| "pre"
| "section"
| "article"
| "header"
| "footer"
| "nav"
| "main"
| "aside"
| "figure"
| "figcaption"
| "table"
| "thead"
| "tbody"
| "tfoot"
| "dl"
| "dt"
| "dd"
| "ul"
| "ol"
); );
if is_block { if is_block {
@@ -1308,7 +1465,11 @@ async fn run_parallel_scrape(
); );
} }
let worker_name = if cfg!(windows) { "obscura-worker.exe" } else { "obscura-worker" }; let worker_name = if cfg!(windows) {
"obscura-worker.exe"
} else {
"obscura-worker"
};
let worker_path = std::env::current_exe() let worker_path = std::env::current_exe()
.ok() .ok()
.and_then(|p| p.parent().map(|d| d.join(worker_name))) .and_then(|p| p.parent().map(|d| d.join(worker_name)))
@@ -1382,44 +1543,10 @@ async fn run_parallel_scrape(
}; };
let mut reader = BufReader::new(stdout); let mut reader = BufReader::new(stdout);
let worker_result: Result<serde_json::Value, String> = match timeout(worker_timeout, async { let worker_result: Result<serde_json::Value, String> =
let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url}); match timeout(worker_timeout, async {
let mut line = serde_json::to_string(&nav_cmd).unwrap(); let nav_cmd = serde_json::json!({"cmd": "navigate", "url": url});
line.push('\n'); let mut line = serde_json::to_string(&nav_cmd).unwrap();
if stdin.write_all(line.as_bytes()).await.is_err() {
return Err("Write failed".to_string());
}
if stdin.flush().await.is_err() {
return Err("Write failed".to_string());
}
let mut resp_line = String::new();
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
Ok(Ok(bytes)) if bytes > 0 => {}
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
Err(_) => return Err("timeout".to_string()),
};
let nav_resp: serde_json::Value =
serde_json::from_str(resp_line.trim()).unwrap_or(serde_json::json!({"ok": false}));
if !nav_resp["ok"].as_bool().unwrap_or(false) {
return Err(
nav_resp["error"]
.as_str()
.unwrap_or("navigate failed")
.to_string(),
);
}
let title = nav_resp["result"]["title"]
.as_str()
.unwrap_or("")
.to_string();
let eval_result = if let Some(ref expr) = *eval {
let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr});
let mut line = serde_json::to_string(&eval_cmd).unwrap();
line.push('\n'); line.push('\n');
if stdin.write_all(line.as_bytes()).await.is_err() { if stdin.write_all(line.as_bytes()).await.is_err() {
return Err("Write failed".to_string()); return Err("Write failed".to_string());
@@ -1430,39 +1557,73 @@ async fn run_parallel_scrape(
let mut resp_line = String::new(); let mut resp_line = String::new();
match timeout(read_timeout, reader.read_line(&mut resp_line)).await { match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
Ok(Ok(bytes)) if bytes > 0 => { Ok(Ok(bytes)) if bytes > 0 => {}
let resp: serde_json::Value = serde_json::from_str(resp_line.trim())
.unwrap_or(serde_json::json!({"ok": false}));
resp["result"].clone()
}
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()), Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
Err(_) => return Err("timeout".to_string()), Err(_) => return Err("timeout".to_string()),
};
let nav_resp: serde_json::Value = serde_json::from_str(resp_line.trim())
.unwrap_or(serde_json::json!({"ok": false}));
if !nav_resp["ok"].as_bool().unwrap_or(false) {
return Err(nav_resp["error"]
.as_str()
.unwrap_or("navigate failed")
.to_string());
} }
} else {
serde_json::Value::Null let title = nav_resp["result"]["title"]
.as_str()
.unwrap_or("")
.to_string();
let eval_result = if let Some(ref expr) = *eval {
let eval_cmd = serde_json::json!({"cmd": "evaluate", "expression": expr});
let mut line = serde_json::to_string(&eval_cmd).unwrap();
line.push('\n');
if stdin.write_all(line.as_bytes()).await.is_err() {
return Err("Write failed".to_string());
}
if stdin.flush().await.is_err() {
return Err("Write failed".to_string());
}
let mut resp_line = String::new();
match timeout(read_timeout, reader.read_line(&mut resp_line)).await {
Ok(Ok(bytes)) if bytes > 0 => {
let resp: serde_json::Value =
serde_json::from_str(resp_line.trim())
.unwrap_or(serde_json::json!({"ok": false}));
resp["result"].clone()
}
Ok(Ok(_)) | Ok(Err(_)) => return Err("Read failed".to_string()),
Err(_) => return Err("timeout".to_string()),
}
} else {
serde_json::Value::Null
};
let shutdown_cmd = serde_json::json!({"cmd": "shutdown"});
let mut line = serde_json::to_string(&shutdown_cmd).unwrap();
line.push('\n');
let _ = stdin.write_all(line.as_bytes()).await;
let _ = stdin.flush().await;
let _ = timeout(shutdown_timeout, child.wait()).await;
Ok(serde_json::json!({
"url": url,
"title": title,
"eval": eval_result,
"time_ms": task_start.elapsed().as_millis(),
"worker": i,
}))
})
.await
{
Ok(result) => result,
Err(_) => Err("timeout".to_string()),
}; };
let shutdown_cmd = serde_json::json!({"cmd": "shutdown"});
let mut line = serde_json::to_string(&shutdown_cmd).unwrap();
line.push('\n');
let _ = stdin.write_all(line.as_bytes()).await;
let _ = stdin.flush().await;
let _ = timeout(shutdown_timeout, child.wait()).await;
Ok(serde_json::json!({
"url": url,
"title": title,
"eval": eval_result,
"time_ms": task_start.elapsed().as_millis(),
"worker": i,
}))
})
.await
{
Ok(result) => result,
Err(_) => Err("timeout".to_string()),
};
match worker_result { match worker_result {
Ok(result) => result, Ok(result) => result,
Err(error) => { Err(error) => {
@@ -1537,7 +1698,9 @@ fn dump_links(page: &Page) -> String {
let full_url = if href.starts_with("http://") || href.starts_with("https://") { let full_url = if href.starts_with("http://") || href.starts_with("https://") {
href.clone() href.clone()
} else if let Some(ref base) = base_url { } else if let Some(ref base) = base_url {
base.join(&href).map(|u| u.to_string()).unwrap_or(href.clone()) base.join(&href)
.map(|u| u.to_string())
.unwrap_or(href.clone())
} else { } else {
href.clone() href.clone()
}; };
@@ -1552,7 +1715,8 @@ fn dump_links(page: &Page) -> String {
} }
} }
rendered.join("\n") rendered.join("\n")
}).unwrap_or_default() })
.unwrap_or_default()
} }
/// Selectors paired with the attribute whose URL we extract and the /// Selectors paired with the attribute whose URL we extract and the
@@ -1575,7 +1739,13 @@ const ASSET_SELECTORS: &[(&str, &str, &str)] = &[
/// Unknown / missing `rel` falls back to a generic "link" so the /// Unknown / missing `rel` falls back to a generic "link" so the
/// caller still sees the URL. /// caller still sees the URL.
fn link_kind_from_rel(rel: &str) -> &'static str { fn link_kind_from_rel(rel: &str) -> &'static str {
match rel.split_ascii_whitespace().next().unwrap_or("").to_ascii_lowercase().as_str() { match rel
.split_ascii_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"stylesheet" => "stylesheet", "stylesheet" => "stylesheet",
"icon" | "shortcut" => "icon", "icon" | "shortcut" => "icon",
"manifest" => "manifest", "manifest" => "manifest",
@@ -1616,9 +1786,13 @@ fn extract_assets(dom: &obscura_dom::DomTree, base_url: Option<&url::Url>) -> St
for (selector, attr, default_kind) in ASSET_SELECTORS { for (selector, attr, default_kind) in ASSET_SELECTORS {
let nodes = dom.query_selector_all(selector).unwrap_or_default(); let nodes = dom.query_selector_all(selector).unwrap_or_default();
for node_id in nodes { for node_id in nodes {
let Some(node) = dom.get_node(node_id) else { continue }; let Some(node) = dom.get_node(node_id) else {
continue;
};
let raw = node.get_attribute(attr).unwrap_or_default().to_string(); let raw = node.get_attribute(attr).unwrap_or_default().to_string();
let Some(url) = resolve_asset_url(&raw, base_url) else { continue }; let Some(url) = resolve_asset_url(&raw, base_url) else {
continue;
};
let kind = if *default_kind == "link" { let kind = if *default_kind == "link" {
let rel = node.get_attribute("rel").unwrap_or_default().to_string(); let rel = node.get_attribute("rel").unwrap_or_default().to_string();
@@ -1643,8 +1817,11 @@ fn dump_assets(page: &Page) -> String {
.with_dom(|dom| extract_assets(dom, base_url.as_ref())) .with_dom(|dom| extract_assets(dom, base_url.as_ref()))
.unwrap_or_default(); .unwrap_or_default();
let mut lines: Vec<String> = let mut lines: Vec<String> = dom_ndjson
dom_ndjson.lines().filter(|l| !l.is_empty()).map(|l| l.to_string()).collect(); .lines()
.filter(|l| !l.is_empty())
.map(|l| l.to_string())
.collect();
// URLs already listed from static DOM attributes, so a resource the script // URLs already listed from static DOM attributes, so a resource the script
// fetches that the markup also references is not emitted twice. // fetches that the markup also references is not emitted twice.
@@ -1669,9 +1846,9 @@ fn dump_assets(page: &Page) -> String {
mod tests { mod tests {
use super::{ use super::{
effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes, effective_v8_flags, extract_assets, extract_readable_text, fetch_original_bytes,
is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, is_quiet_command, link_kind_from_rel, merge_proxy, normalize_v8_flags, read_urls_from_file,
read_urls_from_file, resolve_asset_url, select_log_filter, write_or_print, resolve_asset_url, select_log_filter, write_or_print, write_or_print_bytes, Args, Command,
write_or_print_bytes, Args, Command, DumpFormat, DEFAULT_V8_FLAGS, DumpFormat, DEFAULT_V8_FLAGS,
}; };
use clap::Parser; use clap::Parser;
use obscura_dom::parse_html; use obscura_dom::parse_html;
@@ -1720,7 +1897,13 @@ mod tests {
]) ])
.expect("clap should accept --file with --concurrency and no positional URL"); .expect("clap should accept --file with --concurrency and no positional URL");
match args.command { match args.command {
Some(Command::Fetch { url, file, concurrency, dump, .. }) => { Some(Command::Fetch {
url,
file,
concurrency,
dump,
..
}) => {
assert!(url.is_none()); assert!(url.is_none());
assert_eq!(file, Some(std::path::PathBuf::from("urls.txt"))); assert_eq!(file, Some(std::path::PathBuf::from("urls.txt")));
assert_eq!(concurrency.get(), 25); assert_eq!(concurrency.get(), 25);
@@ -1734,7 +1917,8 @@ mod tests {
fn concurrency_rejects_zero() { fn concurrency_rejects_zero() {
// NonZeroUsize means --concurrency 0 is a parse error, not a silent hang // NonZeroUsize means --concurrency 0 is a parse error, not a silent hang
// on a zero-permit semaphore. // on a zero-permit semaphore.
let err = Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]); let err =
Args::try_parse_from(["obscura", "fetch", "--file", "u.txt", "--concurrency", "0"]);
assert!(err.is_err()); assert!(err.is_err());
} }
@@ -1788,7 +1972,10 @@ mod tests {
let _ = tokio::fs::remove_file(&path).await; let _ = tokio::fs::remove_file(&path).await;
assert_eq!(bytes, PNG_BYTES, "raw response body must match the file byte-for-byte"); assert_eq!(
bytes, PNG_BYTES,
"raw response body must match the file byte-for-byte"
);
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
@@ -1811,7 +1998,10 @@ mod tests {
let read_back = tokio::fs::read(&path).await.expect("read back"); let read_back = tokio::fs::read(&path).await.expect("read back");
let _ = tokio::fs::remove_file(&path).await; let _ = tokio::fs::remove_file(&path).await;
assert_eq!(read_back, payload, "file bytes must match the payload exactly"); assert_eq!(
read_back, payload,
"file bytes must match the payload exactly"
);
} }
#[tokio::test(flavor = "current_thread")] #[tokio::test(flavor = "current_thread")]
@@ -1856,13 +2046,8 @@ mod tests {
#[test] #[test]
fn parsed_fetch_with_quiet_flag_is_detected() { fn parsed_fetch_with_quiet_flag_is_detected() {
let args = Args::try_parse_from([ let args = Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"])
"obscura", .expect("clap should accept --quiet on fetch");
"fetch",
"--quiet",
"https://example.com",
])
.expect("clap should accept --quiet on fetch");
assert!(is_quiet_command(&args.command)); assert!(is_quiet_command(&args.command));
} }
@@ -1875,8 +2060,7 @@ mod tests {
#[test] #[test]
fn parsed_serve_command_is_not_quiet() { fn parsed_serve_command_is_not_quiet() {
let args = Args::try_parse_from(["obscura", "serve"]) let args = Args::try_parse_from(["obscura", "serve"]).expect("clap should accept serve");
.expect("clap should accept serve");
assert!(!is_quiet_command(&args.command)); assert!(!is_quiet_command(&args.command));
} }
@@ -1938,14 +2122,9 @@ mod tests {
#[test] #[test]
fn parsed_v8_flags_empty_string_is_accepted() { fn parsed_v8_flags_empty_string_is_accepted() {
let args = Args::try_parse_from([ let args =
"obscura", Args::try_parse_from(["obscura", "--v8-flags", "", "fetch", "https://example.com"])
"--v8-flags", .expect("clap should accept empty --v8-flags value");
"",
"fetch",
"https://example.com",
])
.expect("clap should accept empty --v8-flags value");
assert_eq!(args.v8_flags.as_deref(), Some("")); assert_eq!(args.v8_flags.as_deref(), Some(""));
} }
@@ -2001,17 +2180,29 @@ mod tests {
#[test] #[test]
fn parsed_fetch_quiet_resolves_to_off_filter() { fn parsed_fetch_quiet_resolves_to_off_filter() {
let args = Args::try_parse_from([ let args =
"obscura", Args::try_parse_from(["obscura", "fetch", "--quiet", "https://example.com"]).unwrap();
"fetch",
"--quiet",
"https://example.com",
])
.unwrap();
let filter = select_log_filter(args.verbose, is_quiet_command(&args.command)); let filter = select_log_filter(args.verbose, is_quiet_command(&args.command));
assert_eq!(filter, "off"); assert_eq!(filter, "off");
} }
#[test]
fn fetch_wait_distinguishes_adaptive_default_from_fixed_delay() {
let default = Args::try_parse_from(["obscura", "fetch", "https://example.com"]).unwrap();
match default.command {
Some(Command::Fetch { wait, .. }) => assert_eq!(wait, None),
_ => panic!("expected Fetch command"),
}
let fixed =
Args::try_parse_from(["obscura", "fetch", "https://example.com", "--wait", "0"])
.unwrap();
match fixed.command {
Some(Command::Fetch { wait, .. }) => assert_eq!(wait, Some(0)),
_ => panic!("expected Fetch command"),
}
}
#[test] #[test]
fn matcher_still_uses_fetch_variant() { fn matcher_still_uses_fetch_variant() {
let cmd = Some(Command::Fetch { let cmd = Some(Command::Fetch {
@@ -2020,7 +2211,7 @@ mod tests {
selector: None, selector: None,
file: None, file: None,
concurrency: std::num::NonZeroUsize::new(1).unwrap(), concurrency: std::num::NonZeroUsize::new(1).unwrap(),
wait: 5, wait: Some(5),
timeout: 30, timeout: 30,
wait_until: "load".to_string(), wait_until: "load".to_string(),
user_agent: None, user_agent: None,
@@ -2040,7 +2231,10 @@ mod tests {
.ok() .ok()
.flatten() .flatten()
.expect("body must exist"); .expect("body must exist");
extract_readable_text(&dom, body).split_whitespace().collect::<Vec<_>>().join(" ") extract_readable_text(&dom, body)
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
} }
#[test] #[test]
@@ -2267,5 +2461,4 @@ mod tests {
assert!(lines[0].contains("\"https://example.test/ok.html\"")); assert!(lines[0].contains("\"https://example.test/ok.html\""));
assert!(lines[0].contains("\"iframe\"")); assert!(lines[0].contains("\"iframe\""));
} }
} }
+463 -62
View File
@@ -9,8 +9,8 @@ use selectors::matching::{
NeedsSelectorFlags, NeedsSelectorFlags,
}; };
use selectors::parser::{self, ParseRelative, SelectorParseErrorKind}; use selectors::parser::{self, ParseRelative, SelectorParseErrorKind};
use selectors::{Element, OpaqueElement, SelectorList};
use selectors::visitor::SelectorVisitor; use selectors::visitor::SelectorVisitor;
use selectors::{Element, OpaqueElement, SelectorList};
use crate::tree::{DomTree, NodeData, NodeId}; use crate::tree::{DomTree, NodeData, NodeId};
@@ -251,7 +251,13 @@ impl<'a> DomElement<'a> {
.map(|name| { .map(|name| {
matches!( matches!(
name.local.as_ref(), name.local.as_ref(),
"input" | "button" | "select" | "textarea" | "optgroup" | "option" | "fieldset" "input"
| "button"
| "select"
| "textarea"
| "optgroup"
| "option"
| "fieldset"
) )
}) })
.unwrap_or(false) .unwrap_or(false)
@@ -390,20 +396,26 @@ impl<'a> Element for DomElement<'a> {
fn has_namespace(&self, ns: &CssNamespace) -> bool { fn has_namespace(&self, ns: &CssNamespace) -> bool {
self.tree self.tree
.with_node(self.node_id, |n| { .with_node(self.node_id, |n| {
n.as_element() n.as_element().map(|name| name.ns == ns.0).unwrap_or(false)
.map(|name| name.ns == ns.0)
.unwrap_or(false)
}) })
.unwrap_or(false) .unwrap_or(false)
} }
fn is_same_type(&self, other: &Self) -> bool { fn is_same_type(&self, other: &Self) -> bool {
let self_name = self.tree.with_node(self.node_id, |n| { let self_name = self
n.as_element().map(|name| (name.local.clone(), name.ns.clone())) .tree
}).flatten(); .with_node(self.node_id, |n| {
let other_name = self.tree.with_node(other.node_id, |n| { n.as_element()
n.as_element().map(|name| (name.local.clone(), name.ns.clone())) .map(|name| (name.local.clone(), name.ns.clone()))
}).flatten(); })
.flatten();
let other_name = self
.tree
.with_node(other.node_id, |n| {
n.as_element()
.map(|name| (name.local.clone(), name.ns.clone()))
})
.flatten();
match (self_name, other_name) { match (self_name, other_name) {
(Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns, (Some((al, ans)), Some((bl, bns))) => al == bl && ans == bns,
_ => false, _ => false,
@@ -441,10 +453,9 @@ impl<'a> Element for DomElement<'a> {
.with_node(self.node_id, |node| { .with_node(self.node_id, |node| {
node.attrs() node.attrs()
.map(|attrs| { .map(|attrs| {
attrs.iter().any(|a| { attrs
a.name.ns == html5ever::ns!() .iter()
&& a.name.local == local_name.0 .any(|a| a.name.ns == html5ever::ns!() && a.name.local == local_name.0)
})
}) })
.unwrap_or(false) .unwrap_or(false)
}) })
@@ -469,7 +480,9 @@ impl<'a> Element for DomElement<'a> {
// correctly are) made every such base rule silently inert. // correctly are) made every such base rule silently inert.
PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"), PseudoClass::Enabled => self.is_form_control() && !self.has_boolean_attr("disabled"),
PseudoClass::Disabled => self.is_form_control() && self.has_boolean_attr("disabled"), PseudoClass::Disabled => self.is_form_control() && self.has_boolean_attr("disabled"),
PseudoClass::Checked => self.has_boolean_attr("checked") || self.has_boolean_attr("selected"), PseudoClass::Checked => {
self.has_boolean_attr("checked") || self.has_boolean_attr("selected")
}
// Dynamic user-interaction pseudo-classes have no meaning against // Dynamic user-interaction pseudo-classes have no meaning against
// a static DOM snapshot with no live user input. // a static DOM snapshot with no live user input.
PseudoClass::Hover PseudoClass::Hover
@@ -640,7 +653,10 @@ fn simple_id_selector(selector: &str) -> Option<&str> {
if !(first.is_ascii_alphabetic() || first == b'_') { if !(first.is_ascii_alphabetic() || first == b'_') {
return None; return None;
} }
if id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { if id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
{
Some(id) Some(id)
} else { } else {
None None
@@ -656,7 +672,11 @@ impl DomTree {
self.query_selector_all_from(self.document(), selector) self.query_selector_all_from(self.document(), selector)
} }
pub fn query_selector_from(&self, root: NodeId, selector: &str) -> Result<Option<NodeId>, String> { pub fn query_selector_from(
&self,
root: NodeId,
selector: &str,
) -> Result<Option<NodeId>, String> {
// Fast path: a bare "#id" selector resolves through the id index in O(1) // Fast path: a bare "#id" selector resolves through the id index in O(1)
// instead of scanning every descendant. The index holds the first element // instead of scanning every descendant. The index holds the first element
// in tree order per id, which is exactly what the full scan would return. // in tree order per id, which is exactly what the full scan would return.
@@ -714,7 +734,11 @@ impl DomTree {
} }
} }
pub fn query_selector_all_from(&self, root: NodeId, selector: &str) -> Result<Vec<NodeId>, String> { pub fn query_selector_all_from(
&self,
root: NodeId,
selector: &str,
) -> Result<Vec<NodeId>, String> {
let selector_list = parse_selector(selector)?; let selector_list = parse_selector(selector)?;
let mut caches = selectors::context::SelectorCaches::default(); let mut caches = selectors::context::SelectorCaches::default();
let mut context = MatchingContext::new( let mut context = MatchingContext::new(
@@ -746,7 +770,10 @@ impl DomTree {
/// Test one element as the selector subject, including when it is /// Test one element as the selector subject, including when it is
/// detached or is a direct child of a shadow-tree compatibility root. /// detached or is a direct child of a shadow-tree compatibility root.
pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result<bool, String> { pub fn matches_selector(&self, nid: NodeId, selector: &str) -> Result<bool, String> {
if !self.with_node(nid, |node| node.is_element()).unwrap_or(false) { if !self
.with_node(nid, |node| node.is_element())
.unwrap_or(false)
{
return Ok(false); return Ok(false);
} }
let selector_list = parse_selector(selector)?; let selector_list = parse_selector(selector)?;
@@ -768,7 +795,7 @@ impl DomTree {
/// Parse a single selector once for repeated single-element matching, and /// Parse a single selector once for repeated single-element matching, and
/// precompute its specificity, ancestor hashes, and "subject key" (the /// precompute its specificity, ancestor hashes, and "subject key" (the
/// rightmost id/class/tag used to bucket the rule for fast candidate /// rightmost id/class/attribute/tag used to bucket the rule for fast candidate
/// lookup). Returns `None` if the selector fails to parse. /// lookup). Returns `None` if the selector fails to parse.
/// ///
/// This is the primitive a stylesheet cascade builds on: compile every rule /// This is the primitive a stylesheet cascade builds on: compile every rule
@@ -778,9 +805,14 @@ impl DomTree {
let list = parse_selector(selector).ok()?; let list = parse_selector(selector).ok()?;
let sel = list.slice().first()?.clone(); let sel = list.slice().first()?.clone();
let specificity = sel.specificity(); let specificity = sel.specificity();
let key = subject_key(&sel); let keys = SubjectKeys::from_vec(subject_keys(&sel));
let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks); let hashes = parser::AncestorHashes::new(&sel, QuirksMode::NoQuirks);
Some(CompiledSelector { sel, specificity, key, hashes }) Some(CompiledSelector {
sel,
specificity,
keys,
hashes,
})
} }
/// Does a single element match a precompiled selector? Allocates a fresh /// Does a single element match a precompiled selector? Allocates a fresh
@@ -798,6 +830,8 @@ impl DomTree {
Matcher { Matcher {
caches: selectors::context::SelectorCaches::default(), caches: selectors::context::SelectorCaches::default(),
ancestors: AncestorFilter::new(), ancestors: AncestorFilter::new(),
candidate_generation: 0,
candidate_seen: Vec::new(),
} }
} }
} }
@@ -809,9 +843,41 @@ impl DomTree {
pub struct Matcher { pub struct Matcher {
caches: selectors::context::SelectorCaches, caches: selectors::context::SelectorCaches,
ancestors: AncestorFilter, ancestors: AncestorFilter,
candidate_generation: u32,
candidate_seen: Vec<u32>,
} }
impl Matcher { impl Matcher {
/// Start one indexed-candidate collection without clearing the reusable
/// seen table. Stylesheet rules that live in several selector buckets use
/// this to avoid matching and cascading the same rule twice.
#[doc(hidden)]
pub fn begin_candidate_collection(&mut self, candidate_count: usize) {
self.candidate_generation = self.candidate_generation.wrapping_add(1);
if self.candidate_generation == 0 {
self.candidate_seen.fill(0);
self.candidate_generation = 1;
}
self.candidate_seen.resize(candidate_count, 0);
}
/// Return true once per candidate index in the current collection.
#[doc(hidden)]
pub fn mark_candidate(&mut self, index: usize) -> bool {
debug_assert!(index < self.candidate_seen.len());
let Some(seen) = self.candidate_seen.get_mut(index) else {
// Candidate collection is an optimization. If a future caller
// supplies inconsistent bounds, fail open to an extra match rather
// than silently dropping authored CSS.
return true;
};
if *seen == self.candidate_generation {
return false;
}
*seen = self.candidate_generation;
true
}
/// Match `nid` (matched as if it were the subject element; the ancestor /// Match `nid` (matched as if it were the subject element; the ancestor
/// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`. /// filter reflects `nid`'s ancestors, not `nid` itself) against `compiled`.
pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool { pub fn matches(&mut self, tree: &DomTree, nid: NodeId, compiled: &CompiledSelector) -> bool {
@@ -827,7 +893,13 @@ impl Matcher {
MatchingForInvalidation::No, MatchingForInvalidation::No,
); );
let element = DomElement::new(tree, nid); let element = DomElement::new(tree, nid);
selectors::matching::matches_selector(&compiled.sel, 0, Some(&compiled.hashes), &element, &mut context) selectors::matching::matches_selector(
&compiled.sel,
0,
Some(&compiled.hashes),
&element,
&mut context,
)
} }
/// Push `nid`'s hashes onto the ancestor filter before descending into its /// Push `nid`'s hashes onto the ancestor filter before descending into its
@@ -854,7 +926,10 @@ struct AncestorFilter {
impl AncestorFilter { impl AncestorFilter {
fn new() -> Self { fn new() -> Self {
AncestorFilter { filter: Box::default(), levels: Vec::new() } AncestorFilter {
filter: Box::default(),
levels: Vec::new(),
}
} }
fn filter(&self) -> &selectors::bloom::BloomFilter { fn filter(&self) -> &selectors::bloom::BloomFilter {
@@ -865,13 +940,25 @@ impl AncestorFilter {
let mut hashes = Vec::new(); let mut hashes = Vec::new();
tree.with_node(nid, |node| { tree.with_node(nid, |node| {
if let Some(elem) = node.as_element() { if let Some(elem) = node.as_element() {
push_hash(&mut hashes, &mut self.filter, CssLocalName(elem.local.clone()).precomputed_hash()); push_hash(
&mut hashes,
&mut self.filter,
CssLocalName(elem.local.clone()).precomputed_hash(),
);
if let Some(id) = node.get_attribute("id") { if let Some(id) = node.get_attribute("id") {
push_hash(&mut hashes, &mut self.filter, CssString(id.to_string()).precomputed_hash()); push_hash(
&mut hashes,
&mut self.filter,
CssString(id.to_string()).precomputed_hash(),
);
} }
if let Some(class) = node.get_attribute("class") { if let Some(class) = node.get_attribute("class") {
for c in class.split_whitespace() { for c in class.split_whitespace() {
push_hash(&mut hashes, &mut self.filter, CssString(c.to_string()).precomputed_hash()); push_hash(
&mut hashes,
&mut self.filter,
CssString(c.to_string()).precomputed_hash(),
);
} }
} }
} }
@@ -898,8 +985,13 @@ fn push_hash(hashes: &mut Vec<u32>, filter: &mut selectors::bloom::BloomFilter,
/// only tested against elements that carry its key. /// only tested against elements that carry its key.
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum SelectorKey { pub enum SelectorKey {
/// The document element. `:root` can match only this one subject, so it is
/// substantially more selective than the universal bucket despite having
/// no id/class/tag token.
Root,
Id(String), Id(String),
Class(String), Class(String),
Attribute(String),
Local(String), Local(String),
Universal, Universal,
} }
@@ -908,51 +1000,359 @@ pub enum SelectorKey {
pub struct CompiledSelector { pub struct CompiledSelector {
sel: parser::Selector<ObscuraSelector>, sel: parser::Selector<ObscuraSelector>,
specificity: u32, specificity: u32,
key: SelectorKey, keys: SubjectKeys,
hashes: parser::AncestorHashes, hashes: parser::AncestorHashes,
} }
impl CompiledSelector { impl CompiledSelector {
/// The one bucket usable by legacy selector indexes. Selectors whose
/// subject has multiple disjoint alternatives deliberately report
/// `Universal`; use [`Self::candidate_keys`] to opt into multi-bucket
/// indexing.
pub fn key(&self) -> &SelectorKey { pub fn key(&self) -> &SelectorKey {
&self.key match &self.keys {
SubjectKeys::One(key) => key,
// Existing single-bucket consumers must keep treating disjoint
// alternatives as universal until they opt into `candidate_keys`.
SubjectKeys::Many(_) => &SelectorKey::Universal,
}
}
/// Deduplicated buckets that together cover every element this selector
/// can match. A consumer must insert the rule in every returned bucket and
/// deduplicate rule candidates before matching, since one element can
/// carry keys from more than one alternative.
pub fn candidate_keys(&self) -> &[SelectorKey] {
match &self.keys {
SubjectKeys::One(key) => std::slice::from_ref(key),
SubjectKeys::Many(keys) => keys,
}
} }
pub fn specificity(&self) -> u32 { pub fn specificity(&self) -> u32 {
self.specificity self.specificity
} }
} }
/// Pick the index key from a selector's rightmost compound: prefer id, then enum SubjectKeys {
/// class, then local name; fall back to Universal for `*`, attribute-only, or One(SelectorKey),
/// pseudo-only subjects. Many(Box<[SelectorKey]>),
fn subject_key(sel: &parser::Selector<ObscuraSelector>) -> SelectorKey { }
impl SubjectKeys {
fn from_vec(mut keys: Vec<SelectorKey>) -> Self {
if keys.len() == 1 {
Self::One(keys.pop().unwrap())
} else {
Self::Many(keys.into_boxed_slice())
}
}
}
fn key_rank(key: &SelectorKey) -> u8 {
match key {
SelectorKey::Universal => 0,
SelectorKey::Local(_) => 1,
SelectorKey::Attribute(_) => 2,
SelectorKey::Class(_) => 3,
SelectorKey::Id(_) => 4,
SelectorKey::Root => 5,
}
}
fn push_unique_key(keys: &mut Vec<SelectorKey>, key: SelectorKey) {
if !keys.contains(&key) {
keys.push(key);
}
}
/// Candidate buckets for a selector's rightmost compound. A single ordinary
/// id/class/local selector keeps the allocation-free common path. A pure
/// `:is()`/`:where()` subject returns the deduplicated union of its arms, but
/// only when every arm has a concrete key; one universal/pseudo-only
/// arm makes the whole set universal because that arm can match an element
/// carrying none of the other keys.
///
/// When an outer compound key exists, disjoint functional-pseudo keys replace
/// it only if every arm is strictly more selective. This is the same
/// conservative contract as Gecko's `SelectorMap::find_bucket`: for
/// `.control:is(button, #save)` keep `.control`, while
/// `.control:is(#save, #cancel)` may use the two id buckets.
fn subject_keys(sel: &parser::Selector<ObscuraSelector>) -> Vec<SelectorKey> {
use parser::Component; use parser::Component;
let mut local: Option<String> = None; let mut local: Option<String> = None;
let mut attribute: Option<String> = None;
let mut class: Option<String> = None; let mut class: Option<String> = None;
let mut id: Option<String> = None; let mut id: Option<String> = None;
let mut root = false;
let mut disjoint_sets: Vec<Vec<SelectorKey>> = Vec::new();
for comp in sel.iter_raw_match_order() { for comp in sel.iter_raw_match_order() {
match comp { match comp {
Component::Combinator(_) => break, Component::Combinator(_) => break,
Component::ID(v) => id = Some(v.0.clone()), Component::ID(v) => id = Some(v.0.clone()),
Component::Root => root = true,
Component::Class(v) => class = Some(v.0.clone()), Component::Class(v) => class = Some(v.0.clone()),
Component::LocalName(l) => local = Some(l.lower_name.0.to_string()), Component::LocalName(l) => local = Some(l.lower_name.0.to_string()),
Component::AttributeInNoNamespaceExists {
local_name_lower, ..
} => attribute = Some(local_name_lower.0.to_string()),
Component::AttributeInNoNamespace { local_name, .. } => {
attribute = Some(local_name.0.to_string())
}
Component::AttributeOther(selector) => {
attribute = Some(selector.local_name_lower.0.to_string())
}
Component::Is(list) | Component::Where(list) => {
let mut keys = Vec::new();
let mut universal = false;
for alternative in list.slice() {
let alternative_keys = subject_keys(alternative);
if alternative_keys
.iter()
.any(|key| matches!(key, SelectorKey::Universal))
{
universal = true;
break;
}
for key in alternative_keys {
push_unique_key(&mut keys, key);
}
}
if universal || keys.is_empty() {
keys.clear();
keys.push(SelectorKey::Universal);
}
disjoint_sets.push(keys);
}
_ => {} _ => {}
} }
} }
if let Some(i) = id { let direct = if root {
SelectorKey::Root
} else if let Some(i) = id {
SelectorKey::Id(i) SelectorKey::Id(i)
} else if let Some(c) = class { } else if let Some(c) = class {
SelectorKey::Class(c) SelectorKey::Class(c)
} else if let Some(a) = attribute {
SelectorKey::Attribute(a)
} else if let Some(l) = local { } else if let Some(l) = local {
SelectorKey::Local(l) SelectorKey::Local(l)
} else { } else {
SelectorKey::Universal SelectorKey::Universal
};
let direct_rank = key_rank(&direct);
let mut best: Option<Vec<SelectorKey>> = None;
for keys in disjoint_sets {
if keys.iter().any(|key| key_rank(key) <= direct_rank) {
continue;
}
let min_rank = keys.iter().map(key_rank).min().unwrap_or(0);
let replaces = best.as_ref().is_none_or(|current| {
let current_min = current.iter().map(key_rank).min().unwrap_or(0);
min_rank > current_min || (min_rank == current_min && keys.len() < current.len())
});
if replaces {
best = Some(keys);
}
} }
best.unwrap_or_else(|| vec![direct])
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::tree_sink::parse_html; use crate::tree_sink::parse_html;
use super::SelectorKey;
fn candidate_keys(tree: &crate::tree::DomTree, selector: &str) -> Vec<SelectorKey> {
tree.compile_rule_selector(selector)
.unwrap_or_else(|| panic!("failed to compile {selector}"))
.candidate_keys()
.to_vec()
}
fn element_has_selector_key(
tree: &crate::tree::DomTree,
node: crate::tree::NodeId,
key: &SelectorKey,
) -> bool {
let node = tree.get_node(node).expect("matched node must exist");
match key {
SelectorKey::Root => node
.parent
.and_then(|parent| tree.get_node(parent))
.is_some_and(|parent| parent.is_document()),
SelectorKey::Universal => true,
SelectorKey::Id(id) => node.get_attribute("id") == Some(id.as_str()),
SelectorKey::Class(class) => node.get_attribute("class").is_some_and(|classes| {
classes.split_ascii_whitespace().any(|value| value == class)
}),
SelectorKey::Attribute(attribute) => node.get_attribute(attribute).is_some(),
SelectorKey::Local(local) => node
.as_element()
.is_some_and(|element| element.local.as_ref() == local),
}
}
#[test]
fn selector_candidate_keys_expand_rightmost_is_and_where() {
let tree = parse_html("<main></main>");
assert_eq!(
candidate_keys(&tree, ":is(.IssueLabel, .Label):hover"),
vec![
SelectorKey::Class("IssueLabel".into()),
SelectorKey::Class("Label".into()),
]
);
assert_eq!(
candidate_keys(&tree, ":where(button, input, select)"),
vec![
SelectorKey::Local("button".into()),
SelectorKey::Local("input".into()),
SelectorKey::Local("select".into()),
]
);
// A legacy single-bucket index stays correct until it explicitly opts
// into inserting all candidate keys.
let compiled = tree
.compile_rule_selector(":is(.IssueLabel, .Label):hover")
.unwrap();
assert_eq!(compiled.key(), &SelectorKey::Universal);
}
#[test]
fn selector_candidate_keys_deduplicate_equivalent_arms() {
let tree = parse_html("<main></main>");
let compiled = tree
.compile_rule_selector(":is(.btn, .btn:hover, :where(.btn))")
.unwrap();
assert_eq!(
compiled.candidate_keys(),
&[SelectorKey::Class("btn".into())]
);
assert_eq!(compiled.key(), &SelectorKey::Class("btn".into()));
}
#[test]
fn selector_candidate_keys_index_attributes_and_fall_back_for_pseudos() {
let tree = parse_html("<main></main>");
assert_eq!(
candidate_keys(&tree, ":is(.btn, [data-kind])"),
vec![
SelectorKey::Class("btn".into()),
SelectorKey::Attribute("data-kind".into()),
]
);
assert_eq!(
candidate_keys(&tree, ":is(.btn, :first-child)"),
vec![SelectorKey::Universal]
);
}
#[test]
fn selector_candidate_keys_index_root_subjects_and_functional_arms() {
let tree = parse_html("<html><body><main class=app></main></body></html>");
assert_eq!(candidate_keys(&tree, ":root"), vec![SelectorKey::Root]);
assert_eq!(
candidate_keys(&tree, ":root[data-color-mode]"),
vec![SelectorKey::Root],
);
assert_eq!(
candidate_keys(&tree, ":is(:root, .app)"),
vec![SelectorKey::Root, SelectorKey::Class("app".into())],
);
}
#[test]
fn selector_candidate_keys_only_replace_outer_key_when_more_selective() {
let tree = parse_html("<main></main>");
assert_eq!(
candidate_keys(&tree, ".control:is(#save, #cancel)"),
vec![
SelectorKey::Id("save".into()),
SelectorKey::Id("cancel".into()),
]
);
assert_eq!(
candidate_keys(&tree, ".control:is(button, #save)"),
vec![SelectorKey::Class("control".into())]
);
}
#[test]
fn selector_candidate_keys_use_subjects_of_complex_is_arms() {
let tree = parse_html("<main></main>");
assert_eq!(
candidate_keys(&tree, ".scope :is(.a > .x, .b > .y)"),
vec![
SelectorKey::Class("x".into()),
SelectorKey::Class("y".into()),
]
);
}
#[test]
fn selector_candidate_keys_cover_every_actual_match() {
let tree = parse_html(
r#"<section class="scope">
<div class="alpha control" id="save" data-kind="x"></div>
<button class="beta control"></button>
<input class="control" id="cancel">
<div class="a"><span class="x"></span></div>
<div class="b"><span class="y"></span></div>
</section>"#,
);
let selectors = [
":is(.alpha, .beta)",
":where(button, input)",
".control:is(#save, #cancel)",
".control:is(button, #save)",
":is(.alpha, [data-kind])",
":is(:root, .control)",
".scope :is(.a > .x, .b > .y)",
];
for selector in selectors {
let keys = candidate_keys(&tree, selector);
let matches = tree.query_selector_all(selector).unwrap();
assert!(!matches.is_empty(), "fixture must exercise {selector}");
for matched in matches {
assert!(
keys.iter()
.any(|key| element_has_selector_key(&tree, matched, key)),
"{selector} matched an element outside all candidate buckets: {keys:?}"
);
}
}
}
#[test]
fn matcher_candidate_collection_deduplicates_and_reuses_indices() {
let tree = parse_html("<main></main>");
let mut matcher = tree.matcher();
matcher.begin_candidate_collection(4);
assert!(matcher.mark_candidate(2));
assert!(!matcher.mark_candidate(2));
matcher.begin_candidate_collection(4);
assert!(matcher.mark_candidate(2));
assert!(!matcher.mark_candidate(2));
matcher.begin_candidate_collection(9);
assert!(matcher.mark_candidate(8));
assert!(!matcher.mark_candidate(8));
matcher.candidate_generation = u32::MAX;
matcher.begin_candidate_collection(9);
assert_eq!(matcher.candidate_generation, 1);
assert!(matcher.mark_candidate(2));
assert!(!matcher.mark_candidate(2));
}
#[test] #[test]
fn test_query_selector_tag() { fn test_query_selector_tag() {
let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>"); let tree = parse_html("<html><body><h1>Title</h1><p>Text</p></body></html>");
@@ -964,8 +1364,7 @@ mod tests {
#[test] #[test]
fn test_query_selector_class() { fn test_query_selector_class() {
let tree = let tree = parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
parse_html(r#"<div class="foo bar">Content</div><div class="baz">Other</div>"#);
let result = tree.query_selector(".foo").unwrap(); let result = tree.query_selector(".foo").unwrap();
assert!(result.is_some()); assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap(); let node = tree.get_node(result.unwrap()).unwrap();
@@ -989,7 +1388,10 @@ mod tests {
#[test] #[test]
fn test_has_parses() { fn test_has_parses() {
// Isolate parse from match: :has must parse, not error. // Isolate parse from match: :has must parse, not error.
assert!(super::parse_selector("a:has(p.bt)").is_ok(), ":has failed to parse"); assert!(
super::parse_selector("a:has(p.bt)").is_ok(),
":has failed to parse"
);
} }
#[test] #[test]
@@ -1005,9 +1407,17 @@ mod tests {
fn test_enabled_matches_form_control_without_disabled_attr() { fn test_enabled_matches_form_control_without_disabled_attr() {
let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#); let tree = parse_html(r#"<button>Click</button><button disabled>Nope</button>"#);
let enabled = tree.query_selector_all("button:enabled").unwrap(); let enabled = tree.query_selector_all("button:enabled").unwrap();
assert_eq!(enabled.len(), 1, ":enabled should match only the non-disabled button"); assert_eq!(
enabled.len(),
1,
":enabled should match only the non-disabled button"
);
let disabled = tree.query_selector_all("button:disabled").unwrap(); let disabled = tree.query_selector_all("button:disabled").unwrap();
assert_eq!(disabled.len(), 1, ":disabled should match only the disabled button"); assert_eq!(
disabled.len(),
1,
":disabled should match only the disabled button"
);
} }
#[test] #[test]
@@ -1021,33 +1431,23 @@ mod tests {
#[test] #[test]
fn test_checked_matches_checked_attribute() { fn test_checked_matches_checked_attribute() {
let tree = parse_html( let tree = parse_html(r#"<input type="checkbox" checked><input type="checkbox">"#);
r#"<input type="checkbox" checked><input type="checkbox">"#,
);
assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1); assert_eq!(tree.query_selector_all("input:checked").unwrap().len(), 1);
} }
#[test] #[test]
fn unfocused_snapshot_matches_focus_negation_selectors() { fn unfocused_snapshot_matches_focus_negation_selectors() {
let tree = parse_html( let tree = parse_html(r#"<div class="visually-hidden-focusable">Skip</div>"#);
r#"<div class="visually-hidden-focusable">Skip</div>"#,
);
let hidden = tree let hidden = tree
.query_selector_all( .query_selector_all(".visually-hidden-focusable:not(:focus):not(:focus-within)")
".visually-hidden-focusable:not(:focus):not(:focus-within)",
)
.unwrap(); .unwrap();
assert_eq!(hidden.len(), 1); assert_eq!(hidden.len(), 1);
assert_eq!( assert_eq!(tree.query_selector_all(":focus-visible").unwrap().len(), 0);
tree.query_selector_all(":focus-visible").unwrap().len(),
0
);
} }
#[test] #[test]
fn test_query_selector_descendant() { fn test_query_selector_descendant() {
let tree = let tree = parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
parse_html(r#"<div id="outer"><div id="inner"><span>Target</span></div></div>"#);
let result = tree.query_selector("#outer span").unwrap(); let result = tree.query_selector("#outer span").unwrap();
assert!(result.is_some()); assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap(); let node = tree.get_node(result.unwrap()).unwrap();
@@ -1072,9 +1472,8 @@ mod tests {
#[test] #[test]
fn test_query_selector_attribute() { fn test_query_selector_attribute() {
let tree = parse_html( let tree =
r#"<input type="text" name="user"><input type="password" name="pass">"#, parse_html(r#"<input type="text" name="user"><input type="password" name="pass">"#);
);
let result = tree.query_selector(r#"input[type="password"]"#).unwrap(); let result = tree.query_selector(r#"input[type="password"]"#).unwrap();
assert!(result.is_some()); assert!(result.is_some());
let node = tree.get_node(result.unwrap()).unwrap(); let node = tree.get_node(result.unwrap()).unwrap();
@@ -1151,13 +1550,15 @@ mod tests {
#[test] #[test]
fn test_query_selector_from_returns_first_in_subtree_only() { fn test_query_selector_from_returns_first_in_subtree_only() {
let tree = parse_html( let tree =
r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#, parse_html(r#"<section id="s"><p>first</p><p>second</p></section><p>outside</p>"#);
);
let s = tree.get_element_by_id("s").expect("section#s"); let s = tree.get_element_by_id("s").expect("section#s");
// Scoped to #s: skip the outside paragraph; return the first inside. // Scoped to #s: skip the outside paragraph; return the first inside.
let first_in_s = tree.query_selector_from(s, "p").unwrap().expect("a p inside"); let first_in_s = tree
.query_selector_from(s, "p")
.unwrap()
.expect("a p inside");
assert_eq!(tree.text_content(first_in_s), "first"); assert_eq!(tree.text_content(first_in_s), "first");
} }
+4
View File
@@ -25,6 +25,10 @@ tokio = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
url = { workspace = true } url = { workspace = true }
# document.domain must reject public suffixes (including private suffixes such
# as github.io), not just compare dotted strings. `psl` embeds the Mozilla
# Public Suffix List and adds no runtime I/O.
psl = "2"
tracing = { workspace = true } tracing = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
+400 -50
View File
@@ -87,8 +87,6 @@ const _DOM_MUTATION_COMMANDS = new Set([
"set_attribute", "remove_attribute", "set_attribute", "remove_attribute",
"set_text_content", "set_inner_html", "set_inner_html_context", "set_text_content", "set_inner_html", "set_inner_html_context",
"set_fragment_html_executable", "set_fragment_html_executable",
"create_element", "create_text_node", "create_comment_node",
"create_document_fragment", "clone_node",
]); ]);
const _dom = (cmd, a1, a2) => { const _dom = (cmd, a1, a2) => {
const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? "")); const result = Deno.core.ops.op_dom(cmd, String(a1 ?? ""), String(a2 ?? ""));
@@ -622,11 +620,27 @@ globalThis.console = {
let _tid = 0; let _tid = 0;
const _clearedTimers = new Set(); const _clearedTimers = new Set();
const _intervals = new Set(); const _intervals = new Set();
const _nativeTimerIds = new Map();
const __obscuraPendingTimeoutDeadlines = new Map();
const _scheduleAfter = (delay, fn) => { const _scheduleAfter = (delay, fn) => {
const d = Math.max(0, Number(delay) || 0); const d = Math.max(0, Number(delay) || 0);
if (d === 0) Promise.resolve().then(fn); // HTML timers queue tasks even when their delay is zero. Treating a
else Deno.core.ops.op_sleep(d).then(fn); // zero-delay timer as a Promise reaction turns recursive framework
// schedulers into an unbounded microtask checkpoint: timers and networking
// never regain control and V8 can burn seconds before navigation completes.
// deno_core's timer queue requires a Tokio reactor even to enqueue. Some
// low-level embedders intentionally do a synchronous geometry mutation and
// capture without pumping an event loop. Such a host cannot observe queued
// tasks, so leave them pending instead of aborting or incorrectly turning a
// task into a microtask. Normal browser and CDP execution always takes the
// task-queue path below.
if (!Deno.core.ops.op_async_runtime_available()) {
return undefined;
}
// The callback runs only when the embedder pumps the event loop, after the
// current microtask checkpoint.
return Deno.core.queueUserTimer(0, false, d, fn);
}; };
// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF // Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF
@@ -652,14 +666,29 @@ globalThis.setTimeout = (fn, delay = 0, ...args) => {
const f = _coerceTimerFn(fn); const f = _coerceTimerFn(fn);
if (f === null) return ++_tid; if (f === null) return ++_tid;
const id = ++_tid; const id = ++_tid;
_scheduleAfter(delay, () => { const normalizedDelay = Math.max(0, Number(delay) || 0);
const nativeId = _scheduleAfter(normalizedDelay, () => {
_nativeTimerIds.delete(id);
__obscuraPendingTimeoutDeadlines.delete(id);
if (_clearedTimers.has(id)) return; if (_clearedTimers.has(id)) return;
try { f(...args); } catch(e) { console.error("Timer error:", e); } try { f(...args); } catch(e) { console.error("Timer error:", e); }
}); });
if (nativeId !== undefined) {
_nativeTimerIds.set(id, nativeId);
__obscuraPendingTimeoutDeadlines.set(id, performance.now() + normalizedDelay);
}
return id; return id;
}; };
globalThis.clearTimeout = (id) => { _clearedTimers.add(id); }; globalThis.clearTimeout = (id) => {
_clearedTimers.add(id);
__obscuraPendingTimeoutDeadlines.delete(id);
const nativeId = _nativeTimerIds.get(id);
if (nativeId !== undefined) {
Deno.core.cancelTimer(nativeId);
_nativeTimerIds.delete(id);
}
};
globalThis.setInterval = (fn, delay = 0, ...args) => { globalThis.setInterval = (fn, delay = 0, ...args) => {
const f = _coerceTimerFn(fn); const f = _coerceTimerFn(fn);
@@ -670,13 +699,18 @@ globalThis.setInterval = (fn, delay = 0, ...args) => {
if (!_intervals.has(id)) return; if (!_intervals.has(id)) return;
try { f(...args); } catch(e) { console.error("Interval error:", e); } try { f(...args); } catch(e) { console.error("Interval error:", e); }
if (!_intervals.has(id)) return; if (!_intervals.has(id)) return;
_scheduleAfter(delay, tick); const nativeId = _scheduleAfter(delay, tick);
if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId);
}; };
_scheduleAfter(delay, tick); const nativeId = _scheduleAfter(delay, tick);
if (nativeId !== undefined) _nativeTimerIds.set(id, nativeId);
return id; return id;
}; };
globalThis.clearInterval = (id) => { _intervals.delete(id); _clearedTimers.add(id); }; globalThis.clearInterval = (id) => {
_intervals.delete(id);
globalThis.clearTimeout(id);
};
// Animation callbacks are a rendering-phase batch, not zero-delay // Animation callbacks are a rendering-phase batch, not zero-delay
// microtasks. In particular, a callback which queues itself must yield to // microtasks. In particular, a callback which queues itself must yield to
@@ -739,20 +773,155 @@ globalThis.cancelAnimationFrame = (id) => {
}; };
globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn)); globalThis.queueMicrotask = globalThis.queueMicrotask || ((fn) => Promise.resolve().then(fn));
// MessagePort is a task-backed EventTarget, not a pair of callback slots.
// React currently uses `onmessage`, while Angular/Zone.js and worker-style
// schedulers commonly use addEventListener + start and inspect the prototype.
// Keep stopped-port messages queued, clone payloads synchronously, and deliver
// one message per task so every delivery gets its own microtask checkpoint.
const _messagePortConstructionKey = {};
const _messagePortState = new WeakMap();
function _messagePortStateFor(port) {
const state = _messagePortState.get(port);
if (!state) throw new TypeError("Illegal invocation");
return state;
}
function _messagePortInstallEventHandler(port, type, callback) {
const state = _messagePortStateFor(port);
const slot = type === "message" ? "onmessage" : "onmessageerror";
const wrapperSlot = type === "message" ? "messageHandlerWrapper" : "messageErrorHandlerWrapper";
const oldCallback = state[slot];
state[slot] = callback;
// Event-handler IDL attributes participate in the same listener list as
// addEventListener. Install their stable wrapper when the slot first becomes
// non-null so mixed registrations run in registration order. Reassigning a
// live handler keeps its position; clearing and setting it again appends it.
if (callback && !oldCallback) {
const wrapper = (event) => {
const current = _messagePortState.get(port)?.[slot];
if (!current) return;
if (typeof current === "function") current.call(port, event);
else current.handleEvent.call(current, event);
};
state[wrapperSlot] = wrapper;
_eventTargetAdd(port, type, wrapper);
} else if (!callback && oldCallback) {
_eventTargetRemove(port, type, state[wrapperSlot]);
state[wrapperSlot] = null;
}
}
function _messagePortScheduleDelivery(port) {
const state = _messagePortStateFor(port);
if (state.closed || !state.messageQueueEnabled || state.messageDeliveryPending || !state.messageQueue.length) return;
state.messageDeliveryPending = true;
_scheduleAfter(0, () => {
const current = _messagePortState.get(port);
if (!current) return;
current.messageDeliveryPending = false;
if (current.closed || !current.messageQueueEnabled || !current.messageQueue.length) return;
const data = current.messageQueue.shift();
const event = new MessageEvent("message", {
data,
origin: "",
lastEventId: "",
source: null,
ports: [],
});
_eventTargetDispatch(port, event);
_messagePortScheduleDelivery(port);
});
}
class MessagePort {
constructor(key) {
if (key !== _messagePortConstructionKey) throw new TypeError("Illegal constructor");
_messagePortState.set(this, {
entangled: null,
messageQueue: [],
messageQueueEnabled: false,
messageDeliveryPending: false,
closed: false,
onmessage: null,
onmessageerror: null,
messageHandlerWrapper: null,
messageErrorHandlerWrapper: null,
});
}
postMessage(message, options) {
// Structured serialization happens before inspecting the entanglement.
// This preserves the browser-observable DataCloneError on closed ports and
// prevents mutations after postMessage from changing the delivered value.
let cloned;
try {
cloned = globalThis.structuredClone(message, options);
} catch (error) {
throw error;
}
const state = _messagePortStateFor(this);
const target = state.entangled;
const targetState = target && _messagePortState.get(target);
if (state.closed || !targetState || targetState.closed) return;
targetState.messageQueue.push(cloned);
_messagePortScheduleDelivery(target);
}
start() {
const state = _messagePortStateFor(this);
if (state.messageQueueEnabled || state.closed) return;
state.messageQueueEnabled = true;
_messagePortScheduleDelivery(this);
}
close() {
const state = _messagePortStateFor(this);
if (state.closed) return;
state.closed = true;
state.messageQueue.length = 0;
state.messageQueueEnabled = false;
const peer = state.entangled;
state.entangled = null;
const peerState = peer && _messagePortState.get(peer);
if (peerState?.entangled === this) peerState.entangled = null;
// A previously scheduled task cannot be removed from the shared task
// source, but it observes `closed` and therefore cannot dispatch.
}
addEventListener(type, callback, options) {
_eventTargetAdd(this, type, callback, options);
}
removeEventListener(type, callback, options) {
_eventTargetRemove(this, type, callback, options);
}
dispatchEvent(event) {
_messagePortStateFor(this);
return _eventTargetDispatch(this, event);
}
get onmessage() { return _messagePortStateFor(this).onmessage; }
set onmessage(callback) {
callback = typeof callback === "function"
|| (callback && typeof callback.handleEvent === "function")
? callback : null;
_messagePortInstallEventHandler(this, "message", callback);
// Setting the event-handler IDL attribute implicitly starts the port,
// including when the assigned value is null.
this.start();
}
get onmessageerror() { return _messagePortStateFor(this).onmessageerror; }
set onmessageerror(callback) {
callback = typeof callback === "function"
|| (callback && typeof callback.handleEvent === "function")
? callback : null;
_messagePortInstallEventHandler(this, "messageerror", callback);
}
get [Symbol.toStringTag]() { return "MessagePort"; }
}
class MessageChannel { class MessageChannel {
constructor() { constructor() {
this.port1 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; this.port1 = new MessagePort(_messagePortConstructionKey);
this.port2 = { onmessage: null, postMessage: () => {}, close() {}, addEventListener() {}, removeEventListener() {} }; this.port2 = new MessagePort(_messagePortConstructionKey);
this.port1.postMessage = (data) => { _messagePortStateFor(this.port1).entangled = this.port2;
Promise.resolve().then(() => { if (this.port2.onmessage) this.port2.onmessage({ data }); }); _messagePortStateFor(this.port2).entangled = this.port1;
};
this.port2.postMessage = (data) => {
Promise.resolve().then(() => { if (this.port1.onmessage) this.port1.onmessage({ data }); });
};
} }
} }
globalThis.MessageChannel = MessageChannel; globalThis.MessageChannel = MessageChannel;
globalThis.MessagePort = class MessagePort { constructor(){} postMessage(){} close(){} addEventListener(){} removeEventListener(){} }; globalThis.MessagePort = MessagePort;
const _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase()); const _cssCamelToKebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); const _cssKebabToCamel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
@@ -3529,6 +3698,26 @@ function _makeXPathResult(type, nodes) {
}; };
} }
// `document.domain` exposes the document's effective host. Keep the relaxed
// value on the live Document object so a navigation (which installs a new
// Document in __obscura_init) naturally restores the URL host. Detached
// documents inherit the incumbent realm's principal for reads, which is why a
// `new Document().domain` read reflects the live document rather than its own
// about:blank URL.
function _documentUrlHost() {
try { return new URL(_domParse("document_url") || "about:blank").hostname; }
catch (_) { return ""; }
}
function _incumbentDocumentDomain() {
const live = globalThis.document;
return live && typeof live._effectiveDomain === "string"
? live._effectiveDomain
: _documentUrlHost();
}
function _throwDocumentDomainSecurityError() {
throw new DOMException("Failed to set the 'domain' property on 'Document'", "SecurityError");
}
class Document extends Node { class Document extends Node {
get documentElement() { return _wrapEl(+_dom("document_element")); } get documentElement() { return _wrapEl(+_dom("document_element")); }
get children() { get children() {
@@ -3569,6 +3758,28 @@ class Document extends Node {
} }
get URL() { return _domParse("document_url") ?? ""; } get URL() { return _domParse("document_url") ?? ""; }
get documentURI() { return this.URL; } get documentURI() { return this.URL; }
get domain() {
return this === globalThis.document
? (typeof this._effectiveDomain === "string" ? this._effectiveDomain : _documentUrlHost())
: _incumbentDocumentDomain();
}
set domain(value) {
// Web IDL performs DOMString conversion before the setter algorithm checks
// whether the Document has a browsing context.
const input = String(value);
if (this !== globalThis.document) _throwDocumentDomainSecurityError();
const current = this.domain;
if (!current) _throwDocumentDomainSecurityError();
const candidate = Deno.core.ops.op_document_domain_candidate(current, input);
if (!candidate) _throwDocumentDomainSecurityError();
// This runtime currently has one top-level browsing context and no
// principal-backed same-origin-domain comparison. Persisting the
// validated effective domain supplies the standards-shaped API without
// weakening iframe/fetch/storage origin checks; those must be wired to a
// future browsing-context principal model before domain relaxation can
// grant cross-document access.
this._effectiveDomain = candidate;
}
get referrer() { return _domParse("document_referrer") ?? ""; } get referrer() { return _domParse("document_referrer") ?? ""; }
get location() { return globalThis.location; } get location() { return globalThis.location; }
set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); } set location(url) { Deno.core.ops.op_navigate(_resolveUrl(String(url)), 'GET', ''); }
@@ -6054,28 +6265,36 @@ globalThis.matchMedia = _markNative(function matchMedia(q) {
dispatchEvent(){return true;} dispatchEvent(){return true;}
}; };
}); });
// getComputedStyle() returns a fresh declaration object, but those objects all
// observe the same computed style for an element until the document mutates.
// Share the immutable native snapshot behind them. Frameworks routinely call
// getComputedStyle() repeatedly on the same few roots; rebuilding and parsing
// several hundred properties for every wrapper dominated real-page startup.
const _computedStyleSnapshotCache = new WeakMap();
globalThis.getComputedStyle = (el) => { globalThis.getComputedStyle = (el) => {
if (!el) el = document.body || {}; if (!el) el = document.body || {};
const style = el?.style || el?._style || new CSSStyleDeclaration(); const style = el?.style || el?._style || new CSSStyleDeclaration();
// Render builds expose one immutable snapshot from the retained final // Render builds expose one immutable snapshot from the retained final
// cascade/layout. Fetch it once per CSSStyleDeclaration proxy: reading a // cascade/layout. The native snapshot is shared per element and epoch while
// dozen properties must not trigger a dozen layout/native crossings. // each call still returns a distinct, live CSSStyleDeclaration proxy.
let rendered = null; const cacheable = (typeof el === 'object' && el !== null) || typeof el === 'function';
let renderedEpoch = -1; let snapshot = cacheable ? _computedStyleSnapshotCache.get(el) : null;
let computedNames = []; if (!snapshot) {
snapshot = { rendered: null, epoch: -1, names: [] };
if (cacheable) _computedStyleSnapshotCache.set(el, snapshot);
}
const refreshRendered = () => { const refreshRendered = () => {
if (renderedEpoch === _domMutationEpoch) return; if (snapshot.epoch === _domMutationEpoch) return;
renderedEpoch = _domMutationEpoch; snapshot.epoch = _domMutationEpoch;
rendered = null; snapshot.rendered = null;
if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) { if (typeof Deno.core.ops.op_computed_style === 'function' && el?._nid != null) {
try { try {
const raw = Deno.core.ops.op_computed_style(String(el._nid | 0)); const raw = Deno.core.ops.op_computed_style(String(el._nid | 0));
rendered = raw ? JSON.parse(raw) : null; snapshot.rendered = raw ? JSON.parse(raw) : null;
} catch (e) {} } catch (e) {}
} }
computedNames = rendered ? Object.keys(rendered) : []; snapshot.names = snapshot.rendered ? Object.keys(snapshot.rendered) : [];
}; };
refreshRendered();
// React virtualization libraries (react-window, tanstack-virtual, // React virtualization libraries (react-window, tanstack-virtual,
// react-virtuoso) all compute container dimensions via getComputedStyle. // react-virtuoso) all compute container dimensions via getComputedStyle.
// The defaults table previously returned `auto` for width/height and // The defaults table previously returned `auto` for width/height and
@@ -6139,8 +6358,8 @@ globalThis.getComputedStyle = (el) => {
// (`-webkit-line-clamp`). Normalize the prefix once for every WebKit // (`-webkit-line-clamp`). Normalize the prefix once for every WebKit
// property instead of adding per-property aliases to the native snapshot. // property instead of adding per-property aliases to the native snapshot.
if (kebab.startsWith('webkit-')) kebab = '-' + kebab; if (kebab.startsWith('webkit-')) kebab = '-' + kebab;
if (rendered && Object.prototype.hasOwnProperty.call(rendered, kebab)) if (snapshot.rendered && Object.prototype.hasOwnProperty.call(snapshot.rendered, kebab))
return rendered[kebab]; return snapshot.rendered[kebab];
// Non-render builds and properties outside the renderer snapshot retain // Non-render builds and properties outside the renderer snapshot retain
// the lightweight inline CSSOM behavior. // the lightweight inline CSSOM behavior.
const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : ''; const inlineVal = target.getPropertyValue ? target.getPropertyValue(rawProp) : '';
@@ -6160,11 +6379,11 @@ globalThis.getComputedStyle = (el) => {
if (prop === 'getPropertyPriority') return () => ''; if (prop === 'getPropertyPriority') return () => '';
if (prop === 'item') return (i) => { if (prop === 'item') return (i) => {
refreshRendered(); refreshRendered();
return computedNames[i | 0] || ''; return snapshot.names[i | 0] || '';
}; };
if (prop === 'length') { if (prop === 'length') {
refreshRendered(); refreshRendered();
return computedNames.length; return snapshot.names.length;
} }
if (prop === 'cssText') return ''; if (prop === 'cssText') return '';
if (prop === 'parentRule') return null; if (prop === 'parentRule') return null;
@@ -6530,6 +6749,23 @@ globalThis.NodeFilter = {
// target boxes through getBoundingClientRect. Element scroll-container roots // target boxes through getBoundingClientRect. Element scroll-container roots
// remain intentionally unsupported until their offsets participate in layout. // remain intentionally unsupported until their offsets participate in layout.
globalThis.__intersectionObservers = []; globalThis.__intersectionObservers = [];
let _intersectionRenderCheckpointPending = false;
function _scheduleIntersectionRenderCheckpoint() {
if (_intersectionRenderCheckpointPending) return;
_intersectionRenderCheckpointPending = true;
// Intersection observation is part of the browser's rendering update, not
// a synchronous side effect of observe() or a DOM mutation. One queued task
// coalesces every observer/target and therefore performs at most one layout
// flush for the checkpoint.
_scheduleAfter(0, () => {
_intersectionRenderCheckpointPending = false;
for (const observer of globalThis.__intersectionObservers) {
if (observer._connected && observer._targets.size) {
observer._check([...observer._targets], false);
}
}
});
}
function _ioRect(x, y, width, height) { function _ioRect(x, y, width, height) {
return { return {
x, y, width, height, x, y, width, height,
@@ -6664,7 +6900,7 @@ globalThis.IntersectionObserver = class IntersectionObserver {
} }
this._targets.add(el); this._targets.add(el);
this._previous.delete(el); this._previous.delete(el);
this._check([el], true); _scheduleIntersectionRenderCheckpoint();
} }
unobserve(el) { unobserve(el) {
this._targets.delete(el); this._targets.delete(el);
@@ -6686,18 +6922,8 @@ globalThis.IntersectionObserver = class IntersectionObserver {
get thresholds() { return this._thresholds.slice(); } get thresholds() { return this._thresholds.slice(); }
}; };
(function() { (function() {
let pending = false;
const recompute = () => { const recompute = () => {
if (pending) return; _scheduleIntersectionRenderCheckpoint();
pending = true;
Promise.resolve().then(() => {
pending = false;
for (const observer of globalThis.__intersectionObservers) {
if (observer._connected && observer._targets.size) {
observer._check([...observer._targets], false);
}
}
});
}; };
globalThis.__obscura_recompute_intersections = recompute; globalThis.__obscura_recompute_intersections = recompute;
globalThis.addEventListener("resize", recompute); globalThis.addEventListener("resize", recompute);
@@ -6920,7 +7146,16 @@ globalThis.PopStateEvent = class extends Event {
} }
}; };
globalThis.HashChangeEvent = class extends Event {}; globalThis.HashChangeEvent = class extends Event {};
globalThis.MessageEvent = class extends Event { constructor(t,o={}) { super(t,o);this.data=o.data; } }; globalThis.MessageEvent = class extends Event {
constructor(t,o={}) {
super(t,o);
this.data = Object.prototype.hasOwnProperty.call(o, "data") ? o.data : null;
this.origin = o.origin == null ? "" : String(o.origin);
this.lastEventId = o.lastEventId == null ? "" : String(o.lastEventId);
this.source = o.source == null ? null : o.source;
this.ports = Array.isArray(o.ports) ? o.ports.slice() : [];
}
};
globalThis.ProgressEvent = class ProgressEvent extends Event { globalThis.ProgressEvent = class ProgressEvent extends Event {
constructor(type, init) { constructor(type, init) {
super(type, init || {}); super(type, init || {});
@@ -7315,6 +7550,8 @@ globalThis.DOMParser = class DOMParser {
// URL about:blank, are already fully parsed, and carry no stylesheets. // URL about:blank, are already fully parsed, and carry no stylesheets.
get URL() { return "about:blank"; }, get URL() { return "about:blank"; },
get documentURI() { return "about:blank"; }, get documentURI() { return "about:blank"; },
get domain() { return _incumbentDocumentDomain(); },
set domain(value) { String(value); _throwDocumentDomainSecurityError(); },
get referrer() { return ""; }, get referrer() { return ""; },
get baseURI() { return "about:blank"; }, get baseURI() { return "about:blank"; },
get compatMode() { return "CSS1Compat"; }, get compatMode() { return "CSS1Compat"; },
@@ -10813,14 +11050,127 @@ if (typeof WebSocket === 'undefined') {
} }
if (typeof BroadcastChannel === 'undefined') { if (typeof BroadcastChannel === 'undefined') {
// BroadcastChannel is used by authentication/session coordinators and by
// modern framework dev/runtime clients. Keep the registry realm-local: one
// Obscura page is one origin-bound browsing context today, so every channel
// in this registry has the same storage key and origin by construction.
const channelsByName = new Map();
const channelState = new WeakMap();
const stateFor = (channel) => {
const state = channelState.get(channel);
if (!state) throw new TypeError('Illegal invocation');
return state;
};
const installHandler = (channel, type, callback) => {
const state = stateFor(channel);
const slot = type === 'message' ? 'onmessage' : 'onmessageerror';
const wrapperSlot = type === 'message' ? 'messageWrapper' : 'messageErrorWrapper';
const oldCallback = state[slot];
state[slot] = callback;
if (callback && !oldCallback) {
const wrapper = (event) => {
const current = channelState.get(channel)?.[slot];
if (!current) return;
if (typeof current === 'function') current.call(channel, event);
else current.handleEvent.call(current, event);
};
state[wrapperSlot] = wrapper;
_eventTargetAdd(channel, type, wrapper);
} else if (!callback && oldCallback) {
_eventTargetRemove(channel, type, state[wrapperSlot]);
state[wrapperSlot] = null;
}
};
globalThis.BroadcastChannel = class BroadcastChannel { globalThis.BroadcastChannel = class BroadcastChannel {
constructor(name) { constructor(name) {
this.name = name; this.onmessage = null; this.onmessageerror = null; if (arguments.length < 1) {
_makeListenerBox(this); throw new TypeError("Failed to construct 'BroadcastChannel': 1 argument required.");
}
const normalizedName = String(name);
const state = {
name: normalizedName,
closed: false,
onmessage: null,
onmessageerror: null,
messageWrapper: null,
messageErrorWrapper: null,
};
channelState.set(this, state);
let channels = channelsByName.get(normalizedName);
if (!channels) channelsByName.set(normalizedName, channels = new Set());
channels.add(this);
} }
postMessage(msg) {} get name() { return stateFor(this).name; }
close() {} get onmessage() { return stateFor(this).onmessage; }
set onmessage(callback) {
callback = typeof callback === 'function'
|| (callback && typeof callback.handleEvent === 'function')
? callback : null;
installHandler(this, 'message', callback);
}
get onmessageerror() { return stateFor(this).onmessageerror; }
set onmessageerror(callback) {
callback = typeof callback === 'function'
|| (callback && typeof callback.handleEvent === 'function')
? callback : null;
installHandler(this, 'messageerror', callback);
}
addEventListener(type, callback, options) {
stateFor(this);
_eventTargetAdd(this, type, callback, options);
}
removeEventListener(type, callback, options) {
stateFor(this);
_eventTargetRemove(this, type, callback, options);
}
dispatchEvent(event) {
stateFor(this);
return _eventTargetDispatch(this, event);
}
postMessage(message) {
const state = stateFor(this);
if (state.closed) {
throw new DOMException("BroadcastChannel is closed.", "InvalidStateError");
}
// Serialization is synchronous and precedes recipient selection. This
// preserves DataCloneError even when no peer is listening and freezes
// the posted graph before the caller can mutate it.
const snapshot = globalThis.structuredClone(message);
const recipients = Array.from(channelsByName.get(state.name) || [])
.filter((channel) => channel !== this && !channelState.get(channel)?.closed);
const origin = globalThis.location?.origin || '';
for (const recipient of recipients) {
// Each destination gets an independent deserialization, not a shared
// JS object. Clone now so all serialization remains part of postMessage.
const data = globalThis.structuredClone(snapshot);
_scheduleAfter(0, () => {
const recipientState = channelState.get(recipient);
if (!recipientState || recipientState.closed) return;
_eventTargetDispatch(recipient, new MessageEvent('message', {
data,
origin,
source: null,
ports: [],
}));
});
}
}
close() {
const state = stateFor(this);
if (state.closed) return;
state.closed = true;
const channels = channelsByName.get(state.name);
if (!channels) return;
channels.delete(this);
if (!channels.size) channelsByName.delete(state.name);
}
get [Symbol.toStringTag]() { return 'BroadcastChannel'; }
}; };
// EventTarget is currently Node-backed in this runtime; link the prototype
// without invoking Node's DOM-node constructor or exposing a fake `_nid`.
Object.setPrototypeOf(globalThis.BroadcastChannel.prototype, globalThis.EventTarget.prototype);
} }
if (typeof MediaQueryList === 'undefined') { if (typeof MediaQueryList === 'undefined') {
+5 -5
View File
@@ -1,10 +1,10 @@
pub mod cdp_watchdog; pub mod cdp_watchdog;
mod import_map; mod import_map;
pub mod module_loader;
pub mod runtime;
pub mod ops;
pub mod v8_flags;
pub mod markdown; pub mod markdown;
pub mod module_loader;
pub mod ops;
pub mod runtime;
pub mod v8_flags;
pub use markdown::HTML_TO_MARKDOWN_JS; pub use markdown::HTML_TO_MARKDOWN_JS;
pub use v8_flags::set_v8_flags; pub use v8_flags::set_v8_flags;
@@ -13,6 +13,6 @@ pub use v8_flags::set_v8_flags;
// render feature (which enables obscura-render/paint) is compiled in. // render feature (which enables obscura-render/paint) is compiled in.
#[cfg(feature = "render")] #[cfg(feature = "render")]
pub use obscura_render::{ pub use obscura_render::{
screenshot_png, screenshot_png_scrolled, CaptureError, CaptureRegion, screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
}; };
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+111 -38
View File
@@ -14,7 +14,7 @@
use taffy::prelude::*; use taffy::prelude::*;
pub mod css; pub mod css;
pub use css::Stylesheet; pub use css::{Stylesheet, StylesheetCache};
pub mod style; pub mod style;
pub use style::compute_style; pub use style::compute_style;
@@ -37,7 +37,10 @@ pub use dom::{
/// are ASCII case-insensitive. Keep this list aligned with the `image` crate /// are ASCII case-insensitive. Keep this list aligned with the `image` crate
/// features in `Cargo.toml` plus the SVG path in `paint.rs`. /// features in `Cargo.toml` plus the SVG path in `paint.rs`.
pub(crate) fn source_type_supported(value: &str) -> bool { pub(crate) fn source_type_supported(value: &str) -> bool {
let essence = value.split_once(';').map_or(value, |(essence, _)| essence).trim(); let essence = value
.split_once(';')
.map_or(value, |(essence, _)| essence)
.trim();
[ [
"image/apng", "image/apng",
"image/bmp", "image/bmp",
@@ -94,11 +97,12 @@ mod paint;
#[cfg(feature = "paint")] #[cfg(feature = "paint")]
pub use paint::{ pub use paint::{
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll, paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts, screenshot_png, paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
screenshot_png_scrolled, screenshot_prepared, screenshot_prepared_region_with_scroll, prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
screenshot_prepared_with_scroll, CaptureError, CaptureRegion, DynamicFontFace, screenshot_prepared, screenshot_prepared_region_with_scroll, screenshot_prepared_with_scroll,
ElementScrollMetrics, PreparedRender, RenderResourceCache, RenderResourceLoader, validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
ResolvedScrollState, SelectedImage, MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS, PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
}; };
// Real inline text layout (cosmic-text) lives behind the paint feature; the // Real inline text layout (cosmic-text) lives behind the paint feature; the
@@ -122,7 +126,12 @@ pub mod inline {
/// Layout-only builds have no shaper, so no container is ever treated /// Layout-only builds have no shaper, so no container is ever treated
/// as a cosmic-text inline formatting context: the word-split path /// as a cosmic-text inline formatting context: the word-split path
/// handles text geometry for `getBoundingClientRect`. /// handles text geometry for `getBoundingClientRect`.
pub fn try_build(&mut self, _tree: &DomTree, _id: NodeId, _styles: &HashMap<NodeId, crate::LayoutStyle>) -> Option<usize> { pub fn try_build(
&mut self,
_tree: &DomTree,
_id: NodeId,
_styles: &HashMap<NodeId, crate::LayoutStyle>,
) -> Option<usize> {
None None
} }
/// See `try_build`: inline runs likewise fall back to word-split /// See `try_build`: inline runs likewise fall back to word-split
@@ -224,16 +233,31 @@ impl Affine2 {
} }
pub fn translate(x: f32, y: f32) -> Self { pub fn translate(x: f32, y: f32) -> Self {
Self { e: x, f: y, ..Self::IDENTITY } Self {
e: x,
f: y,
..Self::IDENTITY
}
} }
pub fn scale(x: f32, y: f32) -> Self { pub fn scale(x: f32, y: f32) -> Self {
Self { a: x, d: y, ..Self::IDENTITY } Self {
a: x,
d: y,
..Self::IDENTITY
}
} }
pub fn rotate(degrees: f32) -> Self { pub fn rotate(degrees: f32) -> Self {
let (sin, cos) = degrees.to_radians().sin_cos(); let (sin, cos) = degrees.to_radians().sin_cos();
Self { a: cos, b: sin, c: -sin, d: cos, e: 0.0, f: 0.0 } Self {
a: cos,
b: sin,
c: -sin,
d: cos,
e: 0.0,
f: 0.0,
}
} }
pub fn skew(x_degrees: f32, y_degrees: f32) -> Self { pub fn skew(x_degrees: f32, y_degrees: f32) -> Self {
@@ -254,7 +278,10 @@ impl Affine2 {
} }
pub fn map_point(self, x: f32, y: f32) -> (f32, f32) { pub fn map_point(self, x: f32, y: f32) -> (f32, f32) {
(self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f) (
self.a * x + self.c * y + self.e,
self.b * x + self.d * y + self.f,
)
} }
pub fn map_rect(self, rect: Rect) -> Rect { pub fn map_rect(self, rect: Rect) -> Rect {
@@ -264,10 +291,22 @@ impl Affine2 {
self.map_point(rect.x, rect.y + rect.height), self.map_point(rect.x, rect.y + rect.height),
self.map_point(rect.x + rect.width, rect.y + rect.height), self.map_point(rect.x + rect.width, rect.y + rect.height),
]; ];
let left = points.iter().map(|point| point.0).fold(f32::INFINITY, f32::min); let left = points
let top = points.iter().map(|point| point.1).fold(f32::INFINITY, f32::min); .iter()
let right = points.iter().map(|point| point.0).fold(f32::NEG_INFINITY, f32::max); .map(|point| point.0)
let bottom = points.iter().map(|point| point.1).fold(f32::NEG_INFINITY, f32::max); .fold(f32::INFINITY, f32::min);
let top = points
.iter()
.map(|point| point.1)
.fold(f32::INFINITY, f32::min);
let right = points
.iter()
.map(|point| point.0)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = points
.iter()
.map(|point| point.1)
.fold(f32::NEG_INFINITY, f32::max);
Rect { Rect {
x: left, x: left,
y: top, y: top,
@@ -317,7 +356,10 @@ pub struct TransformLength {
impl TransformLength { impl TransformLength {
pub fn px(value: f32) -> Self { pub fn px(value: f32) -> Self {
Self { value: Dimension::Px(value), expression: None } Self {
value: Dimension::Px(value),
expression: None,
}
} }
} }
@@ -343,7 +385,12 @@ impl Rect {
let x1 = (self.x + self.width).min(other.x + other.width); let x1 = (self.x + self.width).min(other.x + other.width);
let y1 = (self.y + self.height).min(other.y + other.height); let y1 = (self.y + self.height).min(other.y + other.height);
if x1 > x0 && y1 > y0 { if x1 > x0 && y1 > y0 {
Some(Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 }) Some(Rect {
x: x0,
y: y0,
width: x1 - x0,
height: y1 - y0,
})
} else { } else {
None None
} }
@@ -356,7 +403,12 @@ impl Rect {
let y0 = self.y.min(other.y); let y0 = self.y.min(other.y);
let x1 = (self.x + self.width).max(other.x + other.width); let x1 = (self.x + self.width).max(other.x + other.width);
let y1 = (self.y + self.height).max(other.y + other.height); let y1 = (self.y + self.height).max(other.y + other.height);
Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } Rect {
x: x0,
y: y0,
width: x1 - x0,
height: y1 - y0,
}
} }
} }
@@ -380,11 +432,7 @@ pub fn quantize_scroll_value(value: f32, device_scale_factor: f32) -> f32 {
(value * scale).round() / scale (value * scale).round() / scale
} }
pub(crate) fn quantized_scroll_range( pub(crate) fn quantized_scroll_range(content: f32, client: f32, device_scale_factor: f32) -> f32 {
content: f32,
client: f32,
device_scale_factor: f32,
) -> f32 {
(quantize_scroll_value(content, device_scale_factor) (quantize_scroll_value(content, device_scale_factor)
- quantize_scroll_value(client, device_scale_factor)) - quantize_scroll_value(client, device_scale_factor))
.max(0.0) .max(0.0)
@@ -532,8 +580,7 @@ impl BackgroundPositionAxis {
pub(crate) fn interpolate(self, other: Self, position: f32) -> Self { pub(crate) fn interpolate(self, other: Self, position: f32) -> Self {
Self { Self {
length: self.length + (other.length - self.length) * position, length: self.length + (other.length - self.length) * position,
percentage: self.percentage percentage: self.percentage + (other.percentage - self.percentage) * position,
+ (other.percentage - self.percentage) * position,
} }
} }
} }
@@ -816,8 +863,7 @@ pub struct LayoutStyle {
/// First `radial-gradient(...)` layer: center in box-relative fractions /// First `radial-gradient(...)` layer: center in box-relative fractions
/// and color stops. It is painted below the first linear layer, matching /// and color stops. It is painted below the first linear layer, matching
/// the common `linear-gradient(...), radial-gradient(...)` hero pattern. /// the common `linear-gradient(...), radial-gradient(...)` hero pattern.
pub background_radial_gradient: pub background_radial_gradient: Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
Option<((f32, f32), Vec<([u8; 4], Option<f32>)>)>,
/// `conic-gradient(...)` background. The angle is the CSS `from` angle, /// `conic-gradient(...)` background. The angle is the CSS `from` angle,
/// the center is a fraction of the border box, and stops are normalized /// the center is a fraction of the border box, and stops are normalized
/// during paint. Conic gradients commonly provide the color source for a /// during paint. Conic gradients commonly provide the color source for a
@@ -1536,7 +1582,11 @@ pub struct LayoutNode {
impl LayoutNode { impl LayoutNode {
pub fn leaf(style: LayoutStyle) -> Self { pub fn leaf(style: LayoutStyle) -> Self {
LayoutNode { style, text: None, children: Vec::new() } LayoutNode {
style,
text: None,
children: Vec::new(),
}
} }
} }
@@ -1568,9 +1618,9 @@ fn build_node(tree: &mut TaffyTree, node: &LayoutNode) -> NodeId {
if node.children.is_empty() { if node.children.is_empty() {
tree.new_leaf(style).expect("taffy new_leaf") tree.new_leaf(style).expect("taffy new_leaf")
} else { } else {
let child_ids: Vec<NodeId> = let child_ids: Vec<NodeId> = node.children.iter().map(|c| build_node(tree, c)).collect();
node.children.iter().map(|c| build_node(tree, c)).collect(); tree.new_with_children(style, &child_ids)
tree.new_with_children(style, &child_ids).expect("taffy new_with_children") .expect("taffy new_with_children")
} }
} }
@@ -1609,7 +1659,10 @@ pub(crate) fn to_taffy_style(style: &LayoutStyle) -> Style {
// `text_align` is separate from real flex/grid `align-items`, so a // `text_align` is separate from real flex/grid `align-items`, so a
// text-align declaration never changes how flex children are sized. // text-align declaration never changes how flex children are sized.
let promote_for_alignment = style.display == Display::Block let promote_for_alignment = style.display == Display::Block
&& matches!(style.text_align, Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END)); && matches!(
style.text_align,
Some(taffy::AlignItems::CENTER) | Some(taffy::AlignItems::FLEX_END)
);
s.display = match style.display { s.display = match style.display {
Display::Block if promote_for_alignment => taffy::style::Display::Flex, Display::Block if promote_for_alignment => taffy::style::Display::Flex,
@@ -1816,7 +1869,9 @@ fn dimension(v: Dimension) -> taffy::style::Dimension {
// rather than panicking. // rather than panicking.
Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0), Dimension::Em(v) | Dimension::Rem(v) => taffy::style::Dimension::length(v * 16.0),
Dimension::Ex(v) => taffy::style::Dimension::length(v * 16.0 * 0.528_320_3), Dimension::Ex(v) => taffy::style::Dimension::length(v * 16.0 * 0.528_320_3),
Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => taffy::style::Dimension::length(v), Dimension::Vw(v) | Dimension::Vh(v) | Dimension::Vmin(v) | Dimension::Vmax(v) => {
taffy::style::Dimension::length(v)
}
} }
} }
@@ -1866,7 +1921,12 @@ mod tests {
use super::*; use super::*;
fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle { fn make_box(display: Display, w: f32, h: f32) -> LayoutStyle {
LayoutStyle { display, width: Dimension::Px(w), height: Dimension::Px(h), ..Default::default() } LayoutStyle {
display,
width: Dimension::Px(w),
height: Dimension::Px(h),
..Default::default()
}
} }
#[test] #[test]
@@ -1909,7 +1969,10 @@ mod tests {
display: Display::Block, display: Display::Block,
width: Dimension::Px(200.0), width: Dimension::Px(200.0),
height: Dimension::Px(40.0), height: Dimension::Px(40.0),
margin: Edges { right: 50.0, ..Default::default() }, margin: Edges {
right: 50.0,
..Default::default()
},
margin_auto: [false, false, false, true], margin_auto: [false, false, false, true],
..Default::default() ..Default::default()
}; };
@@ -1957,7 +2020,12 @@ mod tests {
#[test] #[test]
fn flex_row_lays_out_horizontally() { fn flex_row_lays_out_horizontally() {
let root = LayoutNode { let root = LayoutNode {
style: LayoutStyle { display: Display::Flex, width: Dimension::Px(600.0), height: Dimension::Px(100.0), ..Default::default() }, style: LayoutStyle {
display: Display::Flex,
width: Dimension::Px(600.0),
height: Dimension::Px(100.0),
..Default::default()
},
text: None, text: None,
children: vec![ children: vec![
LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)), LayoutNode::leaf(make_box(Display::Block, 200.0, 100.0)),
@@ -1983,7 +2051,12 @@ mod tests {
display: Display::Block, display: Display::Block,
width: Dimension::Px(100.0), width: Dimension::Px(100.0),
height: Dimension::Px(100.0), height: Dimension::Px(100.0),
padding: Edges { top: 10.0, right: 10.0, bottom: 10.0, left: 10.0 }, padding: Edges {
top: 10.0,
right: 10.0,
bottom: 10.0,
left: 10.0,
},
..Default::default() ..Default::default()
}, },
text: None, text: None,
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff