From 5f2e207df352dfd9b072bf8586e98c6ff5a2a089 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Wed, 9 Sep 2026 20:32:40 +0800 Subject: [PATCH] fix(dom): scope touch feature detection to native Window lifetime --- .../emulation/tests/native_navigator.rs | 2 + .../touch_feature_detection.rs | 234 ++++++++++++++++++ moli-renderer-v8/src/context_bootstrap.rs | 1 + .../src/context_bootstrap/event_document.rs | 28 ++- .../src/context_bootstrap/events.rs | 22 ++ .../exposed_interfaces/finalize.rs | 25 +- .../src/context_bootstrap/runtime_state.rs | 2 + .../touch_feature_detection.rs | 24 ++ .../src/context_bootstrap/touch_runtime.rs | 23 +- .../window_events/install.rs | 6 + .../src/frame_owner_model/records.rs | 3 + .../src/frame_owner_model/store.rs | 14 ++ .../src/frame_owner_model/store_tests.rs | 38 +++ .../context_host/navigator_overrides.rs | 13 + .../src/native_bridge/document.rs | 17 +- moli-renderer-v8/src/native_bridge/element.rs | 1 + .../element/event_handlers/generic.rs | 97 +++++--- .../element/event_handlers/mod.rs | 5 +- moli-renderer-v8/src/runtime/page_vm/mod.rs | 4 +- moli-renderer-v8/src/script_vm.rs | 12 +- .../src/script_vm/document_environment.rs | 9 - moli-renderer-v8/src/script_vm/post_parse.rs | 17 +- .../misc/extracted/platform_compat.rs | 7 +- .../live_document/legacy_element_surfaces.rs | 25 +- 24 files changed, 571 insertions(+), 58 deletions(-) create mode 100644 moli-protocol/src/domains/emulation/tests/native_navigator/touch_feature_detection.rs create mode 100644 moli-renderer-v8/src/context_bootstrap/touch_feature_detection.rs diff --git a/moli-protocol/src/domains/emulation/tests/native_navigator.rs b/moli-protocol/src/domains/emulation/tests/native_navigator.rs index 37170b3be5..2366b2ce3e 100644 --- a/moli-protocol/src/domains/emulation/tests/native_navigator.rs +++ b/moli-protocol/src/domains/emulation/tests/native_navigator.rs @@ -3,6 +3,8 @@ use crate::devtools_runtime::{ DevToolsDevicePixelRatioSetting, DevToolsSetViewportCommand, DevToolsViewportSetting, }; +mod touch_feature_detection; + async fn setup() -> TestContext { let mut ctx = TestContext::new(); let mut bc = BrowserContext::new("BID-1".into()); diff --git a/moli-protocol/src/domains/emulation/tests/native_navigator/touch_feature_detection.rs b/moli-protocol/src/domains/emulation/tests/native_navigator/touch_feature_detection.rs new file mode 100644 index 0000000000..28310f1f86 --- /dev/null +++ b/moli-protocol/src/domains/emulation/tests/native_navigator/touch_feature_detection.rs @@ -0,0 +1,234 @@ +use super::*; + +const SNAPSHOT: &str = r#"function(w) { + const names = ['ontouchstart', 'ontouchend', 'ontouchmove', 'ontouchcancel']; + const owners = [w, w.Document.prototype, w.HTMLElement.prototype, w.SVGElement.prototype, w.MathMLElement.prototype]; + const present = names.every(name => owners.every(owner => { + const d = Object.getOwnPropertyDescriptor(owner, name); + return d && typeof d.get === 'function' && typeof d.set === 'function' && d.enumerable && d.configurable; + })); + let legacy; + try { + const e = w.document.createEvent('TouchEvent'); + let beforeInit; + try { w.document.dispatchEvent(e); } catch (error) { beforeInit = error.name; } + legacy = [e instanceof w.TouchEvent, e.type, e.touches, e.targetTouches, e.changedTouches, beforeInit]; + e.initEvent('touchstart', false, false); + if (!w.document.dispatchEvent(e)) throw new Error('initialized event should dispatch'); + } catch (error) { legacy = error.name; } + return {points: w.navigator.maxTouchPoints, present, + absent: names.every(name => owners.every(owner => !(name in owner))), + constructor: new w.TouchEvent('touchstart').touches.length === 0 && typeof w.Touch === 'function', legacy}; +}"#; + +fn expected(points: u32, enabled: bool) -> serde_json::Value { + json!({"points": points, "present": enabled, "absent": !enabled, "constructor": true, + "legacy": if enabled { json!([true, "", null, null, null, "InvalidStateError"]) } else { json!("NotSupportedError") }}) +} + +async fn command( + ctx: &mut TestContext, + method: &str, + params: serde_json::Value, +) -> serde_json::Value { + ctx.process_async( + json!({"id": 88010, "sessionId": "SID-1", "method": method, "params": params}), + ) + .await; + crate::testing::wait_until_scheduler_message(ctx, "touch capability command", |message| { + message["id"] == json!(88010) + }) + .await; + let response = ctx.take_response_by_id(88010); + assert!(response["error"].is_null(), "{response}"); + response["result"].clone() +} + +async fn isolated_snapshot( + ctx: &mut TestContext, + frame: &serde_json::Value, + name: &str, +) -> serde_json::Value { + let world = command( + ctx, + "Page.createIsolatedWorld", + json!({"frameId": frame, "worldName": name}), + ) + .await; + let result = command(ctx, "Runtime.evaluate", json!({ + "contextId": world["executionContextId"], "expression": format!("({SNAPSHOT})(globalThis)"), "returnByValue": true + })).await; + assert!(result["exceptionDetails"].is_null(), "{result}"); + result["result"]["value"].clone() +} + +#[tokio::test(flavor = "multi_thread")] +async fn touch_feature_detection_follows_document_lifetime_not_live_emulation() { + let mut ctx = setup().await; + for (stage, points, reload, existing_enabled, new_enabled) in [ + ("desktop", 0, false, false, false), + ("enable", 5, false, false, true), + ("reload-enabled", 5, true, true, true), + ("disable", 0, false, true, false), + ("reload-disabled", 0, true, false, false), + ] { + if stage == "enable" || stage == "disable" { + command( + &mut ctx, + "Emulation.setTouchEmulationEnabled", + json!({"enabled": points > 0, "maxTouchPoints": 5}), + ) + .await; + } + if reload { + ctx.install_buffered_navigation_fixture_for_session_owner( + url::Url::parse(&format!("https://geolocation.example/{stage}")).unwrap(), + "".into(), + Some("SID-1"), + ).await; + assert_eq!( + evaluate(&mut ctx, "initialTouchHandler").await, + json!(existing_enabled) + ); + } + assert_eq!( + evaluate(&mut ctx, &format!("({SNAPSHOT})(globalThis)")).await, + expected(points, existing_enabled), + "main: {stage}" + ); + let tree = command(&mut ctx, "Page.getFrameTree", json!({})).await; + assert_eq!( + isolated_snapshot(&mut ctx, &tree["frameTree"]["frame"]["id"], stage).await, + expected(points, existing_enabled), + "isolated: {stage}" + ); + let child = evaluate(&mut ctx, &format!(r#"(async () => {{ + const frame = document.createElement('iframe'); + frame.id = 'touch-child'; frame.srcdoc = 'child'; + await new Promise(resolve => {{ frame.onload = resolve; document.body.appendChild(frame); }}); + return ({SNAPSHOT})(frame.contentWindow); + }})()"#)).await; + assert_eq!(child, expected(points, new_enabled), "child: {stage}"); + let tree = command(&mut ctx, "Page.getFrameTree", json!({})).await; + let child_frame = &tree["frameTree"]["childFrames"][0]["frame"]["id"]; + assert!(child_frame.is_string(), "{tree}"); + assert_eq!( + isolated_snapshot(&mut ctx, child_frame, stage).await, + expected(points, new_enabled), + "child isolated: {stage}" + ); + let borrowed = evaluate( + &mut ctx, + r#"(() => { + const child = document.getElementById('touch-child').contentWindow; + const attempt = (method, receiver, realm) => { + try { return method.call(receiver, 'TouchEvent') instanceof realm.TouchEvent; } + catch (error) { return error.name; } + }; + return [attempt(Document.prototype.createEvent, child.document, child), + attempt(child.Document.prototype.createEvent, document, window)]; + })()"#, + ) + .await; + assert_eq!( + borrowed, + json!([ + if new_enabled { + json!(true) + } else { + json!("NotSupportedError") + }, + if existing_enabled { + json!(true) + } else { + json!("NotSupportedError") + } + ]), + "receiver realm: {stage}" + ); + evaluate(&mut ctx, "document.getElementById('touch-child').remove()").await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn touch_feature_detection_is_shared_when_child_isolated_world_is_created_first() { + let mut ctx = setup().await; + command( + &mut ctx, + "Emulation.setTouchEmulationEnabled", + json!({"enabled": true, "maxTouchPoints": 5}), + ) + .await; + ctx.install_buffered_navigation_fixture_for_session_owner( + url::Url::parse("https://geolocation.example/touch-isolated-first").unwrap(), + "".into(), + Some("SID-1"), + ) + .await; + let tree = command(&mut ctx, "Page.getFrameTree", json!({})).await; + let child = &tree["frameTree"]["childFrames"][0]["frame"]["id"]; + assert!(child.is_string(), "{tree}"); + assert_eq!( + isolated_snapshot(&mut ctx, child, "first").await, + expected(5, true) + ); + command( + &mut ctx, + "Emulation.setTouchEmulationEnabled", + json!({"enabled": false}), + ) + .await; + assert_eq!( + evaluate( + &mut ctx, + &format!("({SNAPSHOT})(document.getElementById('touch-child').contentWindow)") + ) + .await, + expected(0, true) + ); + assert_eq!( + isolated_snapshot(&mut ctx, child, "second").await, + expected(0, true) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn touch_feature_detection_keeps_author_properties_and_native_event_factory() { + let mut ctx = setup().await; + evaluate(&mut ctx, r#" + window.ontouchstart = 'author window'; + Object.defineProperty(SVGElement.prototype, 'ontouchstart', {value: 'author SVG', configurable: false}); + void 0; + "#).await; + for enabled in [true, false, true] { + command( + &mut ctx, + "Emulation.setTouchEmulationEnabled", + json!({"enabled": enabled}), + ) + .await; + assert_eq!( + evaluate( + &mut ctx, + "[window.ontouchstart, SVGElement.prototype.ontouchstart]" + ) + .await, + json!(["author window", "author SVG"]) + ); + } + ctx.install_buffered_navigation_fixture_for_session_owner( + url::Url::parse("https://geolocation.example/touch-factory").unwrap(), + "touch".into(), + Some("SID-1"), + ) + .await; + assert_eq!(evaluate(&mut ctx, r#"(() => { + const OriginalTouchEvent = TouchEvent; + window.TouchEvent = function() { throw new Error('author constructor must not run'); }; + const e = document.createEvent('tOuChEvEnT'); + let invalidReceiver; + try { Document.prototype.createEvent.call(Object.create(Document.prototype), 'TouchEvent'); } + catch (error) { invalidReceiver = error.name; } + return [e instanceof OriginalTouchEvent, e.touches, e.type, invalidReceiver]; + })()"#).await, json!([true, null, "", "TypeError"])); +} diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 8f68490297..f39df433f3 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -180,6 +180,7 @@ mod stream_objects; mod streams; mod style_font_template; pub(crate) mod svg_runtime; +pub(crate) mod touch_feature_detection; mod touch_runtime; mod trusted_types; mod url_form; diff --git a/moli-renderer-v8/src/context_bootstrap/event_document.rs b/moli-renderer-v8/src/context_bootstrap/event_document.rs index 4dbfb46ced..2fc7c04314 100644 --- a/moli-renderer-v8/src/context_bootstrap/event_document.rs +++ b/moli-renderer-v8/src/context_bootstrap/event_document.rs @@ -1,4 +1,6 @@ -use super::events::{new_uninitialized_text_event, set_event_initialized}; +use super::events::{ + new_uninitialized_text_event, new_uninitialized_touch_event, set_event_initialized, +}; use super::*; use crate::webidl; use std::str::FromStr; @@ -28,6 +30,7 @@ enum DocumentCreateEventKind { #[strum(serialize = "uievent", serialize = "uievents")] UiEvent, TextEvent, + TouchEvent, CompositionEvent, FocusEvent, HashChangeEvent, @@ -50,6 +53,7 @@ impl DocumentCreateEventKind { DocumentCreateEventKind::DragEvent => "Event", DocumentCreateEventKind::UiEvent => "UIEvent", DocumentCreateEventKind::TextEvent => "TextEvent", + DocumentCreateEventKind::TouchEvent => "TouchEvent", DocumentCreateEventKind::CompositionEvent => "CompositionEvent", DocumentCreateEventKind::FocusEvent => "FocusEvent", DocumentCreateEventKind::HashChangeEvent => "Event", @@ -71,6 +75,12 @@ pub(super) fn document_create_event_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if crate::native_bridge::document::document_receiver_runtime_and_handle(scope, args.this()) + .is_none() + { + throw_type_error(scope, "Illegal invocation"); + return; + } let Some(parsed) = webidl::parse_args::(scope, &args) else { return; }; @@ -78,6 +88,22 @@ pub(super) fn document_create_event_callback<'s>( throw_not_supported_dom_exception(scope, "The provided event type is not supported."); return; }; + if kind == DocumentCreateEventKind::TouchEvent { + let Some(context) = + crate::native_bridge::document::document_relevant_context(scope, args.this()) + else { + return; + }; + let scope = &mut v8::ContextScope::new(scope, context); + if !super::touch_feature_detection::enabled(scope) { + throw_not_supported_dom_exception(scope, "The provided event type is not supported."); + return; + } + if let Some(event) = new_uninitialized_touch_event(scope) { + rv.set(event.into()); + } + return; + } if kind == DocumentCreateEventKind::TextEvent { match new_uninitialized_text_event(scope) { Some(event) => rv.set(event.into()), diff --git a/moli-renderer-v8/src/context_bootstrap/events.rs b/moli-renderer-v8/src/context_bootstrap/events.rs index 73659b7a6c..acc334f8a2 100644 --- a/moli-renderer-v8/src/context_bootstrap/events.rs +++ b/moli-renderer-v8/src/context_bootstrap/events.rs @@ -94,6 +94,28 @@ pub(in crate::context_bootstrap) fn new_uninitialized_text_event<'s>( Some(wrapper) } +pub(in crate::context_bootstrap) fn new_uninitialized_touch_event<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Option> { + let prototype = + super::exposed_interfaces::ensure_intrinsic_interface_prototype(scope, "TouchEvent") + .ok()?; + let event = v8::Object::new(scope); + if event.set_prototype(scope, prototype.into()) != Some(true) { + return None; + } + base::initialize_event_object(scope, event, "", false, false); + super::touch_runtime::initialize_uninitialized_touch_event(scope, event); + set_private_value( + scope, + event, + EVENT_SUBCLASS_KIND_SLOT, + v8::Integer::new(scope, EventSubclassKind::TouchEvent as i32).into(), + ); + base::set_event_initialized(scope, event, false); + Some(event) +} + pub(crate) fn construct_original_page_transition_event<'s>( scope: &mut v8::PinScope<'s, '_>, event_type: &str, diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs index 71389fde62..60017eea66 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs @@ -6,6 +6,8 @@ use super::materialize::{ #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum RealmDependentFinalizer { + Document, + TouchEventHandlers, NodeMixinUnscopables, CryptoSecureContextSurface, XmlHttpRequestEventTargetState, @@ -17,7 +19,7 @@ enum RealmDependentFinalizer { const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] = &[ // The template creates @@unscopables, but its object needs a realm-local // null prototype after materialization. - ("Document", RealmDependentFinalizer::NodeMixinUnscopables), + ("Document", RealmDependentFinalizer::Document), ( "DocumentFragment", RealmDependentFinalizer::NodeMixinUnscopables, @@ -33,6 +35,9 @@ const REALM_DEPENDENT_FINALIZER_ALLOWLIST: &[(&str, RealmDependentFinalizer)] = ), // These entries install private realm state, context-conditional surface, // or constructor data whose JavaScript identity must be realm-local. + ("HTMLElement", RealmDependentFinalizer::TouchEventHandlers), + ("SVGElement", RealmDependentFinalizer::TouchEventHandlers), + ("MathMLElement", RealmDependentFinalizer::TouchEventHandlers), ( "Crypto", RealmDependentFinalizer::CryptoSecureContextSurface, @@ -77,6 +82,21 @@ pub(super) fn finalize_materialized_interface( return Ok(()); }; match finalizer { + RealmDependentFinalizer::Document => { + finalize_node_mixin_unscopables(scope, prototype); + crate::native_bridge::element::install_touch_event_handler_realm_bindings( + scope, + prototype, + crate::native_bridge::element::GlobalEventHandlerOwner::Document, + )?; + } + RealmDependentFinalizer::TouchEventHandlers => { + crate::native_bridge::element::install_touch_event_handler_realm_bindings( + scope, + prototype, + crate::native_bridge::element::GlobalEventHandlerOwner::Element, + )?; + } RealmDependentFinalizer::NodeMixinUnscopables => { finalize_node_mixin_unscopables(scope, prototype); } @@ -140,6 +160,9 @@ mod tests { "Element", "DocumentType", "CharacterData", + "HTMLElement", + "SVGElement", + "MathMLElement", "Crypto", "XMLHttpRequestEventTarget", "Notification", diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 882ede6389..cece613425 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -1737,7 +1737,9 @@ pub(crate) fn finish_context_bootstrap( scope: &mut v8::PinScope<'_, '_>, document_runtime: &mut JsContextHost, secure_context_url: &url::Url, + touch_feature_detection: bool, ) -> Result<()> { + super::touch_feature_detection::initialize(scope, touch_feature_detection); super::exposed_interfaces::initialize_realm_interface_registry( scope, super::exposed_interfaces::RealmKind::Window, diff --git a/moli-renderer-v8/src/context_bootstrap/touch_feature_detection.rs b/moli-renderer-v8/src/context_bootstrap/touch_feature_detection.rs new file mode 100644 index 0000000000..6b91d456c8 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/touch_feature_detection.rs @@ -0,0 +1,24 @@ +use crate::util::{get_private_value, set_private_value}; + +const WINDOW_TOUCH_FEATURE_DETECTION_SLOT: &str = "__moliTouchEventFeatureDetection"; + +pub(crate) const TOUCH_EVENT_HANDLER_PROPERTIES: &[&str] = + &["ontouchstart", "ontouchend", "ontouchmove", "ontouchcancel"]; + +/// Each realm receives its LocalWindow's frozen capability snapshot, not the +/// live CDP override. Changing emulation must not mutate author properties. +pub(crate) fn initialize(scope: &mut v8::PinScope<'_, '_>, enabled: bool) { + let global = scope.get_current_context().global(scope); + set_private_value( + scope, + global, + WINDOW_TOUCH_FEATURE_DETECTION_SLOT, + v8::Boolean::new(scope, enabled).into(), + ); +} + +pub(crate) fn enabled(scope: &mut v8::PinScope<'_, '_>) -> bool { + let global = scope.get_current_context().global(scope); + get_private_value(scope, global, WINDOW_TOUCH_FEATURE_DETECTION_SLOT) + .is_some_and(|value| value.boolean_value(scope)) +} diff --git a/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs b/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs index c4ef7f8137..300143dfa5 100644 --- a/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/touch_runtime.rs @@ -119,11 +119,11 @@ struct TouchUiEventInitDeclaration<'scope> { #[webapi(interface = web_api_interfaces::TouchEvent)] struct TouchEventObjectDeclaration<'scope> { #[webapi(slot = TOUCH_EVENT_TOUCHES_SLOT)] - touches: v8::Local<'scope, v8::Object>, + touches: v8::Local<'scope, v8::Value>, #[webapi(slot = TOUCH_EVENT_TARGET_TOUCHES_SLOT)] - target_touches: v8::Local<'scope, v8::Object>, + target_touches: v8::Local<'scope, v8::Value>, #[webapi(slot = TOUCH_EVENT_CHANGED_TOUCHES_SLOT)] - changed_touches: v8::Local<'scope, v8::Object>, + changed_touches: v8::Local<'scope, v8::Value>, #[webapi(slot = TOUCH_EVENT_ALT_KEY_SLOT)] alt_key: bool, #[webapi(slot = TOUCH_EVENT_META_KEY_SLOT)] @@ -425,9 +425,9 @@ pub(in crate::context_bootstrap) fn initialize_touch_event<'s>( let ctrl_key = touch_event_init_bool(scope, init, "ctrlKey", false); let shift_key = touch_event_init_bool(scope, init, "shiftKey", false); TouchEventObjectDeclaration::new( - touches, - target_touches, - changed_touches, + touches.into(), + target_touches.into(), + changed_touches.into(), alt_key, meta_key, ctrl_key, @@ -437,6 +437,17 @@ pub(in crate::context_bootstrap) fn initialize_touch_event<'s>( .expect("TouchEvent declaration should initialize object"); } +pub(in crate::context_bootstrap) fn initialize_uninitialized_touch_event<'s>( + scope: &mut v8::PinScope<'s, '_>, + event: v8::Local<'s, v8::Object>, +) { + initialize_touch_ui_event(scope, event, None); + let null = v8::null(scope).into(); + TouchEventObjectDeclaration::new(null, null, null, false, false, false, false) + .initialize(scope, event) + .expect("uninitialized TouchEvent state"); +} + pub(in crate::context_bootstrap) fn install_touch_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, diff --git a/moli-renderer-v8/src/context_bootstrap/window_events/install.rs b/moli-renderer-v8/src/context_bootstrap/window_events/install.rs index f44a58d8d8..efca37d5c7 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_events/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_events/install.rs @@ -54,6 +54,12 @@ pub(in crate::context_bootstrap) fn install_window_global_accessors<'s>( .initialize(scope, global) .expect("Window global event handler accessors declaration should initialize"); for name in WINDOW_EVENT_HANDLER_PROPERTIES { + if crate::context_bootstrap::touch_feature_detection::TOUCH_EVENT_HANDLER_PROPERTIES + .contains(name) + && !crate::context_bootstrap::touch_feature_detection::enabled(scope) + { + continue; + } if matches!( *name, "onerror" | "onunhandledrejection" | "onrejectionhandled" diff --git a/moli-renderer-v8/src/frame_owner_model/records.rs b/moli-renderer-v8/src/frame_owner_model/records.rs index 409a4bcfb2..f2a14c8803 100644 --- a/moli-renderer-v8/src/frame_owner_model/records.rs +++ b/moli-renderer-v8/src/frame_owner_model/records.rs @@ -482,6 +482,9 @@ pub(crate) struct LocalWindowRecord { pub(crate) realm_id: Option, pub(crate) settings: FrameSettingsObject, pub(crate) lifecycle: LocalWindowLifecycleState, + /// The first realm freezes conditional touch bindings for all worlds of + /// this Window. Live emulation updates do not rewrite installed bindings. + pub(crate) touch_feature_detection: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/moli-renderer-v8/src/frame_owner_model/store.rs b/moli-renderer-v8/src/frame_owner_model/store.rs index a5f5d2144f..d97b400405 100644 --- a/moli-renderer-v8/src/frame_owner_model/store.rs +++ b/moli-renderer-v8/src/frame_owner_model/store.rs @@ -143,6 +143,7 @@ impl FrameOwnerStore { realm_id: Some(realm_id), settings, lifecycle: LocalWindowLifecycleState::Current, + touch_feature_detection: None, }, ); let lifecycle_progress = @@ -484,6 +485,7 @@ impl FrameOwnerStore { realm_id: None, settings, lifecycle: LocalWindowLifecycleState::Current, + touch_feature_detection: None, }, ); self.documents.insert( @@ -3286,6 +3288,18 @@ impl FrameOwnerStore { .is_some_and(|frame| frame.scheduler_lane_id == owner.scheduler_lane_id) } + pub(crate) fn init_window_touch_feature_detection( + &mut self, + owner: FrameDocumentTaskOwner, + enabled: bool, + ) -> Option { + if !self.document_task_owner_is_current(owner) { + return None; + } + let window = self.local_windows.get_mut(&owner.local_window_id)?; + Some(*window.touch_feature_detection.get_or_insert(enabled)) + } + pub(crate) fn current_reserved_realm_id_for_document_task_owner( &self, owner: FrameDocumentTaskOwner, diff --git a/moli-renderer-v8/src/frame_owner_model/store_tests.rs b/moli-renderer-v8/src/frame_owner_model/store_tests.rs index 029f75c705..9ebada9e01 100644 --- a/moli-renderer-v8/src/frame_owner_model/store_tests.rs +++ b/moli-renderer-v8/src/frame_owner_model/store_tests.rs @@ -138,6 +138,44 @@ fn finish_test_child_load_delivery( unreachable!("frame-finish phase returns from the loop") } +#[test] +fn touch_feature_detection_is_frozen_per_window_without_materializing_a_realm() { + let mut store = FrameOwnerStore::default(); + store.ensure_main_frame( + handle(1), + url("https://example.test/"), + url("https://example.test/"), + "https://example.test".to_owned(), + policy_container(), + policy_context(), + None, + ); + let first = commit_test_child_document(&mut store, handle(2), handle(3), "child", Some("main")); + assert_eq!( + store.init_window_touch_feature_detection(first, true), + Some(true) + ); + assert_eq!( + store.init_window_touch_feature_detection(first, false), + Some(true) + ); + assert_eq!(store.local_windows[&first.local_window_id].realm_id, None); + let second = + commit_test_child_document(&mut store, handle(4), handle(5), "other", Some("main")); + assert_eq!( + store.init_window_touch_feature_detection(second, false), + Some(false) + ); + let replacement = + commit_test_child_document(&mut store, handle(2), handle(6), "child", Some("main")); + assert_ne!(replacement.local_window_id, first.local_window_id); + assert_eq!(store.init_window_touch_feature_detection(first, true), None); + assert_eq!( + store.init_window_touch_feature_detection(replacement, false), + Some(false) + ); +} + #[test] fn main_frame_identity_uses_reserved_owner_records() { let mut store = FrameOwnerStore::default(); diff --git a/moli-renderer-v8/src/native_bridge/context_host/navigator_overrides.rs b/moli-renderer-v8/src/native_bridge/context_host/navigator_overrides.rs index aeca48c595..8b2fc4a728 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/navigator_overrides.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/navigator_overrides.rs @@ -18,6 +18,19 @@ impl JsContextHost { geolocation_changed } + pub(crate) fn init_window_touch_feature_detection( + &mut self, + owner: crate::frame_owner_model::FrameDocumentTaskOwner, + ) -> Option { + let max_touch_points = self + .navigator_overrides + .max_touch_points + .map(f64::from) + .unwrap_or(moli_browser_profile::DEFAULT_WINDOW_SURFACE_PROFILE.max_touch_points); + self.frame_owner_store + .init_window_touch_feature_detection(owner, max_touch_points > 0.0) + } + pub(crate) fn register_geolocation_object<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index b3fc9286cb..4646c2a82d 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -504,7 +504,7 @@ struct DocumentFragmentCollectionQueryPrototypeDeclaration { get_elements_by_name: (), } -fn document_receiver_runtime_and_handle<'s>( +pub(crate) fn document_receiver_runtime_and_handle<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, ) -> Option<(*mut JsContextHost, DomHandle)> { @@ -1303,6 +1303,21 @@ pub(in crate::native_bridge) fn set_document_associated_window<'s>( ); } +pub(crate) fn document_relevant_context<'s>( + scope: &mut v8::PinScope<'s, '_>, + document: v8::Local<'s, v8::Object>, +) -> Option> { + // A shared default-world reflector can predate its child realm. Its V8 + // allocation context is then not the Document's current associated Window. + if let Some(window) = get_private_value(scope, document, DOCUMENT_ASSOCIATED_WINDOW_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + { + return window.get_creation_context(scope); + } + // Detached documents have no Window and retain their creation realm. + document.get_creation_context(scope) +} + pub(crate) fn install_document_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index 2121bd02a5..d2966a8ff9 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -259,6 +259,7 @@ use event_handlers::install_global_event_handler_template_bindings as install_gl pub(crate) use event_handlers::{ EventAttributeHandlerScope, GlobalEventHandlerOwner, compile_event_attribute_handler_for_owner, initialize_parser_inserted_body_window_event_handlers, + install_touch_event_handler_realm_bindings, }; use event_handlers::{ body_onerror_getter_function, body_onerror_setter_function, body_onload_getter_function, diff --git a/moli-renderer-v8/src/native_bridge/element/event_handlers/generic.rs b/moli-renderer-v8/src/native_bridge/element/event_handlers/generic.rs index 828774067e..2eb0f6e15c 100644 --- a/moli-renderer-v8/src/native_bridge/element/event_handlers/generic.rs +++ b/moli-renderer-v8/src/native_bridge/element/event_handlers/generic.rs @@ -1,4 +1,5 @@ use crate::{ + context_bootstrap::touch_feature_detection::{self, TOUCH_EVENT_HANDLER_PROPERTIES}, document_runtime::EventTargetHandle, util::{ context_host_ptr_from_global_bridge, node_wrapper_from_handle, throw_type_error, v8_string, @@ -133,35 +134,10 @@ pub(crate) fn install_global_event_handler_template_bindings<'s>( if matches!(owner, GlobalEventHandlerOwner::Document) && *name == "onstorage" { continue; } - let data = v8str(scope, name).into(); - let (getter, setter) = match owner { - GlobalEventHandlerOwner::Document => ( - v8::FunctionTemplate::builder(document_event_handler_getter_function) - .data(data) - .length(0) - .build(scope), - v8::FunctionTemplate::builder(document_event_handler_setter_function) - .data(data) - .length(1) - .build(scope), - ), - GlobalEventHandlerOwner::Element => ( - v8::FunctionTemplate::builder(node_event_handler_getter_function) - .data(data) - .length(0) - .build(scope), - v8::FunctionTemplate::builder(node_event_handler_setter_function) - .data(data) - .length(1) - .build(scope), - ), - }; - if let Some(function_name) = v8_string(scope, &format!("get {name}")) { - getter.set_class_name(function_name); - } - if let Some(function_name) = v8_string(scope, &format!("set {name}")) { - setter.set_class_name(function_name); + if TOUCH_EVENT_HANDLER_PROPERTIES.contains(name) { + continue; } + let (getter, setter) = event_handler_templates(scope, name, owner); prototype.set_accessor_property( v8str(scope, name).into(), Some(getter), @@ -171,6 +147,71 @@ pub(crate) fn install_global_event_handler_template_bindings<'s>( } } +fn event_handler_templates<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + name: &'static str, + owner: GlobalEventHandlerOwner, +) -> ( + v8::Local<'s, v8::FunctionTemplate>, + v8::Local<'s, v8::FunctionTemplate>, +) { + let data = v8str(scope, name).into(); + let (getter, setter) = match owner { + GlobalEventHandlerOwner::Document => ( + v8::FunctionTemplate::builder(document_event_handler_getter_function) + .data(data) + .length(0) + .build(scope), + v8::FunctionTemplate::builder(document_event_handler_setter_function) + .data(data) + .length(1) + .build(scope), + ), + GlobalEventHandlerOwner::Element => ( + v8::FunctionTemplate::builder(node_event_handler_getter_function) + .data(data) + .length(0) + .build(scope), + v8::FunctionTemplate::builder(node_event_handler_setter_function) + .data(data) + .length(1) + .build(scope), + ), + }; + if let Some(function_name) = v8_string(scope, &format!("get {name}")) { + getter.set_class_name(function_name); + } + if let Some(function_name) = v8_string(scope, &format!("set {name}")) { + setter.set_class_name(function_name); + } + (getter, setter) +} + +pub(crate) fn install_touch_event_handler_realm_bindings<'s>( + scope: &mut v8::PinScope<'s, '_>, + prototype: v8::Local<'s, v8::Object>, + owner: GlobalEventHandlerOwner, +) -> anyhow::Result<()> { + if !touch_feature_detection::enabled(scope) { + return Ok(()); + } + for name in TOUCH_EVENT_HANDLER_PROPERTIES { + let (getter, setter) = event_handler_templates(scope, name, owner); + let getter = getter.get_function(scope).expect("touch handler getter"); + let setter = setter.get_function(scope).expect("touch handler setter"); + crate::definitions::define_get_set_property( + scope, + prototype, + v8str(scope, name).into(), + getter.into(), + setter.into(), + v8::PropertyAttribute::NONE, + name, + )?; + } + Ok(()) +} + fn event_handler_property_value_for_target<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut super::super::super::JsContextHost, diff --git a/moli-renderer-v8/src/native_bridge/element/event_handlers/mod.rs b/moli-renderer-v8/src/native_bridge/element/event_handlers/mod.rs index fd8dcb2303..ab7959bf3f 100644 --- a/moli-renderer-v8/src/native_bridge/element/event_handlers/mod.rs +++ b/moli-renderer-v8/src/native_bridge/element/event_handlers/mod.rs @@ -12,7 +12,10 @@ pub(crate) use body_window::compile_window_body_onmessageerror_attribute; pub(super) use body_window::{ body_onmessageerror_getter_function, body_onmessageerror_setter_function, }; -pub(crate) use generic::{GlobalEventHandlerOwner, install_global_event_handler_template_bindings}; +pub(crate) use generic::{ + GlobalEventHandlerOwner, install_global_event_handler_template_bindings, + install_touch_event_handler_realm_bindings, +}; pub(crate) use shared::{EventAttributeHandlerScope, compile_event_attribute_handler_for_owner}; fn is_body_or_frameset_element( diff --git a/moli-renderer-v8/src/runtime/page_vm/mod.rs b/moli-renderer-v8/src/runtime/page_vm/mod.rs index d7f620759b..81028b0e75 100644 --- a/moli-renderer-v8/src/runtime/page_vm/mod.rs +++ b/moli-renderer-v8/src/runtime/page_vm/mod.rs @@ -4255,6 +4255,7 @@ impl PageVm { let vm_bootstrap = ScriptVmDefaultWorldBootstrap::from_dom_host_with_resource_completion_sender_browser_context_runtime_and_document_isolate( bootstrap_document, env.bypass_content_security_policy, + &env.navigator_overrides, post_domcontentloaded_page_task_sender, script_event_parser_boundary_sender, resource_completion_sender, @@ -4385,9 +4386,6 @@ impl PageVm { .vm_mut() .set_emulated_media_for_bootstrap(&env.emulated_media); page_vm.vm_mut().set_idle_override(env.idle_override); - page_vm - .vm_mut() - .set_navigator_overrides(&env.navigator_overrides); page_vm .vm_mut() .set_viewport_surface_for_bootstrap(env.viewport_surface); diff --git a/moli-renderer-v8/src/script_vm.rs b/moli-renderer-v8/src/script_vm.rs index fd96c5724b..0985e3d935 100644 --- a/moli-renderer-v8/src/script_vm.rs +++ b/moli-renderer-v8/src/script_vm.rs @@ -2193,6 +2193,7 @@ impl ScriptVmDefaultWorldBootstrap { Self::from_dom_host_with_resource_completion_sender_browser_context_runtime_and_document_isolate( bootstrap_dom_host, false, + &moli_page_types::NavigatorOverrides::default(), page_task_tx, page_task_parser_boundary_injection_tx, resource_completion_tx, @@ -2212,6 +2213,7 @@ impl ScriptVmDefaultWorldBootstrap { pub(super) fn from_dom_host_with_resource_completion_sender_browser_context_runtime_and_document_isolate( bootstrap_dom_host: DomHost, bypass_content_security_policy: bool, + navigator_overrides: &moli_page_types::NavigatorOverrides, page_task_tx: RuntimePageTaskSender, page_task_parser_boundary_injection_tx: tokio::sync::mpsc::UnboundedSender, resource_completion_tx: RendererResourceCompletionSender, @@ -2229,7 +2231,7 @@ impl ScriptVmDefaultWorldBootstrap { crate::service_worker_runtime::ServiceWorkerClientId, >, ) -> std::result::Result { - ScriptVmPageRealmBootstrap::new_from_dom_host( + let bootstrap = ScriptVmPageRealmBootstrap::new_from_dom_host( bootstrap_dom_host, bypass_content_security_policy, page_task_tx, @@ -2245,8 +2247,12 @@ impl ScriptVmDefaultWorldBootstrap { main_document_commit, top_level_storage_key, reserved_service_worker_client_id, - )? - .bootstrap_default_world() + )?; + bootstrap + .context_host + .borrow_mut() + .set_navigator_overrides(navigator_overrides); + bootstrap.bootstrap_default_world() } fn attach_context_and_capture_baseline_globals( diff --git a/moli-renderer-v8/src/script_vm/document_environment.rs b/moli-renderer-v8/src/script_vm/document_environment.rs index 1a20a4e048..88f3fd5fec 100644 --- a/moli-renderer-v8/src/script_vm/document_environment.rs +++ b/moli-renderer-v8/src/script_vm/document_environment.rs @@ -218,15 +218,6 @@ impl ScriptVm { self._context_host.borrow_mut().set_network_offline(offline); } - pub(crate) fn set_navigator_overrides( - &mut self, - overrides: &moli_page_types::NavigatorOverrides, - ) { - self._context_host - .borrow_mut() - .set_navigator_overrides(overrides); - } - pub(crate) fn set_navigator_overrides_and_sync_surface( &mut self, overrides: &moli_page_types::NavigatorOverrides, diff --git a/moli-renderer-v8/src/script_vm/post_parse.rs b/moli-renderer-v8/src/script_vm/post_parse.rs index c09aab2f68..f96dee0267 100644 --- a/moli-renderer-v8/src/script_vm/post_parse.rs +++ b/moli-renderer-v8/src/script_vm/post_parse.rs @@ -316,7 +316,22 @@ impl ScriptVmContextBootstrap { let owner = owner_context.global(scope); crate::context_bootstrap::bind_isolated_window_history_owner(scope, global, owner); } - finish_context_bootstrap(scope, unsafe { &mut *host_ptr }, &secure_context_url)?; + let binding_owner = match mode { + WindowContextBootstrapMode::MainDefault => unsafe { &*host_ptr } + .current_main_document_task_owner() + .ok_or_else(|| anyhow::anyhow!("main realm requires a current document owner"))?, + WindowContextBootstrapMode::ChildDefault { expected_owner, .. } + | WindowContextBootstrapMode::Isolated { expected_owner, .. } => expected_owner, + }; + let touch_feature_detection = unsafe { &mut *host_ptr } + .init_window_touch_feature_detection(binding_owner) + .ok_or_else(|| anyhow::anyhow!("touch binding owner is no longer current"))?; + finish_context_bootstrap( + scope, + unsafe { &mut *host_ptr }, + &secure_context_url, + touch_feature_detection, + )?; match mode { WindowContextBootstrapMode::Isolated { child_handle: Some(child_handle), diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/platform_compat.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/platform_compat.rs index 67f717118d..9ae67a6f92 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/platform_compat.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc/extracted/platform_compat.rs @@ -400,7 +400,7 @@ fn zhihu_bot_detection_harness_fixture_matches_stable_moli_baseline() { assert_eq!(value["screenTouch"]["orientationType"], "landscape-primary"); assert_eq!(value["screenTouch"]["touchType"], "function"); assert_eq!(value["screenTouch"]["touchEventType"], "function"); - assert_eq!(value["screenTouch"]["ontouchstartType"], "object"); + assert_eq!(value["screenTouch"]["ontouchstartType"], "undefined"); assert_eq!(value["canvasWebgl"]["canvasCtor"], "HTMLCanvasElement"); assert_eq!(value["canvasWebgl"]["canvasTag"], "CANVAS"); @@ -1556,13 +1556,14 @@ fn automation_override_preserves_the_native_webdriver_baseline() { }) .unwrap(); for enabled in [false, true, false] { - vm.set_navigator_overrides(&moli_page_types::NavigatorOverrides { + vm.set_navigator_overrides_and_sync_surface(&moli_page_types::NavigatorOverrides { queries: moli_page_types::NavigatorQueryOverrides { automation: enabled, ..Default::default() }, ..Default::default() - }); + }) + .unwrap(); assert_eq!( vm.eval("navigator.webdriver").unwrap(), (baseline || enabled).to_string() diff --git a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/legacy_element_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/legacy_element_surfaces.rs index c3deb6067f..a20553d216 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/legacy_element_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_elements/live_document/legacy_element_surfaces.rs @@ -730,13 +730,21 @@ fn global_touch_event_handlers_live_on_mixin_owners() { "https://global-touch-event-handlers.test/", "", ); + vm.set_navigator_overrides_and_sync_surface(&moli_page_types::NavigatorOverrides { + max_touch_points: Some(5), + ..Default::default() + }) + .unwrap(); let result = vm .eval( r#" (() => { + const frame = document.createElement('iframe'); + document.body.appendChild(frame); + return frame.contentWindow.eval('(' + function() { const names = ['ontouchstart', 'ontouchend', 'ontouchmove', 'ontouchcancel']; - const owners = [window, HTMLElement.prototype, SVGElement.prototype, Document.prototype]; + const owners = [window, HTMLElement.prototype, SVGElement.prototype, MathMLElement.prototype, Document.prototype]; const descriptorIsEventHandler = (owner, name) => { const descriptor = Object.getOwnPropertyDescriptor(owner, name); return !!descriptor && @@ -763,6 +771,7 @@ fn global_touch_event_handlers_live_on_mixin_owners() { } return JSON.stringify({ ownAccessors, absentFromElement, calls }); + }.toString() + ')()'); })() "#, ) @@ -774,6 +783,20 @@ fn global_touch_event_handlers_live_on_mixin_owners() { ); } +#[test] +fn desktop_touch_feature_detection_does_not_hide_event_constructors() { + let mut vm = new_parsed_test_vm( + "https://desktop-touch.test/", + "", + ); + assert_eq!(vm.eval(r#"(() => { + const names = ['ontouchstart', 'ontouchend', 'ontouchmove', 'ontouchcancel']; + const owners = [window, Document.prototype, HTMLElement.prototype, SVGElement.prototype, MathMLElement.prototype]; + return names.every(name => owners.every(owner => !(name in owner))) && + new TouchEvent('touchstart') instanceof TouchEvent && typeof Touch === 'function'; + })()"#).unwrap(), "true"); +} + #[test] fn media_accessors_live_on_owner_prototypes() { let mut vm = new_parsed_test_vm(