Make rendering asynchronous and timeline-aware
This commit is contained in:
1 parent
68f20cf525
commit
66f04d0f43
18 files changed
+3952
-290
No files matched your search
@@ -9,7 +9,10 @@ pub use context::BrowserContext;
|
||||
pub use lifecycle::{LifecycleState, WaitUntil};
|
||||
pub use obscura_js::HTML_TO_MARKDOWN_JS;
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_js::{validate_capture_region, CaptureError, CaptureRegion};
|
||||
pub use obscura_js::{
|
||||
validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime,
|
||||
CaptureError, CaptureRegion,
|
||||
};
|
||||
pub use page::{NetworkEvent, Page, PageError};
|
||||
#[cfg(feature = "render")]
|
||||
pub use pdf::{RasterPdfError, RasterPdfOptions, RasterPdfPageRange};
|
||||
|
||||
@@ -233,6 +233,10 @@ pub struct Page {
|
||||
/// Exposed to JS as `document.characterSet` and used for the URL query
|
||||
/// encoding override on `<a>`/`<area>` hrefs in legacy-charset documents.
|
||||
pub encoding: String,
|
||||
/// Monotonic origin for the current document's CSS animation timeline.
|
||||
/// It is reset once author styles are installed, so stylesheet download
|
||||
/// latency does not incorrectly advance newly-created animations.
|
||||
document_timeline_origin: std::time::Instant,
|
||||
/// Navigation history for Page.getNavigationHistory / navigateToHistoryEntry.
|
||||
/// Entries are URLs in visit order; `history_index` is the current position.
|
||||
/// Pushed on every successful navigation; truncated on goBack -> new nav.
|
||||
@@ -724,6 +728,7 @@ impl Page {
|
||||
viewport: (1280.0, 720.0),
|
||||
device_scale_factor: 1.0,
|
||||
encoding: "UTF-8".to_string(),
|
||||
document_timeline_origin: std::time::Instant::now(),
|
||||
history: Vec::new(),
|
||||
history_index: 0,
|
||||
network_events: Vec::new(),
|
||||
@@ -1852,6 +1857,11 @@ impl Page {
|
||||
let mut idle_count = 0u32;
|
||||
loop {
|
||||
let now = tokio::time::Instant::now();
|
||||
// `has_pending_dynamic_scripts` includes both inserted script
|
||||
// elements and loader-owned ES-module graph activity. A lazy
|
||||
// import may be the work which removes an SSR loading shell;
|
||||
// do not take the 500ms fast exit while that graph is fetching.
|
||||
// Unrelated fetch/XHR activity remains on the fast path.
|
||||
if now >= deadline && (now >= dynamic_deadline || !js.has_pending_dynamic_scripts())
|
||||
{
|
||||
break;
|
||||
@@ -2274,6 +2284,10 @@ impl Page {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.document_timeline_origin = std::time::Instant::now();
|
||||
if let Some(js) = &self.js {
|
||||
js.reset_animation_timeline();
|
||||
}
|
||||
if let Some(js) = &mut self.js {
|
||||
let _ = js.execute_script("<iframe-load>",
|
||||
"(function() { var iframes = document.querySelectorAll('iframe[src]'); for (var i = 0; i < iframes.length; i++) { var src = iframes[i].getAttribute('src'); if (src && src !== 'about:blank') iframes[i]._loadIframeSrc(src); } })()");
|
||||
@@ -2410,6 +2424,7 @@ impl Page {
|
||||
));
|
||||
self.title = String::new();
|
||||
self.lifecycle = LifecycleState::Loaded;
|
||||
self.document_timeline_origin = std::time::Instant::now();
|
||||
}
|
||||
|
||||
pub fn url_string(&self) -> String {
|
||||
@@ -2445,10 +2460,10 @@ impl Page {
|
||||
let mut candidates = std::collections::BTreeMap::new();
|
||||
|
||||
if let Some(js) = &self.js {
|
||||
for raw in js.pending_render_image_urls() {
|
||||
for (raw, profile) in js.pending_render_image_urls() {
|
||||
if let Ok(mut url) = url::Url::parse(&raw) {
|
||||
url.set_fragment(None);
|
||||
candidates.insert(url.to_string(), ResourceType::Image);
|
||||
candidates.insert((url.to_string(), Some(profile)), ResourceType::Image);
|
||||
}
|
||||
}
|
||||
let css_sources = js
|
||||
@@ -2484,15 +2499,20 @@ impl Page {
|
||||
.unwrap_or_default();
|
||||
for css in css_sources {
|
||||
for raw in css_resource_urls(&css, &base_url) {
|
||||
if let Ok(url) = url::Url::parse(&raw) {
|
||||
candidates.insert(raw, render_resource_type(&url));
|
||||
if let Ok(mut url) = url::Url::parse(&raw) {
|
||||
let kind = render_resource_type(&url);
|
||||
url.set_fragment(None);
|
||||
candidates.insert((url.to_string(), None), kind);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates.retain(|url, _| !js.render_resource_is_known(url));
|
||||
candidates.retain(|(url, profile), _| match profile {
|
||||
Some(profile) => !js.render_image_resource_is_known(url, *profile),
|
||||
None => !js.render_resource_is_known(url),
|
||||
});
|
||||
}
|
||||
|
||||
candidates.retain(|url, _| {
|
||||
candidates.retain(|(url, _), _| {
|
||||
subresource_allowed(Some(&document_url), url) && !self.should_block_url(url)
|
||||
});
|
||||
if candidates.len() > 128 {
|
||||
@@ -2502,14 +2522,18 @@ impl Page {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let requested: Vec<(String, ResourceType)> = candidates.into_iter().collect();
|
||||
let requested: Vec<(String, Option<obscura_js::ImageRequestProfile>, ResourceType)> =
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|((url, profile), kind)| (url, profile, kind))
|
||||
.collect();
|
||||
let client = self.http_client.clone();
|
||||
#[cfg(feature = "stealth")]
|
||||
let stealth_client = self.stealth_client.clone();
|
||||
let callbacks = self.callbacks.clone();
|
||||
let initiator = document_url.clone();
|
||||
use futures::StreamExt as _;
|
||||
let requests = futures::stream::iter(requested.clone().into_iter().map(|(raw, kind)| {
|
||||
let requests = futures::stream::iter(requested.into_iter().map(|(raw, profile, kind)| {
|
||||
let client = client.clone();
|
||||
#[cfg(feature = "stealth")]
|
||||
let stealth_client = stealth_client.clone();
|
||||
@@ -2517,7 +2541,18 @@ impl Page {
|
||||
let initiator = initiator.clone();
|
||||
async move {
|
||||
let parsed = url::Url::parse(&raw).expect("validated render resource URL");
|
||||
let request = ResourceRequest::subresource(kind, &initiator);
|
||||
let mut request = ResourceRequest::subresource(kind, &initiator);
|
||||
match profile {
|
||||
Some(obscura_js::ImageRequestProfile::CorsSameOrigin) => {
|
||||
request.mode = obscura_net::RequestMode::Cors;
|
||||
request.credentials = obscura_net::RequestCredentials::SameOrigin;
|
||||
}
|
||||
Some(obscura_js::ImageRequestProfile::CorsInclude) => {
|
||||
request.mode = obscura_net::RequestMode::Cors;
|
||||
request.credentials = obscura_net::RequestCredentials::Include;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
#[cfg(feature = "stealth")]
|
||||
let result = if let Some(stealth_client) = stealth_client {
|
||||
stealth_client
|
||||
@@ -2532,57 +2567,54 @@ impl Page {
|
||||
let result = client
|
||||
.fetch_resource_with_callbacks(&parsed, request, Some(&callbacks))
|
||||
.await;
|
||||
(raw, kind, result)
|
||||
(raw, profile, kind, result)
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(16);
|
||||
futures::pin_mut!(requests);
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(max_ms);
|
||||
let mut outcomes = std::collections::HashMap::new();
|
||||
let mut loaded = 0usize;
|
||||
loop {
|
||||
match tokio::time::timeout_at(deadline, requests.next()).await {
|
||||
Ok(Some((raw, kind, result))) => {
|
||||
outcomes.insert(raw, (kind, result));
|
||||
Ok(Some((raw, profile, kind, result))) => {
|
||||
let outcome = match result {
|
||||
Ok(response) => {
|
||||
self.record_network_event_with_body(
|
||||
response.url.as_str(),
|
||||
"GET",
|
||||
match kind {
|
||||
ResourceType::Font => "Font",
|
||||
_ => "Image",
|
||||
},
|
||||
response.status,
|
||||
&response.headers,
|
||||
&response.body,
|
||||
true,
|
||||
);
|
||||
if (200..300).contains(&response.status) {
|
||||
loaded += 1;
|
||||
Some(response.body)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Err(_) => None,
|
||||
};
|
||||
if let Some(js) = &mut self.js {
|
||||
match profile {
|
||||
Some(profile) => {
|
||||
js.seed_render_image_resource(raw, profile, outcome)
|
||||
}
|
||||
None => js.seed_render_resource(raw, outcome),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
// A deadline drops unfinished futures without negative-caching them,
|
||||
// so a later warmup can retry slow resources.
|
||||
drop(requests);
|
||||
|
||||
let mut loaded = 0usize;
|
||||
for (raw, kind) in requested {
|
||||
let outcome = match outcomes.remove(&raw) {
|
||||
Some((_, Ok(response))) => {
|
||||
self.record_network_event_with_body(
|
||||
response.url.as_str(),
|
||||
"GET",
|
||||
match kind {
|
||||
ResourceType::Font => "Font",
|
||||
_ => "Image",
|
||||
},
|
||||
response.status,
|
||||
&response.headers,
|
||||
&response.body,
|
||||
true,
|
||||
);
|
||||
if (200..300).contains(&response.status) {
|
||||
loaded += 1;
|
||||
Some(response.body)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Some((_, Err(_))) => None,
|
||||
// The shared deadline cancelled this request before it had a
|
||||
// transport outcome. Keep it unknown so the final capture can
|
||||
// retry instead of suppressing a valid slow image/font behind
|
||||
// the renderer's negative-cache TTL.
|
||||
None => continue,
|
||||
};
|
||||
if let Some(js) = &mut self.js {
|
||||
js.seed_render_resource(raw, outcome);
|
||||
}
|
||||
}
|
||||
tracing::debug!(
|
||||
loaded,
|
||||
elapsed_ms = started.elapsed().as_millis(),
|
||||
@@ -2596,11 +2628,41 @@ impl Page {
|
||||
/// viewport is zero-sized.
|
||||
#[cfg(feature = "render")]
|
||||
pub fn screenshot(&self, viewport: (f32, f32)) -> Option<Vec<u8>> {
|
||||
self.screenshot_with_animation_sample(viewport, self.live_animation_sample())
|
||||
}
|
||||
|
||||
/// Rasterize every CSS animation at one explicit local time. This mirrors
|
||||
/// Web Animations `currentTime` and is intended for deterministic parity
|
||||
/// capture; ordinary screenshots use each live instance's start epoch.
|
||||
#[cfg(feature = "render")]
|
||||
pub fn screenshot_at_animation_time(
|
||||
&self,
|
||||
viewport: (f32, f32),
|
||||
animation_sample_time: obscura_js::AnimationSampleTime,
|
||||
) -> Option<Vec<u8>> {
|
||||
self.screenshot_with_animation_sample(
|
||||
viewport,
|
||||
obscura_js::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: obscura_js::AnimationSampleMode::LocalOverride,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn screenshot_with_animation_sample(
|
||||
&self,
|
||||
viewport: (f32, f32),
|
||||
animation_sample: obscura_js::AnimationSample,
|
||||
) -> Option<Vec<u8>> {
|
||||
// Needed to resolve the relative image URLs ("logo.svg") that make up
|
||||
// the overwhelming majority of real markup.
|
||||
let base_url = self.resolve_base_url();
|
||||
let base_url = base_url.as_ref().map(|u| u.as_str());
|
||||
if let Some(js) = &self.js {
|
||||
if !js.set_animation_sample(animation_sample) {
|
||||
return None;
|
||||
}
|
||||
if let Some(png) = js.screenshot_prepared(viewport, base_url) {
|
||||
return Some(png);
|
||||
}
|
||||
@@ -2612,7 +2674,15 @@ impl Page {
|
||||
.as_ref()
|
||||
.map(|js| js.scroll_offset())
|
||||
.unwrap_or((0.0, 0.0));
|
||||
self.with_dom(|dom| obscura_js::screenshot_png_scrolled(dom, viewport, base_url, scroll))
|
||||
self.with_dom(|dom| {
|
||||
obscura_js::screenshot_png_scrolled_at_animation_time(
|
||||
dom,
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
animation_sample.time,
|
||||
)
|
||||
})
|
||||
.flatten()
|
||||
}
|
||||
|
||||
@@ -2624,10 +2694,38 @@ impl Page {
|
||||
&self,
|
||||
region: obscura_js::CaptureRegion,
|
||||
) -> Result<Vec<u8>, obscura_js::CaptureError> {
|
||||
self.js
|
||||
self.screenshot_region_with_animation_sample(region, self.live_animation_sample())
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn screenshot_region_at_animation_time(
|
||||
&self,
|
||||
region: obscura_js::CaptureRegion,
|
||||
animation_sample_time: obscura_js::AnimationSampleTime,
|
||||
) -> Result<Vec<u8>, obscura_js::CaptureError> {
|
||||
self.screenshot_region_with_animation_sample(
|
||||
region,
|
||||
obscura_js::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: obscura_js::AnimationSampleMode::LocalOverride,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn screenshot_region_with_animation_sample(
|
||||
&self,
|
||||
region: obscura_js::CaptureRegion,
|
||||
animation_sample: obscura_js::AnimationSample,
|
||||
) -> Result<Vec<u8>, obscura_js::CaptureError> {
|
||||
let js = self
|
||||
.js
|
||||
.as_ref()
|
||||
.ok_or(obscura_js::CaptureError::PaintFailed)?
|
||||
.screenshot_prepared_region(region)
|
||||
.ok_or(obscura_js::CaptureError::PaintFailed)?;
|
||||
if !js.set_animation_sample(animation_sample) {
|
||||
return Err(obscura_js::CaptureError::PaintFailed);
|
||||
}
|
||||
js.screenshot_prepared_region(region)
|
||||
}
|
||||
|
||||
/// Scrollable document dimensions from the retained render layout. Unlike
|
||||
@@ -2635,7 +2733,50 @@ impl Page {
|
||||
/// monkey-patched by the document being captured.
|
||||
#[cfg(feature = "render")]
|
||||
pub fn prepared_content_size(&self) -> Option<(f32, f32)> {
|
||||
self.js.as_ref()?.prepared_content_size()
|
||||
self.prepared_content_size_with_animation_sample(self.live_animation_sample())
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn prepared_content_size_at_animation_time(
|
||||
&self,
|
||||
animation_sample_time: obscura_js::AnimationSampleTime,
|
||||
) -> Option<(f32, f32)> {
|
||||
self.prepared_content_size_with_animation_sample(obscura_js::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: obscura_js::AnimationSampleMode::LocalOverride,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn prepared_content_size_with_animation_sample(
|
||||
&self,
|
||||
animation_sample: obscura_js::AnimationSample,
|
||||
) -> Option<(f32, f32)> {
|
||||
let js = self.js.as_ref()?;
|
||||
js.set_animation_sample(animation_sample)
|
||||
.then(|| js.prepared_content_size())
|
||||
.flatten()
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn live_animation_sample(&self) -> obscura_js::AnimationSample {
|
||||
if let Some(js) = &self.js {
|
||||
return js.live_animation_sample();
|
||||
}
|
||||
let milliseconds = self.document_timeline_origin.elapsed().as_secs_f64() * 1_000.0;
|
||||
obscura_js::AnimationSample {
|
||||
time: obscura_js::AnimationSampleTime {
|
||||
milliseconds: milliseconds.min(f64::from(f32::MAX)) as f32,
|
||||
},
|
||||
mode: obscura_js::AnimationSampleMode::DocumentTime,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub fn prepared_has_active_css_animations(&self) -> bool {
|
||||
self.js
|
||||
.as_ref()
|
||||
.is_some_and(|js| js.prepared_has_active_css_animations())
|
||||
}
|
||||
|
||||
/// Renderer-owned root scroll offset for document-space capture routing.
|
||||
@@ -4026,6 +4167,128 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn lazy_module_graph_keeps_post_script_settle_alive() {
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..2 {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = [0u8; 2048];
|
||||
let length = stream.read(&mut request).unwrap();
|
||||
let request_text = String::from_utf8_lossy(&request[..length]);
|
||||
let path = request_text
|
||||
.lines()
|
||||
.next()
|
||||
.and_then(|line| line.split_ascii_whitespace().nth(1))
|
||||
.unwrap_or("/");
|
||||
let body = match path {
|
||||
"/app/lazy.js" => {
|
||||
"import { ready } from './lazy-child.js'; export { ready };"
|
||||
}
|
||||
"/app/lazy-child.js" => {
|
||||
// Cross the lifecycle's 500ms fast-settle floor on a
|
||||
// descendant edge. deno_core must propagate the lazy
|
||||
// graph marker beyond its root for this to stay alive.
|
||||
std::thread::sleep(std::time::Duration::from_millis(700));
|
||||
"export const ready = 'lazy-ready';"
|
||||
}
|
||||
unexpected => panic!("unexpected module request: {unexpected}"),
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/javascript\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
stream.write_all(response.as_bytes()).unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
let base = format!("http://{address}");
|
||||
let mut page = import_map_test_page(
|
||||
"lazy-module-readiness",
|
||||
&base,
|
||||
r#"<html><body><script>
|
||||
import("./lazy.js").then(module => {
|
||||
document.body.setAttribute("data-lazy-state", module.ready);
|
||||
});
|
||||
</script></body></html>"#,
|
||||
);
|
||||
page.execute_scripts().await;
|
||||
|
||||
assert_eq!(
|
||||
page.js
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.evaluate("document.body.getAttribute('data-lazy-state')")
|
||||
.unwrap(),
|
||||
serde_json::json!("lazy-ready"),
|
||||
"post-script readiness must include an in-flight lazy module graph",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn ordinary_fetch_does_not_extend_dynamic_module_settle() {
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (accepted_tx, accepted_rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let _ = accepted_tx.send(());
|
||||
let mut request = [0u8; 2048];
|
||||
let length = stream.read(&mut request).unwrap();
|
||||
assert!(String::from_utf8_lossy(&request[..length])
|
||||
.starts_with("GET /app/analytics "));
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
let body = "{}";
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len(),
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
});
|
||||
|
||||
let base = format!("http://{address}");
|
||||
let html = format!(
|
||||
r#"<html><body><script>
|
||||
globalThis.__analyticsStarted = true;
|
||||
fetch("{base}/app/analytics").catch(error => {{
|
||||
globalThis.__analyticsError = error.message;
|
||||
}});
|
||||
</script></body></html>"#,
|
||||
);
|
||||
let mut page = import_map_test_page(
|
||||
"ordinary-fetch-readiness",
|
||||
&base,
|
||||
&html,
|
||||
);
|
||||
let started = std::time::Instant::now();
|
||||
page.execute_scripts().await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
accepted_rx
|
||||
.recv_timeout(std::time::Duration::from_millis(100))
|
||||
.is_ok(),
|
||||
"ordinary fetch fixture must actually start its network request",
|
||||
);
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(1_500),
|
||||
"ordinary fetch/XHR must retain the fast settle path; elapsed={elapsed:?}",
|
||||
);
|
||||
assert_eq!(
|
||||
page.js
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.evaluate("globalThis.__analyticsStarted")
|
||||
.unwrap(),
|
||||
serde_json::json!(true),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn dynamic_import_map_uses_live_document_base_at_insertion() {
|
||||
let (base, requests) = spawn_parser_import_map_server(1);
|
||||
@@ -4142,7 +4405,8 @@ mod tests {
|
||||
let mut page = super::Page::new("render-prefetch".to_string(), context);
|
||||
page.set_viewport((100.0, 80.0));
|
||||
let page_url = format!("http://{address}/page");
|
||||
let asset_url = format!("http://{address}/asset.svg");
|
||||
let asset_network_url = format!("http://{address}/asset.svg");
|
||||
let asset_url = format!("{asset_network_url}#icon");
|
||||
let dom = parse_html(&format!(
|
||||
r#"<html><body><img src="{asset_url}" style="width:20px;height:10px"></body></html>"#
|
||||
));
|
||||
@@ -4155,6 +4419,15 @@ mod tests {
|
||||
page.url = Some(url::Url::parse(&page_url).unwrap());
|
||||
|
||||
assert_eq!(page.prepare_screenshot_resources(1_000).await, 1);
|
||||
assert_eq!(
|
||||
page.js
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.evaluate("document.querySelector('img').currentSrc")
|
||||
.unwrap(),
|
||||
serde_json::json!(asset_url),
|
||||
"cache/network fragment normalization must not alter currentSrc"
|
||||
);
|
||||
page.screenshot(page.viewport).expect("prefetched capture");
|
||||
assert!(seen_rx
|
||||
.recv_timeout(std::time::Duration::from_secs(1))
|
||||
|
||||
@@ -278,12 +278,37 @@ impl Page {
|
||||
/// vertical slices become pages. This does not reflow into `@media print`
|
||||
/// or implement CSS paged media, headers, or footers.
|
||||
pub fn raster_pdf(&self, options: RasterPdfOptions) -> Result<Vec<u8>, RasterPdfError> {
|
||||
self.raster_pdf_with_animation_sample(options, self.live_animation_sample())
|
||||
}
|
||||
|
||||
pub fn raster_pdf_at_animation_time(
|
||||
&self,
|
||||
options: RasterPdfOptions,
|
||||
animation_sample_time: obscura_js::AnimationSampleTime,
|
||||
) -> Result<Vec<u8>, RasterPdfError> {
|
||||
self.raster_pdf_with_animation_sample(
|
||||
options,
|
||||
obscura_js::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: obscura_js::AnimationSampleMode::LocalOverride,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn raster_pdf_with_animation_sample(
|
||||
&self,
|
||||
options: RasterPdfOptions,
|
||||
animation_sample: obscura_js::AnimationSample,
|
||||
) -> 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)?;
|
||||
if !js.set_animation_sample(animation_sample) {
|
||||
return Err(RasterPdfError::NoRenderableDocument);
|
||||
}
|
||||
let (content_width, content_height) = js
|
||||
.prepared_content_size()
|
||||
.ok_or(RasterPdfError::NoRenderableDocument)?;
|
||||
|
||||
@@ -425,6 +425,7 @@ pub(crate) fn queue_screencast_frame(
|
||||
let page = ctx
|
||||
.get_session_page_mut(&attached_session)
|
||||
.ok_or("No page for session")?;
|
||||
let animation_sample = page.live_animation_sample();
|
||||
let viewport = page.viewport;
|
||||
let scroll = page
|
||||
.evaluate("[window.scrollX, window.scrollY]")
|
||||
@@ -444,9 +445,12 @@ pub(crate) fn queue_screencast_frame(
|
||||
1.0,
|
||||
))
|
||||
.map_err(capture_error_message)?;
|
||||
let png = page.screenshot(viewport).ok_or_else(|| {
|
||||
"Page.startScreencast failed: the page has no visible DOM surface to render".to_string()
|
||||
})?;
|
||||
let png = page
|
||||
.screenshot_with_animation_sample(viewport, animation_sample)
|
||||
.ok_or_else(|| {
|
||||
"Page.startScreencast failed: the page has no visible DOM surface to render"
|
||||
.to_string()
|
||||
})?;
|
||||
let activity_generation = page
|
||||
.js
|
||||
.as_ref()
|
||||
@@ -501,7 +505,7 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
|
||||
continue;
|
||||
}
|
||||
let attached_session = Some(cdp_session_id.clone());
|
||||
let activity_generation = {
|
||||
let (activity_generation, css_animation_active) = {
|
||||
let Some(page) = ctx.get_session_page_mut(&attached_session) else {
|
||||
continue;
|
||||
};
|
||||
@@ -511,14 +515,16 @@ pub(crate) async fn pump_screencast_frames(ctx: &mut CdpContext) {
|
||||
// A V8 watchdog bounds synchronous callbacks as well as the async
|
||||
// timeout, so a hostile timer cannot freeze the CDP connection.
|
||||
let _ = js.run_event_loop_bounded(EVENT_LOOP_SLICE_MS).await;
|
||||
js.activity_generation()
|
||||
let generation = js.activity_generation();
|
||||
let css_animation_active = page.prepared_has_active_css_animations();
|
||||
(generation, css_animation_active)
|
||||
};
|
||||
|
||||
let should_attempt = {
|
||||
let Some(state) = ctx.screencasts.get_mut(&cdp_session_id) else {
|
||||
continue;
|
||||
};
|
||||
if state.observed_activity_generation != activity_generation {
|
||||
if state.observed_activity_generation != activity_generation || css_animation_active {
|
||||
state.observed_activity_generation = activity_generation;
|
||||
state.autonomous_frame_pending = true;
|
||||
}
|
||||
@@ -1253,6 +1259,7 @@ pub async fn handle(
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page for session")?;
|
||||
prepare_capture_resources_if_requested(page).await;
|
||||
let animation_sample = page.live_animation_sample();
|
||||
let viewport = page.viewport;
|
||||
let device_scale_factor = f64::from(page.device_scale_factor);
|
||||
let trusted_scroll = page.screenshot_scroll_offset();
|
||||
@@ -1275,9 +1282,11 @@ pub async fn handle(
|
||||
}
|
||||
Some(chromium_clip_region(clip, device_scale_factor)?)
|
||||
} else if options.capture_beyond_viewport {
|
||||
let content_size = page.prepared_content_size().ok_or_else(|| {
|
||||
"Page.captureScreenshot failed: no retained document size".to_string()
|
||||
})?;
|
||||
let content_size = page
|
||||
.prepared_content_size_with_animation_sample(animation_sample)
|
||||
.ok_or_else(|| {
|
||||
"Page.captureScreenshot failed: no retained document size".to_string()
|
||||
})?;
|
||||
Some(obscura_browser::CaptureRegion::new(
|
||||
0.0,
|
||||
0.0,
|
||||
@@ -1299,7 +1308,7 @@ pub async fn handle(
|
||||
|
||||
let png = match region {
|
||||
Some(region) => page
|
||||
.screenshot_region(region)
|
||||
.screenshot_region_with_animation_sample(region, animation_sample)
|
||||
.map_err(capture_error_message)?,
|
||||
None => {
|
||||
obscura_browser::validate_capture_region(
|
||||
@@ -1312,10 +1321,11 @@ pub async fn handle(
|
||||
),
|
||||
)
|
||||
.map_err(capture_error_message)?;
|
||||
page.screenshot(viewport).ok_or_else(|| {
|
||||
"Page.captureScreenshot failed: the page has no DOM to render"
|
||||
.to_string()
|
||||
})?
|
||||
page.screenshot_with_animation_sample(viewport, animation_sample)
|
||||
.ok_or_else(|| {
|
||||
"Page.captureScreenshot failed: the page has no DOM to render"
|
||||
.to_string()
|
||||
})?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1535,6 +1545,116 @@ mod tests {
|
||||
assert_eq!(frame.params["sessionId"], 1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test]
|
||||
async fn css_animation_drives_autonomous_screencast_frames_until_completion() {
|
||||
let mut ctx = CdpContext::new();
|
||||
let page_id = ctx.create_page();
|
||||
let session_id = format!("{page_id}-session");
|
||||
ctx.sessions.insert(session_id.clone(), page_id);
|
||||
let session = Some(session_id);
|
||||
ctx.get_session_page_mut(&session)
|
||||
.expect("page")
|
||||
.set_viewport((80.0, 60.0));
|
||||
handle(
|
||||
"navigate",
|
||||
&json!({
|
||||
"url": "data:text/html,<html style='margin:0'><head><style>@keyframes hide{from{opacity:1}to{opacity:0}}#cover{width:80px;height:60px;background:red;animation:hide 100ms linear forwards}</style></head><body style='margin:0;background:lime'><div id=cover></div></body></html>",
|
||||
"waitUntil": "load",
|
||||
}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("navigate animation fixture");
|
||||
|
||||
ctx.pending_events.clear();
|
||||
handle("startScreencast", &json!({}), &mut ctx, &session)
|
||||
.await
|
||||
.expect("start screencast");
|
||||
let initial = ctx
|
||||
.pending_events
|
||||
.iter()
|
||||
.find(|event| event.method == "Page.screencastFrame")
|
||||
.expect("initial frame")
|
||||
.params["data"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let generation = ctx
|
||||
.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.js
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.activity_generation();
|
||||
|
||||
ctx.pending_events.clear();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
pump_screencast_frames(&mut ctx).await;
|
||||
let (animated, frame_session_id) = {
|
||||
let event = ctx
|
||||
.pending_events
|
||||
.iter()
|
||||
.find(|event| event.method == "Page.screencastFrame")
|
||||
.expect("CSS-only animation frame");
|
||||
(
|
||||
event.params["data"].as_str().unwrap().to_string(),
|
||||
event.params["sessionId"].as_u64().unwrap(),
|
||||
)
|
||||
};
|
||||
assert_ne!(animated, initial);
|
||||
assert_eq!(
|
||||
ctx.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.js
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.activity_generation(),
|
||||
generation,
|
||||
"CSS timeline damage must not depend on DOM or task activity"
|
||||
);
|
||||
|
||||
for _ in 0..2 {
|
||||
handle(
|
||||
"screencastFrameAck",
|
||||
&json!({"sessionId": frame_session_id}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("ack frame");
|
||||
}
|
||||
ctx.pending_events.clear();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
pump_screencast_frames(&mut ctx).await;
|
||||
assert!(
|
||||
ctx.pending_events
|
||||
.iter()
|
||||
.any(|event| event.method == "Page.screencastFrame"),
|
||||
"the stream must emit the finite animation's final frame"
|
||||
);
|
||||
assert!(!ctx
|
||||
.get_session_page_mut(&session)
|
||||
.unwrap()
|
||||
.prepared_has_active_css_animations());
|
||||
|
||||
handle(
|
||||
"screencastFrameAck",
|
||||
&json!({"sessionId": frame_session_id}),
|
||||
&mut ctx,
|
||||
&session,
|
||||
)
|
||||
.await
|
||||
.expect("ack final frame");
|
||||
ctx.pending_events.clear();
|
||||
pump_screencast_frames(&mut ctx).await;
|
||||
assert!(
|
||||
ctx.pending_events.is_empty(),
|
||||
"a completed finite animation must not keep rasterizing idle frames"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[tokio::test]
|
||||
async fn screencast_sampling_backpressure_and_stale_acks_are_bounded() {
|
||||
|
||||
@@ -226,8 +226,9 @@ pub async fn print_to_pdf(
|
||||
.get_session_page_mut(session_id)
|
||||
.ok_or("No page for session")?;
|
||||
crate::domains::page::prepare_capture_resources_if_requested(page).await;
|
||||
let animation_sample = page.live_animation_sample();
|
||||
let pdf = page
|
||||
.raster_pdf(options.raster)
|
||||
.raster_pdf_with_animation_sample(options.raster, animation_sample)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let mut response = json!({
|
||||
"obscuraPrintMode": "screen-raster",
|
||||
|
||||
@@ -902,6 +902,29 @@ async fn run_fetch(
|
||||
// OBSCURA_SHOT_W / OBSCURA_SHOT_H override it (e.g. a tall viewport to
|
||||
// capture below-the-fold content in one shot).
|
||||
let viewport = screenshot_viewport.unwrap_or((1280.0, 720.0));
|
||||
// Ordinary screenshots sample the live document timeline. The
|
||||
// comparison harness can request an exact instant (normally T=0)
|
||||
// so both engines paint the same animation frame.
|
||||
let requested_animation_sample = std::env::var("OBSCURA_SHOT_ANIMATION_TIME_MS")
|
||||
.ok()
|
||||
.map(|raw| {
|
||||
let milliseconds = raw.parse::<f32>().map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number"
|
||||
)
|
||||
})?;
|
||||
if !milliseconds.is_finite() || milliseconds < 0.0 {
|
||||
anyhow::bail!(
|
||||
"OBSCURA_SHOT_ANIMATION_TIME_MS must be a finite non-negative number"
|
||||
);
|
||||
}
|
||||
Ok(obscura_browser::AnimationSampleTime { milliseconds })
|
||||
})
|
||||
.transpose()?;
|
||||
let capture_screenshot = |page: &Page| match requested_animation_sample {
|
||||
Some(sample) => page.screenshot_at_animation_time(viewport, sample),
|
||||
None => page.screenshot(viewport),
|
||||
};
|
||||
// The parity harness performs one throwaway paint in both engines
|
||||
// before observing image/font readiness. Obscura resolves retained
|
||||
// render resources during prepare/paint, so sampling first would
|
||||
@@ -910,7 +933,7 @@ async fn run_fetch(
|
||||
let warmup_capture =
|
||||
std::env::var("OBSCURA_SHOT_RESOURCE_WARMUP").is_ok_and(|value| value == "1");
|
||||
if warmup_capture {
|
||||
if page.screenshot(viewport).is_none() {
|
||||
if capture_screenshot(&page).is_none() {
|
||||
anyhow::bail!("resource warm-up screenshot failed: page has no DOM to render");
|
||||
}
|
||||
// Give completion callbacks one bounded task turn before the
|
||||
@@ -962,7 +985,7 @@ async fn run_fetch(
|
||||
}))()",
|
||||
)
|
||||
});
|
||||
match page.screenshot(viewport) {
|
||||
match capture_screenshot(&page) {
|
||||
Some(bytes) => std::fs::write(path, &bytes)?,
|
||||
None => anyhow::bail!("screenshot failed: page has no DOM to render"),
|
||||
}
|
||||
|
||||
@@ -72,6 +72,57 @@ fn screenshot_is_captured_after_eval_scrolls_the_live_page() {
|
||||
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn screenshots_use_live_animation_time_unless_harness_pins_a_sample() {
|
||||
let directory = output_directory();
|
||||
std::fs::create_dir_all(&directory).expect("temporary output directory");
|
||||
let live = directory.join("live.png");
|
||||
let initial = directory.join("initial.png");
|
||||
let url = concat!(
|
||||
"data:text/html,<html style=\"margin:0\"><head><style>",
|
||||
"@keyframes hide{from{opacity:1}to{opacity:0}}",
|
||||
"#cover{width:80px;height:60px;background:red;",
|
||||
"animation:hide 50ms linear forwards}",
|
||||
"</style></head><body style=\"margin:0;background:lime\">",
|
||||
"<div id=\"cover\"></div></body></html>"
|
||||
);
|
||||
|
||||
let run = |path: &std::path::Path, animation_time: Option<&str>| {
|
||||
let mut command = Command::new(env!("CARGO_BIN_EXE_obscura"));
|
||||
command
|
||||
.args(["fetch", url, "--screenshot"])
|
||||
.arg(path)
|
||||
.args(["--wait", "1", "--timeout", "5", "--quiet"])
|
||||
.env("OBSCURA_SHOT_W", "80")
|
||||
.env("OBSCURA_SHOT_H", "60")
|
||||
.env_remove("OBSCURA_SHOT_ANIMATION_TIME_MS");
|
||||
if let Some(milliseconds) = animation_time {
|
||||
command.env("OBSCURA_SHOT_ANIMATION_TIME_MS", milliseconds);
|
||||
}
|
||||
command.output().expect("run obscura fetch")
|
||||
};
|
||||
|
||||
let live_output = run(&live, None);
|
||||
assert!(
|
||||
live_output.status.success(),
|
||||
"live capture failed: {}",
|
||||
String::from_utf8_lossy(&live_output.stderr)
|
||||
);
|
||||
let initial_output = run(&initial, Some("0"));
|
||||
assert!(
|
||||
initial_output.status.success(),
|
||||
"explicit capture failed: {}",
|
||||
String::from_utf8_lossy(&initial_output.stderr)
|
||||
);
|
||||
assert_ne!(
|
||||
std::fs::read(live).expect("live PNG"),
|
||||
std::fs::read(initial).expect("initial PNG"),
|
||||
"live capture must advance past a finite overlay while explicit T=0 preserves it"
|
||||
);
|
||||
|
||||
std::fs::remove_dir_all(directory).expect("remove temporary output directory");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paired_capture_reasserts_scroll_after_the_post_eval_settle() {
|
||||
let directory = output_directory();
|
||||
|
||||
Vendored
+35
-11
@@ -647,7 +647,13 @@ const _scheduleAfter = (delay, fn) => {
|
||||
}
|
||||
// The callback runs only when the embedder pumps the event loop, after the
|
||||
// current microtask checkpoint.
|
||||
return Deno.core.queueUserTimer(0, false, d, fn);
|
||||
return Deno.core.queueUserTimer(0, false, d, () => {
|
||||
// HTML timer/observer/rAF delivery starts a new task. Freeze animation
|
||||
// time lazily on that task's first style/layout read so a callback that
|
||||
// waited in the host queue samples its actual delivery instant.
|
||||
Deno.core.ops.op_begin_render_task?.();
|
||||
return fn();
|
||||
});
|
||||
};
|
||||
|
||||
// Timers accept a string first arg per the HTML spec (e.g. the Aliyun WAF
|
||||
@@ -4530,7 +4536,8 @@ class HTMLImageElement extends Element {
|
||||
setAttribute(name, value) {
|
||||
const normalized = String(name).toLowerCase();
|
||||
super.setAttribute(name, value);
|
||||
if (normalized === "src" || normalized === "srcset" || normalized === "sizes") {
|
||||
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|
||||
|| normalized === "crossorigin") {
|
||||
this._imageSourceChanged();
|
||||
}
|
||||
else if ((normalized === "onload" || normalized === "onerror")
|
||||
@@ -4540,7 +4547,8 @@ class HTMLImageElement extends Element {
|
||||
removeAttribute(name) {
|
||||
const normalized = String(name).toLowerCase();
|
||||
super.removeAttribute(name);
|
||||
if (normalized === "src" || normalized === "srcset" || normalized === "sizes") {
|
||||
if (normalized === "src" || normalized === "srcset" || normalized === "sizes"
|
||||
|| normalized === "crossorigin") {
|
||||
this._imageSourceChanged();
|
||||
}
|
||||
}
|
||||
@@ -4581,30 +4589,46 @@ class HTMLImageElement extends Element {
|
||||
this._imageQueued = true;
|
||||
const request = this._imageRequest;
|
||||
setTimeout(() => {
|
||||
this._imageQueued = false;
|
||||
if (request === this._imageRequest && !this._imageComplete) {
|
||||
this._runImageRequest(request);
|
||||
} else if (request === this._imageRequest) {
|
||||
this._imageQueued = false;
|
||||
}
|
||||
}, 1);
|
||||
}
|
||||
|
||||
_runImageRequest(request) {
|
||||
let metadata = null;
|
||||
const finish = (metadata) => {
|
||||
if (request !== this._imageRequest) return;
|
||||
this._imageQueued = false;
|
||||
if (metadata && metadata.state === "stale") {
|
||||
this._refreshImageFromCache(true);
|
||||
this._queueImageRequest();
|
||||
return;
|
||||
}
|
||||
this._applyImageMetadata(metadata, request, true);
|
||||
};
|
||||
try {
|
||||
const op = Deno.core.ops.op_image_metadata;
|
||||
const op = Deno.core.ops.op_load_image_metadata;
|
||||
if (typeof op === "function") {
|
||||
metadata = JSON.parse(op(this._nid >>> 0, false));
|
||||
Promise.resolve(op(this._nid >>> 0)).then(
|
||||
raw => {
|
||||
let metadata = null;
|
||||
try { metadata = JSON.parse(raw); }
|
||||
catch (_error) { metadata = { ok: false, currentSrc: this.src }; }
|
||||
finish(metadata);
|
||||
},
|
||||
() => finish({ ok: false, currentSrc: this.src }),
|
||||
);
|
||||
} else {
|
||||
// Non-render builds have no authoritative resource cache. Preserve the
|
||||
// old non-blocking compatibility behavior without issuing a duplicate
|
||||
// network fetch: the request succeeds with unknown intrinsic size.
|
||||
metadata = { ok: true, currentSrc: this.src, width: 0, height: 0 };
|
||||
finish({ ok: true, currentSrc: this.src, width: 0, height: 0 });
|
||||
}
|
||||
} catch (_error) {
|
||||
metadata = { ok: false, currentSrc: this.src };
|
||||
finish({ ok: false, currentSrc: this.src });
|
||||
}
|
||||
if (request !== this._imageRequest) return;
|
||||
this._applyImageMetadata(metadata, request, true);
|
||||
}
|
||||
|
||||
_refreshImageFromCache(deferCompletion) {
|
||||
|
||||
@@ -13,6 +13,8 @@ pub use v8_flags::set_v8_flags;
|
||||
// render feature (which enables obscura-render/paint) is compiled in.
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_render::{
|
||||
screenshot_png, screenshot_png_scrolled, validate_capture_region, CaptureError, CaptureRegion,
|
||||
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
|
||||
validate_capture_region, AnimationSample, AnimationSampleMode, AnimationSampleTime,
|
||||
CaptureError, CaptureRegion, ImageRequestProfile,
|
||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
};
|
||||
@@ -14,6 +14,58 @@ use deno_core::RequestedModuleType;
|
||||
use crate::import_map::ImportMap;
|
||||
use crate::ops::ObscuraState;
|
||||
|
||||
/// Observable network activity for ES-module graphs.
|
||||
///
|
||||
/// deno_core keeps dynamic-import state inside its private module map. The
|
||||
/// browser lifecycle still needs to distinguish a genuinely idle page from a
|
||||
/// graph whose fetch future is being advanced in short event-loop slices. A
|
||||
/// loader-owned counter provides that signal without treating unrelated
|
||||
/// fetch/XHR analytics as render-blocking work.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ModuleLoadActivity {
|
||||
pending: std::sync::atomic::AtomicUsize,
|
||||
last_activity: std::sync::Mutex<Option<std::time::Instant>>,
|
||||
}
|
||||
|
||||
impl ModuleLoadActivity {
|
||||
fn begin(self: &Arc<Self>) -> ModuleLoadGuard {
|
||||
self.pending
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
*self
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now());
|
||||
ModuleLoadGuard(self.clone())
|
||||
}
|
||||
|
||||
pub(crate) fn is_pending_or_recent(&self, grace: std::time::Duration) -> bool {
|
||||
if self.pending.load(std::sync::atomic::Ordering::Relaxed) != 0 {
|
||||
return true;
|
||||
}
|
||||
self.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.is_some_and(|last| last.elapsed() <= grace)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModuleLoadGuard(Arc<ModuleLoadActivity>);
|
||||
|
||||
impl Drop for ModuleLoadGuard {
|
||||
fn drop(&mut self) {
|
||||
let previous = self
|
||||
.0
|
||||
.pending
|
||||
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
debug_assert!(previous > 0, "module load activity counter underflow");
|
||||
*self
|
||||
.0
|
||||
.last_activity
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(std::time::Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ObscuraModuleLoader {
|
||||
pub base_url: String,
|
||||
/// Proxy URL threaded through to every dynamic ES-module fetch (#139).
|
||||
@@ -29,6 +81,7 @@ pub struct ObscuraModuleLoader {
|
||||
/// connection pool; they simply have an isolated cookie jar.
|
||||
standalone_client: Option<Arc<obscura_net::ObscuraHttpClient>>,
|
||||
import_map: Rc<RefCell<ImportMap>>,
|
||||
activity: Arc<ModuleLoadActivity>,
|
||||
}
|
||||
|
||||
impl ObscuraModuleLoader {
|
||||
@@ -56,6 +109,7 @@ impl ObscuraModuleLoader {
|
||||
page_state: None,
|
||||
standalone_client: Some(standalone_client),
|
||||
import_map,
|
||||
activity: Arc::new(ModuleLoadActivity::default()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +125,13 @@ impl ObscuraModuleLoader {
|
||||
page_state: Some(Rc::downgrade(page_state)),
|
||||
standalone_client: None,
|
||||
import_map,
|
||||
activity: Arc::new(ModuleLoadActivity::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn activity(&self) -> Arc<ModuleLoadActivity> {
|
||||
self.activity.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn io_err(msg: String) -> ModuleLoaderError {
|
||||
@@ -117,13 +176,19 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
&self,
|
||||
module_specifier: &ModuleSpecifier,
|
||||
_maybe_referrer: Option<&ModuleSpecifier>,
|
||||
_is_dyn_import: bool,
|
||||
is_dyn_import: bool,
|
||||
_requested_module_type: RequestedModuleType,
|
||||
) -> ModuleLoadResponse {
|
||||
let url = module_specifier.to_string();
|
||||
// Capture the loader's proxy here so the async closure below owns a
|
||||
// plain Option<String> rather than borrowing &self across an `await`.
|
||||
let proxy_url = self.proxy_url.clone();
|
||||
let activity = self.activity.clone();
|
||||
// Register before returning the future. The lifecycle can inspect the
|
||||
// runtime between deno_core accepting the load and first polling it.
|
||||
// Keeping the guard inside the future makes cancellation/navigation
|
||||
// decrement the count through Drop as well as success and failure.
|
||||
let activity_guard = is_dyn_import.then(|| activity.begin());
|
||||
let page_network = match self.page_state.as_ref() {
|
||||
Some(weak) => (|| {
|
||||
let state = weak
|
||||
@@ -146,6 +211,10 @@ impl ModuleLoader for ObscuraModuleLoader {
|
||||
};
|
||||
|
||||
ModuleLoadResponse::Async(Pin::from(Box::new(async move {
|
||||
// deno_core propagates `is_dyn_import` to every dependency edge in
|
||||
// the recursive graph, so this excludes parser-discovered/static
|
||||
// graphs without losing descendant fetches of a lazy graph.
|
||||
let _activity_guard = activity_guard;
|
||||
tracing::debug!(
|
||||
"Loading ES module: {} (proxy: {})",
|
||||
url,
|
||||
|
||||
+589
-127
@@ -8,6 +8,8 @@ use deno_core::op2;
|
||||
use deno_core::Extension;
|
||||
use deno_core::OpState;
|
||||
use obscura_dom::{DomTree, NodeData, NodeId};
|
||||
#[cfg(feature = "render")]
|
||||
use obscura_net::{RequestCredentials, RequestMode, ResourceRequest};
|
||||
#[cfg(feature = "stealth")]
|
||||
use obscura_net::StealthHttpClient;
|
||||
use obscura_net::{
|
||||
@@ -74,6 +76,9 @@ pub struct JsNetworkEvent {
|
||||
pub timestamp: f64,
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub use obscura_render::ImageRequestProfile;
|
||||
|
||||
pub struct ObscuraState {
|
||||
pub dom: Option<DomTree>,
|
||||
pub url: String,
|
||||
@@ -125,10 +130,28 @@ pub struct ObscuraState {
|
||||
/// The browser settle policy samples this to distinguish useful deferred
|
||||
/// rendering work from unrelated long-lived timers.
|
||||
pub activity_generation: u64,
|
||||
/// Monotonic identity of the currently installed document. Async resource
|
||||
/// completions use this to discard bytes and lifecycle results belonging
|
||||
/// to a navigation that has already been replaced.
|
||||
pub document_generation: u64,
|
||||
/// Final image/font-aware layout shared by CSSOM geometry and screenshots.
|
||||
/// DOM/style/viewport changes clear this value but retain resource bytes.
|
||||
#[cfg(feature = "render")]
|
||||
pub prepared_render: Option<obscura_render::PreparedRender>,
|
||||
/// Explicit document-timeline sample used by the next style/layout flush.
|
||||
/// Captures set this to either deterministic T=0 or live document time.
|
||||
#[cfg(feature = "render")]
|
||||
pub animation_sample: obscura_render::AnimationSample,
|
||||
#[cfg(feature = "render")]
|
||||
pub animation_timeline: obscura_render::AnimationTimelineState,
|
||||
#[cfg(feature = "render")]
|
||||
pub animation_timeline_origin: std::time::Instant,
|
||||
/// Host/HTML task epoch for document-timeline sampling. Geometry and
|
||||
/// computed-style reads within one task share one frozen animation frame.
|
||||
#[cfg(feature = "render")]
|
||||
pub animation_task_generation: u64,
|
||||
#[cfg(feature = "render")]
|
||||
pub animation_sampled_task_generation: u64,
|
||||
/// Connected mutations awaiting dependency-indexed retained style refresh.
|
||||
/// Tree changes carry stable node/parent ids so a later geometry read can
|
||||
/// coalesce framework DOM churn into one conservative local cascade.
|
||||
@@ -138,6 +161,12 @@ pub struct ObscuraState {
|
||||
/// relayout of the same document reuses it without refetching.
|
||||
#[cfg(feature = "render")]
|
||||
pub render_resources: obscura_render::RenderResourceCache,
|
||||
/// Waiters sharing an asynchronous HTMLImageElement request. The key keeps
|
||||
/// navigation identity and request credentials separate so neither stale
|
||||
/// pages nor incompatible CORS profiles share a completion.
|
||||
#[cfg(feature = "render")]
|
||||
pub render_image_in_flight:
|
||||
HashMap<(u64, String, ImageRequestProfile), Vec<tokio::sync::oneshot::Sender<()>>>,
|
||||
/// One exact-key compiled author stylesheet for this document. Connected
|
||||
/// mutations still discard `prepared_render`; the next prepare reuses only
|
||||
/// parsing/indexing when ordered CSS source and viewport remain identical.
|
||||
@@ -199,13 +228,26 @@ impl ObscuraState {
|
||||
js_network_events: Vec::new(),
|
||||
page_in_flight: Arc::new(std::sync::atomic::AtomicU32::new(0)),
|
||||
activity_generation: 0,
|
||||
document_generation: 0,
|
||||
#[cfg(feature = "render")]
|
||||
prepared_render: None,
|
||||
#[cfg(feature = "render")]
|
||||
animation_sample: obscura_render::AnimationSample::default(),
|
||||
#[cfg(feature = "render")]
|
||||
animation_timeline: obscura_render::AnimationTimelineState::default(),
|
||||
#[cfg(feature = "render")]
|
||||
animation_timeline_origin: std::time::Instant::now(),
|
||||
#[cfg(feature = "render")]
|
||||
animation_task_generation: 0,
|
||||
#[cfg(feature = "render")]
|
||||
animation_sampled_task_generation: 0,
|
||||
#[cfg(feature = "render")]
|
||||
pending_style_mutations: Vec::new(),
|
||||
#[cfg(feature = "render")]
|
||||
render_resources: obscura_render::RenderResourceCache::default(),
|
||||
#[cfg(feature = "render")]
|
||||
render_image_in_flight: HashMap::new(),
|
||||
#[cfg(feature = "render")]
|
||||
stylesheet_cache: obscura_render::StylesheetCache::default(),
|
||||
#[cfg(feature = "render")]
|
||||
dynamic_fonts: Vec::new(),
|
||||
@@ -480,18 +522,12 @@ fn retained_style_mutation(
|
||||
arg2: &str,
|
||||
) -> Option<obscura_render::RetainedStyleMutation> {
|
||||
let node = NodeId::new(arg1.parse::<u32>().ok()?);
|
||||
let can_retain = |name: &str| {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name == "class"
|
||||
|| name == "id"
|
||||
|| name == "style"
|
||||
|| name.starts_with("data-")
|
||||
|| name.starts_with("aria-")
|
||||
};
|
||||
match cmd {
|
||||
"set_attribute" => {
|
||||
let (name, value) = arg2.split_once('\0')?;
|
||||
if !can_retain(name) {
|
||||
if obscura_render::dom::retained_attribute_mutation_kind(dom, node, name)
|
||||
== obscura_render::dom::RetainedAttributeMutationKind::Full
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let keeps_selector_value = !name.eq_ignore_ascii_case("style");
|
||||
@@ -510,9 +546,14 @@ fn retained_style_mutation(
|
||||
}
|
||||
.into())
|
||||
}
|
||||
"remove_attribute" => can_retain(arg2).then(|| {
|
||||
"remove_attribute" => {
|
||||
if obscura_render::dom::retained_attribute_mutation_kind(dom, node, arg2)
|
||||
== obscura_render::dom::RetainedAttributeMutationKind::Full
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let keeps_selector_value = !arg2.eq_ignore_ascii_case("style");
|
||||
obscura_render::AttributeStyleMutation {
|
||||
Some(obscura_render::AttributeStyleMutation {
|
||||
node,
|
||||
name: arg2.to_string(),
|
||||
old_value: keeps_selector_value
|
||||
@@ -525,8 +566,8 @@ fn retained_style_mutation(
|
||||
.flatten(),
|
||||
new_value: None,
|
||||
}
|
||||
.into()
|
||||
}),
|
||||
.into())
|
||||
}
|
||||
"append_child" => {
|
||||
let child = NodeId::new(arg2.parse::<u32>().ok()?);
|
||||
dom.get_node(node)?;
|
||||
@@ -559,13 +600,19 @@ fn retained_style_mutation(
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
"set_text_content" => Some(
|
||||
obscura_render::TreeStyleMutation::Text {
|
||||
node,
|
||||
parent: dom.get_node(node)?.parent,
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
"set_text_content" => match &dom.get_node(node)?.data {
|
||||
NodeData::Text { .. } => Some(
|
||||
obscura_render::TreeStyleMutation::Text {
|
||||
node,
|
||||
parent: dom.get_node(node)?.parent,
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
// Element/fragment textContent replaces a child list. That can
|
||||
// flip :empty and structural/relational selectors, so the local
|
||||
// text fast path cannot describe the mutation safely.
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -725,11 +772,68 @@ fn op_dom_inner(state: &OpState, cmd: String, arg1: String, arg2: String) -> Str
|
||||
.element_scroll_offsets
|
||||
.retain(|node, _| !reset_nodes.contains(node));
|
||||
state.scroll_generation = state.scroll_generation.wrapping_add(1);
|
||||
if invalidate {
|
||||
state.animation_timeline.remove_subtree(reset_nodes.iter());
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "render")]
|
||||
let had_prepared_render = state.prepared_render.is_some();
|
||||
#[cfg(feature = "render")]
|
||||
if invalidate {
|
||||
let mutation_time_ms = (state.animation_timeline_origin.elapsed().as_secs_f64()
|
||||
* 1_000.0)
|
||||
.min(f64::from(f32::MAX)) as f32;
|
||||
// Keep animation birth epochs local to the changed subtree. A
|
||||
// single document-global timestamp made a later unrelated write
|
||||
// restart every not-yet-sampled animation at the same instant.
|
||||
let direct_root = match cmd.as_str() {
|
||||
"append_child" => arg2.parse::<u32>().ok(),
|
||||
"insert_before"
|
||||
| "set_attribute"
|
||||
| "remove_attribute"
|
||||
| "set_attribute_ns"
|
||||
| "remove_attribute_ns" => arg1.parse::<u32>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
.map(NodeId::new);
|
||||
let direct_nodes = direct_root
|
||||
.and_then(|root| {
|
||||
state.dom.as_ref().map(|dom| {
|
||||
std::iter::once(root)
|
||||
.chain(dom.descendants(root))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for node in direct_nodes {
|
||||
state
|
||||
.animation_timeline
|
||||
.note_start_candidate(node, mutation_time_ms);
|
||||
}
|
||||
let scope_root = match cmd.as_str() {
|
||||
"append_child" => arg1.parse::<u32>().ok().map(NodeId::new),
|
||||
"insert_before" => arg2
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.map(NodeId::new)
|
||||
.and_then(|reference| {
|
||||
state.dom.as_ref()?.get_node(reference)?.parent
|
||||
}),
|
||||
"remove_child" => arg1
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.map(NodeId::new)
|
||||
.and_then(|child| state.dom.as_ref()?.get_node(child)?.parent),
|
||||
"set_inner_html" | "set_inner_html_context" | "set_text_content" => {
|
||||
arg1.parse::<u32>().ok().map(NodeId::new)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(root) = scope_root {
|
||||
state
|
||||
.animation_timeline
|
||||
.note_subtree_start_candidate(root, mutation_time_ms);
|
||||
}
|
||||
if let Some(mutation) = retained_style_mutation {
|
||||
if state.prepared_render.is_some()
|
||||
&& state.pending_style_mutations.len() < 256
|
||||
@@ -1556,20 +1660,6 @@ async fn op_fetch_url(
|
||||
url
|
||||
);
|
||||
|
||||
if let Ok(parsed_url) = url::Url::parse(&url) {
|
||||
if let Err(e) = validate_fetch_url(&parsed_url) {
|
||||
return Ok(serde_json::json!({
|
||||
"status": 0,
|
||||
"body": "",
|
||||
"url": url,
|
||||
"headers": {},
|
||||
"blocked": true,
|
||||
"error": e,
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let (cookie_jar, in_flight, page_in_flight, intercept_tx, proxy_url, callbacks, http_client) = {
|
||||
let state_borrow = state.borrow();
|
||||
let gs = state_borrow.borrow::<SharedState>().clone();
|
||||
@@ -1622,6 +1712,26 @@ async fn op_fetch_url(
|
||||
gs.http_client.clone(),
|
||||
)
|
||||
};
|
||||
// The private-network opt-in is a BrowserContext policy, not only a
|
||||
// process-wide environment setting. Navigation already honours the
|
||||
// context's configured HTTP client; scripted fetch/XHR must use the same
|
||||
// policy for its initial URL and every URL it can reach below.
|
||||
let allow_private_network = http_client
|
||||
.as_ref()
|
||||
.is_some_and(|client| client.allow_private_network);
|
||||
if let Ok(parsed_url) = url::Url::parse(&url) {
|
||||
if let Err(e) = validate_fetch_url(&parsed_url, allow_private_network) {
|
||||
return Ok(serde_json::json!({
|
||||
"status": 0,
|
||||
"body": "",
|
||||
"url": url,
|
||||
"headers": {},
|
||||
"blocked": true,
|
||||
"error": e,
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
}
|
||||
struct PageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
|
||||
impl Drop for PageInFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
@@ -1707,7 +1817,7 @@ async fn op_fetch_url(
|
||||
// bypass validate_fetch_url entirely.
|
||||
let url = if let Some(new_url) = override_url {
|
||||
if let Ok(parsed) = url::Url::parse(&new_url) {
|
||||
if let Err(reason) = validate_fetch_url(&parsed) {
|
||||
if let Err(reason) = validate_fetch_url(&parsed, allow_private_network) {
|
||||
return Ok(serde_json::json!({
|
||||
"status": 0,
|
||||
"body": "",
|
||||
@@ -1838,6 +1948,7 @@ async fn op_fetch_url(
|
||||
mode.clone(),
|
||||
credentials,
|
||||
callbacks.clone(),
|
||||
allow_private_network,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1947,7 +2058,7 @@ async fn op_fetch_url(
|
||||
};
|
||||
|
||||
// Re-validate every redirect target against the SSRF policy.
|
||||
if let Err(reason) = validate_fetch_url(&next_url) {
|
||||
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
|
||||
return Ok(serde_json::json!({
|
||||
"status": 0,
|
||||
"body": "",
|
||||
@@ -2143,6 +2254,7 @@ async fn stealth_fetch_all(
|
||||
mode: String,
|
||||
credentials: FetchCredentials,
|
||||
callbacks: Option<Arc<CallbackRegistry>>,
|
||||
allow_private_network: bool,
|
||||
) -> Result<String, deno_error::JsErrorBox> {
|
||||
let mut current_url = url.clone();
|
||||
let mut current_method = method;
|
||||
@@ -2194,7 +2306,7 @@ async fn stealth_fetch_all(
|
||||
};
|
||||
// Re-validate every redirect target against the SSRF policy, matching
|
||||
// op_fetch_url (GHSA-8v6v-g4rh-jmcm).
|
||||
if let Err(reason) = validate_fetch_url(&next_url) {
|
||||
if let Err(reason) = validate_fetch_url(&next_url, allow_private_network) {
|
||||
return Ok(serde_json::json!({
|
||||
"status": 0, "body": "", "url": next_url.to_string(), "headers": {},
|
||||
"blocked": true,
|
||||
@@ -2305,7 +2417,7 @@ fn glob_match(pattern: &str, url: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{cors_response_allows, glob_match, FetchCredentials};
|
||||
use super::{cors_response_allows, glob_match, validate_fetch_url, FetchCredentials};
|
||||
|
||||
#[test]
|
||||
fn glob_match_handles_cdp_blocked_url_patterns() {
|
||||
@@ -2378,9 +2490,15 @@ mod tests {
|
||||
"true",
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_url_validation_honors_per_context_private_network_opt_in() {
|
||||
let loopback = url::Url::parse("http://127.0.0.1:8080/resource").unwrap();
|
||||
assert!(validate_fetch_url(&loopback, true).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
|
||||
fn validate_fetch_url(url: &url::Url, allow_private_network: bool) -> Result<(), String> {
|
||||
let scheme = url.scheme();
|
||||
if scheme != "http" && scheme != "https" && scheme != "file" {
|
||||
return Err(format!(
|
||||
@@ -2389,7 +2507,10 @@ fn validate_fetch_url(url: &url::Url) -> Result<(), String> {
|
||||
));
|
||||
}
|
||||
|
||||
if scheme == "file" || obscura_net::env_allows_private_network() {
|
||||
if scheme == "file"
|
||||
|| allow_private_network
|
||||
|| obscura_net::env_allows_private_network()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -3119,8 +3240,10 @@ pub fn build_extension() -> Extension {
|
||||
// probes with typeof before calling, so the op's absence is a clean fallback.
|
||||
#[cfg(feature = "render")]
|
||||
{
|
||||
ops.push(op_begin_render_task());
|
||||
ops.push(op_set_dynamic_fonts());
|
||||
ops.push(op_image_metadata());
|
||||
ops.push(op_load_image_metadata());
|
||||
ops.push(op_layout_geometry());
|
||||
ops.push(op_computed_style());
|
||||
ops.push(op_css_supports());
|
||||
@@ -3161,16 +3284,24 @@ pub(crate) fn ensure_prepared_render(
|
||||
) -> Option<&obscura_render::PreparedRender> {
|
||||
let base_url = document_base_url(state);
|
||||
let viewport = state.viewport;
|
||||
let animation_sample = state.animation_sample;
|
||||
let stale = state.prepared_render.as_ref().map_or(true, |prepared| {
|
||||
prepared.viewport() != viewport || prepared.base_url() != base_url.as_deref()
|
||||
prepared.viewport() != viewport
|
||||
|| prepared.base_url() != base_url.as_deref()
|
||||
|| prepared.animation_sample() != animation_sample
|
||||
});
|
||||
if stale || !state.pending_style_mutations.is_empty() {
|
||||
if let Some(dom) = state.dom.as_ref() {
|
||||
state
|
||||
.animation_timeline
|
||||
.materialize_start_candidates(dom);
|
||||
}
|
||||
let previous = (!stale).then(|| state.prepared_render.take()).flatten();
|
||||
let mutations = std::mem::take(&mut state.pending_style_mutations);
|
||||
let prepared = {
|
||||
let dom = state.dom.as_ref()?;
|
||||
match previous {
|
||||
Some(previous) => obscura_render::prepare_dom_with_retained_styles(
|
||||
Some(previous) => obscura_render::prepare_dom_with_retained_styles_with_animation_state(
|
||||
dom,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
@@ -3179,33 +3310,92 @@ pub(crate) fn ensure_prepared_render(
|
||||
&mut state.stylesheet_cache,
|
||||
previous,
|
||||
&mutations,
|
||||
animation_sample,
|
||||
&mut state.animation_timeline,
|
||||
)
|
||||
.or_else(|| {
|
||||
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||
obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
|
||||
dom,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
&mut state.render_resources,
|
||||
&state.dynamic_fonts,
|
||||
&mut state.stylesheet_cache,
|
||||
animation_sample,
|
||||
&mut state.animation_timeline,
|
||||
)
|
||||
})?,
|
||||
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||
None => obscura_render::prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
|
||||
dom,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
&mut state.render_resources,
|
||||
&state.dynamic_fonts,
|
||||
&mut state.stylesheet_cache,
|
||||
animation_sample,
|
||||
&mut state.animation_timeline,
|
||||
)?,
|
||||
}
|
||||
};
|
||||
if animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime {
|
||||
state.animation_timeline.clear_start_candidates();
|
||||
}
|
||||
let connected = state.dom.as_ref().map(|dom| {
|
||||
std::iter::once(dom.document())
|
||||
.chain(dom.descendants(dom.document()))
|
||||
.collect::<HashSet<_>>()
|
||||
});
|
||||
if let Some(connected) = connected {
|
||||
state
|
||||
.animation_timeline
|
||||
.retain_nodes(|node| connected.contains(&node));
|
||||
}
|
||||
state.prepared_render = Some(prepared);
|
||||
state.resolved_scroll = None;
|
||||
}
|
||||
state.prepared_render.as_ref()
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn sample_live_document_animations(state: &mut ObscuraState) {
|
||||
if state.animation_sampled_task_generation == state.animation_task_generation {
|
||||
return;
|
||||
}
|
||||
state.animation_sampled_task_generation = state.animation_task_generation;
|
||||
let sample = obscura_render::AnimationSample::document(
|
||||
(state.animation_timeline_origin.elapsed().as_secs_f64() * 1_000.0)
|
||||
.min(f64::from(f32::MAX)) as f32,
|
||||
);
|
||||
if state.animation_sample == sample {
|
||||
return;
|
||||
}
|
||||
if sample.time.milliseconds > state.animation_sample.time.milliseconds
|
||||
&& state.animation_sample.mode == obscura_render::AnimationSampleMode::DocumentTime
|
||||
&& state.prepared_render.as_mut().is_some_and(|prepared| {
|
||||
prepared.advance_inactive_animation_sample_time(sample.time)
|
||||
})
|
||||
{
|
||||
state.animation_sample = sample;
|
||||
return;
|
||||
}
|
||||
state.animation_sample = sample;
|
||||
state.prepared_render = None;
|
||||
state.pending_style_mutations.clear();
|
||||
state.resolved_scroll = None;
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn begin_animation_task(state: &mut ObscuraState) {
|
||||
state.animation_task_generation = state.animation_task_generation.wrapping_add(1);
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(fast)]
|
||||
fn op_begin_render_task(state: &OpState) {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
begin_animation_task(&mut shared.borrow_mut());
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> {
|
||||
ensure_prepared_render(state)?;
|
||||
@@ -3248,81 +3438,13 @@ pub(crate) fn ensure_resolved_scroll(state: &mut ObscuraState) -> Option<()> {
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Probe one ordinary `<img src>` through the renderer's page-scoped resource
|
||||
/// cache. This is deliberately a synchronous render-only op: the current
|
||||
/// renderer loader is synchronous too, and sharing it is what guarantees that
|
||||
/// HTMLImageElement lifecycle state and the subsequent paint see the same
|
||||
/// success/failure and retained bytes.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
let node_id = NodeId::new(nid);
|
||||
let is_image = gs.dom.as_ref().is_some_and(|dom| {
|
||||
dom.get_node(node_id).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "img")
|
||||
})
|
||||
});
|
||||
if !is_image {
|
||||
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||
}
|
||||
let base_url = document_base_url(&gs);
|
||||
let viewport = gs.viewport;
|
||||
let previous_dimensions = (!cached_only)
|
||||
.then(|| {
|
||||
gs.dom.as_ref().and_then(|dom| {
|
||||
gs.render_resources
|
||||
.cached_image_element_metadata(
|
||||
dom,
|
||||
node_id,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
)
|
||||
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let ObscuraState {
|
||||
dom,
|
||||
render_resources,
|
||||
..
|
||||
} = &mut *gs;
|
||||
let Some(dom) = dom.as_ref() else {
|
||||
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||
};
|
||||
let (current_src, density, known, dimensions) = if cached_only {
|
||||
match render_resources.cached_image_element_metadata(
|
||||
dom,
|
||||
node_id,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
) {
|
||||
Some(metadata) => metadata,
|
||||
None => {
|
||||
return serde_json::json!({
|
||||
"state": "error",
|
||||
"ok": false,
|
||||
"currentSrc": "",
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match render_resources.image_element_metadata(dom, node_id, viewport, base_url.as_deref()) {
|
||||
Some((current_src, density, dimensions)) => (current_src, density, true, dimensions),
|
||||
None => {
|
||||
return serde_json::json!({
|
||||
"state": "error",
|
||||
"ok": false,
|
||||
"currentSrc": "",
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
};
|
||||
fn image_metadata_json(
|
||||
current_src: String,
|
||||
density: f32,
|
||||
known: bool,
|
||||
dimensions: Option<(f32, f32)>,
|
||||
) -> String {
|
||||
if !known {
|
||||
return serde_json::json!({
|
||||
"state": "pending",
|
||||
@@ -3331,15 +3453,6 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
// The page transport invalidates prepared layout when it seeds successful
|
||||
// bytes. Cover the synchronous compatibility-loader path too, but only
|
||||
// when this call actually discovers a new decodable intrinsic size. A
|
||||
// cache hit or retained decode failure cannot change used geometry.
|
||||
if !cached_only && dimensions.is_some() && dimensions != previous_dimensions {
|
||||
gs.prepared_render = None;
|
||||
gs.pending_style_mutations.clear();
|
||||
gs.resolved_scroll = None;
|
||||
}
|
||||
match dimensions {
|
||||
Some((width, height)) => serde_json::json!({
|
||||
"state": "loaded",
|
||||
@@ -3360,6 +3473,348 @@ fn op_image_metadata(state: &OpState, nid: u32, cached_only: bool) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn image_request_profile(dom: &DomTree, node_id: NodeId) -> ImageRequestProfile {
|
||||
match dom
|
||||
.get_node(node_id)
|
||||
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("use-credentials") => ImageRequestProfile::CorsInclude,
|
||||
Some(_) => ImageRequestProfile::CorsSameOrigin,
|
||||
None => ImageRequestProfile::NoCorsInclude,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn profiled_cached_image_metadata(
|
||||
gs: &ObscuraState,
|
||||
node_id: NodeId,
|
||||
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
|
||||
let dom = gs.dom.as_ref()?;
|
||||
let base_url = document_base_url(gs);
|
||||
gs.render_resources.cached_image_element_metadata(
|
||||
dom,
|
||||
node_id,
|
||||
gs.viewport,
|
||||
base_url.as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn cached_image_metadata_for_node(gs: &ObscuraState, node_id: NodeId) -> String {
|
||||
match profiled_cached_image_metadata(gs, node_id) {
|
||||
Some((current_src, density, known, dimensions)) => {
|
||||
image_metadata_json(current_src, density, known, dimensions)
|
||||
}
|
||||
None => serde_json::json!({ "ok": false, "currentSrc": "" }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe one ordinary `<img>` through the renderer's page-scoped resource
|
||||
/// cache. This op is intentionally cache-only. Lifecycle getters call it
|
||||
/// synchronously and must never open a socket or wait on network I/O.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2]
|
||||
#[string]
|
||||
fn op_image_metadata(state: &OpState, nid: u32, _cached_only: bool) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let gs = shared.borrow();
|
||||
let node_id = NodeId::new(nid);
|
||||
let is_image = gs.dom.as_ref().is_some_and(|dom| {
|
||||
dom.get_node(node_id).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "img")
|
||||
})
|
||||
});
|
||||
if !is_image {
|
||||
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||
}
|
||||
cached_image_metadata_for_node(&gs, node_id)
|
||||
}
|
||||
|
||||
/// Compatibility path for standalone render runtimes which deliberately
|
||||
/// install an in-memory `RenderResourceLoader` but have no owning page
|
||||
/// transport. Browser pages always install `ObscuraHttpClient` before page
|
||||
/// script runs and never enter this synchronous loader.
|
||||
#[cfg(feature = "render")]
|
||||
fn load_image_metadata_without_page_transport(gs: &mut ObscuraState, node_id: NodeId) -> String {
|
||||
let base_url = document_base_url(&gs);
|
||||
let viewport = gs.viewport;
|
||||
let previous_dimensions = gs.dom.as_ref().and_then(|dom| {
|
||||
gs.render_resources
|
||||
.cached_image_element_metadata(dom, node_id, viewport, base_url.as_deref())
|
||||
.and_then(|(_, _, known, dimensions)| known.then_some(dimensions).flatten())
|
||||
});
|
||||
let Some(dom) = gs.dom.as_ref() else {
|
||||
return serde_json::json!({ "ok": false, "currentSrc": "" }).to_string();
|
||||
};
|
||||
let Some((current_src, density, dimensions)) = gs.render_resources.image_element_metadata(
|
||||
dom,
|
||||
node_id,
|
||||
viewport,
|
||||
base_url.as_deref(),
|
||||
) else {
|
||||
return serde_json::json!({
|
||||
"state": "error",
|
||||
"ok": false,
|
||||
"currentSrc": "",
|
||||
})
|
||||
.to_string();
|
||||
};
|
||||
if dimensions.is_some() && dimensions != previous_dimensions {
|
||||
gs.prepared_render = None;
|
||||
gs.pending_style_mutations.clear();
|
||||
gs.resolved_scroll = None;
|
||||
}
|
||||
image_metadata_json(current_src, density, true, dimensions)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
fn finish_async_image_metadata(
|
||||
shared: &SharedState,
|
||||
node_id: NodeId,
|
||||
document_generation: u64,
|
||||
expected_url: &str,
|
||||
request_profile: ImageRequestProfile,
|
||||
) -> String {
|
||||
let mut gs = shared.borrow_mut();
|
||||
if gs.document_generation != document_generation {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
|
||||
.to_string();
|
||||
}
|
||||
let Some(dom) = gs.dom.as_ref() else {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
|
||||
.to_string();
|
||||
};
|
||||
if image_request_profile(dom, node_id) != request_profile {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
|
||||
.to_string();
|
||||
}
|
||||
let Some((current_src, density, known, dimensions)) =
|
||||
profiled_cached_image_metadata(&gs, node_id)
|
||||
else {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": expected_url })
|
||||
.to_string();
|
||||
};
|
||||
if current_src != expected_url {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": current_src }).to_string();
|
||||
}
|
||||
if known && dimensions.is_some() {
|
||||
// This request began only because the selected resource was unknown.
|
||||
// Newly available intrinsic dimensions can dirty ancestor geometry,
|
||||
// exactly like Gecko's SIZE_AVAILABLE reflow notification.
|
||||
gs.prepared_render = None;
|
||||
gs.pending_style_mutations.clear();
|
||||
gs.resolved_scroll = None;
|
||||
}
|
||||
image_metadata_json(current_src, density, known, dimensions)
|
||||
}
|
||||
|
||||
/// Load HTMLImageElement bytes through the owning page's async transport.
|
||||
/// Network runs after every RefCell borrow is released, requests for the same
|
||||
/// navigation/URL/profile share one fetch, and completion revalidates both the
|
||||
/// document identity and responsive candidate before exposing lifecycle state.
|
||||
#[cfg(feature = "render")]
|
||||
#[op2(async)]
|
||||
#[string]
|
||||
async fn op_load_image_metadata(state: Rc<RefCell<OpState>>, nid: u32) -> String {
|
||||
let shared = {
|
||||
let state = state.borrow();
|
||||
state.borrow::<SharedState>().clone()
|
||||
};
|
||||
let node_id = NodeId::new(nid);
|
||||
let (
|
||||
document_generation,
|
||||
selected_url,
|
||||
request_profile,
|
||||
resource_request,
|
||||
http_client,
|
||||
callbacks,
|
||||
page_in_flight,
|
||||
blocked,
|
||||
) = {
|
||||
let gs = shared.borrow();
|
||||
let Some(dom) = gs.dom.as_ref() else {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
|
||||
};
|
||||
let is_image = dom.get_node(node_id).is_some_and(|node| {
|
||||
node.as_element()
|
||||
.is_some_and(|element| element.local.as_ref() == "img")
|
||||
});
|
||||
if !is_image {
|
||||
return serde_json::json!({ "state": "stale", "currentSrc": "" }).to_string();
|
||||
}
|
||||
let profile = image_request_profile(dom, node_id);
|
||||
let Some((selected_url, _, known, _)) =
|
||||
profiled_cached_image_metadata(&gs, node_id)
|
||||
else {
|
||||
return serde_json::json!({ "state": "error", "ok": false, "currentSrc": "" })
|
||||
.to_string();
|
||||
};
|
||||
if known {
|
||||
return cached_image_metadata_for_node(&gs, node_id);
|
||||
}
|
||||
let initiator = url::Url::parse(&gs.url)
|
||||
.or_else(|_| url::Url::parse(&selected_url))
|
||||
.unwrap_or_else(|_| url::Url::parse("about:blank").unwrap());
|
||||
let mut request = ResourceRequest::subresource(ResourceType::Image, &initiator);
|
||||
match profile {
|
||||
ImageRequestProfile::CorsInclude => {
|
||||
request.mode = RequestMode::Cors;
|
||||
request.credentials = RequestCredentials::Include;
|
||||
}
|
||||
ImageRequestProfile::CorsSameOrigin => {
|
||||
request.mode = RequestMode::Cors;
|
||||
request.credentials = RequestCredentials::SameOrigin;
|
||||
}
|
||||
ImageRequestProfile::NoCorsInclude => {}
|
||||
}
|
||||
let blocked = gs.blocked_urls.iter().any(|pattern| {
|
||||
pattern == "*" || selected_url.contains(pattern) || glob_match(pattern, &selected_url)
|
||||
});
|
||||
(
|
||||
gs.document_generation,
|
||||
selected_url,
|
||||
profile,
|
||||
request,
|
||||
gs.http_client.clone(),
|
||||
gs.callbacks.clone(),
|
||||
Arc::clone(&gs.page_in_flight),
|
||||
blocked,
|
||||
)
|
||||
};
|
||||
|
||||
#[cfg(feature = "stealth")]
|
||||
let stealth_client = shared.borrow().stealth_client.clone();
|
||||
#[cfg(feature = "stealth")]
|
||||
let has_page_transport = http_client.is_some() || stealth_client.is_some();
|
||||
#[cfg(not(feature = "stealth"))]
|
||||
let has_page_transport = http_client.is_some();
|
||||
if !has_page_transport {
|
||||
return load_image_metadata_without_page_transport(&mut shared.borrow_mut(), node_id);
|
||||
}
|
||||
|
||||
// Different CORS/credential profiles do not share an in-flight response.
|
||||
let request_key = (document_generation, selected_url.clone(), request_profile);
|
||||
let follower = {
|
||||
let mut gs = shared.borrow_mut();
|
||||
if let Some(waiters) = gs.render_image_in_flight.get_mut(&request_key) {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
waiters.push(sender);
|
||||
Some(receiver)
|
||||
} else {
|
||||
gs.render_image_in_flight.insert(request_key.clone(), Vec::new());
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(receiver) = follower {
|
||||
let _ = receiver.await;
|
||||
return finish_async_image_metadata(
|
||||
&shared,
|
||||
node_id,
|
||||
document_generation,
|
||||
&selected_url,
|
||||
request_profile,
|
||||
);
|
||||
}
|
||||
|
||||
struct PageImageInFlightGuard(Arc<std::sync::atomic::AtomicU32>);
|
||||
impl Drop for PageImageInFlightGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
page_in_flight.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let _page_in_flight = PageImageInFlightGuard(page_in_flight);
|
||||
|
||||
let parsed_url = url::Url::parse(&selected_url).ok();
|
||||
let response = if blocked || parsed_url.is_none() {
|
||||
None
|
||||
} else {
|
||||
let parsed_url = parsed_url.as_ref().unwrap();
|
||||
#[cfg(feature = "stealth")]
|
||||
{
|
||||
if let Some(client) = stealth_client {
|
||||
client
|
||||
.fetch_resource_with_callbacks(
|
||||
parsed_url,
|
||||
resource_request.clone(),
|
||||
callbacks.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
} else {
|
||||
http_client
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.fetch_resource_with_callbacks(
|
||||
parsed_url,
|
||||
resource_request,
|
||||
callbacks.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "stealth"))]
|
||||
{
|
||||
http_client
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.fetch_resource_with_callbacks(
|
||||
parsed_url,
|
||||
resource_request,
|
||||
callbacks.as_deref(),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
}
|
||||
};
|
||||
let bytes = response.and_then(|response| {
|
||||
(200..300)
|
||||
.contains(&response.status)
|
||||
.then_some(response.body)
|
||||
});
|
||||
let waiters = {
|
||||
let mut gs = shared.borrow_mut();
|
||||
if gs.document_generation == document_generation {
|
||||
match bytes {
|
||||
Some(bytes) => {
|
||||
if obscura_render::image_intrinsic_dimensions(&bytes).is_some() {
|
||||
gs.render_resources.seed_image(
|
||||
selected_url.clone(),
|
||||
request_profile,
|
||||
bytes,
|
||||
);
|
||||
} else {
|
||||
gs.render_resources
|
||||
.seed_image_missing(selected_url.clone(), request_profile);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
gs.render_resources
|
||||
.seed_image_missing(selected_url.clone(), request_profile);
|
||||
}
|
||||
}
|
||||
}
|
||||
gs.render_image_in_flight
|
||||
.remove(&request_key)
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for waiter in waiters {
|
||||
let _ = waiter.send(());
|
||||
}
|
||||
finish_async_image_metadata(
|
||||
&shared,
|
||||
node_id,
|
||||
document_generation,
|
||||
&selected_url,
|
||||
request_profile,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "render")]
|
||||
pub(crate) fn clamp_scroll_offset(state: &mut ObscuraState, requested: (f32, f32)) -> (f32, f32) {
|
||||
let clamped = ensure_prepared_render(state)
|
||||
@@ -3391,6 +3846,7 @@ fn op_layout_geometry(state: &OpState, #[string] nid_str: String) -> String {
|
||||
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
if ensure_resolved_scroll(&mut gs).is_some() {
|
||||
let Some((_, scroll)) = gs.resolved_scroll.as_ref() else {
|
||||
return String::new();
|
||||
@@ -3445,6 +3901,7 @@ fn op_computed_style(state: &OpState, #[string] nid_str: String) -> String {
|
||||
let nid: u32 = nid_str.parse().unwrap_or(0);
|
||||
let nid = obscura_dom::tree::NodeId::new(nid);
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
let Some(prepared) = ensure_prepared_render(&mut gs) else {
|
||||
return String::new();
|
||||
};
|
||||
@@ -3480,6 +3937,7 @@ fn op_css_supports(#[string] name: &str, #[string] value: &str) -> bool {
|
||||
fn op_layout_metrics(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
let viewport = gs.viewport;
|
||||
let content = ensure_prepared_render(&mut gs)
|
||||
.map(|prepared| prepared.content_size())
|
||||
@@ -3497,6 +3955,7 @@ fn op_element_scroll_metrics(state: &OpState, #[string] nid_str: String) -> Stri
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
if ensure_resolved_scroll(&mut gs).is_none() {
|
||||
return String::new();
|
||||
}
|
||||
@@ -3533,6 +3992,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let nid = NodeId::new(nid_str.parse().unwrap_or(0));
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
if ensure_resolved_scroll(&mut gs).is_none() {
|
||||
return String::new();
|
||||
}
|
||||
@@ -3575,6 +4035,7 @@ fn op_element_scroll_to(state: &OpState, #[string] nid_str: String, x: f64, y: f
|
||||
fn op_scroll_offset(state: &OpState) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
let requested = gs.scroll_offset;
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, requested);
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
@@ -3586,6 +4047,7 @@ fn op_scroll_offset(state: &OpState) -> String {
|
||||
fn op_scroll_to(state: &OpState, x: f64, y: f64) -> String {
|
||||
let shared = state.borrow::<SharedState>().clone();
|
||||
let mut gs = shared.borrow_mut();
|
||||
sample_live_document_animations(&mut gs);
|
||||
let (x, y) = clamp_scroll_offset(&mut gs, (x as f32, y as f32));
|
||||
format!("{{\"x\":{},\"y\":{}}}", x, y)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1290,6 +1290,19 @@ fn note_compound_dependencies(
|
||||
}
|
||||
}
|
||||
}
|
||||
"dir" | "lang" => {
|
||||
// The corresponding HTML attributes inherit through
|
||||
// descendants, while the functional selector may
|
||||
// observe language/direction resolved above the
|
||||
// mutated node. Keep the dependency keyed, but require
|
||||
// the retained planner's sound full fallback.
|
||||
map.push_state(
|
||||
name,
|
||||
rule_order,
|
||||
InvalidationReaches::CONSERVATIVE,
|
||||
);
|
||||
map.mark_conservative(rule_order);
|
||||
}
|
||||
_ => {
|
||||
// A compiled functional pseudo outside the explicitly
|
||||
// modeled set may hide selector or document state.
|
||||
@@ -1449,6 +1462,8 @@ fn note_declaration_attribute_dependencies(
|
||||
fn selector_requires_conservative_tracking(selector: &str) -> bool {
|
||||
let selector = selector.to_ascii_lowercase();
|
||||
selector.contains(":has(")
|
||||
|| selector.contains(":dir(")
|
||||
|| selector.contains(":lang(")
|
||||
|| selector.contains(":nth-child(")
|
||||
|| selector.contains(":nth-last-child(")
|
||||
|| selector.contains(":nth-of-type(")
|
||||
@@ -1638,6 +1653,86 @@ impl PseudoRuleMap {
|
||||
}
|
||||
self.rules.push(rule);
|
||||
}
|
||||
|
||||
fn bucket_matches_container_query_rule(
|
||||
&self,
|
||||
bucket: Option<&Vec<usize>>,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
) -> bool {
|
||||
bucket.is_some_and(|indices| {
|
||||
indices.iter().copied().any(|index| {
|
||||
let rule = &self.rules[index];
|
||||
rule.container_condition_id != ContainerConditionId::NONE
|
||||
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|
||||
|| matcher.mark_candidate(rule.candidate_slot as usize))
|
||||
&& matcher.matches(tree, nid, &rule.sel)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn node_matches_container_query_rule(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
) -> bool {
|
||||
if self.candidate_slot_count != 0 {
|
||||
matcher.begin_candidate_collection(self.candidate_slot_count);
|
||||
}
|
||||
let Some(node) = tree.get_node(nid) else {
|
||||
return false;
|
||||
};
|
||||
node.as_element().is_some_and(|element| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_local.get(element.local.as_ref()),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
) || (is_root_element(tree, nid)
|
||||
&& self.bucket_matches_container_query_rule(
|
||||
(!self.by_root.is_empty()).then_some(&self.by_root),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
))
|
||||
|| node.get_attribute("id").is_some_and(|id| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_id.get(id),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
|| node.get_attribute("class").is_some_and(|classes| {
|
||||
classes.split_whitespace().any(|class| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_class.get(class),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
})
|
||||
|| node.attrs().is_some_and(|attributes| {
|
||||
attributes.iter().any(|attribute| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_attribute.get(attribute.name.local.as_ref()),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
})
|
||||
|| self.bucket_matches_container_query_rule(
|
||||
(!self.universal.is_empty()).then_some(&self.universal),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A cascade layer's first-declaration position at each nesting level.
|
||||
@@ -2330,6 +2425,98 @@ impl Stylesheet {
|
||||
.any(|rule| rule.container_condition_id != ContainerConditionId::NONE)
|
||||
}
|
||||
|
||||
/// Whether `nid` can receive a declaration from any container-conditional
|
||||
/// rule in the current DOM state. Retained style uses this to reset only
|
||||
/// conditional subjects (and their inherited subtrees) for its query-free
|
||||
/// seed pass instead of discarding every clean computed style merely
|
||||
/// because the document owns a query container.
|
||||
fn bucket_matches_container_query_rule(
|
||||
&self,
|
||||
bucket: Option<&Vec<usize>>,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
) -> bool {
|
||||
bucket.is_some_and(|indices| {
|
||||
indices.iter().copied().any(|index| {
|
||||
let rule = &self.rules[index];
|
||||
rule.container_condition_id != ContainerConditionId::NONE
|
||||
&& (rule.candidate_slot == NO_CANDIDATE_SLOT
|
||||
|| matcher.mark_candidate(rule.candidate_slot as usize))
|
||||
&& matcher.matches(tree, nid, &rule.sel)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn node_matches_container_query_rule(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
) -> bool {
|
||||
if self.candidate_slot_count != 0 {
|
||||
matcher.begin_candidate_collection(self.candidate_slot_count);
|
||||
}
|
||||
let Some(node) = tree.get_node(nid) else {
|
||||
return false;
|
||||
};
|
||||
let normal_match = node.as_element().is_some_and(|element| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_local.get(element.local.as_ref()),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
) || (is_root_element(tree, nid)
|
||||
&& self.bucket_matches_container_query_rule(
|
||||
(!self.by_root.is_empty()).then_some(&self.by_root),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
))
|
||||
|| node.get_attribute("id").is_some_and(|id| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_id.get(id),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
|| node.get_attribute("class").is_some_and(|classes| {
|
||||
classes.split_whitespace().any(|class| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_class.get(class),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
})
|
||||
|| node.attrs().is_some_and(|attributes| {
|
||||
attributes.iter().any(|attribute| {
|
||||
self.bucket_matches_container_query_rule(
|
||||
self.by_attribute.get(attribute.name.local.as_ref()),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
})
|
||||
})
|
||||
|| self.bucket_matches_container_query_rule(
|
||||
(!self.universal.is_empty()).then_some(&self.universal),
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
)
|
||||
});
|
||||
normal_match
|
||||
|| self
|
||||
.before_rules
|
||||
.node_matches_container_query_rule(tree, matcher, nid)
|
||||
|| self
|
||||
.after_rules
|
||||
.node_matches_container_query_rule(tree, matcher, nid)
|
||||
}
|
||||
|
||||
pub(crate) fn container_condition_depth(&self) -> usize {
|
||||
self.container_conditions
|
||||
.iter()
|
||||
@@ -2860,6 +3047,39 @@ impl Stylesheet {
|
||||
style: &mut LayoutStyle,
|
||||
parent_props: &HashMap<String, String>,
|
||||
inline_css: Option<&str>,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
let mut animation_timeline = crate::AnimationTimelineState::default();
|
||||
self.apply_at_animation_time(
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
id,
|
||||
classes,
|
||||
local,
|
||||
style,
|
||||
parent_props,
|
||||
inline_css,
|
||||
crate::AnimationSample {
|
||||
time: self.animation_sample_time,
|
||||
mode: crate::AnimationSampleMode::DocumentTime,
|
||||
},
|
||||
&mut animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_at_animation_time(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
id: Option<&str>,
|
||||
classes: &[String],
|
||||
local: &str,
|
||||
style: &mut LayoutStyle,
|
||||
parent_props: &HashMap<String, String>,
|
||||
inline_css: Option<&str>,
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
self.apply_internal(
|
||||
tree,
|
||||
@@ -2872,9 +3092,12 @@ impl Stylesheet {
|
||||
parent_props,
|
||||
inline_css,
|
||||
None,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn apply_with_container_queries(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
@@ -2887,6 +3110,41 @@ impl Stylesheet {
|
||||
parent_props: &HashMap<String, String>,
|
||||
inline_css: Option<&str>,
|
||||
evaluator: &mut ContainerQueryEvaluator<'_>,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
let mut animation_timeline = crate::AnimationTimelineState::default();
|
||||
self.apply_with_container_queries_at_animation_time(
|
||||
tree,
|
||||
matcher,
|
||||
nid,
|
||||
id,
|
||||
classes,
|
||||
local,
|
||||
style,
|
||||
parent_props,
|
||||
inline_css,
|
||||
evaluator,
|
||||
crate::AnimationSample {
|
||||
time: self.animation_sample_time,
|
||||
mode: crate::AnimationSampleMode::DocumentTime,
|
||||
},
|
||||
&mut animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_with_container_queries_at_animation_time(
|
||||
&self,
|
||||
tree: &DomTree,
|
||||
matcher: &mut Matcher,
|
||||
nid: NodeId,
|
||||
id: Option<&str>,
|
||||
classes: &[String],
|
||||
local: &str,
|
||||
style: &mut LayoutStyle,
|
||||
parent_props: &HashMap<String, String>,
|
||||
inline_css: Option<&str>,
|
||||
evaluator: &mut ContainerQueryEvaluator<'_>,
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
self.apply_internal(
|
||||
tree,
|
||||
@@ -2899,6 +3157,8 @@ impl Stylesheet {
|
||||
parent_props,
|
||||
inline_css,
|
||||
Some(evaluator),
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2914,6 +3174,8 @@ impl Stylesheet {
|
||||
parent_props: &HashMap<String, String>,
|
||||
inline_css: Option<&str>,
|
||||
mut evaluator: Option<&mut ContainerQueryEvaluator<'_>>,
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
// Keep the two cascade priorities separate from the outset. A typical
|
||||
// stylesheet has very few important declarations, so cloning and
|
||||
@@ -3265,6 +3527,7 @@ impl Stylesheet {
|
||||
|| important_matched
|
||||
.iter()
|
||||
.any(|&(_, _, i)| self.rules[i].important_flags.has_animation);
|
||||
style.animation_has_render_effect = false;
|
||||
if !self.keyframes.is_empty() && (style.animation_name.is_some() || important_has_animation)
|
||||
{
|
||||
let mut animation_style = style.clone();
|
||||
@@ -3290,15 +3553,31 @@ impl Stylesheet {
|
||||
}
|
||||
if let Some(name) = animation_style.animation_name.as_deref() {
|
||||
if let Some(keyframes) = self.keyframes.get(name) {
|
||||
style.animation_has_render_effect = !keyframes.tracks.is_empty();
|
||||
let local_sample = animation_timeline.sample_for(
|
||||
nid,
|
||||
crate::AnimationInstanceKey {
|
||||
name: name.to_string(),
|
||||
},
|
||||
animation_style.animation_timing.play_state,
|
||||
animation_sample,
|
||||
);
|
||||
style.animation_local_time_ms = local_sample.milliseconds;
|
||||
sample_animation_properties(
|
||||
keyframes,
|
||||
style,
|
||||
&animation_style.animation_timing,
|
||||
self.animation_sample_time,
|
||||
local_sample,
|
||||
props,
|
||||
);
|
||||
} else {
|
||||
animation_timeline.clear_animation(nid, animation_sample);
|
||||
}
|
||||
} else {
|
||||
animation_timeline.clear_animation(nid, animation_sample);
|
||||
}
|
||||
} else {
|
||||
animation_timeline.clear_animation(nid, animation_sample);
|
||||
}
|
||||
|
||||
for &(_, _, i) in &important_matched {
|
||||
@@ -4251,11 +4530,10 @@ fn animation_directed_progress(
|
||||
} else {
|
||||
duration * iterations
|
||||
};
|
||||
let local_time = if timing.play_state == crate::AnimationPlayState::Paused {
|
||||
0.0
|
||||
} else {
|
||||
sample_time.milliseconds
|
||||
};
|
||||
// The timeline owns pause/resume hold time. Keeping that state outside the
|
||||
// sampler lets a running animation freeze at its current progress and
|
||||
// later resume without manufacturing a new instance.
|
||||
let local_time = sample_time.milliseconds;
|
||||
if !local_time.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -97,15 +97,24 @@ mod image_capability_tests {
|
||||
mod paint;
|
||||
#[cfg(feature = "paint")]
|
||||
pub use paint::{
|
||||
paint_dom, paint_dom_scrolled, paint_prepared, paint_prepared_region_with_scroll,
|
||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_with_dynamic_fonts,
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache, screenshot_png, screenshot_png_scrolled,
|
||||
image_intrinsic_dimensions, paint_dom, paint_dom_scrolled,
|
||||
paint_dom_scrolled_at_animation_time, paint_prepared,
|
||||
paint_prepared_region_with_scroll,
|
||||
paint_prepared_with_scroll, prepare_dom, prepare_dom_at_animation_time,
|
||||
prepare_dom_with_dynamic_fonts, prepare_dom_with_dynamic_fonts_at_animation_time,
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache,
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time,
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state,
|
||||
screenshot_png, screenshot_png_scrolled, screenshot_png_scrolled_at_animation_time,
|
||||
prepare_dom_with_retained_attribute_styles, prepare_dom_with_retained_styles,
|
||||
prepare_dom_with_retained_styles_at_animation_time,
|
||||
prepare_dom_with_retained_styles_with_animation_state,
|
||||
screenshot_prepared,
|
||||
screenshot_prepared_region_with_scroll,
|
||||
screenshot_prepared_region_with_scroll_and_backgrounds, screenshot_prepared_with_scroll,
|
||||
validate_capture_region, CaptureError, CaptureRegion, DynamicFontFace, ElementScrollMetrics,
|
||||
PreparedRender, RenderResourceCache, RenderResourceLoader, ResolvedScrollState, SelectedImage,
|
||||
ImageRequestProfile, PreparedRender, RenderResourceCache, RenderResourceLoader,
|
||||
ResolvedScrollState, SelectedImage,
|
||||
MAX_CAPTURE_DIMENSION, MAX_CAPTURE_PIXELS,
|
||||
};
|
||||
|
||||
@@ -1140,6 +1149,12 @@ pub struct LayoutStyle {
|
||||
/// before author `!important`, matching the animation cascade origin.
|
||||
pub animation_name: Option<String>,
|
||||
pub animation_timing: AnimationTiming,
|
||||
/// True when the selected keyframes contain at least one property this
|
||||
/// renderer can sample. Unsupported custom-property-only animations must
|
||||
/// not keep layout or screencast damage active forever.
|
||||
pub animation_has_render_effect: bool,
|
||||
/// Local time used for this element's sampled animation instance.
|
||||
pub animation_local_time_ms: f32,
|
||||
/// `vertical-align` for a table cell's content. Cells effectively default
|
||||
/// to `middle` in browsers (the HTML UA sheet sets it on row groups and
|
||||
/// cells inherit it); obscura applies it as main-axis alignment of the
|
||||
@@ -1488,15 +1503,49 @@ pub enum WordBreak {
|
||||
BreakWord,
|
||||
}
|
||||
|
||||
/// Deterministic CSS animation sample time relative to the animation's
|
||||
/// timeline origin. Screenshot rendering currently uses the initial value
|
||||
/// (0ms); keeping it explicit lets capture plumbing freeze other engines at
|
||||
/// the same instant instead of depending on wall-clock settling.
|
||||
/// CSS animation sample time relative to the document timeline origin.
|
||||
/// Live capture uses elapsed document time, while deterministic comparison
|
||||
/// harnesses can request an exact instant such as T=0.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct AnimationSampleTime {
|
||||
pub milliseconds: f32,
|
||||
}
|
||||
|
||||
/// Which clock an animation sample represents.
|
||||
///
|
||||
/// Live rendering subtracts each element's animation-instance start epoch
|
||||
/// from document time. Deterministic comparison instead assigns one local
|
||||
/// time to every animation, matching Web Animations `currentTime` rather than
|
||||
/// pretending all dynamically-created animations began at navigation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnimationSampleMode {
|
||||
#[default]
|
||||
DocumentTime,
|
||||
LocalOverride,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||||
pub struct AnimationSample {
|
||||
pub time: AnimationSampleTime,
|
||||
pub mode: AnimationSampleMode,
|
||||
}
|
||||
|
||||
impl AnimationSample {
|
||||
pub fn document(milliseconds: f32) -> Self {
|
||||
Self {
|
||||
time: AnimationSampleTime { milliseconds },
|
||||
mode: AnimationSampleMode::DocumentTime,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn local_override(milliseconds: f32) -> Self {
|
||||
Self {
|
||||
time: AnimationSampleTime { milliseconds },
|
||||
mode: AnimationSampleMode::LocalOverride,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum AnimationDirection {
|
||||
#[default]
|
||||
@@ -1549,6 +1598,148 @@ impl Default for AnimationTiming {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct AnimationInstanceKey {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct AnimationInstance {
|
||||
key: AnimationInstanceKey,
|
||||
start_ms: f32,
|
||||
hold_time_ms: Option<f32>,
|
||||
was_paused: bool,
|
||||
}
|
||||
|
||||
/// Page-owned CSS animation instance history retained across layout rebuilds.
|
||||
/// Node ids are document-scoped, so navigation must replace this value.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnimationTimelineState {
|
||||
instances: std::collections::HashMap<obscura_dom::tree::NodeId, AnimationInstance>,
|
||||
start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||
subtree_start_candidates: std::collections::HashMap<obscura_dom::tree::NodeId, f32>,
|
||||
}
|
||||
|
||||
impl AnimationTimelineState {
|
||||
pub fn note_start_candidate(
|
||||
&mut self,
|
||||
node: obscura_dom::tree::NodeId,
|
||||
document_time_ms: f32,
|
||||
) {
|
||||
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
|
||||
self.start_candidates.insert(node, document_time_ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// Defer expansion until the next style flush. This is needed for DOM
|
||||
/// string APIs whose imported descendants do not exist until after the
|
||||
/// mutation op has completed.
|
||||
pub fn note_subtree_start_candidate(
|
||||
&mut self,
|
||||
root: obscura_dom::tree::NodeId,
|
||||
document_time_ms: f32,
|
||||
) {
|
||||
if document_time_ms.is_finite() && document_time_ms >= 0.0 {
|
||||
self.subtree_start_candidates
|
||||
.insert(root, document_time_ms);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn materialize_start_candidates(&mut self, tree: &obscura_dom::DomTree) {
|
||||
for (root, start_ms) in std::mem::take(&mut self.subtree_start_candidates) {
|
||||
if tree.get_node(root).is_none() {
|
||||
continue;
|
||||
}
|
||||
self.start_candidates.insert(root, start_ms);
|
||||
for node in tree.descendants(root) {
|
||||
self.start_candidates.entry(node).or_insert(start_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_subtree<'a>(
|
||||
&mut self,
|
||||
nodes: impl IntoIterator<Item = &'a obscura_dom::tree::NodeId>,
|
||||
) {
|
||||
for node in nodes {
|
||||
self.instances.remove(node);
|
||||
self.start_candidates.remove(node);
|
||||
self.subtree_start_candidates.remove(node);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn sample_for(
|
||||
&mut self,
|
||||
node: obscura_dom::tree::NodeId,
|
||||
key: AnimationInstanceKey,
|
||||
play_state: AnimationPlayState,
|
||||
sample: AnimationSample,
|
||||
) -> AnimationSampleTime {
|
||||
if sample.mode == AnimationSampleMode::LocalOverride {
|
||||
return sample.time;
|
||||
}
|
||||
let document_time_ms = sample.time.milliseconds;
|
||||
let transition_time_ms = self.start_candidates.remove(&node);
|
||||
let retained = self
|
||||
.instances
|
||||
.get_mut(&node)
|
||||
.filter(|instance| instance.key == key);
|
||||
let instance = match retained {
|
||||
Some(instance) => instance,
|
||||
None => {
|
||||
let candidate = transition_time_ms.unwrap_or(0.0);
|
||||
let paused = play_state == AnimationPlayState::Paused;
|
||||
self.instances.insert(
|
||||
node,
|
||||
AnimationInstance {
|
||||
key,
|
||||
start_ms: if paused { document_time_ms } else { candidate },
|
||||
hold_time_ms: paused.then_some(0.0),
|
||||
was_paused: paused,
|
||||
},
|
||||
);
|
||||
self.instances.get_mut(&node).expect("inserted animation instance")
|
||||
}
|
||||
};
|
||||
|
||||
let paused = play_state == AnimationPlayState::Paused;
|
||||
if paused && !instance.was_paused {
|
||||
let paused_at = transition_time_ms.unwrap_or(document_time_ms);
|
||||
instance.hold_time_ms = Some((paused_at - instance.start_ms).max(0.0));
|
||||
} else if !paused && instance.was_paused {
|
||||
let held = instance.hold_time_ms.take().unwrap_or(0.0);
|
||||
let resumed_at = transition_time_ms.unwrap_or(document_time_ms);
|
||||
instance.start_ms = resumed_at - held;
|
||||
}
|
||||
instance.was_paused = paused;
|
||||
AnimationSampleTime {
|
||||
milliseconds: instance
|
||||
.hold_time_ms
|
||||
.unwrap_or_else(|| (document_time_ms - instance.start_ms).max(0.0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_animation(&mut self, node: obscura_dom::tree::NodeId, sample: AnimationSample) {
|
||||
if sample.mode == AnimationSampleMode::DocumentTime {
|
||||
self.instances.remove(&node);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retain_nodes(
|
||||
&mut self,
|
||||
mut keep: impl FnMut(obscura_dom::tree::NodeId) -> bool,
|
||||
) {
|
||||
self.instances.retain(|node, _| keep(*node));
|
||||
self.start_candidates.retain(|node, _| keep(*node));
|
||||
self.subtree_start_candidates.retain(|node, _| keep(*node));
|
||||
}
|
||||
|
||||
pub fn clear_start_candidates(&mut self) {
|
||||
self.start_candidates.clear();
|
||||
self.subtree_start_candidates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The implemented `text-wrap-style` values. Other line-breaking strategies
|
||||
/// such as `pretty` remain unsupported until their distinct scoring model is
|
||||
/// available; treating them as `auto` would make `@supports` lie.
|
||||
|
||||
@@ -23,8 +23,8 @@ static FONT_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-obl
|
||||
static FONT_BOLD_OBLIQUE_BYTES: &[u8] = include_bytes!("../assets/liberation-sans-boldoblique.ttf");
|
||||
|
||||
use crate::dom::{
|
||||
layout_dom_with_web_fonts_and_retained_styles,
|
||||
layout_dom_with_web_fonts_and_stylesheet_cache, RetainedStyleMaps,
|
||||
layout_dom_with_web_fonts_and_retained_styles_with_animation_state,
|
||||
layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state, RetainedStyleMaps,
|
||||
};
|
||||
|
||||
const DEFAULT_RESOURCE_CACHE_ENTRIES: usize = 512;
|
||||
@@ -81,6 +81,49 @@ enum CachedResource {
|
||||
Missing(std::time::Instant),
|
||||
}
|
||||
|
||||
/// Fetch credentials/CORS identity for an HTML image request. No-CORS uses
|
||||
/// the ordinary URL cache key so CSS images can share the same response;
|
||||
/// CORS variants use private keys and can never contaminate one another.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub enum ImageRequestProfile {
|
||||
NoCorsInclude,
|
||||
CorsSameOrigin,
|
||||
CorsInclude,
|
||||
}
|
||||
|
||||
fn image_request_profile(tree: &DomTree, id: obscura_dom::tree::NodeId) -> ImageRequestProfile {
|
||||
match tree
|
||||
.get_node(id)
|
||||
.and_then(|node| node.get_attribute("crossorigin").map(str::to_owned))
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("use-credentials") => ImageRequestProfile::CorsInclude,
|
||||
Some(_) => ImageRequestProfile::CorsSameOrigin,
|
||||
None => ImageRequestProfile::NoCorsInclude,
|
||||
}
|
||||
}
|
||||
|
||||
fn image_resource_key(url: &str, profile: ImageRequestProfile) -> String {
|
||||
let url = network_resource_url(url);
|
||||
match profile {
|
||||
ImageRequestProfile::NoCorsInclude => url,
|
||||
ImageRequestProfile::CorsSameOrigin => format!("\0obscura-img-cors\0{url}"),
|
||||
ImageRequestProfile::CorsInclude => format!("\0obscura-img-credentials\0{url}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn network_resource_url(url: &str) -> String {
|
||||
if url.starts_with('\0') || url.starts_with("data:") {
|
||||
return url.to_string();
|
||||
}
|
||||
let Ok(mut parsed) = url::Url::parse(url) else {
|
||||
return url.to_string();
|
||||
};
|
||||
parsed.set_fragment(None);
|
||||
parsed.to_string()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct RememberedContentImageIntrinsic {
|
||||
resolved_url: String,
|
||||
@@ -162,13 +205,35 @@ impl RenderResourceCache {
|
||||
}
|
||||
|
||||
pub fn has_live_outcome(&self, url: &str) -> bool {
|
||||
match self.entries.get(url) {
|
||||
match self.entries.get(&network_resource_url(url)) {
|
||||
Some(CachedResource::Bytes(_)) => true,
|
||||
Some(CachedResource::Missing(at)) => at.elapsed() < MISSING_RESOURCE_RETRY_AFTER,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this URL currently retains usable bytes (as opposed to a
|
||||
/// short-lived negative-cache entry). Profile-specific HTML image loads
|
||||
/// use this to avoid replacing paint bytes from another credential mode.
|
||||
pub fn has_cached_bytes(&self, url: &str) -> bool {
|
||||
matches!(
|
||||
self.entries.get(&network_resource_url(url)),
|
||||
Some(CachedResource::Bytes(_))
|
||||
)
|
||||
}
|
||||
|
||||
pub fn has_live_image_outcome(&self, url: &str, profile: ImageRequestProfile) -> bool {
|
||||
self.has_live_outcome(&image_resource_key(url, profile))
|
||||
}
|
||||
|
||||
pub fn seed_image(&mut self, url: String, profile: ImageRequestProfile, bytes: Vec<u8>) {
|
||||
self.seed(image_resource_key(&url, profile), bytes);
|
||||
}
|
||||
|
||||
pub fn seed_image_missing(&mut self, url: String, profile: ImageRequestProfile) {
|
||||
self.seed_missing(image_resource_key(&url, profile));
|
||||
}
|
||||
|
||||
/// Seed bytes fetched by the owning page's asynchronous browser transport.
|
||||
///
|
||||
/// Layout and paint are synchronous, so letting them open their own HTTP
|
||||
@@ -176,6 +241,7 @@ impl RenderResourceCache {
|
||||
/// layer uses this entry point to fetch a bounded resource batch through
|
||||
/// its cookie/proxy/CORS-aware connection pool before entering layout.
|
||||
pub fn seed(&mut self, url: String, bytes: Vec<u8>) {
|
||||
let url = network_resource_url(&url);
|
||||
self.remove(&url);
|
||||
self.insert_bytes(url, Arc::from(bytes));
|
||||
}
|
||||
@@ -183,6 +249,7 @@ impl RenderResourceCache {
|
||||
/// Retain a page-transport failure so capture does not immediately repeat
|
||||
/// the same slow request through the renderer's compatibility loader.
|
||||
pub fn seed_missing(&mut self, url: String) {
|
||||
let url = network_resource_url(&url);
|
||||
self.remove(&url);
|
||||
self.insert_missing(url);
|
||||
}
|
||||
@@ -224,7 +291,30 @@ impl RenderResourceCache {
|
||||
.map(|(width, height)| (resolved_url, width, height)),
|
||||
);
|
||||
}
|
||||
match self.entries.get(&resolved_url) {
|
||||
match self.entries.get(&network_resource_url(&resolved_url)) {
|
||||
Some(CachedResource::Bytes(bytes)) => Some(
|
||||
image_metadata_from_bytes(bytes)
|
||||
.map(|(width, height)| (resolved_url, width, height)),
|
||||
),
|
||||
Some(CachedResource::Missing(at)) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
|
||||
Some(None)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_profiled_image_metadata(
|
||||
&self,
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
profile: ImageRequestProfile,
|
||||
) -> Option<Option<(String, f32, f32)>> {
|
||||
let resolved_url = resolve_resource_url(src, base_url)?;
|
||||
if resolved_url.starts_with("data:") {
|
||||
return self.cached_image_metadata(&resolved_url, None);
|
||||
}
|
||||
let key = image_resource_key(&resolved_url, profile);
|
||||
match self.entries.get(&key) {
|
||||
Some(CachedResource::Bytes(bytes)) => Some(
|
||||
image_metadata_from_bytes(bytes)
|
||||
.map(|(width, height)| (resolved_url, width, height)),
|
||||
@@ -250,7 +340,8 @@ impl RenderResourceCache {
|
||||
) -> Option<(String, f32, Option<(f32, f32)>)> {
|
||||
let (src, density) = resolve_img_url(tree, id, viewport)?;
|
||||
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
|
||||
let dimensions = fetch_bytes(&resolved_url, None, self)
|
||||
let profile = image_request_profile(tree, id);
|
||||
let dimensions = fetch_profiled_image_bytes(&resolved_url, None, self, profile)
|
||||
.and_then(|bytes| image_metadata_from_bytes(&bytes))
|
||||
.map(|(width, height)| (width / density, height / density));
|
||||
Some((resolved_url, density, dimensions))
|
||||
@@ -268,7 +359,8 @@ impl RenderResourceCache {
|
||||
) -> Option<(String, f32, bool, Option<(f32, f32)>)> {
|
||||
let (src, density) = resolve_img_url(tree, id, viewport)?;
|
||||
let resolved_url = resolve_resource_url(&src, base_url).unwrap_or(src);
|
||||
match self.cached_image_metadata(&resolved_url, None) {
|
||||
let profile = image_request_profile(tree, id);
|
||||
match self.cached_profiled_image_metadata(&resolved_url, None, profile) {
|
||||
None => Some((resolved_url, density, false, None)),
|
||||
Some(dimensions) => Some((
|
||||
resolved_url,
|
||||
@@ -280,7 +372,8 @@ impl RenderResourceCache {
|
||||
}
|
||||
|
||||
fn get_or_load(&mut self, url: &str) -> Option<Arc<[u8]>> {
|
||||
if let Some(entry) = self.entries.get(url) {
|
||||
let url = network_resource_url(url);
|
||||
if let Some(entry) = self.entries.get(&url) {
|
||||
match entry {
|
||||
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
|
||||
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
|
||||
@@ -292,16 +385,53 @@ impl RenderResourceCache {
|
||||
if !self.sync_loading_enabled {
|
||||
return None;
|
||||
}
|
||||
self.remove(url);
|
||||
self.remove(&url);
|
||||
|
||||
let loaded = self.loader.load(url).map(Arc::<[u8]>::from);
|
||||
let loaded = self.loader.load(&url).map(Arc::<[u8]>::from);
|
||||
match loaded {
|
||||
Some(bytes) => {
|
||||
self.insert_bytes(url.to_string(), Arc::clone(&bytes));
|
||||
self.insert_bytes(url, Arc::clone(&bytes));
|
||||
Some(bytes)
|
||||
}
|
||||
None => {
|
||||
self.insert_missing(url.to_string());
|
||||
self.insert_missing(url);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_load_image(
|
||||
&mut self,
|
||||
url: &str,
|
||||
profile: ImageRequestProfile,
|
||||
) -> Option<Arc<[u8]>> {
|
||||
let key = image_resource_key(url, profile);
|
||||
if let Some(entry) = self.entries.get(&key) {
|
||||
match entry {
|
||||
CachedResource::Bytes(bytes) => return Some(Arc::clone(bytes)),
|
||||
CachedResource::Missing(at) if at.elapsed() < MISSING_RESOURCE_RETRY_AFTER => {
|
||||
return None;
|
||||
}
|
||||
CachedResource::Missing(_) => {}
|
||||
}
|
||||
}
|
||||
if !self.sync_loading_enabled {
|
||||
return None;
|
||||
}
|
||||
self.remove(&key);
|
||||
// The private profile key is cache identity only and must never escape
|
||||
// into a network loader.
|
||||
let loaded = self
|
||||
.loader
|
||||
.load(&network_resource_url(url))
|
||||
.map(Arc::<[u8]>::from);
|
||||
match loaded {
|
||||
Some(bytes) => {
|
||||
self.insert_bytes(key, Arc::clone(&bytes));
|
||||
Some(bytes)
|
||||
}
|
||||
None => {
|
||||
self.insert_missing(key);
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -382,6 +512,7 @@ impl RenderResourceCache {
|
||||
SelectedImage {
|
||||
resolved_url: value.resolved_url,
|
||||
density: 1.0,
|
||||
profile: ImageRequestProfile::NoCorsInclude,
|
||||
},
|
||||
);
|
||||
seeded.insert(nid);
|
||||
@@ -426,11 +557,19 @@ impl RenderResourceCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect already-fetched image bytes without inserting them into a resource
|
||||
/// cache. HTMLImageElement keeps request-profile-specific lifecycle outcomes
|
||||
/// even though the renderer's ordinary paint cache is URL-keyed.
|
||||
pub fn image_intrinsic_dimensions(bytes: &[u8]) -> Option<(f32, f32)> {
|
||||
image_metadata_from_bytes(bytes)
|
||||
}
|
||||
|
||||
/// The exact responsive image candidate chosen during preparation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SelectedImage {
|
||||
pub resolved_url: String,
|
||||
pub density: f32,
|
||||
pub profile: ImageRequestProfile,
|
||||
}
|
||||
|
||||
/// CSSOM scrolling metrics for one element box. Non-scroll containers expose
|
||||
@@ -616,6 +755,7 @@ impl ResolvedScrollState {
|
||||
/// The DOM must not be mutated while this value is reused.
|
||||
pub struct PreparedRender {
|
||||
viewport: (f32, f32),
|
||||
animation_sample: crate::AnimationSample,
|
||||
root_font_size: f32,
|
||||
base_url: Option<String>,
|
||||
content_size: (f32, f32),
|
||||
@@ -632,6 +772,51 @@ impl PreparedRender {
|
||||
self.viewport
|
||||
}
|
||||
|
||||
pub fn animation_sample_time(&self) -> crate::AnimationSampleTime {
|
||||
self.animation_sample.time
|
||||
}
|
||||
|
||||
pub fn animation_sample(&self) -> crate::AnimationSample {
|
||||
self.animation_sample
|
||||
}
|
||||
|
||||
/// Whether advancing the document timeline can still change a sampled CSS
|
||||
/// animation. Finite animations stop producing compositor damage after
|
||||
/// their active interval; paused and zero-duration animations never do.
|
||||
pub fn has_active_css_animations(&self) -> bool {
|
||||
self.layout.styles.values().any(|style| {
|
||||
if style.animation_name.is_none()
|
||||
|| !style.animation_has_render_effect
|
||||
|| style.animation_timing.play_state == crate::AnimationPlayState::Paused
|
||||
|| style.animation_timing.duration_ms <= 0.0
|
||||
|| style.animation_timing.iteration_count <= 0.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let end = style.animation_timing.delay_ms
|
||||
+ style.animation_timing.duration_ms * style.animation_timing.iteration_count;
|
||||
end.is_infinite() || style.animation_local_time_ms < end.max(0.0)
|
||||
})
|
||||
}
|
||||
|
||||
/// Advance a frame whose animation cascade is already time-invariant.
|
||||
/// This preserves retained layout for static pages and completed finite
|
||||
/// animations. Backward seeks are never eligible because they can re-enter
|
||||
/// an earlier active interval or undo forwards fill.
|
||||
pub fn advance_inactive_animation_sample_time(
|
||||
&mut self,
|
||||
sample: crate::AnimationSampleTime,
|
||||
) -> bool {
|
||||
if self.animation_sample.mode != crate::AnimationSampleMode::DocumentTime
|
||||
|| sample.milliseconds < self.animation_sample.time.milliseconds
|
||||
|| self.has_active_css_animations()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
self.animation_sample.time = sample;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> Option<&str> {
|
||||
self.base_url.as_deref()
|
||||
}
|
||||
@@ -1562,9 +1747,31 @@ pub fn paint_dom_scrolled(
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
scroll: (f32, f32),
|
||||
) -> Option<Pixmap> {
|
||||
paint_dom_scrolled_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn paint_dom_scrolled_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
scroll: (f32, f32),
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<Pixmap> {
|
||||
let mut resources = RenderResourceCache::default();
|
||||
let mut prepared = prepare_dom(tree, viewport, base_url, &mut resources)?;
|
||||
let mut prepared = prepare_dom_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
&mut resources,
|
||||
animation_sample_time,
|
||||
)?;
|
||||
paint_prepared(tree, &mut prepared, &mut resources, scroll)
|
||||
}
|
||||
|
||||
@@ -1576,7 +1783,30 @@ pub fn prepare_dom(
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts(tree, viewport, base_url, resources, &[])
|
||||
prepare_dom_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
&[],
|
||||
animation_sample_time,
|
||||
)
|
||||
}
|
||||
|
||||
/// Prepare a DOM with the document's script-created `FontFace` registrations.
|
||||
@@ -1589,15 +1819,34 @@ pub fn prepare_dom_with_dynamic_fonts(
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_with_dynamic_fonts_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<PreparedRender> {
|
||||
let mut stylesheet_cache = crate::css::StylesheetCache::default();
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
&mut stylesheet_cache,
|
||||
animation_sample_time,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1611,6 +1860,52 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<PreparedRender> {
|
||||
let mut animation_timeline = crate::AnimationTimelineState::default();
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
crate::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: crate::AnimationSampleMode::DocumentTime,
|
||||
},
|
||||
&mut animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
tree,
|
||||
@@ -1620,6 +1915,8 @@ pub fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache(
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
None,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1663,9 +1960,76 @@ pub fn prepare_dom_with_retained_styles(
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
mut previous: PreparedRender,
|
||||
previous: PreparedRender,
|
||||
mutations: &[crate::dom::RetainedStyleMutation],
|
||||
) -> Option<PreparedRender> {
|
||||
prepare_dom_with_retained_styles_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
previous,
|
||||
mutations,
|
||||
crate::AnimationSampleTime::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_with_retained_styles_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
previous: PreparedRender,
|
||||
mutations: &[crate::dom::RetainedStyleMutation],
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<PreparedRender> {
|
||||
let mut animation_timeline = crate::AnimationTimelineState::default();
|
||||
prepare_dom_with_retained_styles_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
previous,
|
||||
mutations,
|
||||
crate::AnimationSample {
|
||||
time: animation_sample_time,
|
||||
mode: crate::AnimationSampleMode::DocumentTime,
|
||||
},
|
||||
&mut animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_dom_with_retained_styles_with_animation_state(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
resources: &mut RenderResourceCache,
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
mut previous: PreparedRender,
|
||||
mutations: &[crate::dom::RetainedStyleMutation],
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<PreparedRender> {
|
||||
if previous.animation_sample != animation_sample {
|
||||
drop(previous);
|
||||
return prepare_dom_with_dynamic_fonts_and_stylesheet_cache_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
resources,
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
);
|
||||
}
|
||||
let retained = RetainedStyleMaps {
|
||||
styles: std::mem::take(&mut previous.layout.styles),
|
||||
custom_properties: std::mem::take(&mut previous.layout.custom_properties),
|
||||
@@ -1679,6 +2043,8 @@ pub fn prepare_dom_with_retained_styles(
|
||||
dynamic_fonts,
|
||||
stylesheet_cache,
|
||||
Some((retained, mutations)),
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1690,6 +2056,8 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
dynamic_fonts: &[DynamicFontFace],
|
||||
stylesheet_cache: &mut crate::css::StylesheetCache,
|
||||
retained: Option<(RetainedStyleMaps, &[crate::dom::RetainedStyleMutation])>,
|
||||
animation_sample: crate::AnimationSample,
|
||||
animation_timeline: &mut crate::AnimationTimelineState,
|
||||
) -> Option<PreparedRender> {
|
||||
if !viewport.0.is_finite() || !viewport.1.is_finite() || viewport.0 <= 0.0 || viewport.1 <= 0.0
|
||||
{
|
||||
@@ -1731,7 +2099,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
svg_font_database()
|
||||
};
|
||||
let mut laid = match retained {
|
||||
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles(
|
||||
Some((retained, mutations)) => layout_dom_with_web_fonts_and_retained_styles_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
&intrinsic,
|
||||
@@ -1739,13 +2107,17 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
stylesheet_cache,
|
||||
retained,
|
||||
mutations,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
),
|
||||
None => layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
None => layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
&intrinsic,
|
||||
&fonts,
|
||||
stylesheet_cache,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
),
|
||||
};
|
||||
// `content:url(...)` is computed by the author cascade, whereas ordinary
|
||||
@@ -1767,12 +2139,14 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
{
|
||||
resources.content_image_layout_retries += 1;
|
||||
}
|
||||
laid = layout_dom_with_web_fonts_and_stylesheet_cache(
|
||||
laid = layout_dom_with_web_fonts_and_stylesheet_cache_with_animation_state(
|
||||
tree,
|
||||
viewport,
|
||||
&intrinsic,
|
||||
&fonts,
|
||||
stylesheet_cache,
|
||||
animation_sample,
|
||||
animation_timeline,
|
||||
);
|
||||
}
|
||||
let content_size = laid.scrolling_content_size(tree, viewport);
|
||||
@@ -1788,6 +2162,7 @@ fn prepare_dom_with_dynamic_fonts_and_stylesheet_cache_internal(
|
||||
.unwrap_or(16.0);
|
||||
Some(PreparedRender {
|
||||
viewport,
|
||||
animation_sample,
|
||||
root_font_size,
|
||||
base_url: base_url.map(str::to_string),
|
||||
content_size,
|
||||
@@ -2945,6 +3320,7 @@ fn paint_laid_dom_scrolled(
|
||||
&mut pixmap,
|
||||
image_cache,
|
||||
None,
|
||||
None,
|
||||
background.clip_radii,
|
||||
background_mask,
|
||||
);
|
||||
@@ -3048,6 +3424,7 @@ fn paint_laid_dom_scrolled(
|
||||
style.object_fit,
|
||||
&mut pixmap,
|
||||
image_cache,
|
||||
Some(source.profile),
|
||||
None,
|
||||
radius,
|
||||
element_clip_mask,
|
||||
@@ -3676,6 +4053,7 @@ fn paint_inline_fragment_decorations(
|
||||
pixmap,
|
||||
image_cache,
|
||||
None,
|
||||
None,
|
||||
background.clip_radii,
|
||||
background_mask.as_ref(),
|
||||
);
|
||||
@@ -4704,6 +5082,23 @@ pub fn screenshot_png_scrolled(
|
||||
paint_dom_scrolled(tree, viewport, base_url, scroll).and_then(|pixmap| pixmap.encode_png().ok())
|
||||
}
|
||||
|
||||
pub fn screenshot_png_scrolled_at_animation_time(
|
||||
tree: &DomTree,
|
||||
viewport: (f32, f32),
|
||||
base_url: Option<&str>,
|
||||
scroll: (f32, f32),
|
||||
animation_sample_time: crate::AnimationSampleTime,
|
||||
) -> Option<Vec<u8>> {
|
||||
paint_dom_scrolled_at_animation_time(
|
||||
tree,
|
||||
viewport,
|
||||
base_url,
|
||||
scroll,
|
||||
animation_sample_time,
|
||||
)
|
||||
.and_then(|pixmap| pixmap.encode_png().ok())
|
||||
}
|
||||
|
||||
/// PNG convenience wrapper for a retained resource-aware layout.
|
||||
pub fn screenshot_prepared(
|
||||
tree: &DomTree,
|
||||
@@ -5181,6 +5576,19 @@ fn fetch_bytes(
|
||||
cache.get_or_load(&resolved)
|
||||
}
|
||||
|
||||
fn fetch_profiled_image_bytes(
|
||||
src: &str,
|
||||
base_url: Option<&str>,
|
||||
cache: &mut RenderResourceCache,
|
||||
profile: ImageRequestProfile,
|
||||
) -> Option<Arc<[u8]>> {
|
||||
if src.starts_with("data:") {
|
||||
return fetch_bytes(src, base_url, cache);
|
||||
}
|
||||
let resolved = resolve_resource_url(src, base_url)?;
|
||||
cache.get_or_load_image(&resolved, profile)
|
||||
}
|
||||
|
||||
fn resolve_resource_url(src: &str, base_url: Option<&str>) -> Option<String> {
|
||||
if src.starts_with("data:") {
|
||||
return Some(src.to_string());
|
||||
@@ -6818,6 +7226,7 @@ fn paint_in_flow_generated_box(
|
||||
pixmap,
|
||||
image_cache,
|
||||
None,
|
||||
None,
|
||||
background.clip_radii,
|
||||
background_mask.as_ref(),
|
||||
);
|
||||
@@ -7051,6 +7460,7 @@ fn paint_positioned_pseudo(
|
||||
pixmap,
|
||||
image_cache,
|
||||
None,
|
||||
None,
|
||||
background.clip_radii,
|
||||
background_mask.as_ref(),
|
||||
);
|
||||
@@ -7143,14 +7553,16 @@ fn collect_image_intrinsics(
|
||||
continue;
|
||||
};
|
||||
let resolved_url = resolve_resource_url(&url, base_url).unwrap_or(url);
|
||||
let profile = image_request_profile(tree, nid);
|
||||
selected.insert(
|
||||
nid,
|
||||
SelectedImage {
|
||||
resolved_url: resolved_url.clone(),
|
||||
density,
|
||||
profile,
|
||||
},
|
||||
);
|
||||
let Some(bytes) = fetch_bytes(&resolved_url, None, cache) else {
|
||||
let Some(bytes) = fetch_profiled_image_bytes(&resolved_url, None, cache, profile) else {
|
||||
continue;
|
||||
};
|
||||
let dimensions = image_dimensions(&bytes)
|
||||
@@ -7208,6 +7620,7 @@ fn collect_content_image_intrinsics(
|
||||
SelectedImage {
|
||||
resolved_url: resolved_url.clone(),
|
||||
density: 1.0,
|
||||
profile: ImageRequestProfile::NoCorsInclude,
|
||||
},
|
||||
);
|
||||
// CSS content replaces the element's ordinary source. Remove the src
|
||||
@@ -7528,6 +7941,7 @@ fn paint_image(
|
||||
object_fit: crate::ObjectFit,
|
||||
pixmap: &mut Pixmap,
|
||||
cache: &mut RenderResourceCache,
|
||||
profile: Option<ImageRequestProfile>,
|
||||
transform: Option<crate::Affine2>,
|
||||
clip_radius: crate::ResolvedBorderRadii,
|
||||
extra_clip: Option<&tiny_skia::Mask>,
|
||||
@@ -7541,7 +7955,11 @@ fn paint_image(
|
||||
if !rect_intersects_paint_surface(visible_rect, pixmap, 1.0) {
|
||||
return false;
|
||||
}
|
||||
let Some(bytes) = fetch_bytes(src, base_url, cache) else {
|
||||
let bytes = match profile {
|
||||
Some(profile) => fetch_profiled_image_bytes(src, base_url, cache, profile),
|
||||
None => fetch_bytes(src, base_url, cache),
|
||||
};
|
||||
let Some(bytes) = bytes else {
|
||||
return false;
|
||||
};
|
||||
let svg = is_svg(&bytes);
|
||||
@@ -8746,6 +9164,56 @@ mod tests {
|
||||
assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_image_profiles_keep_intrinsic_geometry_and_paint_separate() {
|
||||
let network_url = "https://assets.test/shared.svg";
|
||||
let url = "https://assets.test/shared.svg#icon";
|
||||
let tree = parse_html(&format!(
|
||||
r#"<html style="margin:0"><body style="margin:0">
|
||||
<img id="plain" src="{url}" style="display:block">
|
||||
<img id="anonymous" crossorigin="anonymous" src="{url}" style="display:block">
|
||||
<img id="credentialed" crossorigin="use-credentials" alt="" src="{url}"
|
||||
style="display:block;width:20px;height:10px">
|
||||
</body></html>"#
|
||||
));
|
||||
let mut resources = RenderResourceCache::with_loader(|_url: &str| {
|
||||
panic!("seeded profile resources must not reach the loader")
|
||||
});
|
||||
resources.seed_image(
|
||||
network_url.to_string(),
|
||||
ImageRequestProfile::NoCorsInclude,
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="20" height="10"><rect width="20" height="10" fill="#f00"/></svg>"##.to_vec(),
|
||||
);
|
||||
resources.seed_image(
|
||||
network_url.to_string(),
|
||||
ImageRequestProfile::CorsSameOrigin,
|
||||
br##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="30"><rect width="40" height="30" fill="#0f0"/></svg>"##.to_vec(),
|
||||
);
|
||||
resources.seed_image_missing(network_url.to_string(), ImageRequestProfile::CorsInclude);
|
||||
|
||||
let mut prepared =
|
||||
prepare_dom(&tree, (80.0, 60.0), None, &mut resources).expect("profiled layout");
|
||||
let node = |selector| tree.query_selector(selector).unwrap().unwrap();
|
||||
let plain = prepared.layout().rects[&node("#plain")];
|
||||
let anonymous = prepared.layout().rects[&node("#anonymous")];
|
||||
assert_eq!(prepared.selected_image(node("#plain")).unwrap().resolved_url, url);
|
||||
assert_eq!((plain.width, plain.height), (20.0, 10.0));
|
||||
assert_eq!((anonymous.width, anonymous.height), (40.0, 30.0));
|
||||
|
||||
let pixmap = paint_prepared(&tree, &mut prepared, &mut resources, (0.0, 0.0))
|
||||
.expect("profiled paint");
|
||||
let red = pixmap.pixel(5, 5).unwrap();
|
||||
assert!(red.red() > 240 && red.green() < 20, "{red:?}");
|
||||
let green = pixmap.pixel(5, 20).unwrap();
|
||||
assert!(green.green() > 240 && green.red() < 20, "{green:?}");
|
||||
let failed = pixmap.pixel(5, 45).unwrap();
|
||||
assert_eq!(
|
||||
(failed.red(), failed.green(), failed.blue(), failed.alpha()),
|
||||
(255, 255, 255, 255),
|
||||
"failed credentialed image must not paint bytes from another profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_accept_advertises_exactly_decodable_mime_types() {
|
||||
assert!(!IMAGE_ACCEPT.contains('*'));
|
||||
@@ -9075,6 +9543,7 @@ mod tests {
|
||||
Some(&SelectedImage {
|
||||
resolved_url: "https://assets.test/hero.svg".to_string(),
|
||||
density: 2.0,
|
||||
profile: ImageRequestProfile::NoCorsInclude,
|
||||
})
|
||||
);
|
||||
let hero_rect = prepared.layout().rects.get(&hero).expect("hero rect");
|
||||
@@ -12572,6 +13041,7 @@ mod tests {
|
||||
&mut pixmap,
|
||||
&mut resources,
|
||||
None,
|
||||
None,
|
||||
crate::ResolvedBorderRadii::default(),
|
||||
None,
|
||||
));
|
||||
@@ -12612,4 +13082,49 @@ mod tests {
|
||||
let sticky = pixmap.pixel(75, 15).expect("sticky pixel");
|
||||
assert!(sticky.blue() > 240 && sticky.red() < 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepared_render_samples_explicit_animation_time_and_reports_live_damage() {
|
||||
let tree = parse_html(
|
||||
r#"<style>
|
||||
@keyframes dismiss {
|
||||
from { opacity:1; visibility:visible }
|
||||
to { opacity:0; visibility:hidden }
|
||||
}
|
||||
#overlay { opacity:1; animation:dismiss 600ms linear forwards }
|
||||
</style><div id="overlay" style="width:20px;height:20px;background:red"></div>"#,
|
||||
);
|
||||
let overlay = tree.query_selector("#overlay").unwrap().unwrap();
|
||||
let mut resources = RenderResourceCache::with_loader(|_url: &str| None);
|
||||
let mut stylesheets = crate::css::StylesheetCache::default();
|
||||
let at_zero = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
|
||||
&tree,
|
||||
(40.0, 40.0),
|
||||
None,
|
||||
&mut resources,
|
||||
&[],
|
||||
&mut stylesheets,
|
||||
crate::AnimationSampleTime { milliseconds: 0.0 },
|
||||
)
|
||||
.expect("T=0 render");
|
||||
assert_eq!(at_zero.animation_sample_time().milliseconds, 0.0);
|
||||
assert_eq!(at_zero.layout.styles[&overlay].opacity, Some(1.0));
|
||||
assert_ne!(at_zero.layout.styles[&overlay].visibility_hidden, Some(true));
|
||||
assert!(at_zero.has_active_css_animations());
|
||||
|
||||
let at_end = prepare_dom_with_dynamic_fonts_and_stylesheet_cache_at_animation_time(
|
||||
&tree,
|
||||
(40.0, 40.0),
|
||||
None,
|
||||
&mut resources,
|
||||
&[],
|
||||
&mut stylesheets,
|
||||
crate::AnimationSampleTime { milliseconds: 600.0 },
|
||||
)
|
||||
.expect("animation-end render");
|
||||
assert_eq!(at_end.layout.styles[&overlay].opacity, Some(0.0));
|
||||
assert_eq!(at_end.layout.styles[&overlay].visibility_hidden, Some(true));
|
||||
assert!(at_end.layout.styles[&overlay].effectively_invisible);
|
||||
assert!(!at_end.has_active_css_animations());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user