fix(fullscreen): expose unsupported request failures

This commit is contained in:
ldm0
2026-10-02 03:27:48 +08:00
parent 4818417cb0
commit d678ba2a43
11 changed files with 459 additions and 49 deletions
@@ -2718,10 +2718,7 @@ focus/iframe-focus-with-same-as-top-intermediate-frame.html
font-access/permissions-policy/local-fonts-supported-by-permissions-policy.html
fullscreen/api/document-exit-fullscreen-active-document.html
fullscreen/api/document-fullscreen-enabled.html
fullscreen/api/document-onfullscreenerror.html
fullscreen/api/element-request-fullscreen-active-document.html
fullscreen/api/element-request-fullscreen-not-allowed.html
fullscreen/api/promises-reject.html
gyroscope/Gyroscope-supported-by-permissions-policy.html
hr-time/test_cross_frame_start.html
html/browsers/browsing-the-web/back-forward-cache/eligibility/dedicated-worker.html
@@ -5562,7 +5562,10 @@ focus/iframe-focuses-parent-same-site.html
focus/scroll-matches-focus.html
focus/synthetic-focus-event-crash.html
fullscreen/api/document-fullscreen-enabled-active-document.html
fullscreen/api/document-onfullscreenerror.html
fullscreen/api/element-request-fullscreen-not-allowed.html
fullscreen/api/historical.html
fullscreen/api/promises-reject.html
fullscreen/crashtests/backdrop-list-item.html
fullscreen/rendering/fullscreen-pseudo-class-support.html
geolocation/non-secure-contexts.http.html
@@ -120,6 +120,12 @@ struct DocumentPrototypeRuntimeDeclaration {
setter = document_fullscreen_enabled_lenient_setter
)]
fullscreen_enabled: (),
#[webapi(
method = "exitFullscreen",
length = 0,
callback = native_bridge::fullscreen::document_exit_fullscreen_callback
)]
exit_fullscreen: (),
#[webapi(
accessor_property = "pointerLockElement",
getter = native_bridge::pointer_lock::document_pointer_lock_element_getter
+17 -9
View File
@@ -263,6 +263,7 @@ pub(crate) use details_dialog::{
};
use event_handlers::install_body_or_frameset_window_event_handler_accessors;
use event_handlers::install_global_event_handler_template_bindings as install_global_event_handler_templates_for_owner;
use event_handlers::install_node_event_handler_template_bindings;
pub(crate) use event_handlers::{
EventAttributeHandlerScope, GlobalEventHandlerOwner,
body_or_frameset_reflects_window_event_type, canonical_event_handler_event_type,
@@ -1481,15 +1482,22 @@ pub(crate) fn install_element_template_bindings<'s>(
}
match interface_name {
"Element" => install!(
ElementAriaStringReflectionDeclaration,
ElementAriaElementReflectionDeclaration,
ElementPrototypeReflectionDeclaration,
ElementPrototypeQueryAndAttributeMethodsDeclaration,
ExtendedElementPrototypeMethodsDeclaration,
ElementGeometryPrototypeDeclaration,
ElementStylePrototypeDeclaration,
),
"Element" => {
install!(
ElementAriaStringReflectionDeclaration,
ElementAriaElementReflectionDeclaration,
ElementPrototypeReflectionDeclaration,
ElementPrototypeQueryAndAttributeMethodsDeclaration,
ExtendedElementPrototypeMethodsDeclaration,
ElementGeometryPrototypeDeclaration,
ElementStylePrototypeDeclaration,
);
install_node_event_handler_template_bindings(
scope,
prototype,
&["onfullscreenchange", "onfullscreenerror"],
);
}
"Document" => install!(DocumentCustomElementRegistryPrototypeDeclaration),
"HTMLElement" => install!(
ElementStylePrototypeDeclaration,
@@ -159,6 +159,13 @@ pub(super) struct ElementPrototypeReflectionDeclaration {
callback = super::super::pointer_lock::element_request_pointer_lock_callback
)]
request_pointer_lock: (),
#[webapi(
method = "requestFullscreen",
length = 0,
enumerable,
callback = super::fullscreen::element_request_fullscreen_callback
)]
request_fullscreen: (),
#[webapi(accessor_property = "shadowRoot", enumerable, getter = element_shadow_root_getter_function)]
shadow_root: (),
#[webapi(
@@ -120,6 +120,8 @@ pub(crate) const GENERIC_EVENT_HANDLER_PROPERTIES: &[&str] = &[
const DOCUMENT_EVENT_HANDLER_PROPERTIES: &[&str] = &[
"onfreeze",
"onfullscreenchange",
"onfullscreenerror",
"onpointerlockchange",
"onpointerlockerror",
"onreadystatechange",
@@ -169,44 +171,68 @@ 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);
}
prototype.set_accessor_property(
v8str(scope, name).into(),
Some(getter),
Some(setter),
v8::PropertyAttribute::NONE,
install_event_handler_template_binding(scope, prototype, owner, name);
}
}
pub(crate) fn install_node_event_handler_template_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
prototype: v8::Local<'s, v8::ObjectTemplate>,
names: &[&'static str],
) {
for name in names {
install_event_handler_template_binding(
scope,
prototype,
GlobalEventHandlerOwner::Element,
name,
);
}
}
fn install_event_handler_template_binding<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
prototype: v8::Local<'s, v8::ObjectTemplate>,
owner: GlobalEventHandlerOwner,
name: &'static str,
) {
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);
}
prototype.set_accessor_property(
v8str(scope, name).into(),
Some(getter),
Some(setter),
v8::PropertyAttribute::NONE,
);
}
fn event_handler_property_value_for_target<'s>(
scope: &mut v8::PinScope<'s, '_>,
runtime_ptr: *mut super::super::super::JsContextHost,
@@ -328,7 +354,9 @@ pub(crate) fn node_event_handler_getter_function<'s>(
rv.set(current);
return;
}
if !handler_name.starts_with("on") {
if !handler_name.starts_with("on")
|| !is_element_event_handler_content_attribute_name(&handler_name)
{
rv.set_null();
return;
}
@@ -562,4 +590,3 @@ fn handle_invalid_event_handler_receiver<'s>(
throw_type_error(scope, "Illegal invocation");
}
}
@@ -12,7 +12,8 @@ pub(super) use generic::is_element_event_handler_content_attribute_name;
pub(crate) use generic::{
GlobalEventHandlerOwner, canonical_event_handler_event_type,
event_handler_content_attribute_name, install_global_event_handler_template_bindings,
node_event_handler_getter_function, node_event_handler_setter_function,
install_node_event_handler_template_bindings, node_event_handler_getter_function,
node_event_handler_setter_function,
};
pub(crate) use shared::{EventAttributeHandlerScope, compile_event_attribute_handler_for_owner};
@@ -0,0 +1,231 @@
use super::{
JsContextHost,
element::{construct_simple_event, dispatch_public_event},
node::{
node_is_document, node_runtime_and_handle_from_args_or_detached,
node_runtime_and_handle_from_object_or_detached, require_element_method_receiver,
throw_incompatible_method_receiver,
},
};
use crate::{
document_runtime::DomHandle,
host::HostTimerOwner,
util::{context_host_ptr_from_global_bridge, v8_string},
webidl,
};
#[derive(Clone, Copy, Default, webidl::WebIdlEnum)]
#[webidl(name = "FullscreenKeyboardLock")]
enum FullscreenKeyboardLock {
#[webidl(token = "browser")]
Browser,
#[default]
#[webidl(token = "none")]
None,
}
#[derive(Clone, Copy, Default, webidl::WebIdlEnum)]
#[webidl(name = "FullscreenNavigationUI")]
enum FullscreenNavigationUi {
#[default]
#[webidl(token = "auto")]
Auto,
#[webidl(token = "show")]
Show,
#[webidl(token = "hide")]
Hide,
}
#[derive(Default, webidl::WebIdlDictionary)]
#[webidl(prefix = "FullscreenOptions")]
struct FullscreenOptions {
#[webidl(
name = "keyboardLock",
converter = "enum",
default = FullscreenKeyboardLock::None
)]
keyboard_lock: FullscreenKeyboardLock,
#[webidl(
name = "navigationUI",
converter = "enum",
default = FullscreenNavigationUi::Auto
)]
navigation_ui: FullscreenNavigationUi,
}
pub(crate) fn element_request_fullscreen_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let Ok((runtime_ptr, element)) = node_runtime_and_handle_from_args_or_detached(scope, &args)
else {
throw_incompatible_method_receiver(scope, "Element", "requestFullscreen");
return;
};
let runtime = unsafe { &*runtime_ptr };
if !require_element_method_receiver(scope, runtime, element, "requestFullscreen") {
return;
}
let Some(resolver) = v8::PromiseResolver::new(scope) else {
return;
};
rv.set(resolver.get_promise(scope).into());
let options = match fullscreen_options_for_promise(scope, &args) {
Ok(options) => options,
Err(reason) => {
let _ = resolver.reject(scope, reason);
return;
}
};
// Dictionary conversion is observable even though this headless platform
// deliberately reports that fullscreen is unsupported.
let _ = (options.keyboard_lock, options.navigation_ui);
let active_document = runtime.document_handle();
let Some(owner_document) = runtime.dom_host().owner_document_handle(element) else {
reject_fullscreen_promise(scope, resolver, "Element has no node document.");
return;
};
if owner_document != active_document {
reject_fullscreen_promise(scope, resolver, "Document is not fully active.");
return;
}
queue_fullscreen_error_event(scope, runtime_ptr, element, owner_document);
reject_fullscreen_promise(scope, resolver, "Fullscreen is not supported.");
}
pub(crate) fn document_exit_fullscreen_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
let Ok((runtime_ptr, document)) =
node_runtime_and_handle_from_object_or_detached(scope, args.this())
else {
throw_incompatible_method_receiver(scope, "Document", "exitFullscreen");
return;
};
if !node_is_document(unsafe { &*runtime_ptr }, document) {
throw_incompatible_method_receiver(scope, "Document", "exitFullscreen");
return;
}
let Some(resolver) = v8::PromiseResolver::new(scope) else {
return;
};
rv.set(resolver.get_promise(scope).into());
reject_fullscreen_promise(scope, resolver, "Document is not in fullscreen.");
}
fn fullscreen_options<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: &v8::FunctionCallbackArguments<'s>,
) -> Result<FullscreenOptions, webidl::WebIdlError> {
let context = webidl::Context::argument("Element.requestFullscreen", 1);
webidl::dictionary_arg(args, 0, context)?
.map(|object| webidl::parse_dictionary_object(scope, object))
.transpose()
.map(|options| options.unwrap_or_default())
}
fn fullscreen_options_for_promise<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: &v8::FunctionCallbackArguments<'s>,
) -> Result<FullscreenOptions, v8::Local<'s, v8::Value>> {
let try_catch = std::pin::pin!(v8::TryCatch::new(scope));
let mut scope = try_catch.init();
match fullscreen_options(&mut scope, args) {
Ok(options) => Ok(options),
Err(error) if error.is_pending_exception() => Err(scope
.exception()
.unwrap_or_else(|| v8::undefined(&scope).into())),
Err(error) => {
let message =
v8_string(&scope, &error.to_string()).unwrap_or_else(|| v8::String::empty(&scope));
Err(v8::Exception::type_error(&scope, message))
}
}
}
fn queue_fullscreen_error_event(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
element: DomHandle,
document: DomHandle,
) {
let data = v8::Array::new(scope, 2);
let element = v8::BigInt::new_from_u64(scope, element.index() as u64);
let document = v8::BigInt::new_from_u64(scope, document.index() as u64);
if data.set_index(scope, 0, element.into()) != Some(true)
|| data.set_index(scope, 1, document.into()) != Some(true)
{
return;
}
let Some(callback) = v8::Function::builder(queued_fullscreen_error_event_callback)
.data(data.into())
.build(scope)
else {
return;
};
let _ = unsafe { &mut *runtime_ptr }.queue_timeout(
scope,
callback,
0,
HostTimerOwner::Window,
Vec::new(),
);
}
fn queued_fullscreen_error_event_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(runtime_ptr) = context_host_ptr_from_global_bridge(scope) else {
return;
};
let Some((element, document)) = fullscreen_error_event_task_data(scope, args.data()) else {
return;
};
let runtime = unsafe { &*runtime_ptr };
let target = if runtime.dom_host().owner_document_handle(element) == Some(document)
&& runtime.dom_host().is_connected_to_document(element)
{
element
} else {
document
};
if let Some(event) = construct_simple_event(scope, "fullscreenerror", true, false, true) {
let _ = dispatch_public_event(scope, runtime_ptr, target, event);
}
}
fn fullscreen_error_event_task_data(
scope: &mut v8::PinScope<'_, '_>,
value: v8::Local<'_, v8::Value>,
) -> Option<(DomHandle, DomHandle)> {
let data = v8::Local::<v8::Array>::try_from(value).ok()?;
Some((
dom_handle_from_value(data.get_index(scope, 0)?)?,
dom_handle_from_value(data.get_index(scope, 1)?)?,
))
}
fn reject_fullscreen_promise(
scope: &mut v8::PinScope<'_, '_>,
resolver: v8::Local<'_, v8::PromiseResolver>,
message: &str,
) {
let error = v8_string(scope, message)
.map(|message| v8::Exception::type_error(scope, message))
.unwrap_or_else(|| v8::undefined(scope).into());
let _ = resolver.reject(scope, error);
}
fn dom_handle_from_value(value: v8::Local<'_, v8::Value>) -> Option<DomHandle> {
let value = v8::Local::<v8::BigInt>::try_from(value).ok()?;
let (index, lossless) = value.u64_value();
lossless.then(|| DomHandle::new(index as usize))
}
@@ -15,6 +15,7 @@ pub(crate) use current_input::{
};
pub(crate) mod document;
pub(crate) mod element;
pub(crate) mod fullscreen;
mod helpers;
mod history_queue;
pub(crate) mod history_traversal;
@@ -0,0 +1,128 @@
use super::*;
#[tokio::test]
async fn unsupported_fullscreen_requests_reject_and_queue_spec_shaped_error_events() {
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");
let mut vm = new_parsed_test_vm(
"https://fullscreen-unsupported.test/",
"<!doctype html><html><body><div></div></body></html>",
);
let before = vm
.eval(
r#"
(() => {
globalThis.__fullscreenEvents = [];
globalThis.__fullscreenRejections = [];
globalThis.__fullscreenConversionRejections = [];
globalThis.__fullscreenOptionReads = [];
document.body.setAttribute("onfullscreenerror", "globalThis.__fullscreenAttributeRan = true");
const contentAttributeIgnored = document.body.onfullscreenerror === null;
document.onfullscreenerror = event => {
__fullscreenEvents.push([
event.type,
event.target === document.body,
event.bubbles,
event.cancelable,
event.composed
].join(":"));
};
const options = {
get keyboardLock() {
__fullscreenOptionReads.push("keyboardLock");
return "none";
},
get navigationUI() {
__fullscreenOptionReads.push("navigationUI");
return "hide";
}
};
const request = document.body.requestFullscreen(options);
const exit = document.exitFullscreen();
request.catch(error => __fullscreenRejections.push(`request:${error.name}`));
exit.catch(error => __fullscreenRejections.push(`exit:${error.name}`));
const getterError = new RangeError("navigationUI getter failed");
const conversionInputs = [
["enum", { navigationUI: "invalid-value" }, null],
["string", "foo", null],
["number", 123, null],
["getter", {
get navigationUI() {
throw getterError;
}
}, getterError]
];
const conversionPromiseShapes = conversionInputs.map(([label, input, expected]) => {
try {
const promise = document.body.requestFullscreen(input);
promise.catch(error => __fullscreenConversionRejections.push(
`${label}:${error.name}:${expected === null || error === expected}`
));
return promise instanceof Promise;
} catch (error) {
return `throw:${error.name}`;
}
});
const outcome = callback => {
try {
callback();
return "return";
} catch (error) {
return error.name;
}
};
return JSON.stringify({
requestShape: [
typeof Element.prototype.requestFullscreen,
Element.prototype.requestFullscreen.length,
Object.hasOwn(Element.prototype, "onfullscreenchange"),
Object.hasOwn(Element.prototype, "onfullscreenerror")
],
documentShape: [
typeof Document.prototype.exitFullscreen,
Document.prototype.exitFullscreen.length,
Object.hasOwn(Document.prototype, "onfullscreenchange"),
Object.hasOwn(Document.prototype, "onfullscreenerror"),
document.fullscreenEnabled
],
promises: request instanceof Promise && exit instanceof Promise,
conversionPromiseShapes,
contentAttributeIgnored,
optionReads: __fullscreenOptionReads,
wrongReceivers: [
outcome(() => Element.prototype.requestFullscreen.call({})),
outcome(() => Document.prototype.exitFullscreen.call({}))
],
events: __fullscreenEvents.length
});
})()
"#,
)
.expect("fullscreen rejection setup should evaluate");
assert_eq!(
before,
r#"{"requestShape":["function",0,true,true],"documentShape":["function",0,true,true,false],"promises":true,"conversionPromiseShapes":[true,true,true,true],"contentAttributeIgnored":true,"optionReads":["keyboardLock","navigationUI"],"wrongReceivers":["TypeError","TypeError"],"events":0}"#,
);
vm.advance_timers_until_deadline_for_test(&loader)
.await
.expect("fullscreen error task should drain");
let after = vm
.eval(
r#"
JSON.stringify({
events: __fullscreenEvents,
rejections: __fullscreenRejections.sort(),
conversionRejections: __fullscreenConversionRejections.sort(),
contentAttributeRan: globalThis.__fullscreenAttributeRan === true
})
"#,
)
.expect("fullscreen rejection result should evaluate");
assert_eq!(
after,
r#"{"events":["fullscreenerror:true:true:false:true"],"rejections":["exit:TypeError","request:TypeError"],"conversionRejections":["enum:TypeError:true","getter:RangeError:true","number:TypeError:true","string:TypeError:true"],"contentAttributeRan":false}"#,
);
}
@@ -20,6 +20,7 @@ mod event_handlers;
mod event_listener_options;
mod events_selection_storage;
mod fontface_descriptors;
mod fullscreen;
mod gamepad;
mod history_worlds;
mod ice_candidate;