feat(media): bind native PictureInPicture event payloads

This commit is contained in:
ldm0
2026-10-12 22:41:13 +08:00
committed by Donough Liu
parent 02eee0e4f8
commit 6e78b7cd8b
11 changed files with 401 additions and 2 deletions
@@ -28,6 +28,9 @@ mod form_data_runtime;
mod geometry_runtime;
mod history_mutation;
mod history_runtime;
mod picture_in_picture;
#[cfg(test)]
pub(crate) use picture_in_picture::closed_window_for_test as closed_picture_in_picture_window_for_test;
mod time_ranges;
pub(crate) mod web_mcp;
pub(crate) use history_runtime::state::bind_isolated_window_history_owner;
@@ -115,6 +115,9 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>(
}
ConstructorKind::BlobEvent => build_value_event_template(scope, ValueEventKind::Blob),
ConstructorKind::GamepadEvent => build_value_event_template(scope, ValueEventKind::Gamepad),
ConstructorKind::PictureInPictureEvent => {
build_value_event_template(scope, ValueEventKind::PictureInPicture)
}
ConstructorKind::WebGLContextEvent => {
build_value_event_template(scope, ValueEventKind::WebGlContext)
}
@@ -426,6 +426,7 @@ pub(super) fn install_constructor_template_bindings<'s>(
}
install_media_cue_template_bindings(scope, template, spec.interface.name());
install_media_source_template_bindings(scope, template, spec.interface.name());
super::super::picture_in_picture::install(scope, template, spec.interface.name());
super::super::video_codecs::install_video_codec_template_bindings(
scope,
template,
@@ -19,6 +19,7 @@ const STATUS_MESSAGE_SLOT: &str = "__moliWebGlContextEventStatusMessage";
const TIMECODE_SLOT: &str = "__moliValueEventTimecode";
const TOOL_NAME_SLOT: &str = "__moliToolEventName";
const GAMEPAD_SLOT: &str = "__moliValueEventGamepad";
const PICTURE_IN_PICTURE_WINDOW_SLOT: &str = "__moliPictureInPictureEventWindow";
#[derive(Clone, Copy)]
pub(in crate::context_bootstrap) enum ValueEventKind {
@@ -29,6 +30,7 @@ pub(in crate::context_bootstrap) enum ValueEventKind {
ToolActivated,
ToolCancel,
Gamepad,
PictureInPicture,
}
impl ValueEventKind {
@@ -41,12 +43,13 @@ impl ValueEventKind {
Self::ToolActivated => "ToolActivatedEvent",
Self::ToolCancel => "ToolCancelEvent",
Self::Gamepad => "GamepadEvent",
Self::PictureInPicture => "PictureInPictureEvent",
}
}
fn length(self) -> i32 {
match self {
Self::Blob => 2,
Self::Blob | Self::PictureInPicture => 2,
_ => 1,
}
}
@@ -200,6 +203,22 @@ struct BlobEventInit<'s> {
timecode: Option<f64>,
}
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::PictureInPictureEvent, enumerable, receiver)]
struct PictureInPictureEventPrototype {
#[webapi(accessor_property, getter = payload_getter, data = v8str(scope, PICTURE_IN_PICTURE_WINDOW_SLOT))]
picture_in_picture_window: (),
}
#[derive(webidl::WebIdlDictionary)]
#[webidl(prefix = "PictureInPictureEventInit")]
struct PictureInPictureEventInit<'s> {
#[webidl(inherit)]
base: EventInit,
#[webidl(required, interface = web_api_interfaces::PictureInPictureWindow)]
picture_in_picture_window: v8::Local<'s, v8::Object>,
}
fn string_member<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
@@ -240,6 +259,9 @@ pub(in crate::context_bootstrap) fn install_value_event_template_bindings<'s>(
"ToolCancelEvent" => {
ToolCancelEventPrototypeDeclaration::initialize_prototype_template(scope, prototype)
}
"PictureInPictureEvent" => {
PictureInPictureEventPrototype::initialize_prototype_template(scope, prototype)
}
"GamepadEvent" => {
GamepadEventPrototypeDeclaration::initialize_prototype_template(scope, prototype)
}
@@ -253,7 +275,9 @@ fn payload_getter<'s>(
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let key = args.data().to_rust_string_lossy(scope);
if let Some(value) = event_private_value(scope, args.this(), &key) {
let target = moli_webapi_declare::web_api_object_target(scope, args.this())
.expect("validated value event receiver");
if let Some(value) = event_private_value(scope, target, &key) {
rv.set(value);
}
}
@@ -281,6 +305,7 @@ fn value_event_constructor<'s>(
Some(4) => ValueEventKind::ToolActivated,
Some(5) => ValueEventKind::ToolCancel,
Some(6) => ValueEventKind::Gamepad,
Some(7) => ValueEventKind::PictureInPicture,
_ => return,
};
if !args.is_construct_call() {
@@ -386,6 +411,22 @@ fn value_event_constructor<'s>(
parsed.base.composed,
)
}
ValueEventKind::PictureInPicture => {
let parsed = webidl::parse_dictionary_object::<PictureInPictureEventInit>(
scope, dictionary,
)?;
set_event_private_value(
scope,
state,
PICTURE_IN_PICTURE_WINDOW_SLOT,
parsed.picture_in_picture_window.into(),
);
(
parsed.base.bubbles,
parsed.base.cancelable,
parsed.base.composed,
)
}
ValueEventKind::Gamepad => {
let parsed =
webidl::parse_dictionary_object::<GamepadEventInit>(scope, dictionary)?;
@@ -0,0 +1,106 @@
//! Picture-in-Picture interface members. Opening a native window still requires
//! a presentation backend; these bindings do not manufacture a successful request.
use super::media_queries;
use crate::{
util::{get_private_value, set_private_value, v8str},
web_api_interfaces,
};
use moli_webapi_declare::WebApiFunctionTemplate;
const WIDTH: &str = "__moliPictureInPictureWindowWidth";
const HEIGHT: &str = "__moliPictureInPictureWindowHeight";
const ON_RESIZE: &str = "__moliPictureInPictureWindowOnresize";
const LISTENERS: &str = "__moliPictureInPictureWindowListeners";
#[derive(WebApiFunctionTemplate)]
#[webapi(interface = web_api_interfaces::PictureInPictureWindow, enumerable, receiver)]
struct PictureInPictureWindowPrototype {
#[webapi(accessor_property, getter = dimension_getter, data = v8str(scope, WIDTH))]
width: (),
#[webapi(accessor_property, getter = dimension_getter, data = v8str(scope, HEIGHT))]
height: (),
#[webapi(accessor_property, getter = resize_handler_getter, setter = resize_handler_setter)]
onresize: (),
}
pub(super) fn install<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
name: &str,
) {
if name == "PictureInPictureWindow" {
PictureInPictureWindowPrototype::initialize_prototype_template(
scope,
template.prototype_template(scope),
);
}
}
fn target<'s>(
scope: &mut v8::PinScope<'s, '_>,
receiver: v8::Local<'s, v8::Object>,
) -> v8::Local<'s, v8::Object> {
moli_webapi_declare::web_api_object_target(scope, receiver)
.expect("PictureInPictureWindow receiver was validated")
}
fn dimension_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let window = target(scope, args.this());
let slot = args.data().to_rust_string_lossy(scope);
// Closed windows have zero dimensions. No presentation backend currently
// produces an opened PictureInPictureWindow.
rv.set(
get_private_value(scope, window, &slot)
.unwrap_or_else(|| v8::Integer::new(scope, 0).into()),
);
}
fn resize_handler_getter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let window = target(scope, args.this());
rv.set(get_private_value(scope, window, ON_RESIZE).unwrap_or_else(|| v8::null(scope).into()));
}
fn resize_handler_setter<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
_rv: v8::ReturnValue<'_, v8::Value>,
) {
let window = target(scope, args.this());
let active = args.get(0).is_object();
let handler = if active {
args.get(0)
} else {
v8::null(scope).into()
};
set_private_value(scope, window, ON_RESIZE, handler);
media_queries::simple_object_event_set_ordered_handler(
scope, window, LISTENERS, "resize", ON_RESIZE, active,
);
}
/// A closed native window for binding tests, never a production PiP request.
#[cfg(test)]
pub(crate) fn closed_window_for_test<'s>(
scope: &mut v8::PinScope<'s, '_>,
) -> v8::Local<'s, v8::Object> {
#[derive(Default, moli_webapi_declare::WebApiObject)]
#[webapi(interface = web_api_interfaces::PictureInPictureWindow)]
struct ClosedWindow {
#[webapi(slot = super::shared::SIMPLE_EVENT_TARGET_SLOT, value = LISTENERS)]
event_target: (),
#[webapi(slot = super::shared::SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)]
ordered_handlers: (),
}
ClosedWindow::default()
.bind(scope)
.expect("closed native PiP window")
}
@@ -4,6 +4,14 @@ use crate::web_api_interfaces;
use std::collections::HashSet;
const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[
ConstructorSpec {
interface: web_api_interfaces::PictureInPictureEvent::DESCRIPTOR,
kind: ConstructorKind::PictureInPictureEvent,
},
ConstructorSpec {
interface: web_api_interfaces::PictureInPictureWindow::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
ConstructorSpec {
interface: web_api_interfaces::AuthenticatorAssertionResponse::DESCRIPTOR,
kind: ConstructorKind::Illegal,
@@ -14,6 +14,7 @@ pub(in crate::context_bootstrap) enum ConstructorKind {
ToolActivatedEvent,
ToolCancelEvent,
GamepadEvent,
PictureInPictureEvent,
UiEvent,
FocusEvent,
CompositionEvent,
@@ -1,5 +1,6 @@
mod credential_container;
mod credential_json_options;
mod picture_in_picture_interfaces;
mod writable_stream_native_receivers;
use super::post_parse::dynamic_script_execute_is_runnable_before_dom_content_loaded;
use super::{
@@ -0,0 +1,99 @@
(() => {
const checks = [];
function check(name, action) {
try { checks.push({name, passed: action() === true}); }
catch (error) { checks.push({name, passed: false, error: String(error)}); }
}
const caught = action => { try { action(); return null; } catch (error) { return error; } };
const frame = document.createElement('iframe');
document.body.appendChild(frame);
// Standalone lightweight popup shells do not provide a full Window realm.
// Exercise the root and child realms that install native interface templates.
const owners = [window, frame.contentWindow];
try {
for (const [realm, owner] of owners.entries()) {
const Window = owner.PictureInPictureWindow;
const Event = owner.PictureInPictureEvent;
const typeError = error => error !== null && Object.getPrototypeOf(error) === owner.TypeError.prototype;
check(`${realm}/Window constructor and inheritance`, () => Window.name === 'PictureInPictureWindow' && Window.length === 0 &&
Object.getPrototypeOf(Window.prototype) === owner.EventTarget.prototype &&
Object.getOwnPropertyDescriptor(Window.prototype, Symbol.toStringTag).value === 'PictureInPictureWindow');
check(`${realm}/event constructor and inheritance`, () => Event.name === 'PictureInPictureEvent' && Event.length === 2 &&
Object.getPrototypeOf(Event.prototype) === owner.Event.prototype &&
Object.getOwnPropertyDescriptor(Event.prototype, Symbol.toStringTag).value === 'PictureInPictureEvent');
let traps = 0;
const proxy = new Proxy({}, {get() { traps++; throw Error('get trap'); }, getPrototypeOf() { traps++; throw Error('prototype trap'); }});
const revoked = Proxy.revocable({}, {}); revoked.revoke();
const invalidReceivers = [undefined, null, {}, new owner.EventTarget(), new owner.Event('x'), Window.prototype,
Object.create(Window.prototype), Object.create(Event.prototype), proxy, revoked.proxy];
for (const [name, prototype, setter] of [['width', Window.prototype, false], ['height', Window.prototype, false],
['onresize', Window.prototype, true], ['pictureInPictureWindow', Event.prototype, false]]) {
const descriptor = Object.getOwnPropertyDescriptor(prototype, name);
check(`${realm}/${name}/own enumerable configurable accessor`, () => !!descriptor && descriptor.enumerable && descriptor.configurable &&
typeof descriptor.get === 'function' && descriptor.get.length === 0 &&
(setter ? typeof descriptor.set === 'function' && descriptor.set.length === 1 : descriptor.set === undefined));
for (const [index, receiver] of invalidReceivers.entries()) {
check(`${realm}/${name}/invalid getter receiver ${index}`, () => typeError(caught(() => descriptor.get.call(receiver))));
if (setter) {
check(`${realm}/${name}/invalid setter receiver ${index}`, () => {
let conversions = 0;
const value = {toString() { conversions++; throw Error('unexpected conversion'); }};
return typeError(caught(() => descriptor.set.call(receiver, value))) && conversions === 0;
});
}
}
}
for (const construct of [false, true]) {
check(`${realm}/Window illegal constructor/${construct}`, () => {
let conversions = 0;
const value = {toString() { conversions++; throw Error('unexpected conversion'); }};
const error = caught(() => construct ? new Window(value) : Window(value));
return typeError(error) && conversions === 0;
});
}
for (const count of [0, 1]) {
check(`${realm}/event requires two arguments before type conversion/${count}`, () => {
let conversions = 0;
const value = {toString() { conversions++; throw Error('unexpected conversion'); }};
return typeError(caught(() => Reflect.construct(Event, count ? [value] : []))) && conversions === 0;
});
}
check(`${realm}/event requires new before argument conversion`, () => {
let conversions = 0;
return typeError(caught(() => Event({toString() { conversions++; throw Error('unexpected conversion'); }}, {}))) && conversions === 0;
});
for (const [index, dictionary] of [undefined, null, {}, false, 1, 'x', 1n, Symbol('dictionary')].entries()) {
check(`${realm}/invalid or incomplete dictionary/${index}`, () => typeError(caught(() => new Event('x', dictionary))));
}
for (const [index, value] of [undefined, null, false, 1, 'x', 1n, Symbol('window'), {}, Window.prototype,
Object.create(Window.prototype), Object.create(new owner.EventTarget()), proxy, revoked.proxy].entries()) {
check(`${realm}/required native window brand/${index}`, () => typeError(caught(() => new Event('x', {pictureInPictureWindow: value}))));
}
for (const throwingMember of ['type', 'bubbles', 'cancelable', 'composed', 'pictureInPictureWindow', 'none']) {
check(`${realm}/dictionary conversion order and exception identity/${throwingMember}`, () => {
const log = [], marker = {};
const read = name => {
log.push(name);
if (name === throwingMember) throw marker;
return name === 'type' ? 'resize' : name === 'pictureInPictureWindow' ? {} : false;
};
const type = {toString() { return read('type'); }};
const dictionary = {};
for (const name of ['bubbles', 'cancelable', 'composed', 'pictureInPictureWindow']) {
Object.defineProperty(dictionary, name, {get() { return read(name); }});
}
const error = caught(() => new Event(type, dictionary));
const names = ['type', 'bubbles', 'cancelable', 'composed', 'pictureInPictureWindow'];
const expected = throwingMember === 'none' ? names : names.slice(0, names.indexOf(throwingMember) + 1);
return JSON.stringify(log) === JSON.stringify(expected) && (throwingMember === 'none' ? typeError(error) : error === marker);
});
}
check(`${realm}/author and revoked Proxy brand checks do not invoke traps`, () => traps === 0);
}
} finally {
frame.remove();
}
const facts = {complete: true, total: checks.length, passed: checks.filter(row => row.passed).length, checks};
globalThis.__uiEventResults = facts;
return facts.passed === facts.total;
})()
@@ -0,0 +1,134 @@
use super::*;
#[test]
fn picture_in_picture_interfaces_validate_descriptors_receivers_and_required_dictionary_members() {
let mut vm = new_storage_page_task_executor_test_vm("https://picture-in-picture.test/");
assert_eq!(
vm.eval(include_str!("picture_in_picture_interfaces.js"))
.unwrap(),
"true",
"{}",
vm.eval("JSON.stringify(__uiEventResults.checks.filter(row => !row.passed))")
.unwrap()
);
}
fn install_closed_window(vm: &mut ScriptVm) {
let context_ptr = &vm.page_default_runtime.context as *const _;
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
let global = scope.get_current_context().global(scope);
let window = crate::context_bootstrap::closed_picture_in_picture_window_for_test(scope);
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, window, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
for (name, value) in [
("nativeWindow", window.into()),
("nativeProxy", proxy.into()),
] {
assert_eq!(
global.create_data_property(scope, crate::util::v8str(scope, name).into(), value),
Some(true)
);
}
Ok(())
})
.unwrap();
}
#[test]
fn picture_in_picture_events_retain_native_window_identity_across_realms_and_author_mutations() {
let mut vm = new_storage_page_task_executor_test_vm("https://picture-in-picture-events.test/");
vm.eval("document.body.innerHTML = '<iframe></iframe>'")
.unwrap();
install_closed_window(&mut vm);
assert_eq!(vm.eval(r#"(() => {
const assert = (ok, name) => {if (!ok) throw Error(name)};
const other = document.querySelector('iframe').contentWindow;
const payload = Object.getOwnPropertyDescriptor(other.PictureInPictureEvent.prototype, 'pictureInPictureWindow').get;
const width = Object.getOwnPropertyDescriptor(other.PictureInPictureWindow.prototype, 'width').get;
assert(nativeWindow.width === 0 && nativeWindow.height === 0 && nativeProxy.width === 0, 'closed dimensions');
for (const window of [nativeWindow, nativeProxy]) {
const event = new other.PictureInPictureEvent('enterpictureinpicture', {pictureInPictureWindow: window, bubbles:true, cancelable:true, composed:true});
assert(event instanceof other.PictureInPictureEvent && event instanceof other.Event && !(event instanceof PictureInPictureEvent), 'constructor realm');
assert(event.pictureInPictureWindow === window && event.pictureInPictureWindow === event.pictureInPictureWindow, 'SameObject preserves original argument');
assert(event.type === 'enterpictureinpicture' && event.bubbles && event.cancelable && event.composed && !event.isTrusted, 'inherited EventInit');
Object.defineProperty(event, 'pictureInPictureWindow', {value: {}, configurable:true});
assert(payload.call(event) === window, 'author payload expando does not alter native state');
const proxy = Proxy.revocable(window, {}); proxy.revoke();
let traps = 0;
const author = new Proxy(window, {get() {traps++; throw Error('get')}, getPrototypeOf() {traps++; throw Error('prototype')}});
for (const bad of [Object.create(window), author, proxy.proxy]) {
let error; try {new other.PictureInPictureEvent('x', {pictureInPictureWindow:bad})} catch (caught) {error=caught}
assert(Object.getPrototypeOf(error) === other.TypeError.prototype, 'author wrapper rejected in callee realm');
}
assert(traps === 0, 'native brand does not invoke author traps');
}
class Derived extends other.PictureInPictureEvent {}
const derived = new Derived('x', {pictureInPictureWindow:nativeWindow});
assert(derived instanceof Derived && payload.call(derived) === nativeWindow, 'subclass native brand');
Object.defineProperty(nativeWindow, 'width', {get() {throw Error('public dimension read')}});
Object.setPrototypeOf(nativeWindow, null);
assert(width.call(nativeWindow) === 0 && payload.call(derived) === nativeWindow, 'private identity survives prototype and expando changes');
return true;
})()"#).unwrap(), "true");
}
#[test]
fn picture_in_picture_resize_handlers_share_listener_order_and_cancellation_state() {
let mut vm = new_storage_page_task_executor_test_vm("https://picture-in-picture-resize.test/");
install_closed_window(&mut vm);
assert_eq!(vm.eval(r#"(() => {
const assert = (ok, name) => {if (!ok) throw Error(name)};
const target = nativeWindow, order = [];
const descriptor = Object.getOwnPropertyDescriptor(PictureInPictureWindow.prototype, 'onresize');
assert(target.onresize === null, 'initial handler');
target.addEventListener('resize', event => {order.push('before'); assert(event.target === target, 'native target')});
target.onresize = function(event) {order.push('original'); assert(this === target && event.currentTarget === target, 'callback this and currentTarget')};
target.addEventListener('resize', () => order.push('after'));
nativeProxy.onresize = function(event) {order.push('replacement'); assert(this === target, 'native proxy updates same handler'); return false};
const event = new Event('resize', {cancelable:true});
assert(target.dispatchEvent(event) === false && event.defaultPrevented, 'handler cancellation');
assert(order.join() === 'before,replacement,after' && target.onresize === nativeProxy.onresize, 'replacement preserves listener position');
for (const value of [undefined, null, false, 3, 'x', 1n, Symbol('handler')]) {
descriptor.set.call(target, value);
assert(target.onresize === null, 'primitive handler becomes null');
}
order.length = 0;
descriptor.set.call(nativeProxy, () => order.push('last'));
assert(target.dispatchEvent(new Event('resize')) && order.join() === 'before,after,last', 'cleared handler is appended when set again');
assert(event.currentTarget === null && target.width === 0 && target.height === 0, 'dispatch does not open a window');
return true;
})()"#).unwrap(), "true");
}
#[test]
fn picture_in_picture_native_event_proxies_read_the_original_private_payload() {
let mut vm =
new_storage_page_task_executor_test_vm("https://picture-in-picture-event-proxy.test/");
install_closed_window(&mut vm);
vm.eval("globalThis.nativeEvent = new PictureInPictureEvent('enterpictureinpicture', {pictureInPictureWindow:nativeWindow})").unwrap();
let context_ptr = &vm.page_default_runtime.context as *const _;
vm.with_context_scope_by_ptr_and_checkpoint_for_test(context_ptr, |scope, _| {
let global = scope.get_current_context().global(scope);
let key = crate::util::v8str(scope, "nativeEvent");
let event =
v8::Local::<v8::Object>::try_from(global.get(scope, key.into()).unwrap()).unwrap();
let handler = crate::util::new_null_prototype_object(scope);
let proxy = v8::Proxy::new(scope, event, handler).unwrap();
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
let key = crate::util::v8str(scope, "eventProxy");
assert_eq!(
global.create_data_property(scope, key.into(), proxy.into()),
Some(true)
);
Ok(())
})
.unwrap();
assert_eq!(vm.eval(r#"(() => {
const get = Object.getOwnPropertyDescriptor(PictureInPictureEvent.prototype, 'pictureInPictureWindow').get;
if (get.call(eventProxy) !== nativeWindow) throw Error('native event Proxy payload');
let error; try {get.call(new Proxy(eventProxy, {}))} catch(caught) {error=caught}
if (!(error instanceof TypeError)) throw Error('author event Proxy accepted');
return true;
})()"#).unwrap(), "true");
}
@@ -192,6 +192,8 @@ interfaces! {
Gamepad;
GamepadButton;
GamepadEvent: Event;
PictureInPictureEvent: Event;
PictureInPictureWindow: EventTarget;
GamepadHapticActuator;
HTMLAllCollection;
HTMLAnchorElement: HTMLElement;