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..39da7d59f4 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/finalize.rs @@ -8,6 +8,7 @@ use super::materialize::{ enum RealmDependentFinalizer { NodeMixinUnscopables, CryptoSecureContextSurface, + BaseAudioContextSecureContextSurface, XmlHttpRequestEventTargetState, NotificationPermission, PointerEventSecureContextSurface, @@ -33,6 +34,10 @@ 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. + ( + "BaseAudioContext", + RealmDependentFinalizer::BaseAudioContextSecureContextSurface, + ), ( "Crypto", RealmDependentFinalizer::CryptoSecureContextSurface, @@ -80,6 +85,9 @@ pub(super) fn finalize_materialized_interface( RealmDependentFinalizer::NodeMixinUnscopables => { finalize_node_mixin_unscopables(scope, prototype); } + RealmDependentFinalizer::BaseAudioContextSecureContextSurface => { + crate::context_bootstrap::web_audio_runtime::finalize_base_audio_context_realm_bindings(scope, prototype)?; + } RealmDependentFinalizer::CryptoSecureContextSurface => { crate::context_bootstrap::crypto::finalize_crypto_realm_bindings(scope, prototype)?; } @@ -140,6 +148,7 @@ mod tests { "Element", "DocumentType", "CharacterData", + "BaseAudioContext", "Crypto", "XMLHttpRequestEventTarget", "Notification", 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 40ca57a34b..d47353d180 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -102,6 +102,8 @@ const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ "SubtleCrypto", "CryptoKey", "IdleDetector", + "Worklet", + "AudioWorklet", "Clipboard", "ClipboardItem", "Cache", diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 2d2a88a4a8..77cf7aaa00 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -2063,6 +2063,14 @@ pub(crate) fn set_interface_prototype_constructor<'s>( ); } +pub(crate) fn window_realm_secure_context_available<'s>( + scope: &mut v8::PinScope<'s, '_>, + window: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, window, WINDOW_SECURE_CONTEXT_AVAILABLE_SLOT) + .is_some_and(|value| value.is_true()) +} + fn install_window_runtime_state<'s>( scope: &mut v8::PinScope<'s, '_>, global: v8::Local<'s, v8::Object>, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 472137b392..0f3304f01b 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -1183,6 +1183,14 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::AudioContext::DESCRIPTOR, kind: ConstructorKind::AudioContext, }, + ConstructorSpec { + interface: web_api_interfaces::Worklet::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + interface: web_api_interfaces::AudioWorklet::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::AudioWorkletNode::DESCRIPTOR, kind: ConstructorKind::AudioWorkletNode, diff --git a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs index 39009103a5..a802ab4a52 100644 --- a/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/web_audio_runtime.rs @@ -6,8 +6,7 @@ use super::media_queries::{ use super::*; use crate::native_bridge::throw_dom_exception; use crate::util::{ - array_push_value, call_object_method, get_private_value, object_string_property, - set_private_value, set_symbol_to_string_tag, + call_object_method, get_private_value, object_string_property, set_private_value, }; use crate::web_api_interfaces; use crate::webidl; @@ -23,11 +22,12 @@ const AUDIO_CONTEXT_LISTENERS_SLOT: &str = "__moliAudioContextListeners"; const AUDIO_CONTEXT_MODULES_SLOT: &str = "__moliAudioContextModules"; const AUDIO_CONTEXT_MODULE_LIST_SLOT: &str = "__moliAudioContextModuleList"; const AUDIO_CONTEXT_PROCESSORS_SLOT: &str = "__moliAudioContextProcessors"; +const AUDIO_CONTEXT_WORKLET_SLOT: &str = "__moliAudioContextWorklet"; const AUDIO_WORKLET_CONTEXT_SLOT: &str = "__moliAudioWorkletContext"; const AUDIO_WORKLET_MODULE_CONTEXT_SLOT: &str = "__moliAudioWorkletModuleContext"; const AUDIO_WORKLET_MODULE_WORKER_SLOT: &str = "__moliAudioWorkletModuleWorker"; -const AUDIO_WORKLET_MODULE_PROMISE_SLOT: &str = "__moliAudioWorkletModulePromise"; -const AUDIO_WORKLET_MODULE_RESOLVER_SLOT: &str = "__moliAudioWorkletModuleResolver"; +const AUDIO_WORKLET_MODULE_RESOLVERS_SLOT: &str = "__moliAudioWorkletModuleResolvers"; +const AUDIO_WORKLET_MODULE_ERROR_SLOT: &str = "__moliAudioWorkletModuleError"; const AUDIO_WORKLET_MODULE_LOADED_SLOT: &str = "__moliAudioWorkletModuleLoaded"; const AUDIO_WORKLET_MODULE_SETTLED_SLOT: &str = "__moliAudioWorkletModuleSettled"; const AUDIO_WORKLET_CALLBACK_MODULE_SLOT: &str = "__moliAudioWorkletCallbackModule"; @@ -51,8 +51,6 @@ struct AudioContextObjectDeclaration<'scope> { state: &'static str, #[webapi(data_property)] destination: v8::Local<'scope, v8::Object>, - #[webapi(data_property = "audioWorklet")] - audio_worklet: v8::Local<'scope, v8::Object>, #[webapi(slot = SIMPLE_EVENT_TARGET_SLOT, value = AUDIO_CONTEXT_LISTENERS_SLOT)] event_target_slot: (), #[webapi(slot = AUDIO_CONTEXT_MODULES_SLOT)] @@ -66,10 +64,10 @@ struct AudioContextObjectDeclaration<'scope> { #[derive(WebApiObject)] #[webapi(interface = web_api_interfaces::AudioWorklet)] struct AudioWorkletObjectDeclaration<'scope> { + #[webapi(prototype)] + prototype: v8::Local<'scope, v8::Object>, #[webapi(slot = AUDIO_WORKLET_CONTEXT_SLOT)] context: v8::Local<'scope, v8::Object>, - #[webapi(method = "addModule", length = 1, callback = audio_worklet_add_module_callback)] - add_module: (), } #[derive(WebApiObject)] @@ -88,10 +86,8 @@ struct AudioWorkletModuleStateDeclaration<'scope> { context: v8::Local<'scope, v8::Object>, #[webapi(slot = AUDIO_WORKLET_MODULE_WORKER_SLOT)] worker: v8::Local<'scope, v8::Object>, - #[webapi(slot = AUDIO_WORKLET_MODULE_PROMISE_SLOT)] - promise: v8::Local<'scope, v8::Promise>, - #[webapi(slot = AUDIO_WORKLET_MODULE_RESOLVER_SLOT)] - resolver: v8::Local<'scope, v8::PromiseResolver>, + #[webapi(slot = AUDIO_WORKLET_MODULE_RESOLVERS_SLOT)] + resolvers: v8::Local<'scope, v8::Array>, #[webapi(slot = AUDIO_WORKLET_MODULE_LOADED_SLOT)] loaded: bool, #[webapi(slot = AUDIO_WORKLET_MODULE_SETTLED_SLOT)] @@ -480,6 +476,10 @@ pub(in crate::context_bootstrap) fn install_web_audio_template_bindings<'s>( graph::install(scope, template); } match interface_name { + "Worklet" => WorkletPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ), "AudioParam" => audio_param::install(scope, template), "BiquadFilterNode" => biquad::install(scope, template), "BaseAudioContext" => { @@ -528,17 +528,12 @@ fn audio_context_constructor_callback<'s>( let modules = new_web_audio_map_object(scope); let module_list = v8::Array::new(scope, 0); let processors = new_web_audio_map_object(scope); - let audio_worklet = AudioWorkletObjectDeclaration::new(context) - .bind(scope) - .expect("AudioWorklet declaration should bind"); - set_symbol_to_string_tag(scope, audio_worklet, "AudioWorklet"); AudioContextObjectDeclaration::new( 0.0, 44_100.0, "running", destination, - audio_worklet, modules, module_list, processors, @@ -579,20 +574,12 @@ fn audio_context_close_callback<'s>( module_state, AUDIO_WORKLET_MODULE_SETTLED_SLOT, ) { - set_audio_worklet_module_bool_slot( + let error = new_dom_exception_value( scope, - module_state, - AUDIO_WORKLET_MODULE_SETTLED_SLOT, - true, + "AudioWorklet module loading was aborted.", + "AbortError", ); - if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) { - let error = new_dom_exception_value( - scope, - "AudioWorklet module loading was aborted.", - "AbortError", - ); - let _ = resolver.reject(scope, error); - } + settle_audio_worklet_module(scope, module_state, Err(error)); } if let Some(worker) = web_audio_object_slot(scope, module_state, AUDIO_WORKLET_MODULE_WORKER_SLOT) @@ -627,76 +614,184 @@ fn audio_context_close_callback<'s>( } } +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::Worklet, enumerable, receiver)] +struct WorkletPrototypeDeclaration { + #[webapi(method = "addModule", length = 1, returns_promise, callback = audio_worklet_add_module_callback)] + add_module: (), +} + +#[derive(Default, WebApiObject)] +#[webapi(fragment, prototype = "BaseAudioContext", enumerable, receiver = web_api_interfaces::BaseAudioContext::is_instance)] +struct BaseAudioContextSecureAttributes { + #[webapi(accessor_property = "audioWorklet", getter = audio_worklet_getter)] + audio_worklet: (), +} + +pub(super) fn finalize_base_audio_context_realm_bindings<'s>( + scope: &mut v8::PinScope<'s, '_>, + prototype: v8::Local<'s, v8::Object>, +) -> Result<()> { + let global = scope.get_current_context().global(scope); + if super::runtime_state::window_realm_secure_context_available(scope, global) { + BaseAudioContextSecureAttributes::default() + .initialize(scope, prototype) + .map_err(|error| anyhow!(error))?; + } + Ok(()) +} + +fn audio_worklet_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let context = args.this(); + if let Some(worklet) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_WORKLET_SLOT) { + rv.set(worklet.into()); + return; + } + let Some(realm) = context.get_creation_context(scope) else { + return; + }; + let scope = &mut v8::ContextScope::new(scope, realm); + let Ok(prototype) = ensure_intrinsic_interface_prototype(scope, "AudioWorklet") else { + return; + }; + let worklet = AudioWorkletObjectDeclaration::new(prototype, context) + .bind(scope) + .expect("AudioWorklet declaration should bind"); + set_private_value(scope, context, AUDIO_CONTEXT_WORKLET_SLOT, worklet.into()); + rv.set(worklet.into()); +} + +#[derive(Clone, Copy, Default, webidl::WebIdlEnum)] +#[webidl(name = "RequestCredentials", rename_all = "kebab-case")] +enum WorkletCredentials { + Omit, + #[default] + SameOrigin, + Include, +} + +impl WorkletCredentials { + fn as_str(self) -> &'static str { + match self { + Self::Omit => "omit", + Self::SameOrigin => "same-origin", + Self::Include => "include", + } + } +} + +#[derive(Default, webidl::WebIdlDictionary)] +#[webidl(prefix = "WorkletOptions")] +struct WorkletOptions { + #[webidl(converter = "enum", default = WorkletCredentials::SameOrigin)] + credentials: WorkletCredentials, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Worklet.addModule")] +struct WorkletAddModuleArgs { + #[webidl(required, converter = "usv_string")] + module_url: String, + #[webidl(with = worklet_options_arg)] + options: WorkletOptions, +} + +fn worklet_options_arg<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + index: i32, +) -> Result { + webidl::parse_dictionary( + scope, + args.get(index), + webidl::Context::argument("Worklet.addModule", 2), + ) + .map(Option::unwrap_or_default) +} + fn audio_worklet_add_module_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'s, v8::Value>, ) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; let worklet = args.this(); let Some(context) = web_audio_object_slot(scope, worklet, AUDIO_WORKLET_CONTEXT_SLOT) else { - let error = type_error_value( - scope, - "AudioWorklet.addModule called on incompatible receiver.", - ) - .unwrap_or_else(|| v8::undefined(scope).into()); - set_rejected_promise_return(scope, &mut rv, error); + throw_type_error(scope, "AudioWorklet context state is unavailable."); return; }; - let Some(module_url) = resolve_audio_worklet_module_url(scope, args.get(0)) else { - let error = type_error_value(scope, "AudioWorklet.addModule module URL is invalid.") - .unwrap_or_else(|| v8::undefined(scope).into()); - set_rejected_promise_return(scope, &mut rv, error); + // Argument conversion failures use the method's realm. The operation's + // Promise, URL resolution and workers use the Worklet's relevant realm. + let Some(realm) = worklet.get_creation_context(scope) else { return; }; - let credentials = match audio_worklet_credentials(scope, &args) { - Ok(credentials) => credentials, - Err(message) => { - let error = - type_error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into()); - set_rejected_promise_return(scope, &mut rv, error); - return; - } - }; - let Some(modules) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_MODULES_SLOT) else { - let error = type_error_value(scope, "AudioWorklet context state is unavailable.") - .unwrap_or_else(|| v8::undefined(scope).into()); - set_rejected_promise_return(scope, &mut rv, error); - return; - }; - if let Some(existing_module) = map_get_object(scope, modules, &module_url) - && let Some(promise) = - get_private_value(scope, existing_module, AUDIO_WORKLET_MODULE_PROMISE_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) - { - rv.set(promise.into()); - return; - } - + let scope = &mut v8::ContextScope::new(scope, realm); let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set_undefined(); return; }; let promise = resolver.get_promise(scope); - let Some(worker) = create_audio_worklet_module_worker(scope, &module_url, credentials) else { + rv.set(promise.into()); + let Some(module_url) = resolve_audio_worklet_module_url(scope, &parsed.module_url) else { + let error = new_dom_exception_value( + scope, + "AudioWorklet.addModule module URL is invalid.", + "SyntaxError", + ); + let _ = resolver.reject(scope, error); + return; + }; + let Some(modules) = web_audio_object_slot(scope, context, AUDIO_CONTEXT_MODULES_SLOT) else { + let error = v8::Exception::type_error( + scope, + v8str(scope, "AudioWorklet context state is unavailable."), + ); + let _ = resolver.reject(scope, error); + return; + }; + if let Some(module_state) = map_get_object(scope, modules, &module_url) { + if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_SETTLED_SLOT) { + if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_LOADED_SLOT) + { + let _ = resolver.resolve(scope, v8::undefined(scope).into()); + } else if let Some(error) = + get_private_value(scope, module_state, AUDIO_WORKLET_MODULE_ERROR_SLOT) + { + let _ = resolver.reject(scope, error); + } + } else if let Some(resolvers) = + web_audio_array_slot(scope, module_state, AUDIO_WORKLET_MODULE_RESOLVERS_SLOT) + { + let key = v8_string(scope, &resolvers.length().to_string()).expect("array index"); + let _ = resolvers.create_data_property(scope, key.into(), resolver.into()); + } + return; + } + let Some(worker) = + create_audio_worklet_module_worker(scope, &module_url, parsed.options.credentials.as_str()) + else { let error = type_error_value(scope, "AudioWorklet module failed.") .unwrap_or_else(|| v8::undefined(scope).into()); let _ = resolver.reject(scope, error); - rv.set(promise.into()); return; }; - + let resolvers = v8::Array::new_with_elements(scope, &[resolver.into()]); let module_state = - AudioWorkletModuleStateDeclaration::new(context, worker, promise, resolver, false, false) + AudioWorkletModuleStateDeclaration::new(context, worker, resolvers, false, false) .bind(scope) .expect("AudioWorklet module state declaration should bind"); install_audio_worklet_worker_callbacks(scope, worker, module_state); let _ = map_set_object(scope, modules, &module_url, module_state); if let Some(module_list) = web_audio_array_slot(scope, context, AUDIO_CONTEXT_MODULE_LIST_SLOT) { - array_push_value(scope, module_list, module_state.into()); + let key = v8_string(scope, &module_list.length().to_string()).expect("array index"); + let _ = module_list.create_data_property(scope, key.into(), module_state.into()); } - - rv.set(promise.into()); } fn install_audio_worklet_worker_callbacks<'s>( @@ -756,35 +851,12 @@ fn audio_worklet_worker_message_callback<'s>( }; let _ = map_set_object(scope, processors, &name, module_state); } - "module-loaded" => { - if audio_worklet_module_bool_slot( - scope, - module_state, - AUDIO_WORKLET_MODULE_SETTLED_SLOT, - ) { - return; - } - set_audio_worklet_module_bool_slot( - scope, - module_state, - AUDIO_WORKLET_MODULE_LOADED_SLOT, - true, - ); - set_audio_worklet_module_bool_slot( - scope, - module_state, - AUDIO_WORKLET_MODULE_SETTLED_SLOT, - true, - ); - if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) { - let _ = resolver.resolve(scope, v8::undefined(scope).into()); - } - } + "module-loaded" => settle_audio_worklet_module(scope, module_state, Ok(())), "processor-error" => { let message = object_string_property(scope, message, "message") .unwrap_or_else(|| "AudioWorklet processor failed.".to_owned()); let error = error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into()); - fail_audio_worklet_module(scope, module_state, error); + settle_audio_worklet_module(scope, module_state, Err(error)); } _ => {} } @@ -793,7 +865,7 @@ fn audio_worklet_worker_message_callback<'s>( fn audio_worklet_worker_error_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, - _rv: v8::ReturnValue<'s, v8::Value>, + mut rv: v8::ReturnValue<'s, v8::Value>, ) { let Some(module_state) = audio_worklet_callback_module_state(scope, &args) else { return; @@ -807,7 +879,10 @@ fn audio_worklet_worker_error_callback<'s>( }) .unwrap_or_else(|| "AudioWorklet module failed.".to_owned()); let error = error_value(scope, &message).unwrap_or_else(|| v8::undefined(scope).into()); - fail_audio_worklet_module(scope, module_state, error); + settle_audio_worklet_module(scope, module_state, Err(error)); + // This internal worker failure is reported through addModule's Promise. + // Cancel the handled error instead of reporting it again on Window. + rv.set_bool(false); } fn audio_worklet_node_constructor_callback<'s>( @@ -884,28 +959,6 @@ fn audio_worklet_node_constructor_callback<'s>( rv.set(node.into()); } -fn set_rejected_promise_return<'s>( - scope: &mut v8::PinScope<'s, '_>, - rv: &mut v8::ReturnValue<'s, v8::Value>, - error: v8::Local<'s, v8::Value>, -) { - if let Some(promise) = rejected_promise(scope, error) { - rv.set(promise.into()); - } else { - rv.set(v8::undefined(scope).into()); - } -} - -fn rejected_promise<'s>( - scope: &mut v8::PinScope<'s, '_>, - error: v8::Local<'s, v8::Value>, -) -> Option> { - let resolver = v8::PromiseResolver::new(scope)?; - let promise = resolver.get_promise(scope); - let _ = resolver.reject(scope, error); - Some(promise) -} - fn resolved_undefined_promise<'s>( scope: &mut v8::PinScope<'s, '_>, ) -> Option> { @@ -958,50 +1011,23 @@ fn map_set_object<'s>( fn resolve_audio_worklet_module_url( scope: &mut v8::PinScope<'_, '_>, - value: v8::Local<'_, v8::Value>, + input: &str, ) -> Option { - let input = value.to_string(scope)?.to_rust_string_lossy(scope); - let base_url = if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - let host = unsafe { &*host_ptr }; - super::worker_host::worker_constructor_base_url(host) - } else { - let global = scope.get_current_context().global(scope); - let location = object_property_as_object(scope, global, "location")?; - let href = object_string_property(scope, location, "href")?; - url::Url::parse(&href).ok()? - }; - base_url.join(&input).ok().map(|url| url.to_string()) -} + use crate::native_bridge::OwnerDispatchScope; -fn audio_worklet_credentials( - scope: &mut v8::PinScope<'_, '_>, - args: &v8::FunctionCallbackArguments<'_>, -) -> std::result::Result<&'static str, String> { - let value = args.get(1); - if value.is_undefined() || value.is_null() { - return Ok("same-origin"); - } - let Ok(options) = v8::Local::::try_from(value) else { - return Ok("same-origin"); + let host_ptr = context_host_ptr_from_global_bridge(scope)?; + // SAFETY: the bridge owns the host for the lifetime of the callback. + let host = unsafe { &*host_ptr }; + let global = scope.get_current_context().global(scope); + let document = match super::navigation_window::runtime_window_dispatch_scope(scope, global)? { + OwnerDispatchScope::Top => host.document_handle(), + OwnerDispatchScope::Child(handle) => host.child_browsing_context_document_handle(handle)?, + OwnerDispatchScope::LightweightPopup(id) => host.lightweight_popup_document_handle(id)?, }; - let Some(credentials) = options.get(scope, v8str(scope, "credentials").into()) else { - return Ok("same-origin"); - }; - if credentials.is_undefined() { - return Ok("same-origin"); - } - let Some(credentials) = credentials.to_string(scope) else { - return Err("AudioWorklet.addModule options.credentials is invalid.".to_owned()); - }; - let credentials = credentials.to_rust_string_lossy(scope); - match credentials.as_str() { - "omit" => Ok("omit"), - "same-origin" => Ok("same-origin"), - "include" => Ok("include"), - _ => Err(format!( - "The provided value '{credentials}' is not a valid enum value of type RequestCredentials." - )), - } + host.document_base_url_for_handle(document) + .join(input) + .ok() + .map(|url| url.to_string()) } fn create_audio_worklet_module_worker<'s>( @@ -1176,10 +1202,10 @@ fn audio_worklet_callback_module_state<'s>( web_audio_object_slot(scope, data, AUDIO_WORKLET_CALLBACK_MODULE_SLOT) } -fn fail_audio_worklet_module<'s>( +fn settle_audio_worklet_module<'s>( scope: &mut v8::PinScope<'s, '_>, module_state: v8::Local<'s, v8::Object>, - error: v8::Local<'s, v8::Value>, + result: Result<(), v8::Local<'s, v8::Value>>, ) { if audio_worklet_module_bool_slot(scope, module_state, AUDIO_WORKLET_MODULE_SETTLED_SLOT) { return; @@ -1190,8 +1216,45 @@ fn fail_audio_worklet_module<'s>( AUDIO_WORKLET_MODULE_SETTLED_SLOT, true, ); - if let Some(resolver) = audio_worklet_module_resolver(scope, module_state) { - let _ = resolver.reject(scope, error); + set_audio_worklet_module_bool_slot( + scope, + module_state, + AUDIO_WORKLET_MODULE_LOADED_SLOT, + result.is_ok(), + ); + if let Err(error) = result { + set_private_value(scope, module_state, AUDIO_WORKLET_MODULE_ERROR_SLOT, error); + } + let Some(resolvers) = + web_audio_array_slot(scope, module_state, AUDIO_WORKLET_MODULE_RESOLVERS_SLOT) + else { + return; + }; + // Drop the state's references before settling, and never invoke author + // Promise.then / constructor properties to join a module response. + set_private_value( + scope, + module_state, + AUDIO_WORKLET_MODULE_RESOLVERS_SLOT, + v8::undefined(scope).into(), + ); + for index in 0..resolvers.length() { + let Some(value) = resolvers.get_index(scope, index) else { + continue; + }; + let Ok(object) = v8::Local::::try_from(value) else { + continue; + }; + // Only native PromiseResolvers are written to this private array. + let resolver = unsafe { v8::Local::::cast_unchecked(object) }; + match result { + Ok(()) => { + let _ = resolver.resolve(scope, v8::undefined(scope).into()); + } + Err(error) => { + let _ = resolver.reject(scope, error); + } + } } } @@ -1215,15 +1278,6 @@ fn set_audio_worklet_module_bool_slot<'s>( set_private_value(scope, object, slot, value.into()); } -fn audio_worklet_module_resolver<'s>( - scope: &mut v8::PinScope<'s, '_>, - object: v8::Local<'s, v8::Object>, -) -> Option> { - get_private_value(scope, object, AUDIO_WORKLET_MODULE_RESOLVER_SLOT) - .and_then(|value| v8::Local::::try_from(value).ok()) - .map(|object| unsafe { v8::Local::::cast_unchecked(object) }) -} - fn dynamics_compressor_reduction_getter_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -1272,6 +1326,22 @@ pub(in crate::context_bootstrap) fn offline_audio_context_constructor_callback<' let context = args.this(); let destination = audio_destination_node(scope, context); let compressors = v8::Array::new(scope, 0); + let modules = new_web_audio_map_object(scope); + let module_list = v8::Array::new(scope, 0); + let processors = new_web_audio_map_object(scope); + set_private_value(scope, context, AUDIO_CONTEXT_MODULES_SLOT, modules.into()); + set_private_value( + scope, + context, + AUDIO_CONTEXT_MODULE_LIST_SLOT, + module_list.into(), + ); + set_private_value( + scope, + context, + AUDIO_CONTEXT_PROCESSORS_SLOT, + processors.into(), + ); OfflineAudioContextObjectDeclaration::new( 0.0, length, diff --git a/moli-renderer-v8/src/runtime/page_vm/tests/worker/audio_worklet_and_message_ports.rs b/moli-renderer-v8/src/runtime/page_vm/tests/worker/audio_worklet_and_message_ports.rs index 0f7da7ac84..14b86962cf 100644 --- a/moli-renderer-v8/src/runtime/page_vm/tests/worker/audio_worklet_and_message_ports.rs +++ b/moli-renderer-v8/src/runtime/page_vm/tests/worker/audio_worklet_and_message_ports.rs @@ -227,7 +227,14 @@ async fn audio_worklet_close_rejects_pending_add_module_response() { globalThis.__audioWorkletCloseDone = false; const context = new AudioContext(); globalThis.__audioWorkletCloseContext = context; - context.audioWorklet.addModule({module_url_literal}).then( + const first = context.audioWorklet.addModule({module_url_literal}); + const second = context.audioWorklet.addModule({module_url_literal}); + Promise.allSettled([first, second]).then(results => {{ + if (first === second || results.some(result => result.status !== "rejected" || result.reason.name !== "AbortError")) {{ + throw new Error("Every pending caller must independently reject on close"); + }} + throw results[0].reason; + }}).then( () => {{ globalThis.__audioWorkletCloseResult = "resolved"; globalThis.__audioWorkletCloseDone = true; @@ -1447,3 +1454,47 @@ async fn worker_owned_messageport_options_transfer_null_clone_path_responds() { }) .await; } + +#[tokio::test] +async fn worklet_module_promises_preserve_independent_native_waiters() { + run_page_vm_async_test(async move { + let mut page_vm = test_page_vm_with_document_url(Url::parse("https://worklet-promises.test/").unwrap()); + let local_executor = page_vm.local_executor.clone(); + let result = local_executor.run(async move { + page_vm.vm_mut().eval(&format!("({}).then(value => globalThis.__workletDone = value, error => globalThis.__workletDone = String(error));", include_str!("../../../../script_vm/tests/worklet_module_promises.js")))?; + drive_websocket_until_done(&mut page_vm, "String(globalThis.__workletDone !== undefined)", "Worklet module waiters should settle").await?; + page_vm.vm_mut().eval("JSON.stringify(__nodeReplacementResults.failures)") + }).await.expect("Worklet module fixture should complete"); + assert_eq!(result, "[]"); + }).await; +} + +#[tokio::test] +async fn borrowed_worklet_add_module_uses_receiver_document_base() { + run_page_vm_async_test(async move { + let (base_url, request_rx, server) = spawn_shared_worker_script_capture_http_server( + "registerProcessor('receiver-base', class extends AudioWorkletProcessor {});", + ).await; + let mut page_vm = test_page_vm_with_document_url(Url::parse(&format!("{base_url}/main/page.html")).unwrap()); + let base = serde_json::to_string(&format!("{base_url}/worklet/")).unwrap(); + let local_executor = page_vm.local_executor.clone(); + let result = local_executor.run(async move { + page_vm.vm_mut().eval(&format!(r#" + const frame = document.createElement('iframe'); document.body.append(frame); + const other = frame.contentWindow; + const base = other.document.createElement('base'); base.href = {base}; other.document.head.append(base); + const context = new other.AudioContext(); + Worklet.prototype.addModule.call(context.audioWorklet, 'entry.js').then(() => {{ + globalThis.__receiverBaseResult = String(new other.AudioWorkletNode(context, 'receiver-base') instanceof other.AudioWorkletNode); + return context.close(); + }}).catch(error => globalThis.__receiverBaseResult = String(error)); + "#))?; + drive_websocket_until_done(&mut page_vm, "String(globalThis.__receiverBaseResult !== undefined)", "borrowed addModule should settle").await?; + page_vm.vm_mut().eval("__receiverBaseResult") + }).await.expect("receiver base URL test should complete"); + assert_eq!(result, "true"); + let request = request_rx.await.expect("module request"); + assert!(request.starts_with("GET /worklet/entry.js "), "{request}"); + server.await.expect("server should finish"); + }).await; +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 730f326813..7cf085e16c 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2156,6 +2156,7 @@ mod webidl_receivers; mod webidl_trusted_types; mod websocket; mod window_execution_context; +mod worklet_interfaces; mod string_timers; diff --git a/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.js b/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.js new file mode 100644 index 0000000000..e0fa5cb218 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.js @@ -0,0 +1,88 @@ +(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 realms = [['main', window], ['child', document.getElementById('child').contentWindow]]; + for (const [label, w] of realms) { + if (!w.isSecureContext) { + await check(label + '/insecure', () => assert(!('Worklet' in w) && !('AudioWorklet' in w) && !('audioWorklet' in w.BaseAudioContext.prototype) && !('audioWorklet' in new w.OfflineAudioContext(1,1,44100)), 'secure globals and attribute hidden')); + continue; + } + for (const name of ['Worklet', 'AudioWorklet']) { + await check(label + '/' + name, () => { + const C = w[name], parent = name === 'AudioWorklet' ? w.Worklet : 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'); + 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'); } + }); + } + await check(label + '/receiver', async () => { + const method = w.Worklet.prototype.addModule, d = Object.getOwnPropertyDescriptor(w.Worklet.prototype, 'addModule'); + assert(method.length === 1 && d.enumerable && d.configurable && d.writable, 'shared operation descriptor'); + let conversions = 0, traps = 0; const trap = () => { traps++; throw Error('author trap'); }; + const url = { toString() { conversions++; return 'data:text/javascript,'; } }, options = { get credentials() { conversions++; return 'same-origin'; } }; + const revoked = Proxy.revocable({}, {}); revoked.revoke(); + for (const receiver of [null, {}, w.Worklet.prototype, w.AudioWorklet.prototype, Object.create(w.Worklet.prototype), new Proxy({}, { get: trap, getPrototypeOf: trap }), revoked.proxy]) { + const promise = method.call(receiver, url, options); assert(promise instanceof w.Promise, 'callee Promise'); + let error; try { await promise; } catch (e) { error = e; } assert(error instanceof w.TypeError, 'callee TypeError rejection'); + } + assert(conversions === 0 && traps === 0, 'receiver validation precedes author callbacks'); + }); + let context; + try { + await check(label + '/native-factory', () => { + const C = w.AudioWorklet, descriptor = Object.getOwnPropertyDescriptor(w, 'AudioWorklet'); let reads = 0; + Object.defineProperty(w, 'AudioWorklet', { configurable: true, get() { reads++; throw Error('author constructor'); } }); + try { context = new w.AudioContext(); } finally { if (descriptor) Object.defineProperty(w, 'AudioWorklet', descriptor); else delete w.AudioWorklet; } + const worklet = context.audioWorklet; + const d = Object.getOwnPropertyDescriptor(w.BaseAudioContext.prototype, 'audioWorklet'); + assert(d && typeof d.get === 'function' && d.set === undefined && d.enumerable && d.configurable && !Object.hasOwn(context, 'audioWorklet'), 'shared readonly attribute'); + const offline = new w.OfflineAudioContext(1, 1, 44100); + assert(Object.getPrototypeOf(offline.audioWorklet) === C.prototype && offline.audioWorklet === offline.audioWorklet, 'offline worklet'); + assert(d.get.call(context) === worklet, 'genuine receiver'); + for (const value of [{}, Object.create(context), new Proxy(context, {})]) { let error; try {d.get.call(value);} catch(e) {error=e;} assert(error instanceof w.TypeError, 'getter receiver'); } + assert(Object.getPrototypeOf(worklet) === C.prototype && worklet instanceof w.Worklet, 'native derived prototype'); + assert(worklet === context.audioWorklet && !Object.hasOwn(worklet, 'addModule') && worklet.addModule === w.Worklet.prototype.addModule && reads === 0, 'shared operation and intrinsic factory'); + }); + await check(label + '/argument-conversion', async () => { + const worklet = context.audioWorklet; + for (const args of [[], [Symbol()], ['data:text/javascript,', 7], ['data:text/javascript,', {credentials:'invalid'}]]) { + let error; const promise=worklet.addModule(...args); assert(promise instanceof w.Promise, 'conversion rejection is Promise'); + try { await promise; } catch (e) { error=e; } assert(error instanceof w.TypeError, 'invalid arguments'); + } + const sentinel = {}, order=[]; let error; + const promise=worklet.addModule({toString(){order.push('url');return 'data:text/javascript,';}},{get credentials(){order.push('credentials');return {toString(){order.push('string');throw sentinel;}};}}); + try { await promise; } catch (e) {error=e;} + assert(error===sentinel && order.join()==='url,credentials,string', 'dictionary order and original exception'); + }); + await check(label + '/invalid-url', async () => { + let error; + try { await context.audioWorklet.addModule('https://['); } catch(e) {error=e;} + assert(error instanceof w.DOMException && error.name === 'SyntaxError', 'invalid URL DOMException'); + const sentinel = {}; + try { await context.audioWorklet.addModule('https://[', {get credentials(){throw sentinel;}}); } catch(e) {error=e;} + assert(error === sentinel, 'dictionary conversion before URL parsing'); + }); + await check(label + '/cross-realm', async () => { + const other = realms.find(([,r]) => r !== w)[1]; + const otherContext = new other.AudioContext(); + try { + const getter = Object.getOwnPropertyDescriptor(w.BaseAudioContext.prototype, 'audioWorklet').get; + const receiver = getter.call(otherContext); + assert(Object.getPrototypeOf(receiver) === other.AudioWorklet.prototype, 'getter uses receiver realm'); + const p = w.Worklet.prototype.addModule.call(receiver, 'https://['); + let error; try {await p;} catch(e) {error=e;} + assert(p instanceof other.Promise && error instanceof other.DOMException && error.name === 'SyntaxError', 'valid receiver realm'); + const invalid = w.Worklet.prototype.addModule.call(receiver, Symbol()); + try {await invalid;} catch(e) {error=e;} + assert(invalid instanceof w.Promise && error instanceof w.TypeError, 'conversion rejection callee realm'); + } finally {await otherContext.close();} + }); + } finally { if (context) await context.close(); } + } + globalThis.__nodeReplacementResults={rows,failures:rows.filter(row=>!row.pass),passed:rows.filter(row=>row.pass).length,total:rows.length}; + return rows.every(row=>row.pass); +})() diff --git a/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.rs b/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.rs new file mode 100644 index 0000000000..a107c15c10 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/worklet_interfaces.rs @@ -0,0 +1,21 @@ +use super::*; + +#[test] +fn worklet_interfaces_preserve_realms_brands_and_secure_exposure() { + for url in [ + "https://worklet-interfaces.test/", + "http://worklet-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.__workletDone = value, error => globalThis.__workletDone = String(error));", include_str!("worklet_interfaces.js"))).unwrap(); + assert_eq!( + vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)") + .unwrap(), + "[]", + "{url}" + ); + assert_eq!(vm.eval("__workletDone").unwrap(), "true", "{url}"); + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/worklet_module_promises.js b/moli-renderer-v8/src/script_vm/tests/worklet_module_promises.js new file mode 100644 index 0000000000..48d5f85b04 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/worklet_module_promises.js @@ -0,0 +1,49 @@ +(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 context = new AudioContext(), worklet = context.audioWorklet; + const source = text => 'data:text/javascript,' + encodeURIComponent(text); + const errors = [], onError = event => errors.push(event.message); + addEventListener('error', onError); + try { + await check('pending-and-completed-module-promises', async () => { + const url = source('registerProcessor("fresh-promise", class extends AudioWorkletProcessor {});'); + const first = worklet.addModule(url); let reads=0; + const poison = () => {reads++; throw Error('public Promise property');}; + Object.defineProperties(first, {then:{get:poison,configurable:true}, constructor:{get:poison,configurable:true}}); + let second; + // A second call appends a native waiter; inherited array setters must not run. + Object.defineProperty(Array.prototype, '1', {configurable:true, set:poison}); + try {second = worklet.addModule(url);} finally {delete Array.prototype[1];delete first.then;delete first.constructor;} + assert(reads===0 && first!==second, 'independent pending Promise without author hooks'); + const values = await Promise.all([first, second]); + assert(values.every(value => value===undefined), 'all callers fulfilled'); + Object.defineProperties(first, {then:{get:poison,configurable:true}, constructor:{get:poison,configurable:true}}); + let third; + try {third=worklet.addModule(url);} finally {delete first.then;delete first.constructor;} + assert(reads===0 && third!==first && third!==second, 'independent completed Promise'); + assert(await third === undefined, 'completed response reused'); + assert(new AudioWorkletNode(context, 'fresh-promise') instanceof AudioWorkletNode, 'processor registration retained'); + }); + await check('failed-module-promises', async () => { + const url=source('export {'); + const first=worklet.addModule(url), second=worklet.addModule(url); + assert(first!==second, 'independent failed pending Promise'); + const results=await Promise.allSettled([first,second]); + assert(results.every(result=>result.status==='rejected'), 'all failed waiters settle'); + const third=worklet.addModule(url);assert(third!==first && third!==second, 'independent rejected cached Promise'); + const [result]=await Promise.allSettled([third]);assert(result.status==='rejected', 'cached failure settles'); + assert(errors.length === 0, 'module rejection does not report internal Worker error on Window'); + }); + await check('offline-worklet-module', async () => { + const offline=new OfflineAudioContext(1,1,44100); + const url=source('registerProcessor("offline-module", class extends AudioWorkletProcessor {});'); + await offline.audioWorklet.addModule(url); + const node=new AudioWorkletNode(offline, 'offline-module'); + assert(node instanceof AudioWorkletNode, 'offline processor registered'); + node.connect(offline.destination); + }); + } finally {removeEventListener('error', onError);await context.close();} + globalThis.__nodeReplacementResults={rows,failures:rows.filter(row=>!row.pass),passed:rows.filter(row=>row.pass).length,total:rows.length}; + return rows.every(row=>row.pass); +})() diff --git a/moli-renderer-v8/src/util.rs b/moli-renderer-v8/src/util.rs index a47c101346..3b9cdb8b3a 100644 --- a/moli-renderer-v8/src/util.rs +++ b/moli-renderer-v8/src/util.rs @@ -13,8 +13,8 @@ pub use moli_v8_util::{ object_own_static_string_property, object_property_as_array, object_property_as_object, object_string_property, private_key, register_intrinsic_interface, registered_intrinsic_constructor, registered_intrinsic_prototype, set_null_prototype, - set_private_value, set_symbol_to_string_tag, throw_range_error, throw_type_error, - v8_json_parse, v8_string, v8str, walk_object_chain, + set_private_value, throw_range_error, throw_type_error, v8_json_parse, v8_string, v8str, + walk_object_chain, }; use moli_webapi_declare::WebApiValue; pub(crate) use moli_webapi_declare::define_array_data_property as define_v8_array_data_property; diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index 7f531fa940..7e4bb8f88c 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -35,7 +35,8 @@ interfaces! { AudioContext: BaseAudioContext; AudioDestinationNode; AudioParam; - AudioWorklet; + Worklet; + AudioWorklet: Worklet; AudioWorkletNode; BaseAudioContext: EventTarget; BatteryManager; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index 6220e689ed..6a50f11731 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -1889,6 +1889,24 @@ async fn worker_self_close() { // ─── Error propagation ────────────────────────────────────────────── +#[tokio::test] +async fn worker_does_not_expose_window_worklet_interfaces() { + ensure_v8(); + let mut handle = spawn_worker( + "postMessage({Worklet: 'Worklet' in self, AudioWorklet: 'AudioWorklet' in self}); close();" + .to_owned(), + "https://worklet-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#"{"Worklet":false,"AudioWorklet":false}"# + ); +} + #[tokio::test] async fn worker_error_propagation() { ensure_v8();