/* * Copyright (c) 2023-2025, Tim Flynn * * SPDX-License-Identifier: BSD-2-Clause */ #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include namespace Web::Clipboard { GC_DEFINE_ALLOCATOR(Clipboard); WebIDL::ExceptionOr> Clipboard::construct_impl(JS::Realm& realm) { return realm.create(realm); } Clipboard::Clipboard(JS::Realm& realm) : DOM::EventTarget(realm) { } Clipboard::~Clipboard() = default; void Clipboard::initialize(JS::Realm& realm) { WEB_SET_PROTOTYPE_FOR_INTERFACE(Clipboard); Base::initialize(realm); } // https://w3c.github.io/clipboard-apis/#os-specific-well-known-format static String os_specific_well_known_format(StringView mime_type_string) { // NOTE: Here we always takes the Linux case, and defer to the browser process to handle OS specific implementations. auto mime_type = MimeSniff::MimeType::parse(mime_type_string); // 1. Let wellKnownFormat be an empty string. String well_known_format {}; // 2. If mimeType’s essence is "text/plain", then if (auto const& essence = mime_type->essence(); essence == "text/plain"sv) { // On Windows, follow the convention described below: // Assign CF_UNICODETEXT to wellKnownFormat. // On MacOS, follow the convention described below: // Assign NSPasteboardTypeString to wellKnownFormat. // On Linux, ChromeOS, and Android, follow the convention described below: // Assign "text/plain" to wellKnownFormat. well_known_format = essence; } // 3. Else, if mimeType’s essence is "text/html", then else if (essence == "text/html"sv) { // On Windows, follow the convention described below: // Assign CF_HTML to wellKnownFormat. // On MacOS, follow the convention described below: // Assign NSHTMLPboardType to wellKnownFormat. // On Linux, ChromeOS, and Android, follow the convention described below: // Assign "text/html" to wellKnownFormat. well_known_format = essence; } // 4. Else, if mimeType’s essence is "image/png", then else if (essence == "image/png"sv) { // On Windows, follow the convention described below: // Assign "PNG" to wellKnownFormat. // On MacOS, follow the convention described below: // Assign NSPasteboardTypePNG to wellKnownFormat. // On Linux, ChromeOS, and Android, follow the convention described below: // Assign "image/png" to wellKnownFormat. well_known_format = essence; } // 5. Return wellKnownFormat. return well_known_format; } // https://w3c.github.io/clipboard-apis/#write-blobs-and-option-to-the-clipboard static void write_blobs_and_option_to_clipboard(JS::Realm& realm, ReadonlySpan> items, StringView presentation_style) { auto& window = as(realm.global_object()); // FIXME: 1. Let webCustomFormats be a sequence. // 2. For each item in items: for (auto const& item : items) { // 1. Let formatString be the result of running os specific well-known format given item’s type. auto format_string = os_specific_well_known_format(item->type()); // 2. If formatString is empty then follow the below steps: if (format_string.is_empty()) { // FIXME: 1. Let webCustomFormatString be the item’s type. // FIXME: 2. Let webCustomFormat be an empty type. // FIXME: 3. If webCustomFormatString starts with `"web "` prefix, then remove the `"web "` prefix and store the // FIXME: remaining string in webMimeTypeString. // FIXME: 4. Let webMimeType be the result of parsing a MIME type given webMimeTypeString. // FIXME: 5. If webMimeType is failure, then abort all steps. // FIXME: 6. Let webCustomFormat’s type's essence equal to webMimeType. // FIXME: 7. Set item’s type to webCustomFormat. // FIXME: 8. Append webCustomFormat to webCustomFormats. } // 3. Let payload be the result of UTF-8 decoding item’s underlying byte sequence. auto decoder = TextCodec::decoder_for("UTF-8"sv); auto payload = MUST(TextCodec::convert_input_to_utf8_using_given_decoder_unless_there_is_a_byte_order_mark(*decoder, item->raw_bytes())); // 4. Insert payload and presentationStyle into the system clipboard using formatString as the native clipboard format. window.page().client().page_did_insert_clipboard_entry({ payload.to_byte_string(), move(format_string) }, presentation_style); } // FIXME: 3. Write web custom formats given webCustomFormats. } // https://w3c.github.io/clipboard-apis/#h-clipboard-read-permission static bool check_clipboard_read_permission(JS::Realm& realm) { // NOTE: The clipboard permission is undergoing a refactor because the clipboard-read permission was removed from // the Permissions spec. So this partially implements the proposed update: // https://pr-preview.s3.amazonaws.com/w3c/clipboard-apis/pull/164.html#read-permission // 1. Let hasGesture be true if the relevant global object of this has transient activation, false otherwise. auto has_gesture = as(realm.global_object()).has_transient_activation(); // 2. If hasGesture then, if (has_gesture) { // FIXME: 1. Return true if the current script is running as a result of user interaction with a "Paste" element // created by the user agent or operating system. return true; } // 3. Otherwise, return false. return false; } // https://w3c.github.io/clipboard-apis/#check-clipboard-write-permission static bool check_clipboard_write_permission(JS::Realm& realm) { // NOTE: The clipboard permission is undergoing a refactor because the clipboard-write permission was removed from // the Permissions spec. So this partially implements the proposed update: // https://pr-preview.s3.amazonaws.com/w3c/clipboard-apis/pull/164.html#write-permission // 1. Let hasGesture be true if the relevant global object of this has transient activation, false otherwise. auto has_gesture = as(realm.global_object()).has_transient_activation(); // 2. If hasGesture then, if (has_gesture) { // FIXME: 1. Return true if the current script is running as a result of user interaction with a "cut" or "copy" // element created by the user agent or operating system. return true; } // 3. Otherwise, return false. return false; } // https://w3c.github.io/clipboard-apis/#dom-clipboard-readtext GC::Ref Clipboard::read(ClipboardUnsanitizedFormats formats) { // 1. Let realm be this's relevant realm. auto& realm = HTML::relevant_realm(*this); // 2. Let p be a new promise in realm. auto promise = WebIDL::create_promise(realm); // 3. If formats is not empty, then: if (formats.unsanitized.has_value()) { // 1. For each format in formats["unsanitized"]: for (auto const& format : *formats.unsanitized) { // FIXME: 1. If format is not in optional unsanitized data types, then reject p with format "NotAllowedError" // DOMException in realm. (void)format; } } // 4. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, promise, formats = move(formats)]() mutable { // 1. Let r be the result of running check clipboard read permission. auto result = check_clipboard_read_permission(realm); // 2. If r is false, then: if (!result) { // 1. Queue a global task on the permission task source, given realm’s global object, to reject p with // "NotAllowedError" DOMException in realm. queue_global_task(HTML::Task::Source::Permissions, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, "Clipboard reading is only allowed through user activation"_string)); })); // 2. Abort these steps. return; } // 3. Let data be a copy of the system clipboard data. as(realm.global_object()).page().request_clipboard_entries(GC::create_function(realm.heap(), [&realm, promise, formats = move(formats)](Vector data) mutable { HTML::TemporaryExecutionContext execution_context { realm }; // 4. Let items be a sequence. GC::RootVector items(realm.heap()); // 5. For each systemClipboardItem in data: for (auto const& system_clipboard_item : data) { // 1. Let item be a new clipboard item. auto item = realm.create(realm); // 2. For each systemClipboardRepresentation in systemClipboardItem: for (auto const& system_clipboard_representation : system_clipboard_item.system_clipboard_representations) { // 1. Let mimeType be the result of running the well-known mime type from os specific format // algorithm given systemClipboardRepresentation’s name. auto mime_type = os_specific_well_known_format(system_clipboard_representation.mime_type); // 2. If mimeType is null, continue this loop. if (mime_type.is_empty()) continue; auto decoder = TextCodec::decoder_for("UTF-8"sv); auto string = MUST(TextCodec::convert_input_to_utf8_using_given_decoder_unless_there_is_a_byte_order_mark(*decoder, system_clipboard_representation.data)); // 3. Let representation be a new representation. ClipboardItem::Representation representation { // 4. Set representation’s MIME type to mimeType. .mime_type = move(mime_type), // 7. Resolve representation’s data with systemClipboardRepresentation’s data. .data = WebIDL::create_resolved_promise(realm, JS::PrimitiveString::create(realm.vm(), move(string))), }; // 5. Let isUnsanitized be false. auto is_unsanitized = false; // 6. If formats is not empty, then: if (formats.unsanitized.has_value()) { // 1. For each format in formats["unsanitized"]: for (auto const& format : *formats.unsanitized) { // 1. If format is equal to MIME type, set isUnsanitized to true. if (format == representation.mime_type) { is_unsanitized = true; break; } } } // 8. The user agent, MAY sanitize representation’s data, unless representation’s MIME type's essence // is "image/png", which should remain unsanitized to preserve meta data, or if it satisfies the // below conditions: // 1. representation’s MIME type is in optional unsanitized data types list. // 2. isUnsanitized is true. // FIXME: Sanitization is an underspecified spec feature. See: // https://github.com/w3c/clipboard-apis/issues/73 (void)is_unsanitized; // 9. Append representation to item’s list of representations. item->append_representation(move(representation)); // 10. Set isUnsanitized to false. is_unsanitized = false; } // 3. If item’s list of representations size is greater than 0, append item to items. if (!item->representations().is_empty()) items.append(item); } // 6. If items has a size > 0, then: if (!items.is_empty()) { // FIXME: 1. Let firstItem be items[0] // FIXME: 2. Run the read web custom format algorithm given firstItem. } // 7. Else: else { // FIXME: 1. Let customItem be a new clipboard item. // FIXME: 2. Run the read web custom format algorithm given customItem. // FIXME: 3. If customItem’s list of representations size is greater than 0, append customItem to items. } // 8. Queue a global task on the clipboard task source, given realm’s global object, to perform the below steps: queue_global_task(HTML::Task::Source::Clipboard, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, items = move(items)]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; // 1. Let clipboardItems be a sequence. // 2. For each clipboard item underlyingItem of items: // 1. Let clipboardItem be the result of running the steps of create a ClipboardItem object given // underlyingItem and realm. // 2. Append clipboardItem to clipboardItems. // NOTE: We do not perform this song-and-dance of having ClipboardItem and "clipboard item" being // separate concepts. But we do still need to convert the RootVector to a JS array. auto clipboard_items = JS::Array::create_from(realm, items); // 2. Resolve p with clipboardItems. WebIDL::resolve_promise(realm, promise, clipboard_items); })); })); })); // 5. Return p. return promise; } // https://w3c.github.io/clipboard-apis/#dom-clipboard-readtext GC::Ref Clipboard::read_text() { // 1. Let realm be this's relevant realm. auto& realm = HTML::relevant_realm(*this); // 2. Let p be a new promise in realm. auto promise = WebIDL::create_promise(realm); // 3. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, promise]() mutable { // 1. Let r be the result of running check clipboard read permission. auto result = check_clipboard_read_permission(realm); // 2. If r is false, then: if (!result) { // 1. Queue a global task on the permission task source, given realm’s global object, to reject p with // "NotAllowedError" DOMException in realm. queue_global_task(HTML::Task::Source::Permissions, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, "Clipboard reading is only allowed through user activation"_string)); })); // 2. Abort these steps. return; } // 3. Let data be a copy of the system clipboard data. as(realm.global_object()).page().request_clipboard_entries(GC::create_function(realm.heap(), [&realm, promise](Vector data) mutable { // 4. Queue a global task on the clipboard task source, given realm’s global object, to perform the below steps: queue_global_task(HTML::Task::Source::Clipboard, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, data = move(data)]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; // 1. For each systemClipboardItem in data: for (auto const& system_clipboard_item : data) { // 1. For each systemClipboardRepresentation in systemClipboardItem: for (auto const& system_clipboard_representation : system_clipboard_item.system_clipboard_representations) { // 1. Let mimeType be the result of running the well-known mime type from os specific format // algorithm given systemClipboardRepresentation’s name. auto mime_type = os_specific_well_known_format(system_clipboard_representation.mime_type); // 2. If mimeType is null, continue this loop. if (mime_type.is_empty()) continue; // 3. Let representation be a new representation. // FIXME: Spec issue: Creating a new representation here and reacting to its promise does not // make sense. Nothing will ever fulfill or reject its promise. So we resolve the outer // promise with the system clipboard data converted to UTF-8 instead. See: // https://github.com/w3c/clipboard-apis/issues/236 // 4. If representation’s MIME type essence is "text/plain", then: if (mime_type == "text/plain"sv) { // 1. Set representation’s MIME type to mimeType. // 2. Let representationDataPromise be the representation’s data. // 3. React to representationDataPromise: // 1. If representationDataPromise was fulfilled with value v, then: // 1. If v is a DOMString, then follow the below steps: // 1. Resolve p with v. // 2. Return p. // 2. If v is a Blob, then follow the below steps: // 1. Let string be the result of UTF-8 decoding v’s underlying byte sequence. // 2. Resolve p with string. // 3. Return p. // 2. If representationDataPromise was rejected, then: // 1. Reject p with "NotFoundError" DOMException in realm. // 2. Return p. auto decoder = TextCodec::decoder_for("UTF-8"sv); auto string = MUST(TextCodec::convert_input_to_utf8_using_given_decoder_unless_there_is_a_byte_order_mark(*decoder, system_clipboard_representation.data)); WebIDL::resolve_promise(realm, promise, JS::PrimitiveString::create(realm.vm(), move(string))); return; } } } // 2. Reject p with "NotFoundError" DOMException in realm. WebIDL::reject_promise(realm, promise, WebIDL::NotFoundError::create(realm, "Did not find a text item in the system clipboard"_string)); })); })); })); // 5. Return p. return promise; } // https://w3c.github.io/clipboard-apis/#dom-clipboard-write GC::Ref Clipboard::write(GC::RootVector>& data) { // 1. Let realm be this's relevant realm. auto& realm = HTML::relevant_realm(*this); // 2. Let p be a new promise in realm. auto promise = WebIDL::create_promise(realm); // 3. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, promise, data = move(data)]() mutable { // 1. Let r be the result of running check clipboard write permission. auto result = check_clipboard_write_permission(realm); // 2. If r is false, then: if (!result) { // 1. Queue a global task on the permission task source, given realm’s global object, to reject p with // "NotAllowedError" DOMException in realm. queue_global_task(HTML::Task::Source::Permissions, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, "Clipboard writing is only allowed through user activation"_string)); })); // 2. Abort these steps. return; } // 3. Queue a global task on the clipboard task source, given realm’s global object, to perform the below steps: queue_global_task(HTML::Task::Source::Clipboard, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, data = move(data)]() mutable { HTML::TemporaryExecutionContext execution_context { realm, HTML::TemporaryExecutionContext::CallbacksEnabled::Yes }; // 1. Let itemList and cleanItemList be an empty sequence. // FIXME: Spec issue: The spec does not clear itemList and cleanItemList in the outer `for` loop below. This // will cause us to re-write the same items after the first iteration. So we defer creating these // lists to prevent this. See: // https://github.com/w3c/clipboard-apis/issues/237 // 2. Let dataList be a sequence. // 3. If data’s size is greater than 1, and the current operating system does not support multiple native // clipboard items on the system clipboard, then add data[0] to dataList, else, set dataList to data. auto data_list = move(data); // 4. For each clipboardItem in dataList: for (auto const& clipboard_item : data_list) { IGNORE_USE_IN_ESCAPING_LAMBDA GC::RootVector> item_list(realm.heap()); GC::RootVector> clean_item_list(realm.heap()); // 1. For each representation in clipboardItem’s clipboard item's list of representations: for (auto const& representation : clipboard_item->representations()) { // 1. Let representationDataPromise be the representation’s data. auto representation_data_promise = representation.data; // 2. React to representationDataPromise: auto reaction = WebIDL::react_to_promise(representation_data_promise, // 1. If representationDataPromise was fulfilled with value v, then: GC::create_function(realm.heap(), [&realm, &item_list, mime_type = representation.mime_type](JS::Value value) mutable -> WebIDL::ExceptionOr { // 1. If v is a DOMString, then follow the below steps: if (value.is_string()) { // 1. Let dataAsBytes be the result of UTF-8 encoding v. auto const& data_as_bytes = value.as_string().utf8_string(); // 2. Let blobData be a Blob created using dataAsBytes with its type set to representation’s MIME type. auto blob_data = FileAPI::Blob::create(realm, MUST(ByteBuffer::copy(data_as_bytes.bytes())), move(mime_type)); // 3. Add blobData to itemList. item_list.append(blob_data); } // 2. If v is a Blob, then add v to itemList. else if (value.is_object()) { if (auto* blob = as_if(value.as_object())) item_list.append(*blob); } return JS::js_undefined(); }), // 2. If representationDataPromise was rejected, then: GC::create_function(realm.heap(), [&realm, promise](JS::Value reason) -> WebIDL::ExceptionOr { HTML::TemporaryExecutionContext execution_context { realm }; // 1. Reject p with "NotAllowedError" DOMException in realm. WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, MUST(String::formatted("Writing to the clipboard failed: {}", reason)))); // 2. Abort these steps. // NOTE: This is handled below. return JS::js_undefined(); })); // FIXME: Spec issue: The spec assumes the reaction steps above occur synchronously. This is never // the case; even if the promise is already settled, the reaction jobs are queued as microtasks. // https://github.com/w3c/clipboard-apis/issues/237 auto& reaction_promise = as(*reaction->promise()); HTML::main_thread_event_loop().spin_until(GC::create_function(realm.heap(), [&reaction_promise]() { return reaction_promise.state() != JS::Promise::State::Pending; })); if (reaction_promise.state() == JS::Promise::State::Rejected) return; } // 2. For each blob in itemList: for (auto blob : item_list) { // 1. Let type be the blob’s type. auto const& type = blob->type(); // 2. If type is not in the mandatory data types or optional data types list, then reject p with // "NotAllowedError" DOMException in realm and abort these steps. if (!ClipboardItem::supports(realm.vm(), type)) { WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, MUST(String::formatted("Clipboard item type {} is not allowed", type)))); return; } // 3. Let cleanItem be an optionally sanitized copy of blob. auto clean_item = blob; // FIXME: 4. If sanitization was attempted and was not successfully completed, then follow the below steps: // 1. Reject p with "NotAllowedError" DOMException in realm. // 2. Abort these steps. // 5. Append cleanItem to cleanItemList. clean_item_list.append(clean_item); } // 3. Let option be clipboardItem’s clipboard item's presentation style. auto option = Bindings::idl_enum_to_string(clipboard_item->presentation_style()); // 4. Write blobs and option to the clipboard with cleanItemList and option. write_blobs_and_option_to_clipboard(realm, clean_item_list, option); } // 5. Resolve p. WebIDL::resolve_promise(realm, promise); })); })); // 4. Return p. return promise; } // https://w3c.github.io/clipboard-apis/#dom-clipboard-writetext GC::Ref Clipboard::write_text(String data) { // 1. Let realm be this's relevant realm. auto& realm = HTML::relevant_realm(*this); // 2. Let p be a new promise in realm. auto promise = WebIDL::create_promise(realm); // 3. Run the following steps in parallel: Platform::EventLoopPlugin::the().deferred_invoke(GC::create_function(realm.heap(), [&realm, promise, data = move(data)]() mutable { // 1. Let r be the result of running check clipboard write permission. auto result = check_clipboard_write_permission(realm); // 2. If r is false, then: if (!result) { // 1. Queue a global task on the permission task source, given realm’s global object, to reject p with // "NotAllowedError" DOMException in realm. queue_global_task(HTML::Task::Source::Permissions, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise]() mutable { HTML::TemporaryExecutionContext execution_context { realm }; WebIDL::reject_promise(realm, promise, WebIDL::NotAllowedError::create(realm, "Clipboard writing is only allowed through user activation"_string)); })); // 2. Abort these steps. return; } // 3. Queue a global task on the clipboard task source, given realm’s global object, to perform the below steps: queue_global_task(HTML::Task::Source::Clipboard, realm.global_object(), GC::create_function(realm.heap(), [&realm, promise, data = move(data)]() mutable { // 1. Let itemList be an empty sequence. GC::RootVector> item_list(realm.heap()); // 2. Let textBlob be a new Blob created with: type attribute set to "text/plain;charset=utf-8", and its // underlying byte sequence set to the UTF-8 encoding of data. // Note: On Windows replace `\n` characters with `\r\n` in data before creating textBlob. auto text_blob = FileAPI::Blob::create(realm, MUST(ByteBuffer::copy(data.bytes())), "text/plain;charset=utf-8"_string); // 3. Add textBlob to itemList. item_list.append(text_blob); // 4. Let option be set to "unspecified". static constexpr auto option = "unspecified"sv; // 5. Write blobs and option to the clipboard with itemList and option. write_blobs_and_option_to_clipboard(realm, item_list, option); // 6. Resolve p. HTML::TemporaryExecutionContext execution_context { realm }; WebIDL::resolve_promise(realm, promise, JS::js_undefined()); })); })); // 4. Return p. return promise; } }