From 14b732c98ac6d9662ba2f31f66807ed598d67496 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 8 Sep 2026 04:05:53 +0800 Subject: [PATCH] fix(media): expose branded MediaDevices enumeration on the prototype --- .../exposed_interfaces/metadata.rs | 1 + .../context_bootstrap/navigator_runtime.rs | 1 + .../navigator_runtime/media_devices.rs | 145 ++++++++++++++++++ .../navigator_runtime/navigator.rs | 29 +--- .../src/context_bootstrap/window_runtime.rs | 1 - .../window_runtime/navigator.rs | 18 --- .../src/script_vm/tests/browser_api/media.rs | 2 +- .../tests/browser_api/media_devices.rs | 112 ++++++++++++++ .../src/script_vm/tests/browser_api/misc.rs | 6 +- .../src/script_vm/tests/browser_api/mod.rs | 1 + 10 files changed, 272 insertions(+), 44 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/media_devices.rs diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs index c4c903393..c0e4d8542 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -81,6 +81,7 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = ]; const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ + "MediaDevices", "SubtleCrypto", "CryptoKey", "IdleDetector", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index d9a38ec8a..84732e140 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -2,6 +2,7 @@ mod clipboard; mod collections; mod geolocation; mod media_capabilities; +mod media_devices; mod navigator; mod navigator_subobjects; mod screen; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs new file mode 100644 index 000000000..189a1f2be --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs @@ -0,0 +1,145 @@ +use super::super::window_runtime::{ + MEDIA_DEVICES_BRAND_SLOT, navigator_media_devices_get_user_media_callback, +}; +use super::super::*; +use crate::util::{get_private_value, set_private_value, throw_type_error}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const MEDIA_DEVICES_LISTENERS_SLOT: &str = "__moliMediaDevicesListeners"; +const MEDIA_DEVICES_ONDEVICECHANGE_SLOT: &str = "__moliMediaDevicesOndevicechange"; + +#[derive(Default, WebApiObject)] +#[webapi(interface = "MediaDevices")] +struct MediaDevicesObjectDeclaration { + #[webapi(slot = MEDIA_DEVICES_BRAND_SLOT, init = true)] + brand: (), + + #[webapi(slot = SIMPLE_EVENT_TARGET_SLOT, value = MEDIA_DEVICES_LISTENERS_SLOT)] + event_target_slot: (), + + #[webapi(slot = SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)] + ordered_handlers: (), + + #[webapi(slot = MEDIA_DEVICES_ONDEVICECHANGE_SLOT, init = "null")] + ondevicechange: (), +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(name = "MediaDevices", enumerable)] +struct MediaDevicesPrototypeDeclaration { + #[webapi(method, length = 0, callback = enumerate_devices_callback)] + enumerate_devices: (), + + #[webapi(method, length = 1, callback = navigator_media_devices_get_user_media_callback)] + get_user_media: (), + + #[webapi(accessor_property, getter = ondevicechange_getter, setter = ondevicechange_setter)] + ondevicechange: (), +} + +pub(super) fn build_media_devices_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Option> { + MediaDevicesObjectDeclaration::default().bind(scope).ok() +} + +pub(super) fn install_media_devices_template_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + let prototype = template.prototype_template(scope); + MediaDevicesPrototypeDeclaration::initialize_prototype_template(scope, prototype); +} + +fn receiver_is_media_devices<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, MEDIA_DEVICES_BRAND_SLOT) + .is_some_and(|value| value.boolean_value(scope)) +} + +fn enumerate_devices_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(resolver) = v8::PromiseResolver::new(scope) else { + return; + }; + rv.set(resolver.get_promise(scope).into()); + if !receiver_is_media_devices(scope, args.this()) { + let message = v8str(scope, "Illegal invocation"); + let error = v8::Exception::type_error(scope, message); + let _ = resolver.reject(scope, error); + return; + } + + // Enumeration waits while its associated document is not fully active. + // In particular, retaining a MediaDevices object must not make a discarded + // iframe's enumeration resolve against the caller's active document. + let Some(context) = args.this().get_creation_context(scope) else { + return; + }; + let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { + return; + }; + // SAFETY: all Window realms use the host owned by this isolate's bridge. + let host = unsafe { &*host_ptr }; + let Some(identity) = host.window_execution_context_identity_for_v8_context(scope, context) + else { + return; + }; + if !host.window_execution_context_identity_is_current(identity) { + return; + } + + // The headless media backend currently has no input or output devices. + let devices = v8::Array::new(scope, 0); + let _ = resolver.resolve(scope, devices.into()); +} + +fn ondevicechange_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !receiver_is_media_devices(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + let value = get_private_value(scope, args.this(), MEDIA_DEVICES_ONDEVICECHANGE_SLOT) + .unwrap_or_else(|| v8::null(scope).into()); + rv.set(value); +} + +fn ondevicechange_setter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'s, v8::Value>, +) { + if !receiver_is_media_devices(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + let value = args.get(0); + let stored = if value.is_function() { + value + } else { + v8::null(scope).into() + }; + set_private_value( + scope, + args.this(), + MEDIA_DEVICES_ONDEVICECHANGE_SLOT, + stored, + ); + simple_object_event_set_ordered_handler( + scope, + args.this(), + MEDIA_DEVICES_LISTENERS_SLOT, + "devicechange", + MEDIA_DEVICES_ONDEVICECHANGE_SLOT, + stored.is_function(), + ); +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index 8c5b18544..14c8f8e2a 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -1,9 +1,7 @@ use super::super::window_runtime::{ - MEDIA_DEVICES_BRAND_SLOT, PERMISSIONS_BRAND_SLOT, build_legacy_storage_quota_object, - build_navigator_ua_data_object, install_initial_service_worker_ready_promise, - navigator_get_battery_callback, navigator_java_enabled_callback, - navigator_media_devices_enumerate_devices_callback, - navigator_media_devices_get_user_media_callback, navigator_permissions_query_callback, + PERMISSIONS_BRAND_SLOT, build_legacy_storage_quota_object, build_navigator_ua_data_object, + install_initial_service_worker_ready_promise, navigator_get_battery_callback, + navigator_java_enabled_callback, navigator_permissions_query_callback, navigator_send_beacon_callback, navigator_service_worker_controller_getter_callback, navigator_service_worker_controllerchange_handler_getter_callback, navigator_service_worker_controllerchange_handler_setter_callback, @@ -29,6 +27,7 @@ use super::geolocation::{build_geolocation_object, install_geolocation_template_ use super::media_capabilities::{ build_media_capabilities_object, install_media_capabilities_template_bindings, }; +use super::media_devices::{build_media_devices_object, install_media_devices_template_bindings}; use super::navigator_subobjects::{NavigatorSubobject, ensure_navigator_subobject}; use crate::document_runtime::DomHandle; use crate::native_bridge::OwnerDispatchScope; @@ -409,19 +408,6 @@ struct NavigatorUaDataPrototypeMethodsDeclaration { get_high_entropy_values: (), } -#[derive(Default, WebApiObject)] -#[webapi(interface = "MediaDevices")] -struct MediaDevicesObjectDeclaration { - #[webapi(slot = MEDIA_DEVICES_BRAND_SLOT, init = true)] - brand: (), - - #[webapi(method, enumerable, length = 0, callback = navigator_media_devices_enumerate_devices_callback)] - enumerate_devices: (), - - #[webapi(method, enumerable, length = 1, callback = navigator_media_devices_get_user_media_callback)] - get_user_media: (), -} - #[derive(Default, WebApiObject)] #[webapi(interface = "Object")] struct ServiceWorkerContainerDeclaration { @@ -821,6 +807,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( install_media_capabilities_template_bindings(scope, template, interface_name); let prototype = template.prototype_template(scope); match interface_name { + "MediaDevices" => install_media_devices_template_bindings(scope, template), "Navigator" => { NavigatorRuntimeDataPrototypeDeclaration::initialize_prototype_template( scope, prototype, @@ -854,6 +841,7 @@ fn filter_navigator_secure_context_exposure<'s>( ) -> Result<()> { if !secure_context { delete_object_property(scope, prototype, "clipboard")?; + delete_object_property(scope, prototype, "mediaDevices")?; delete_object_property(scope, prototype, "storage")?; delete_object_property(scope, prototype, "storageBuckets")?; delete_object_property(scope, prototype, "serviceWorker")?; @@ -998,9 +986,8 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( | NavigatorSubobject::WebkitPersistentStorage => { build_legacy_storage_quota_object(scope)?.into() } - NavigatorSubobject::MediaDevices => MediaDevicesObjectDeclaration::default() - .bind(scope) - .map_err(|error| anyhow!("failed to bind MediaDevices object: {error}"))? + NavigatorSubobject::MediaDevices => build_media_devices_object(scope) + .ok_or_else(|| anyhow!("failed to bind MediaDevices object"))? .into(), NavigatorSubobject::ServiceWorker => { build_service_worker_container(scope, owner_child, owner_popup)?.into() diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime.rs index 68c8d6ca6..4c981ba28 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime.rs @@ -33,7 +33,6 @@ pub(super) use navigator::{ MEDIA_DEVICES_BRAND_SLOT, PERMISSIONS_BRAND_SLOT, build_legacy_storage_info_object, build_legacy_storage_quota_object, build_navigator_ua_data_object, global_caches_getter_callback, navigator_get_battery_callback, navigator_java_enabled_callback, - navigator_media_devices_enumerate_devices_callback, navigator_media_devices_get_user_media_callback, navigator_permissions_query_callback, navigator_send_beacon_callback, navigator_storage_estimate_callback, navigator_storage_get_directory_callback, navigator_storage_persist_callback, diff --git a/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs index 92d09ccd5..e54d19ccf 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_runtime/navigator.rs @@ -3564,24 +3564,6 @@ fn build_storage_usage_details_object<'s>( .expect("StorageUsageDetails declaration should bind") } -pub(in crate::context_bootstrap) fn navigator_media_devices_enumerate_devices_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'_, v8::Value>, -) { - let Some(resolver) = v8::PromiseResolver::new(scope) else { - return; - }; - if !media_devices_receiver_branded(scope, args.this()) { - reject_type_error(scope, resolver, "Illegal invocation"); - rv.set(resolver.get_promise(scope).into()); - return; - } - let devices = v8::Array::new(scope, 0); - let _ = resolver.resolve(scope, devices.into()); - rv.set(resolver.get_promise(scope).into()); -} - pub(in crate::context_bootstrap) fn navigator_media_devices_get_user_media_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs index 4426736c4..2596b18b0 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media.rs @@ -2143,7 +2143,7 @@ fn zhihu_probe_media_devices_surface_exposes_promise_methods() { const proto = Object.getPrototypeOf(devices); const fakeDevices = Object.create(MediaDevices.prototype); const summarizeMethodDescriptor = name => { - const descriptor = Object.getOwnPropertyDescriptor(devices, name); + const descriptor = Object.getOwnPropertyDescriptor(proto, name); return [ !!descriptor, typeof descriptor?.value, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/media_devices.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/media_devices.rs new file mode 100644 index 000000000..20115df56 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/media_devices.rs @@ -0,0 +1,112 @@ +use super::*; + +#[test] +fn media_devices_enumeration_and_devicechange_live_on_a_branded_event_target_prototype() { + let mut vm = new_storage_test_vm("https://media-devices.test/"); + let result = vm.eval(r#" + (() => { + const devices = navigator.mediaDevices; + const prototype = MediaDevices.prototype; + const method = Object.getOwnPropertyDescriptor(prototype, 'enumerateDevices'); + const handler = Object.getOwnPropertyDescriptor(prototype, 'ondevicechange'); + const checks = [ + devices instanceof MediaDevices, devices instanceof EventTarget, + devices === navigator.mediaDevices, + Object.getOwnPropertyNames(devices).length === 0, + method.value.name === 'enumerateDevices', method.value.length === 0, + method.enumerable, method.writable, method.configurable, + handler.get.name === 'get ondevicechange', handler.get.length === 0, + handler.set.name === 'set ondevicechange', handler.set.length === 1, + handler.enumerable, handler.configurable, devices.ondevicechange === null + ]; + const events = []; + devices.addEventListener('devicechange', () => events.push('first')); + devices.ondevicechange = event => events.push('old'); + devices.addEventListener('devicechange', () => events.push('last')); + devices.ondevicechange = event => events.push(event.target === devices ? 'handler' : 'bad target'); + devices.dispatchEvent(new Event('devicechange')); + checks.push(events.join(',') === 'first,handler,last'); + devices.ondevicechange = {}; + checks.push(devices.ondevicechange === null); + for (const fake of [{}, prototype, Object.create(devices)]) { + for (const callback of [() => handler.get.call(fake), () => handler.set.call(fake, null)]) { + try { callback(); checks.push(false); } catch (error) { checks.push(error instanceof TypeError); } + } + } + return checks.every(Boolean); + })() + "#).expect("MediaDevices prototype and event dispatch should evaluate"); + assert_eq!(result, "true"); +} + +#[test] +fn media_devices_enumeration_resolves_fresh_empty_lists_and_rejects_fake_receivers() { + let mut vm = new_storage_test_vm("https://media-devices-enumeration.test/"); + vm.exec(r#" + const devices = navigator.mediaDevices; + const fake = Object.create(devices); + fake.__moliMediaDevicesBrand = true; + const calls = [devices.enumerateDevices(), devices.enumerateDevices(), + ...[null, undefined, {}, fake].map(receiver => devices.enumerateDevices.call(receiver))]; + globalThis.__enumerationPromises = calls.every(value => value instanceof Promise); + Promise.allSettled(calls).then(results => { + globalThis.__enumerationResult = [ + ...results.slice(0, 2).map(result => result.status === 'fulfilled' && + Array.isArray(result.value) && result.value.length === 0), + results[0].value !== results[1].value, + ...results.slice(2).map(result => result.status === 'rejected' && result.reason instanceof TypeError) + ].every(Boolean); + }); + "#, None).expect("enumeration promises should be created without synchronous throws"); + assert_eq!( + vm.eval("__enumerationPromises && __enumerationResult") + .unwrap(), + "true" + ); +} + +#[test] +fn media_devices_enumeration_keeps_discarded_receivers_pending_even_with_borrowed_methods() { + let mut vm = new_parsed_test_vm( + "https://media-devices-discard.test/", + "", + ); + vm.exec( + r#" + const iframe = document.createElement('iframe'); + document.body.appendChild(iframe); + const devices = iframe.contentWindow.navigator.mediaDevices; + const foreignEnumerate = devices.enumerateDevices; + iframe.remove(); + globalThis.__discardedSettled = false; + globalThis.__activeSettled = false; + const markDiscarded = () => { __discardedSettled = true; }; + devices.enumerateDevices().then(markDiscarded, markDiscarded); + navigator.mediaDevices.enumerateDevices.call(devices).then(markDiscarded, markDiscarded); + foreignEnumerate.call(navigator.mediaDevices).then(result => { + __activeSettled = Array.isArray(result) && result.length === 0; + }); + "#, + None, + ) + .expect("enumeration should retain its receiver's document activity"); + assert_eq!( + vm.eval("!__discardedSettled && __activeSettled").unwrap(), + "true" + ); +} + +#[test] +fn media_devices_is_only_exposed_in_secure_windows() { + for (url, expected) in [ + ("https://media-devices.test/", "true|true"), + ("http://media-devices.test/", "false|false"), + ] { + let mut vm = new_storage_test_vm(url); + assert_eq!( + vm.eval("['MediaDevices' in globalThis, 'mediaDevices' in navigator].join('|')") + .unwrap(), + expected + ); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index 1d1d22cb7..2b3c756f1 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -8096,8 +8096,8 @@ fn navigator_runtime_subobjects_keep_declared_brand_and_methods() { mediaDevices.enumerateDevices && mediaDevices.enumerateDevices.length, mediaDevices.getUserMedia && mediaDevices.getUserMedia.name, mediaDevices.getUserMedia && mediaDevices.getUserMedia.length, - summarizeOwnMethod(mediaDevices, "enumerateDevices"), - summarizeOwnMethod(mediaDevices, "getUserMedia") + summarizeOwnMethod(MediaDevices.prototype, "enumerateDevices"), + summarizeOwnMethod(MediaDevices.prototype, "getUserMedia") ].join("|"), clipboard: [ clipboard instanceof Clipboard, @@ -8138,7 +8138,7 @@ fn navigator_runtime_subobjects_keep_declared_brand_and_methods() { assert_eq!( result, - r#"{"permissions":"true|[object Permissions]|false|function|1","storage":"true|[object StorageManager]|false|false|false|false||true|true|true|true|persisted|0|persist|0|estimate|0|false:undefined:::::|[object Promise]|[object Promise]","connection":"[object Object]|string|unknown|Infinity|string|4g|10|50|false|true|type,downlinkMax,effectiveType,downlink,rtt,saveData,onchange,addEventListener,removeEventListener||true:function:addEventListener:2:true:true:true|true:function:removeEventListener:2:true:true:true|undefined|undefined|throw:TypeError|throw:TypeError","mediaDevices":"true|[object MediaDevices]|true|true|enumerateDevices,getUserMedia|enumerateDevices|0|getUserMedia|1|true:function:enumerateDevices:0:true:true:true|true:function:getUserMedia:1:true:true:true","clipboard":"true|true|[object Clipboard]|false|false||true|true|true|true|readText|0|writeText|1|true:function:readText:0:true:true:true|true:function:writeText:1:true:true:true|[object Promise]|[object Promise]","userActivation":"undefined|[object Object]|boolean|false|false|isActive,hasBeenActive|true|throw:TypeError|throw:TypeError"}"# + r#"{"permissions":"true|[object Permissions]|false|function|1","storage":"true|[object StorageManager]|false|false|false|false||true|true|true|true|persisted|0|persist|0|estimate|0|false:undefined:::::|[object Promise]|[object Promise]","connection":"[object Object]|string|unknown|Infinity|string|4g|10|50|false|true|type,downlinkMax,effectiveType,downlink,rtt,saveData,onchange,addEventListener,removeEventListener||true:function:addEventListener:2:true:true:true|true:function:removeEventListener:2:true:true:true|undefined|undefined|throw:TypeError|throw:TypeError","mediaDevices":"true|[object MediaDevices]|false|false||enumerateDevices|0|getUserMedia|1|true:function:enumerateDevices:0:true:true:true|true:function:getUserMedia:1:true:true:true","clipboard":"true|true|[object Clipboard]|false|false||true|true|true|true|readText|0|writeText|1|true:function:readText:0:true:true:true|true:function:writeText:1:true:true:true|[object Promise]|[object Promise]","userActivation":"undefined|[object Object]|boolean|false|false|isActive,hasBeenActive|true|throw:TypeError|throw:TypeError"}"# ); let receiver_errors = vm .eval( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 406769769..448d4609a 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -17,6 +17,7 @@ mod idle_callbacks; mod idle_detection; mod images; mod media; +mod media_devices; mod misc; mod navigation; mod performance;