diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index df7ff226ba..f9b3ea1200 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -28,6 +28,7 @@ use super::super::{ }, media_source::media_source_constructor_callback, message_ports::{message_channel_constructor_callback, message_port_constructor_callback}, + navigator_runtime::clipboard_item_constructor_callback, notification_runtime::notification_constructor_callback, performance_runtime::performance_observer_constructor_callback, range_surface::{ @@ -108,6 +109,11 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( ConstructorKind::ClipboardEvent => { build_event_subclass_template(scope, EventSubclassKind::ClipboardEvent) } + ConstructorKind::ClipboardItem => { + v8::FunctionTemplate::builder(clipboard_item_constructor_callback) + .length(1) + .build(scope) + } ConstructorKind::KeyboardEvent => { build_event_subclass_template(scope, EventSubclassKind::KeyboardEvent) } 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 96ae3fe667..c4c9033935 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -80,7 +80,13 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = "FormData", ]; -const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &["SubtleCrypto", "CryptoKey", "IdleDetector"]; +const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ + "SubtleCrypto", + "CryptoKey", + "IdleDetector", + "Clipboard", + "ClipboardItem", +]; const WORKER_ONLY_INTERFACE_NAMES: &[&str] = &["WorkerNavigator", "WorkerLocation"]; const WINDOW_DEDICATED_AND_SHARED_WORKER_INTERFACE_NAMES: &[&str] = &[ "Worker", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index 7064d617a4..d9a38ec8ab 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -1,3 +1,4 @@ +mod clipboard; mod collections; mod geolocation; mod media_capabilities; @@ -7,6 +8,7 @@ mod screen; mod visual_viewport; mod window_state; +pub(in crate::context_bootstrap) use self::clipboard::clipboard_item_constructor_callback; pub(crate) use self::navigator::build_lightweight_popup_window_navigator_object; pub(super) use self::navigator::install_navigator_template_bindings; pub(crate) use self::navigator::install_worker_navigator_runtime_state; diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs new file mode 100644 index 0000000000..97aea80a78 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/clipboard.rs @@ -0,0 +1,1075 @@ +use super::super::{SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, SIMPLE_EVENT_TARGET_SLOT}; +use crate::{ + blob, + util::{get_private_value, set_private_value, throw_type_error, v8_string}, + webidl, +}; +use anyhow::{Result, anyhow}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const CLIPBOARD_BRAND_SLOT: &str = "__moliClipboardBrand"; +const CLIPBOARD_ITEMS_SLOT: &str = "__moliClipboardItems"; +const CLIPBOARD_TEXT_SLOT: &str = "__moliClipboardText"; +const CLIPBOARD_EVENT_LISTENERS_SLOT: &str = "__moliClipboardEventListeners"; + +const CLIPBOARD_ITEM_BRAND_SLOT: &str = "__moliClipboardItemBrand"; +const CLIPBOARD_ITEM_DATA_SLOT: &str = "__moliClipboardItemData"; +const CLIPBOARD_ITEM_RAW_DATA_SLOT: &str = "__moliClipboardItemRawData"; +const CLIPBOARD_ITEM_TYPES_SLOT: &str = "__moliClipboardItemTypes"; +const CLIPBOARD_ITEM_PRESENTATION_STYLE_SLOT: &str = "__moliClipboardItemPresentationStyle"; + +#[derive(Default, WebApiObject)] +#[webapi(interface = "Clipboard")] +struct ClipboardObjectDeclaration { + #[webapi(slot = CLIPBOARD_BRAND_SLOT, init = true)] + brand: (), + + #[webapi(slot = SIMPLE_EVENT_TARGET_SLOT, value = CLIPBOARD_EVENT_LISTENERS_SLOT)] + event_target_slot: (), + + #[webapi(slot = SIMPLE_EVENT_TARGET_ORDERED_HANDLERS_SLOT, init = true)] + ordered_handlers: (), + + #[webapi(slot = CLIPBOARD_ITEMS_SLOT, init = "undefined")] + items: (), + + #[webapi(slot = CLIPBOARD_TEXT_SLOT, init = "")] + text: (), +} + +#[derive(Default, WebApiFunctionTemplate)] +#[webapi(name = "Clipboard", enumerable)] +struct ClipboardPrototypeDeclaration { + #[webapi(method, length = 0, callback = clipboard_read_callback)] + read: (), + + #[webapi(method, length = 0, callback = clipboard_read_text_callback)] + read_text: (), + + #[webapi(method, length = 1, callback = clipboard_write_callback)] + write: (), + + #[webapi(method, length = 1, callback = clipboard_write_text_callback)] + write_text: (), +} + +#[derive(WebApiObject)] +#[webapi(interface = "ClipboardItem")] +struct ClipboardItemObjectDeclaration<'scope> { + #[webapi(slot = CLIPBOARD_ITEM_BRAND_SLOT, init = true)] + brand: (), + + #[webapi(slot = CLIPBOARD_ITEM_DATA_SLOT)] + data: v8::Local<'scope, v8::Object>, + + #[webapi(slot = CLIPBOARD_ITEM_RAW_DATA_SLOT)] + raw_data: v8::Local<'scope, v8::Object>, + + #[webapi(slot = CLIPBOARD_ITEM_TYPES_SLOT)] + types: v8::Local<'scope, v8::Array>, + + #[webapi(slot = CLIPBOARD_ITEM_PRESENTATION_STYLE_SLOT)] + presentation_style: String, +} + +#[derive(Default, WebApiFunctionTemplate)] +#[webapi(name = "ClipboardItem", enumerable)] +struct ClipboardItemPrototypeDeclaration { + #[webapi(accessor_property, getter = clipboard_item_presentation_style_getter)] + presentation_style: (), + + #[webapi(accessor_property, getter = clipboard_item_types_getter)] + types: (), + + #[webapi(method, length = 1, callback = clipboard_item_get_type_callback)] + get_type: (), +} + +#[derive(Default, WebApiFunctionTemplate)] +#[webapi(name = "ClipboardItem", enumerable)] +struct ClipboardItemConstructorDeclaration { + #[webapi(static_method, length = 1, callback = clipboard_item_supports_callback)] + supports: (), +} + +#[derive(Clone, Copy, Default, webidl::WebIdlEnum)] +#[webidl(name = "PresentationStyle", rename_all = "kebab-case")] +enum PresentationStyle { + #[default] + Unspecified, + Inline, + Attachment, +} + +impl PresentationStyle { + const fn as_str(self) -> &'static str { + match self { + Self::Unspecified => "unspecified", + Self::Inline => "inline", + Self::Attachment => "attachment", + } + } +} + +#[derive(Default, webidl::WebIdlDictionary)] +#[webidl(prefix = "ClipboardItemOptions")] +struct ClipboardItemOptions { + #[webidl(converter = "enum", default = PresentationStyle::Unspecified)] + presentation_style: PresentationStyle, +} + +#[derive(Default, webidl::WebIdlDictionary)] +#[webidl(prefix = "ClipboardUnsanitizedFormats")] +struct ClipboardUnsanitizedFormats { + #[webidl(with = clipboard_unsanitized_formats_member)] + unsanitized: Option>, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "ClipboardItem")] +struct ClipboardItemConstructorArgs<'scope> { + #[webidl(required, with = clipboard_item_record_arg)] + items: Vec<(String, v8::Local<'scope, v8::Promise>)>, + + #[webidl(with = clipboard_item_options_arg)] + options: ClipboardItemOptions, +} + +struct ClipboardItemReference<'scope>(v8::Local<'scope, v8::Object>); + +impl<'scope> webidl::WebIdlConverter<'scope> for ClipboardItemReference<'scope> { + type Options = (); + + fn convert( + scope: &mut v8::PinScope<'scope, '_>, + value: v8::Local<'scope, v8::Value>, + context: webidl::Context, + _options: &Self::Options, + ) -> std::result::Result { + let object = webidl::convert::>(scope, value, context)?; + if !clipboard_item_receiver_branded(scope, object) { + return Err(webidl::WebIdlError::custom_message( + "Clipboard.write data must contain ClipboardItem objects.", + )); + } + Ok(Self(object)) + } +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Clipboard.read")] +struct ClipboardReadArgs { + #[webidl(with = clipboard_read_formats_arg)] + formats: ClipboardUnsanitizedFormats, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Clipboard.write")] +struct ClipboardWriteArgs<'scope> { + #[webidl(required, with = clipboard_write_data_arg)] + data: Vec>, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Clipboard.writeText")] +struct ClipboardWriteTextArgs { + #[webidl(required)] + data: String, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "ClipboardItem.getType")] +struct ClipboardItemGetTypeArgs { + #[webidl(required, name = "type")] + mime_type: String, +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "ClipboardItem.supports")] +struct ClipboardItemSupportsArgs { + #[webidl(required, name = "type")] + mime_type: String, +} + +pub(super) fn install_clipboard_template_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + interface_name: &str, +) { + match interface_name { + "Clipboard" => ClipboardPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ), + "ClipboardItem" => { + ClipboardItemPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); + ClipboardItemConstructorDeclaration::initialize_template(scope, template); + } + _ => {} + } +} + +pub(super) fn build_clipboard_object<'s>( + scope: &mut v8::PinScope<'s, '_>, +) -> Result> { + ClipboardObjectDeclaration::default() + .bind(scope) + .map_err(|error| anyhow!("failed to bind navigator.clipboard object: {error}")) +} + +pub(in crate::context_bootstrap) fn clipboard_item_constructor_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !args.is_construct_call() { + throw_type_error( + scope, + "Failed to construct 'ClipboardItem': Please use the 'new' operator.", + ); + return; + } + let Some(parsed) = webidl::parse_args::>(scope, &args) else { + return; + }; + if parsed.items.is_empty() { + throw_type_error( + scope, + "Failed to construct 'ClipboardItem': The items record must not be empty.", + ); + return; + } + if !initialize_clipboard_item( + scope, + args.this(), + parsed.items, + parsed.options.presentation_style, + ) { + throw_type_error(scope, "Failed to initialize ClipboardItem data."); + return; + } + rv.set(args.this().into()); +} + +fn clipboard_item_options_arg<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + index: i32, +) -> std::result::Result { + let context = webidl::Context::argument("ClipboardItem", (index + 1) as usize); + webidl::dictionary_arg(args, index, context)? + .map(|object| webidl::parse_dictionary_object(scope, object)) + .transpose() + .map(|options| options.unwrap_or_default()) +} + +fn clipboard_item_record_arg<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + index: i32, +) -> std::result::Result)>, webidl::WebIdlError> { + let context = webidl::Context::argument("ClipboardItem", (index + 1) as usize); + if args.length() <= index { + return Err(webidl::WebIdlError::missing_required(context)); + } + webidl::convert::>>( + scope, + args.get(index), + context, + ) + .map(|record| { + record + .0 + .into_iter() + .map(|(mime_type, value)| (mime_type.0, value)) + .collect() + }) +} + +fn clipboard_unsanitized_formats_member<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + name: &'static str, +) -> std::result::Result>, webidl::WebIdlError> { + let context = webidl::Context::member("ClipboardUnsanitizedFormats", name); + let Some(value) = webidl::property_result(scope, object, name, context)? else { + return Ok(None); + }; + if value.is_undefined() { + return Ok(None); + } + webidl::convert::>(scope, value, context) + .map(|sequence| Some(sequence.0.into_iter().map(|format| format.0).collect())) +} + +fn clipboard_write_data_arg<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + index: i32, +) -> std::result::Result>, webidl::WebIdlError> { + let context = webidl::Context::argument("Clipboard.write", (index + 1) as usize); + if args.length() <= index { + return Err(webidl::WebIdlError::missing_required(context)); + } + webidl::convert::>>(scope, args.get(index), context) + .map(|sequence| sequence.0.into_iter().map(|item| item.0).collect()) +} + +fn clipboard_read_formats_arg<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, + index: i32, +) -> std::result::Result { + let context = webidl::Context::argument("Clipboard.read", (index + 1) as usize); + webidl::dictionary_arg(args, index, context)? + .map(|object| webidl::parse_dictionary_object(scope, object)) + .transpose() + .map(|formats| formats.unwrap_or_default()) +} + +fn initialize_clipboard_item<'s>( + scope: &mut v8::PinScope<'s, '_>, + object: v8::Local<'s, v8::Object>, + entries: Vec<(String, v8::Local<'s, v8::Promise>)>, + presentation_style: PresentationStyle, +) -> bool { + let data = v8::Object::new(scope); + let raw_data = v8::Object::new(scope); + let types = v8::Array::new(scope, entries.len() as i32); + for (index, (mime_type, promise)) in entries.into_iter().enumerate() { + let Some(key) = v8_string(scope, &mime_type) else { + return false; + }; + let Some(converter) = v8::Function::builder(clipboard_item_data_to_blob_callback) + .data(key.into()) + .build(scope) + else { + return false; + }; + let Some(normalized) = promise.then(scope, converter) else { + return false; + }; + if raw_data.create_data_property(scope, key.into(), promise.into()) != Some(true) + || data.create_data_property(scope, key.into(), normalized.into()) != Some(true) + || types.set_index(scope, index as u32, key.into()) != Some(true) + { + return false; + } + } + let _ = types.set_integrity_level(scope, v8::IntegrityLevel::Frozen); + ClipboardItemObjectDeclaration::new( + data, + raw_data, + types, + presentation_style.as_str().to_owned(), + ) + .initialize(scope, object) + .is_ok() +} + +fn build_clipboard_item<'s>( + scope: &mut v8::PinScope<'s, '_>, + entries: Vec<(String, v8::Local<'s, v8::Promise>)>, +) -> Option> { + let object = ClipboardItemObjectDeclaration::new( + v8::Object::new(scope), + v8::Object::new(scope), + v8::Array::new(scope, 0), + PresentationStyle::Unspecified.as_str().to_owned(), + ) + .bind(scope) + .ok()?; + initialize_clipboard_item(scope, object, entries, PresentationStyle::Unspecified) + .then_some(object) +} + +fn clipboard_item_data_to_blob_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let value = args.get(0); + if let Ok(object) = v8::Local::::try_from(value) + && blob::is_blob_object(scope, object) + { + rv.set(value); + return; + } + let Some(text) = value.to_string(scope) else { + return; + }; + let mime_type = args + .data() + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) + .unwrap_or_default(); + let bytes = text.to_rust_string_lossy(scope).into_bytes(); + if let Some(blob) = blob::build_blob_object(scope, bytes, mime_type) { + rv.set(blob.into()); + } +} + +fn clipboard_item_receiver_branded<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, CLIPBOARD_ITEM_BRAND_SLOT) + .is_some_and(|value| value.boolean_value(scope)) +} + +fn clipboard_receiver_branded<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> bool { + get_private_value(scope, receiver, CLIPBOARD_BRAND_SLOT) + .is_some_and(|value| value.boolean_value(scope)) +} + +fn clipboard_item_presentation_style_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_item_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + if let Some(value) = + get_private_value(scope, args.this(), CLIPBOARD_ITEM_PRESENTATION_STYLE_SLOT) + { + rv.set(value); + } +} + +fn clipboard_item_types_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_item_receiver_branded(scope, args.this()) { + throw_type_error(scope, "Illegal invocation"); + return; + } + if let Some(value) = get_private_value(scope, args.this(), CLIPBOARD_ITEM_TYPES_SLOT) { + rv.set(value); + } +} + +fn clipboard_item_get_type_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_item_receiver_branded(scope, args.this()) { + let reason = type_error_value(scope, "Illegal invocation"); + set_rejected_promise(scope, &mut rv, reason); + return; + } + let parsed = match try_parse_promise_args::(scope, &args) { + Ok(parsed) => parsed, + Err(reason) => { + set_rejected_promise(scope, &mut rv, reason); + return; + } + }; + let promise = get_private_value(scope, args.this(), CLIPBOARD_ITEM_DATA_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .and_then(|data| { + let key = v8_string(scope, &parsed.mime_type)?; + data.get(scope, key.into()) + }) + .and_then(|value| v8::Local::::try_from(value).ok()); + if let Some(promise) = promise { + rv.set(promise.into()); + return; + } + let reason = super::super::new_dom_exception_value( + scope, + "The requested clipboard type was not found.", + "NotFoundError", + ); + set_rejected_promise(scope, &mut rv, reason); +} + +fn clipboard_item_supports_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + rv.set(v8::Boolean::new(scope, clipboard_item_type_supported(&parsed.mime_type)).into()); +} + +fn clipboard_item_type_supported(mime_type: &str) -> bool { + matches!( + mime_type, + "text/plain" | "text/html" | "image/png" | "text/uri-list" | "image/svg+xml" + ) || mime_type.strip_prefix("web ").is_some_and(valid_mime_type) +} + +fn valid_mime_type(mime_type: &str) -> bool { + if mime_type.contains(';') { + return false; + } + let mut parts = mime_type.split('/'); + let Some(top_level) = parts.next() else { + return false; + }; + let Some(subtype) = parts.next() else { + return false; + }; + parts.next().is_none() && mime_token(top_level) && mime_token(subtype) +} + +fn mime_token(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) +} + +fn clipboard_read_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_receiver_branded(scope, args.this()) { + let reason = type_error_value(scope, "Illegal invocation"); + set_rejected_promise(scope, &mut rv, reason); + return; + } + let parsed = match try_parse_promise_args::(scope, &args) { + Ok(parsed) => parsed, + Err(reason) => { + set_rejected_promise(scope, &mut rv, reason); + return; + } + }; + if let Some(formats) = parsed.formats.unsanitized { + let invalid = formats.len() > 1 || formats.iter().any(|format| format != "text/html"); + if invalid { + let reason = super::super::new_dom_exception_value( + scope, + "The requested unsanitized clipboard format is not allowed.", + "NotAllowedError", + ); + set_rejected_promise(scope, &mut rv, reason); + return; + } + } + let items = copy_clipboard_items(scope, args.this()); + set_resolved_promise(scope, &mut rv, items.into()); +} + +fn clipboard_read_text_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_receiver_branded(scope, args.this()) { + let reason = type_error_value(scope, "Illegal invocation"); + set_rejected_promise(scope, &mut rv, reason); + return; + } + if let Some(text) = + get_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT).filter(|value| value.is_string()) + { + set_resolved_promise(scope, &mut rv, text); + return; + } + let text_promise = first_clipboard_item(scope, args.this()) + .and_then(|item| clipboard_item_data_promise(scope, item, "text/plain")) + .and_then(|promise| { + let converter = v8::Function::builder(clipboard_blob_to_text_callback).build(scope)?; + promise.then(scope, converter) + }); + if let Some(promise) = text_promise { + rv.set(promise.into()); + } else { + let empty = v8::String::empty(scope); + set_resolved_promise(scope, &mut rv, empty.into()); + } +} + +fn clipboard_write_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_receiver_branded(scope, args.this()) { + let reason = type_error_value(scope, "Illegal invocation"); + set_rejected_promise(scope, &mut rv, reason); + return; + } + let parsed = match try_parse_promise_args::>(scope, &args) { + Ok(parsed) => parsed, + Err(reason) => { + set_rejected_promise(scope, &mut rv, reason); + return; + } + }; + if parsed.data.len() != 1 { + let reason = super::super::new_dom_exception_value( + scope, + "Only one ClipboardItem can be written at a time.", + "NotAllowedError", + ); + set_rejected_promise(scope, &mut rv, reason); + return; + } + let item = parsed.data[0]; + let Some(validation) = clipboard_item_write_validation_promise(scope, item) else { + let reason = type_error_value(scope, "Failed to validate ClipboardItem data."); + set_rejected_promise(scope, &mut rv, reason); + return; + }; + let callback_data = v8::Array::new(scope, 2); + let _ = callback_data.set_index(scope, 0, args.this().into()); + let _ = callback_data.set_index(scope, 1, item.into()); + let Some(store_callback) = v8::Function::builder(clipboard_store_validated_item_callback) + .data(callback_data.into()) + .build(scope) + else { + let reason = type_error_value(scope, "Failed to write ClipboardItem data."); + set_rejected_promise(scope, &mut rv, reason); + return; + }; + let Some(promise) = validation.then(scope, store_callback) else { + let reason = type_error_value(scope, "Failed to write ClipboardItem data."); + set_rejected_promise(scope, &mut rv, reason); + return; + }; + rv.set(promise.into()); +} + +fn clipboard_write_text_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + if !clipboard_receiver_branded(scope, args.this()) { + let reason = type_error_value(scope, "Illegal invocation"); + set_rejected_promise(scope, &mut rv, reason); + return; + } + let parsed = match try_parse_promise_args::(scope, &args) { + Ok(parsed) => parsed, + Err(reason) => { + set_rejected_promise(scope, &mut rv, reason); + return; + } + }; + let Some(text) = v8_string(scope, &parsed.data) else { + rv.set_undefined(); + return; + }; + let Some(promise) = resolved_promise(scope, text.into()) else { + rv.set_undefined(); + return; + }; + let Some(item) = build_clipboard_item(scope, vec![("text/plain".to_owned(), promise)]) else { + rv.set_undefined(); + return; + }; + store_clipboard_item(scope, args.this(), item); + set_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT, text.into()); + set_resolved_promise(scope, &mut rv, v8::undefined(scope).into()); +} + +fn clipboard_blob_to_text_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'_, v8::Value>, +) { + let Ok(blob) = v8::Local::::try_from(args.get(0)) else { + throw_type_error(scope, "Clipboard text data is not a Blob."); + return; + }; + let Some(bytes) = blob::blob_bytes_from_object(scope, blob) else { + throw_type_error(scope, "Clipboard text data is not a Blob."); + return; + }; + let text = String::from_utf8_lossy(&bytes); + if let Some(value) = v8_string(scope, &text) { + rv.set(value.into()); + } +} + +fn clipboard_item_write_validation_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, +) -> Option> { + let types = get_private_value(scope, item, CLIPBOARD_ITEM_TYPES_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let mut custom_format_count = 0_u32; + let mut entries = Vec::with_capacity(types.length() as usize); + for index in 0..types.length() { + let key = types.get_index(scope, index)?; + let mime_type = key.to_string(scope)?.to_rust_string_lossy(scope); + if mime_type.starts_with("web ") { + custom_format_count += 1; + } + let raw = clipboard_item_raw_data_promise(scope, item, &mime_type)?; + entries.push((key, raw)); + } + if custom_format_count > 100 { + let reason = super::super::new_dom_exception_value( + scope, + "The ClipboardItem contains too many custom formats.", + "NotAllowedError", + ); + return rejected_promise(scope, reason); + } + if entries.is_empty() { + return resolved_promise(scope, v8::undefined(scope).into()); + } + + let resolver = v8::PromiseResolver::new(scope)?; + let promise = resolver.get_promise(scope); + let state = v8::Array::new(scope, 3); + let _ = state.set_index(scope, 0, resolver.into()); + let _ = state.set_index( + scope, + 1, + v8::Integer::new_from_unsigned(scope, entries.len() as u32).into(), + ); + let _ = state.set_index(scope, 2, v8::Boolean::new(scope, false).into()); + + for (key, raw) in entries { + let validator = v8::Function::builder(clipboard_validate_write_entry_callback) + .data(key) + .build(scope)?; + let validated = raw.then(scope, validator)?; + let on_fulfilled = v8::Function::builder(clipboard_validation_fulfilled_callback) + .data(state.into()) + .build(scope)?; + let on_rejected = v8::Function::builder(clipboard_validation_rejected_callback) + .data(state.into()) + .build(scope)?; + validated.then2(scope, on_fulfilled, on_rejected)?; + } + Some(promise) +} + +fn clipboard_validate_write_entry_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let mime_type = args + .data() + .to_string(scope) + .map(|value| value.to_rust_string_lossy(scope)) + .unwrap_or_default(); + if !clipboard_item_type_supported(&mime_type) { + throw_dom_exception( + scope, + "The ClipboardItem contains an unsupported representation type.", + "NotAllowedError", + ); + return; + } + let value = args.get(0); + let Ok(object) = v8::Local::::try_from(value) else { + if mime_type == "image/png" { + throw_type_error( + scope, + "The image/png clipboard representation must be a Blob.", + ); + } + return; + }; + if !blob::is_blob_object(scope, object) { + if mime_type == "image/png" { + throw_type_error( + scope, + "The image/png clipboard representation must be a Blob.", + ); + } + return; + } + if blob::blob_mime_type_from_object(scope, object).as_deref() != Some(mime_type.as_str()) { + throw_dom_exception( + scope, + "The ClipboardItem representation type does not match its Blob type.", + "NotAllowedError", + ); + } +} + +fn clipboard_validation_fulfilled_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some((state, resolver)) = clipboard_validation_state(scope, args.data()) else { + return; + }; + if state + .get_index(scope, 2) + .is_some_and(|value| value.boolean_value(scope)) + { + return; + } + let remaining = state + .get_index(scope, 1) + .and_then(|value| value.uint32_value(scope)) + .unwrap_or(1); + if remaining <= 1 { + let _ = state.set_index(scope, 2, v8::Boolean::new(scope, true).into()); + let _ = resolver.resolve(scope, v8::undefined(scope).into()); + } else { + let _ = state.set_index( + scope, + 1, + v8::Integer::new_from_unsigned(scope, remaining - 1).into(), + ); + } +} + +fn clipboard_validation_rejected_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Some((state, resolver)) = clipboard_validation_state(scope, args.data()) else { + return; + }; + if state + .get_index(scope, 2) + .is_some_and(|value| value.boolean_value(scope)) + { + return; + } + let _ = state.set_index(scope, 2, v8::Boolean::new(scope, true).into()); + let _ = resolver.reject(scope, args.get(0)); +} + +fn clipboard_validation_state<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option<(v8::Local<'s, v8::Array>, v8::Local<'s, v8::PromiseResolver>)> { + let state = v8::Local::::try_from(value).ok()?; + let resolver = state + .get_index(scope, 0) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|object| unsafe { v8::Local::::cast_unchecked(object) })?; + Some((state, resolver)) +} + +fn clipboard_store_validated_item_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + _rv: v8::ReturnValue<'_, v8::Value>, +) { + let Ok(data) = v8::Local::::try_from(args.data()) else { + throw_type_error(scope, "Failed to write ClipboardItem data."); + return; + }; + let clipboard = data + .get_index(scope, 0) + .and_then(|value| v8::Local::::try_from(value).ok()); + let item = data + .get_index(scope, 1) + .and_then(|value| v8::Local::::try_from(value).ok()); + let (Some(clipboard), Some(item)) = (clipboard, item) else { + throw_type_error(scope, "Failed to write ClipboardItem data."); + return; + }; + store_clipboard_item(scope, clipboard, item); + set_private_value( + scope, + clipboard, + CLIPBOARD_TEXT_SLOT, + v8::undefined(scope).into(), + ); +} + +fn throw_dom_exception(scope: &mut v8::PinScope<'_, '_>, message: &str, name: &str) { + let exception = super::super::new_dom_exception_value(scope, message, name); + scope.throw_exception(exception); +} + +fn store_clipboard_item<'s>( + scope: &mut v8::PinScope<'s, '_>, + clipboard: v8::Local<'s, v8::Object>, + item: v8::Local<'s, v8::Object>, +) { + let items = v8::Array::new(scope, 1); + let _ = items.set_index(scope, 0, item.into()); + let _ = items.set_integrity_level(scope, v8::IntegrityLevel::Frozen); + set_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT, items.into()); +} + +fn copy_clipboard_items<'s>( + scope: &mut v8::PinScope<'s, '_>, + clipboard: v8::Local<'s, v8::Object>, +) -> v8::Local<'s, v8::Array> { + let item = first_clipboard_item(scope, clipboard); + let result = v8::Array::new(scope, i32::from(item.is_some())); + if let Some(item) = item { + let _ = result.set_index(scope, 0, item.into()); + } + result +} + +fn first_clipboard_item<'s>( + scope: &mut v8::PinScope<'s, '_>, + clipboard: v8::Local<'s, v8::Object>, +) -> Option> { + get_private_value(scope, clipboard, CLIPBOARD_ITEMS_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .and_then(|items| items.get_index(scope, 0)) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + +fn clipboard_item_data_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, + mime_type: &str, +) -> Option> { + let data = get_private_value(scope, item, CLIPBOARD_ITEM_DATA_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let key = v8_string(scope, mime_type)?; + data.get(scope, key.into()) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + +fn clipboard_item_raw_data_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + item: v8::Local<'s, v8::Object>, + mime_type: &str, +) -> Option> { + let data = get_private_value(scope, item, CLIPBOARD_ITEM_RAW_DATA_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok())?; + let key = v8_string(scope, mime_type)?; + data.get(scope, key.into()) + .and_then(|value| v8::Local::::try_from(value).ok()) +} + +fn try_parse_promise_args<'s, T>( + scope: &mut v8::PinScope<'s, '_>, + args: &v8::FunctionCallbackArguments<'s>, +) -> std::result::Result> +where + T: webidl::WebIdlArguments<'s>, +{ + let try_catch = std::pin::pin!(v8::TryCatch::new(scope)); + let mut scope = try_catch.init(); + match webidl::try_parse_args::(&mut scope, args) { + Ok(parsed) => Ok(parsed), + Err(error) if error.is_pending_exception() => Err(scope + .exception() + .unwrap_or_else(|| v8::undefined(&scope).into())), + Err(error) => Err(type_error_value(&mut scope, &error.to_string())), + } +} + +fn type_error_value<'s>( + scope: &mut v8::PinScope<'s, '_>, + message: &str, +) -> v8::Local<'s, v8::Value> { + let message = v8_string(scope, message).unwrap_or_else(|| v8::String::empty(scope)); + v8::Exception::type_error(scope, message) +} + +fn resolved_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + value: v8::Local<'s, v8::Value>, +) -> Option> { + let resolver = v8::PromiseResolver::new(scope)?; + let promise = resolver.get_promise(scope); + (resolver.resolve(scope, value) == Some(true)).then_some(promise) +} + +fn rejected_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + reason: v8::Local<'s, v8::Value>, +) -> Option> { + let resolver = v8::PromiseResolver::new(scope)?; + let promise = resolver.get_promise(scope); + (resolver.reject(scope, reason) == Some(true)).then_some(promise) +} + +fn set_resolved_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + rv: &mut v8::ReturnValue<'_, v8::Value>, + value: v8::Local<'s, v8::Value>, +) { + if let Some(promise) = resolved_promise(scope, value) { + rv.set(promise.into()); + } else { + rv.set_undefined(); + } +} + +fn set_rejected_promise<'s>( + scope: &mut v8::PinScope<'s, '_>, + rv: &mut v8::ReturnValue<'_, v8::Value>, + reason: v8::Local<'s, v8::Value>, +) { + let Some(resolver) = v8::PromiseResolver::new(scope) else { + rv.set_undefined(); + return; + }; + let promise = resolver.get_promise(scope); + let _ = resolver.reject(scope, reason); + rv.set(promise.into()); +} + +#[cfg(test)] +mod tests { + use super::{clipboard_item_type_supported, valid_mime_type}; + + #[test] + fn clipboard_item_supports_standard_and_web_custom_types() { + for mime_type in [ + "text/plain", + "text/html", + "image/png", + "text/uri-list", + "image/svg+xml", + "web foo/bar", + "web text/html", + ] { + assert!(clipboard_item_type_supported(mime_type), "{mime_type}"); + } + for mime_type in [ + "web ", + "web", + "web foo", + "foo/bar", + "weB text/html", + " web text/html", + "not a/real type", + "", + " ", + ] { + assert!(!clipboard_item_type_supported(mime_type), "{mime_type}"); + } + assert!(valid_mime_type("application/x.example+json")); + assert!(!valid_mime_type("application//json")); + } +} 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 918120990a..8c5b18544f 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -21,6 +21,7 @@ use super::super::window_runtime::{ service_worker_object_set_owner_scope, }; use super::super::*; +use super::clipboard::{build_clipboard_object, install_clipboard_template_bindings}; use super::collections::{ build_navigator_plugin_collections, install_navigator_collection_template_bindings, }; @@ -106,7 +107,6 @@ const SERVICE_WORKER_CONTAINER_ONMESSAGEERROR_SLOT: &str = const SERVICE_WORKER_CONTAINER_ONCONTROLLERCHANGE_SLOT: &str = "__moliServiceWorkerContainerOncontrollerchange"; const SERVICE_WORKER_CONTAINER_CONTROLLER_SLOT: &str = "__moliServiceWorkerContainerController"; -const CLIPBOARD_TEXT_SLOT: &str = "__moliClipboardText"; pub(in crate::context_bootstrap) const SERVICE_WORKER_OWNER_TOKEN_SLOT: &str = "__moliServiceWorkerOwner"; const USER_ACTIVATION_BRAND_SLOT: &str = "__moliUserActivationBrand"; @@ -489,19 +489,6 @@ struct ServiceWorkerContainerDeclaration { oncontrollerchange: (), } -#[derive(Default, WebApiObject)] -#[webapi(interface = "Object")] -struct ClipboardObjectDeclaration { - #[webapi(slot = CLIPBOARD_TEXT_SLOT, init = "")] - text: (), - - #[webapi(method, enumerable, length = 0, callback = clipboard_read_text_callback)] - read_text: (), - - #[webapi(method, enumerable, length = 1, callback = clipboard_write_text_callback)] - write_text: (), -} - #[derive(Default, WebApiObject)] #[webapi(interface = "UserActivation")] struct UserActivationObjectDeclaration { @@ -752,44 +739,6 @@ fn navigator_user_activation_state_getter_callback<'s>( rv.set(v8::Boolean::new(scope, active).into()); } -fn set_resolved_promise( - scope: &mut v8::PinScope<'_, '_>, - rv: &mut v8::ReturnValue<'_, v8::Value>, - value: v8::Local<'_, v8::Value>, -) { - let Some(resolver) = v8::PromiseResolver::new(scope) else { - rv.set(v8::undefined(scope).into()); - return; - }; - let promise = resolver.get_promise(scope); - let _ = resolver.resolve(scope, value); - rv.set(promise.into()); -} - -fn clipboard_read_text_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'_, v8::Value>, -) { - let value = get_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT) - .filter(|value| value.is_string()) - .unwrap_or_else(|| v8::String::empty(scope).into()); - set_resolved_promise(scope, &mut rv, value); -} - -fn clipboard_write_text_callback<'s>( - scope: &mut v8::PinScope<'s, '_>, - args: v8::FunctionCallbackArguments<'s>, - mut rv: v8::ReturnValue<'_, v8::Value>, -) { - let text = args - .get(0) - .to_string(scope) - .unwrap_or_else(|| v8::String::empty(scope)); - set_private_value(scope, args.this(), CLIPBOARD_TEXT_SLOT, text.into()); - set_resolved_promise(scope, &mut rv, v8::undefined(scope).into()); -} - fn navigator_connection_event_target_noop_callback<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -866,6 +815,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( template: v8::Local<'s, v8::FunctionTemplate>, interface_name: &str, ) { + install_clipboard_template_bindings(scope, template, interface_name); install_geolocation_template_bindings(scope, template, interface_name); install_navigator_collection_template_bindings(scope, template, interface_name); install_media_capabilities_template_bindings(scope, template, interface_name); @@ -903,6 +853,7 @@ fn filter_navigator_secure_context_exposure<'s>( secure_context: bool, ) -> Result<()> { if !secure_context { + delete_object_property(scope, prototype, "clipboard")?; delete_object_property(scope, prototype, "storage")?; delete_object_property(scope, prototype, "storageBuckets")?; delete_object_property(scope, prototype, "serviceWorker")?; @@ -1054,10 +1005,7 @@ pub(super) fn build_lazy_navigator_subobject_in_current_realm<'s>( NavigatorSubobject::ServiceWorker => { build_service_worker_container(scope, owner_child, owner_popup)?.into() } - NavigatorSubobject::Clipboard => ClipboardObjectDeclaration::default() - .bind(scope) - .map_err(|error| anyhow!("failed to bind navigator.clipboard object: {error}"))? - .into(), + NavigatorSubobject::Clipboard => build_clipboard_object(scope)?.into(), NavigatorSubobject::UserActivation => build_user_activation(scope)?.into(), NavigatorSubobject::StorageBuckets => { build_storage_bucket_manager(scope, owner_child, owner_popup)?.into() diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 5f3caf6da7..202c30a2f0 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -1791,6 +1791,8 @@ pub(crate) fn finish_context_bootstrap( ("Permissions", "Permissions"), ("PermissionStatus", "PermissionStatus"), ("MediaDevices", "MediaDevices"), + ("Clipboard", "Clipboard"), + ("ClipboardItem", "ClipboardItem"), ("MediaCapabilities", "MediaCapabilities"), ("Screen", "Screen"), ("ScreenOrientation", "ScreenOrientation"), diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 4664f1ba4d..f250f9b37d 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -628,6 +628,16 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ parent: Some("EventTarget"), kind: ConstructorKind::Illegal, }, + ConstructorSpec { + name: "Clipboard", + parent: Some("EventTarget"), + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + name: "ClipboardItem", + parent: None, + kind: ConstructorKind::ClipboardItem, + }, ConstructorSpec { name: "MediaCapabilities", parent: None, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/types.rs b/moli-renderer-v8/src/context_bootstrap/specs/types.rs index 7868d6fc63..01adf63e6d 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/types.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/types.rs @@ -11,6 +11,7 @@ pub(in crate::context_bootstrap) enum ConstructorKind { CapturedMouseEvent, DragEvent, ClipboardEvent, + ClipboardItem, KeyboardEvent, InputEvent, WheelEvent, 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 1772ef0267..798ec67b47 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 @@ -4180,7 +4180,7 @@ fn web_platform_surface_stubs_are_present_and_brand_correctly() { "CompressionStream","DecompressionStream", "ReadableStreamBYOBReader","ReadableStreamBYOBRequest","ReadableByteStreamController", "Geolocation","GeolocationPosition","GeolocationCoordinates","GeolocationPositionError", - "MediaCapabilities", + "MediaCapabilities","Clipboard","ClipboardItem", ]; const out = []; for (const name of names) { @@ -4191,6 +4191,7 @@ fn web_platform_surface_stubs_are_present_and_brand_correctly() { out.push(`HashChangeEvent { + const clipboard = navigator.clipboard; + const item = new ClipboardItem( + { "text/plain": "hello", "not a/real type": "opaque" }, + { presentationStyle: "inline" } + ); + const writableItem = new ClipboardItem({ "text/plain": "hello" }); + const invalidCustomItem = new ClipboardItem({ + "application/x-private": new Blob(["x"], { + type: "application/x-private" + }) + }); + const mismatchedCustomItem = new ClipboardItem({ + "web text/plain": new Blob(["x"], { type: "text/custom" }) + }); + const stringPngItem = new ClipboardItem({ "image/png": "not an image" }); + const method = (object, name) => { + const descriptor = Object.getOwnPropertyDescriptor(object, name); + return [ + typeof descriptor?.value, + descriptor?.value?.name, + descriptor?.value?.length, + descriptor?.enumerable, + descriptor?.writable, + descriptor?.configurable + ].join(":"); + }; + const accessor = (object, name) => { + const descriptor = Object.getOwnPropertyDescriptor(object, name); + return [ + typeof descriptor?.get, + descriptor?.get?.name, + descriptor?.get?.length, + typeof descriptor?.set, + descriptor?.enumerable, + descriptor?.configurable + ].join(":"); + }; + const errorName = callback => { + try { + callback(); + return "none"; + } catch (error) { + return error && error.name; + } + }; + const presentationStyleGetter = Object.getOwnPropertyDescriptor( + ClipboardItem.prototype, + "presentationStyle" + ).get; + const typesGetter = Object.getOwnPropertyDescriptor( + ClipboardItem.prototype, + "types" + ).get; + + globalThis.__asyncClipboardProbe = { state: "pending" }; + const rejectionName = promise => Promise.resolve(promise).then( + () => "resolved", + error => error && error.name + ); + (async () => { + const initialBlob = await item.getType("text/plain"); + const initialText = await initialBlob.text(); + const opaqueBlob = await item.getType("not a/real type"); + const opaqueText = await opaqueBlob.text(); + await clipboard.write([writableItem]); + const writtenText = await clipboard.readText(); + const readItems = await clipboard.read({ unsanitized: ["text/html"] }); + await clipboard.writeText("next"); + const textItems = await clipboard.read(); + const textBlob = await textItems[0].getType("text/plain"); + const roundTripText = await textBlob.text(); + const rejections = await Promise.all([ + rejectionName(Clipboard.prototype.read.call({})), + rejectionName(Clipboard.prototype.write.call({}, [item])), + rejectionName(clipboard.writeText()), + rejectionName(clipboard.write([])), + rejectionName(clipboard.read({ unsanitized: ["text/html", "text/plain"] })), + rejectionName(item.getType()), + rejectionName(item.getType("missing/type")), + rejectionName(ClipboardItem.prototype.getType.call({}, "text/plain")), + rejectionName(clipboard.write([invalidCustomItem])), + rejectionName(clipboard.write([mismatchedCustomItem])), + rejectionName(clipboard.write([stringPngItem])) + ]); + globalThis.__asyncClipboardProbe = { + state: "done", + initialBlob: [initialBlob instanceof Blob, initialBlob.type, initialText], + writtenText, + read: [ + readItems.length, + readItems[0] === writableItem, + opaqueText, + Object.isFrozen(readItems) + ], + textWrite: [ + textItems.length, + textItems[0] instanceof ClipboardItem, + textItems[0].types.join(","), + roundTripText + ], + rejections + }; + })().catch(error => { + globalThis.__asyncClipboardProbe = { + state: "failed", + error: `${error && error.name}:${error && error.message}` + }; + }); + + return JSON.stringify({ + constructors: [ + typeof Clipboard, + Clipboard.name, + Clipboard.length, + typeof ClipboardItem, + ClipboardItem.name, + ClipboardItem.length + ], + clipboard: [ + navigator.clipboard === navigator.clipboard, + clipboard instanceof Clipboard, + clipboard instanceof EventTarget, + Object.getPrototypeOf(clipboard) === Clipboard.prototype, + Object.prototype.toString.call(clipboard), + Object.keys(clipboard).join(","), + Object.keys(Clipboard.prototype).join(",") + ], + methods: [ + method(Clipboard.prototype, "read"), + method(Clipboard.prototype, "readText"), + method(Clipboard.prototype, "write"), + method(Clipboard.prototype, "writeText") + ], + item: [ + item instanceof ClipboardItem, + Object.getPrototypeOf(item) === ClipboardItem.prototype, + Object.prototype.toString.call(item), + Object.keys(item).join(","), + item.presentationStyle, + item.types.join(","), + Object.isFrozen(item.types), + Object.keys(ClipboardItem.prototype).join(",") + ], + itemMembers: [ + accessor(ClipboardItem.prototype, "presentationStyle"), + accessor(ClipboardItem.prototype, "types"), + method(ClipboardItem.prototype, "getType"), + method(ClipboardItem, "supports") + ], + supports: [ + ClipboardItem.supports("text/plain"), + ClipboardItem.supports("web foo/bar"), + ClipboardItem.supports("foo/bar") + ], + syncErrors: [ + errorName(() => new Clipboard()), + errorName(() => Clipboard()), + errorName(() => ClipboardItem({ "text/plain": "x" })), + errorName(() => new ClipboardItem()), + errorName(() => new ClipboardItem(null)), + errorName(() => new ClipboardItem({})), + errorName(() => presentationStyleGetter.call({})), + errorName(() => typesGetter.call({})) + ] + }); + })() + "#, + ) + .expect("Async Clipboard interface shape should evaluate"); + + assert_eq!( + shape, + r#"{"constructors":["function","Clipboard",0,"function","ClipboardItem",1],"clipboard":[true,true,true,true,"[object Clipboard]","","read,readText,write,writeText"],"methods":["function:read:0:true:true:true","function:readText:0:true:true:true","function:write:1:true:true:true","function:writeText:1:true:true:true"],"item":[true,true,"[object ClipboardItem]","","inline","text/plain,not a/real type",true,"presentationStyle,types,getType"],"itemMembers":["function:get presentationStyle:0:undefined:true:true","function:get types:0:undefined:true:true","function:getType:1:true:true:true","function:supports:1:true:true:true"],"supports":[true,true,false],"syncErrors":["TypeError","TypeError","TypeError","TypeError","TypeError","TypeError","TypeError","TypeError"]}"# + ); + + vm.eval("0") + .expect("Async Clipboard promise operations should drain"); + assert_eq!( + vm.eval("JSON.stringify(globalThis.__asyncClipboardProbe)") + .expect("Async Clipboard promise result should evaluate"), + r#"{"state":"done","initialBlob":[true,"text/plain","hello"],"writtenText":"hello","read":[1,true,"opaque",false],"textWrite":[1,true,"text/plain","next"],"rejections":["TypeError","TypeError","TypeError","NotAllowedError","NotAllowedError","TypeError","NotFoundError","TypeError","NotAllowedError","NotAllowedError","TypeError"]}"# + ); +} + #[test] fn navigator_storage_apis_are_secure_context_only() { let mut vm = new_storage_test_vm("http://insecure-storage-surface.test/"); @@ -8164,16 +8366,21 @@ fn navigator_storage_apis_are_secure_context_only() { const childProto = Object.getPrototypeOf(frame.contentWindow.navigator); return JSON.stringify({ secure: globalThis.isSecureContext === true, + clipboardInNavigator: "clipboard" in navigator, storageInNavigator: "storage" in navigator, storageBucketsInNavigator: "storageBuckets" in navigator, userAgentDataInNavigator: "userAgentData" in navigator, + clipboardInProto: Object.prototype.hasOwnProperty.call(proto, "clipboard"), storageInProto: Object.prototype.hasOwnProperty.call(proto, "storage"), storageBucketsInProto: Object.prototype.hasOwnProperty.call(proto, "storageBuckets"), userAgentDataInProto: Object.prototype.hasOwnProperty.call(proto, "userAgentData"), + clipboardValueType: typeof navigator.clipboard, storageValueType: typeof navigator.storage, storageBucketsValueType: typeof navigator.storageBuckets, userAgentDataValueType: typeof navigator.userAgentData, + clipboardGlobal: "Clipboard" in globalThis, + clipboardItemGlobal: "ClipboardItem" in globalThis, storageManagerGlobal: "StorageManager" in globalThis, storageEstimateGlobal: "StorageEstimate" in globalThis, storageBucketManagerGlobal: "StorageBucketManager" in globalThis, @@ -8185,14 +8392,19 @@ fn navigator_storage_apis_are_secure_context_only() { "FileSystemWritableFileStream" in globalThis, fileSystemSyncAccessHandleGlobal: "FileSystemSyncAccessHandle" in globalThis, + childClipboardInNavigator: "clipboard" in frame.contentWindow.navigator, childStorageInNavigator: "storage" in frame.contentWindow.navigator, childStorageBucketsInNavigator: "storageBuckets" in frame.contentWindow.navigator, childUserAgentDataInNavigator: "userAgentData" in frame.contentWindow.navigator, + childClipboardInProto: + Object.prototype.hasOwnProperty.call(childProto, "clipboard"), childStorageInProto: Object.prototype.hasOwnProperty.call(childProto, "storage"), childStorageBucketsInProto: Object.prototype.hasOwnProperty.call(childProto, "storageBuckets"), childUserAgentDataInProto: Object.prototype.hasOwnProperty.call(childProto, "userAgentData"), + childClipboardGlobal: "Clipboard" in frame.contentWindow, + childClipboardItemGlobal: "ClipboardItem" in frame.contentWindow, childFileSystemHandleGlobal: "FileSystemHandle" in frame.contentWindow, childFileSystemFileHandleGlobal: @@ -8211,7 +8423,7 @@ fn navigator_storage_apis_are_secure_context_only() { assert_eq!( result, - r#"{"secure":false,"storageInNavigator":false,"storageBucketsInNavigator":false,"userAgentDataInNavigator":false,"storageInProto":false,"storageBucketsInProto":false,"userAgentDataInProto":false,"storageValueType":"undefined","storageBucketsValueType":"undefined","userAgentDataValueType":"undefined","storageManagerGlobal":false,"storageEstimateGlobal":false,"storageBucketManagerGlobal":false,"storageBucketGlobal":false,"fileSystemHandleGlobal":false,"fileSystemFileHandleGlobal":false,"fileSystemDirectoryHandleGlobal":false,"fileSystemWritableFileStreamGlobal":false,"fileSystemSyncAccessHandleGlobal":false,"childStorageInNavigator":false,"childStorageBucketsInNavigator":false,"childUserAgentDataInNavigator":false,"childStorageInProto":false,"childStorageBucketsInProto":false,"childUserAgentDataInProto":false,"childFileSystemHandleGlobal":false,"childFileSystemFileHandleGlobal":false,"childFileSystemDirectoryHandleGlobal":false,"childFileSystemWritableFileStreamGlobal":false,"childFileSystemSyncAccessHandleGlobal":false}"# + r#"{"secure":false,"clipboardInNavigator":false,"storageInNavigator":false,"storageBucketsInNavigator":false,"userAgentDataInNavigator":false,"clipboardInProto":false,"storageInProto":false,"storageBucketsInProto":false,"userAgentDataInProto":false,"clipboardValueType":"undefined","storageValueType":"undefined","storageBucketsValueType":"undefined","userAgentDataValueType":"undefined","clipboardGlobal":false,"clipboardItemGlobal":false,"storageManagerGlobal":false,"storageEstimateGlobal":false,"storageBucketManagerGlobal":false,"storageBucketGlobal":false,"fileSystemHandleGlobal":false,"fileSystemFileHandleGlobal":false,"fileSystemDirectoryHandleGlobal":false,"fileSystemWritableFileStreamGlobal":false,"fileSystemSyncAccessHandleGlobal":false,"childClipboardInNavigator":false,"childStorageInNavigator":false,"childStorageBucketsInNavigator":false,"childUserAgentDataInNavigator":false,"childClipboardInProto":false,"childStorageInProto":false,"childStorageBucketsInProto":false,"childUserAgentDataInProto":false,"childClipboardGlobal":false,"childClipboardItemGlobal":false,"childFileSystemHandleGlobal":false,"childFileSystemFileHandleGlobal":false,"childFileSystemDirectoryHandleGlobal":false,"childFileSystemWritableFileStreamGlobal":false,"childFileSystemSyncAccessHandleGlobal":false}"# ); } diff --git a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs index ea01e8b769..7492e111e8 100644 --- a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs @@ -620,6 +620,8 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "Geolocation", "GeolocationPositionError", "MediaCapabilities", + "Clipboard", + "ClipboardItem", ] { assert_eq!( constructor_materialization_count(&mut vm, name), @@ -674,6 +676,9 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { storageRealm: Object.getPrototypeOf(navigator.storage) === StorageManager.prototype, + clipboardRealm: + Object.getPrototypeOf(navigator.clipboard) === + Clipboard.prototype, geolocationRealm: Object.getPrototypeOf(navigator.geolocation) === Geolocation.prototype, @@ -685,7 +690,7 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { "#, ) .expect("Navigator lazy subobjects should materialize"), - r#"{"same":true,"languagesFrozen":false,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"# + r#"{"same":true,"languagesFrozen":false,"permissionsRealm":true,"mediaDevicesRealm":true,"storageRealm":true,"clipboardRealm":true,"geolocationRealm":true,"mediaCapabilitiesRealm":true}"# ); assert_eq!( default_navigator_subobjects(&mut vm), @@ -709,6 +714,12 @@ fn navigator_scalar_access_does_not_materialize_unused_same_object_children() { ] ); assert_eq!(constructor_materialization_count(&mut vm, "Geolocation"), 1); + assert_eq!(constructor_materialization_count(&mut vm, "Clipboard"), 1); + assert_eq!( + constructor_materialization_count(&mut vm, "ClipboardItem"), + 0, + "materializing navigator.clipboard must not eagerly build ClipboardItem" + ); assert_eq!( constructor_materialization_count(&mut vm, "MediaCapabilities"), 1