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 b425c75eec..9e0b5f9279 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,8 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] =
];
const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[
+ "MediaDeviceInfo",
+ "InputDeviceInfo",
"MediaDevices",
"SubtleCrypto",
"CryptoKey",
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
index ce2b444165..bdab11f975 100644
--- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs
+++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs
@@ -4,6 +4,8 @@ use crate::util::{get_private_value, set_private_value, throw_type_error};
use crate::web_api_interfaces;
use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject};
+mod info;
+
const MEDIA_DEVICES_LISTENERS_SLOT: &str = "__moliMediaDevicesListeners";
const MEDIA_DEVICES_ONDEVICECHANGE_SLOT: &str = "__moliMediaDevicesOndevicechange";
@@ -42,9 +44,16 @@ pub(super) fn build_media_devices_object<'s>(
pub(super) fn install_media_devices_template_bindings<'s>(
scope: &mut v8::PinScope<'s, '_, ()>,
template: v8::Local<'s, v8::FunctionTemplate>,
+ name: &str,
) {
- let prototype = template.prototype_template(scope);
- MediaDevicesPrototypeDeclaration::initialize_prototype_template(scope, prototype);
+ if name == "MediaDevices" {
+ MediaDevicesPrototypeDeclaration::initialize_prototype_template(
+ scope,
+ template.prototype_template(scope),
+ );
+ } else {
+ info::install(scope, template, name);
+ }
}
fn receiver_is_media_devices<'s>(
diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs
new file mode 100644
index 0000000000..9003e1cee2
--- /dev/null
+++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs
@@ -0,0 +1,87 @@
+//! Device info interface shims. The current media backend enumerates no devices;
+//! exposing these types does not manufacture a camera, microphone or permission.
+
+use super::*;
+use moli_webapi_declare::ObjectLiteralDeclaration;
+
+const DEVICE_ID: &str = "__moliMediaDeviceId";
+const KIND: &str = "__moliMediaDeviceKind";
+const LABEL: &str = "__moliMediaDeviceLabel";
+const GROUP_ID: &str = "__moliMediaDeviceGroupId";
+
+#[derive(WebApiFunctionTemplate)]
+#[webapi(interface = web_api_interfaces::MediaDeviceInfo, enumerable, receiver)]
+struct DeviceInfoAttributes {
+ #[webapi(accessor_property, getter = info_getter, data = v8str(scope, DEVICE_ID))]
+ device_id: (),
+ #[webapi(accessor_property, getter = info_getter, data = v8str(scope, KIND))]
+ kind: (),
+ #[webapi(accessor_property, getter = info_getter, data = v8str(scope, LABEL))]
+ label: (),
+ #[webapi(accessor_property, getter = info_getter, data = v8str(scope, GROUP_ID))]
+ group_id: (),
+ #[webapi(method = "toJSON", length = 0, callback = to_json)]
+ to_json: (),
+}
+
+#[derive(WebApiFunctionTemplate)]
+#[webapi(interface = web_api_interfaces::InputDeviceInfo, enumerable, receiver)]
+struct InputDeviceInfoMethods {
+ #[webapi(method, length = 0, callback = get_capabilities)]
+ get_capabilities: (),
+}
+
+pub(super) fn install<'s>(
+ scope: &mut v8::PinScope<'s, '_, ()>,
+ template: v8::Local<'s, v8::FunctionTemplate>,
+ name: &str,
+) {
+ let prototype = template.prototype_template(scope);
+ match name {
+ "MediaDeviceInfo" => DeviceInfoAttributes::initialize_prototype_template(scope, prototype),
+ "InputDeviceInfo" => {
+ InputDeviceInfoMethods::initialize_prototype_template(scope, prototype)
+ }
+ _ => {}
+ }
+}
+
+fn info_getter<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ args: v8::FunctionCallbackArguments<'s>,
+ mut rv: v8::ReturnValue<'s, v8::Value>,
+) {
+ let slot = args.data().to_rust_string_lossy(scope);
+ if let Some(value) = get_private_value(scope, args.this(), &slot) {
+ rv.set(value);
+ }
+}
+
+fn to_json<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ args: v8::FunctionCallbackArguments<'s>,
+ mut rv: v8::ReturnValue<'s, v8::Value>,
+) {
+ let object = ObjectLiteralDeclaration::bind(scope);
+ for (name, slot) in [
+ ("deviceId", DEVICE_ID),
+ ("kind", KIND),
+ ("label", LABEL),
+ ("groupId", GROUP_ID),
+ ] {
+ let value = get_private_value(scope, args.this(), slot)
+ .unwrap_or_else(|| v8::undefined(scope).into());
+ object.set_string_property(scope, name, value);
+ }
+ rv.set(object.into_value());
+}
+
+fn get_capabilities<'s>(
+ scope: &mut v8::PinScope<'s, '_>,
+ _args: v8::FunctionCallbackArguments<'s>,
+ mut rv: v8::ReturnValue<'s, v8::Value>,
+) {
+ // There is no capture backend or device permission grant yet. An empty
+ // capabilities dictionary also represents privacy-filtered input devices.
+ rv.set(ObjectLiteralDeclaration::bind(scope).into_value());
+}
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 c28a02738d..43348dce65 100644
--- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs
+++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs
@@ -896,7 +896,9 @@ 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),
+ "MediaDevices" | "MediaDeviceInfo" | "InputDeviceInfo" => {
+ install_media_devices_template_bindings(scope, template, interface_name)
+ }
"Navigator" => {
NavigatorRuntimeDataPrototypeDeclaration::initialize_prototype_template(
scope, prototype,
diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs
index 1822a1e6f3..ddd10dad42 100644
--- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs
+++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs
@@ -524,6 +524,14 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[
interface: web_api_interfaces::FileSystemSyncAccessHandle::DESCRIPTOR,
kind: ConstructorKind::Illegal,
},
+ ConstructorSpec {
+ interface: web_api_interfaces::MediaDeviceInfo::DESCRIPTOR,
+ kind: ConstructorKind::Illegal,
+ },
+ ConstructorSpec {
+ interface: web_api_interfaces::InputDeviceInfo::DESCRIPTOR,
+ kind: ConstructorKind::Illegal,
+ },
ConstructorSpec {
interface: web_api_interfaces::MediaDevices::DESCRIPTOR,
kind: ConstructorKind::Illegal,
diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js
new file mode 100644
index 0000000000..aa16370f95
--- /dev/null
+++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js
@@ -0,0 +1,54 @@
+(async () => {
+ const rows = [], assert = (ok, message) => { if (!ok) throw Error(message); };
+ const check = async (name, run) => { try { await run(); rows.push({name, pass:true}); } catch(error) {rows.push({name, pass:false, message:String(error)});} };
+ const popup = open(), realms = [['main', window], ['child', document.getElementById('child').contentWindow], ['popup', popup]];
+ try {
+ for (const [label, w] of realms) {
+ if (!w.isSecureContext) {
+ await check(label + '/insecure', () => assert(!('MediaDeviceInfo' in w) && !('InputDeviceInfo' in w), 'secure globals hidden'));
+ continue;
+ }
+ for (const name of ['MediaDeviceInfo', 'InputDeviceInfo']) {
+ await check(label + '/' + name, () => {
+ const C=w[name], parent=name==='InputDeviceInfo'?w.MediaDeviceInfo:w.Object;
+ const d=Object.getOwnPropertyDescriptor(w,name);
+ assert(typeof C==='function' && C.name===name && C.length===0,'constructor metadata');
+ assert(d.writable && d.configurable && !d.enumerable,'global descriptor');
+ assert(Object.getPrototypeOf(C.prototype)===parent.prototype && Object.getPrototypeOf(C)===(parent===w.Object?w.Function.prototype:parent),'native inheritance');
+ assert(C.prototype.constructor===C,'prototype constructor');
+ const tag=Object.getOwnPropertyDescriptor(C.prototype,Symbol.toStringTag);
+ assert(tag.value===name && tag.configurable && !tag.writable && !tag.enumerable,'prototype tag');
+ for(const call of [()=>C(),()=>new C()]){let error;try{call();}catch(e){error=e;}assert(error instanceof w.TypeError,'illegal constructor realm');}
+ });
+ }
+ const entries=[['MediaDeviceInfo','deviceId',true],['MediaDeviceInfo','kind',true],['MediaDeviceInfo','label',true],['MediaDeviceInfo','groupId',true],['MediaDeviceInfo','toJSON',false],['InputDeviceInfo','getCapabilities',false]];
+ for(const [owner,name,attribute] of entries) {
+ await check(label+'/'+name,()=>{
+ const C=w[owner],d=Object.getOwnPropertyDescriptor(C.prototype,name),fn=attribute?d.get:d.value;
+ assert(typeof fn==='function' && fn.length===0 && fn.name===(attribute?'get ':'')+name,'member metadata');
+ assert(d.enumerable && d.configurable && (attribute?d.set===undefined:d.writable),'member descriptor');
+ let traps=0, conversions=0;const trap=()=>{traps++;throw Error('author trap');};
+ const revoked=Proxy.revocable({},{});revoked.revoke();
+ const ignored={toString(){conversions++;throw Error('ignored argument');}};
+ for(const receiver of [null,{},C.prototype,Object.create(C.prototype),new Proxy({}, {get:trap,getPrototypeOf:trap}),revoked.proxy]){
+ let error;try{fn.call(receiver,ignored);}catch(e){error=e;}
+ assert(error instanceof w.TypeError,'callee TypeError for unbranded receiver');
+ }
+ assert(traps===0 && conversions===0,'native brand validation ignores author hooks');
+ });
+ }
+ await check(label+'/enumeration',async()=>{
+ const first=await w.navigator.mediaDevices.enumerateDevices(),second=await w.navigator.mediaDevices.enumerateDevices();
+ assert(first instanceof w.Array && first!==second,'fresh native device list');
+ for(const device of first){
+ const parent=device.kind==='audiooutput'?w.MediaDeviceInfo:w.InputDeviceInfo;
+ assert(device instanceof parent,'returned device brand');
+ const json=device.toJSON();
+ assert(Object.keys(json).join()==='deviceId,kind,label,groupId','default JSON fields');
+ for(const key of Object.keys(json))assert(json[key]===device[key],'JSON device values');
+ }
+ });
+ }
+ } finally {popup.close();}
+ globalThis.__nodeReplacementResults={rows,failures:rows.filter(r=>!r.pass),passed:rows.filter(r=>r.pass).length,total:rows.length};return rows.every(r=>r.pass);
+})()
diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs
new file mode 100644
index 0000000000..921c394f25
--- /dev/null
+++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs
@@ -0,0 +1,89 @@
+use super::*;
+
+#[test]
+fn media_device_interfaces_preserve_inheritance_brands_and_secure_exposure() {
+ for url in [
+ "https://media-device-interfaces.test/",
+ "http://media-device-interfaces.test/",
+ ] {
+ let mut vm = new_storage_page_task_executor_test_vm(url);
+ vm.eval("document.body.innerHTML = ''")
+ .unwrap();
+ vm.eval(&format!("({}).then(value => globalThis.__mediaDeviceDone = value, error => globalThis.__mediaDeviceDone = String(error));", include_str!("media_device_interfaces.js"))).unwrap();
+ assert_eq!(
+ vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)")
+ .unwrap(),
+ "[]",
+ "{url}"
+ );
+ assert_eq!(vm.eval("__mediaDeviceDone").unwrap(), "true", "{url}");
+ }
+}
+
+#[test]
+fn media_device_serialization_reads_native_fields_and_preserves_utf16() {
+ let mut vm = new_storage_page_task_executor_test_vm("https://media-device-values.test/");
+ let context_ptr: *const v8::Global = &vm.page_default_context as *const _;
+ vm.renderer_document_isolate
+ .with_entered_renderer_document_isolate(move |isolate| {
+ let scope = std::pin::pin!(v8::HandleScope::new(isolate));
+ let scope = &mut scope.init();
+ let context = unsafe { v8::Local::new(scope, &*context_ptr) };
+ let scope = &mut v8::ContextScope::new(scope, context);
+ let prototype = crate::context_bootstrap::ensure_intrinsic_interface_prototype(
+ scope,
+ "InputDeviceInfo",
+ )?;
+ // A native test fixture, not a fake device published by enumerateDevices.
+ let object = v8::Object::new(scope);
+ assert_eq!(object.set_prototype(scope, prototype.into()), Some(true));
+ moli_webapi_declare::initialize_web_api_object(scope, object, "InputDeviceInfo")
+ .unwrap();
+ for (slot, value) in [
+ ("__moliMediaDeviceId", "native-id"),
+ ("__moliMediaDeviceKind", "audioinput"),
+ ("__moliMediaDeviceGroupId", "native-group"),
+ ] {
+ let value = crate::util::v8str(scope, value);
+ crate::util::set_private_value(scope, object, slot, value.into());
+ }
+ let label =
+ v8::String::new_from_two_byte(scope, &[0xd800], v8::NewStringType::Normal).unwrap();
+ crate::util::set_private_value(scope, object, "__moliMediaDeviceLabel", label.into());
+ assert_eq!(
+ context.global(scope).create_data_property(
+ scope,
+ crate::util::v8str(scope, "nativeDevice").into(),
+ object.into()
+ ),
+ Some(true)
+ );
+ Ok(())
+ })
+ .unwrap();
+ assert_eq!(vm.eval(r#"(() => {
+ const assert = (ok, message) => {if (!ok) throw Error(message);};
+ const device = nativeDevice, prototype = MediaDeviceInfo.prototype;
+ assert(device instanceof InputDeviceInfo && device instanceof MediaDeviceInfo, 'native inherited brand');
+ assert(device.deviceId === 'native-id' && device.kind === 'audioinput' && device.groupId === 'native-group' && device.label.charCodeAt(0) === 0xd800, 'native values');
+ const expected = device.toJSON();
+ let reads = 0, writes = 0;
+ for (const name of ['deviceId','kind','label','groupId']) {
+ Object.defineProperty(device,name,{configurable:true,get(){reads++;throw Error('author getter');}});
+ Object.defineProperty(Object.prototype,name,{configurable:true,set(){writes++;throw Error('inherited setter');}});
+ }
+ let json;
+ try {json = prototype.toJSON.call(device);} finally {for (const name of ['deviceId','kind','label','groupId']) delete Object.prototype[name];}
+ assert(reads === 0 && writes === 0 && JSON.stringify(json) === JSON.stringify(expected), 'private native serialization and data properties');
+ assert(json !== expected && Object.getPrototypeOf(json) === Object.prototype, 'fresh ordinary JSON object');
+ const first = device.getCapabilities(), second = device.getCapabilities();
+ assert(first !== second && Object.keys(first).length === 0, 'fresh empty capabilities shim');
+ Object.setPrototypeOf(device,null);
+ assert(prototype.toJSON.call(device).deviceId === 'native-id', 'brand independent of public prototype');
+ for (const receiver of [Object.create(device), new Proxy(device,{})]) {
+ let error;try {prototype.toJSON.call(receiver);} catch(e){error=e;}
+ assert(error instanceof TypeError, 'author objects do not acquire native brand');
+ }
+ return true;
+ })()"#).unwrap(), "true");
+}
diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs
index 2ade78dee5..9e9f31148f 100644
--- a/moli-renderer-v8/src/script_vm/tests/mod.rs
+++ b/moli-renderer-v8/src/script_vm/tests/mod.rs
@@ -2164,3 +2164,5 @@ mod navigation_timing_inheritance;
mod response_blob_mime;
mod intersection_target_order;
+
+mod media_device_interfaces;
diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs
index e8181b3372..6b2aab0717 100644
--- a/moli-renderer-v8/src/web_api_interfaces.rs
+++ b/moli-renderer-v8/src/web_api_interfaces.rs
@@ -282,6 +282,8 @@ interfaces! {
Location;
MathMLElement: Element;
MediaCapabilities;
+ MediaDeviceInfo;
+ InputDeviceInfo: MediaDeviceInfo;
MediaDevices: EventTarget;
MediaError;
MediaList;
diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs
index bdb53b404a..a1f3879499 100644
--- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs
+++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs
@@ -2959,3 +2959,17 @@ async fn worker_fallback_message_events_are_native_platform_objects() {
r#"{"data":"ping","cloneResult":"DataCloneError"}"#
);
}
+
+#[tokio::test]
+async fn worker_does_not_expose_window_media_device_interfaces() {
+ ensure_v8();
+ let mut handle = spawn_worker("postMessage({MediaDeviceInfo:'MediaDeviceInfo' in self,InputDeviceInfo:'InputDeviceInfo' in self});close();".to_owned(), "https://media-device-worker.test/worker.js".into());
+ let message = timeout(TIMEOUT, handle.recv())
+ .await
+ .expect("timed out")
+ .expect("channel closed");
+ assert_eq!(
+ expect_post_json(message),
+ r#"{"MediaDeviceInfo":false,"InputDeviceInfo":false}"#
+ );
+}