diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp
index b002aa622..11e8a666e 100644
--- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp
+++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp
@@ -4394,6 +4394,10 @@ struct v8_dom_runtime::implementation final {
read_clipboard).ToLocalChecked()).Check();
constexpr std::string_view source = R"JS(
(() => {
+ const supportedClipboardTypes = Object.freeze([
+ 'image/png', 'image/jpeg', 'image/tiff',
+ 'text/plain', 'text/html'
+ ]);
class WebSceneClipboardItem {
constructor(items, options = {}) {
if (items === null || typeof items !== 'object') {
@@ -4417,7 +4421,7 @@ struct v8_dom_runtime::implementation final {
});
}
static supports(type) {
- return ['image/png', 'text/plain', 'text/html'].includes(String(type));
+ return supportedClipboardTypes.includes(String(type).toLowerCase());
}
}
const clipboard = {
@@ -4426,6 +4430,11 @@ struct v8_dom_runtime::implementation final {
'Clipboard.readText', 'supported',
'UTF-8 text read through the native host');
const result = await __webSceneReadClipboard('text/plain');
+ if (result.type !== 'text/plain') {
+ throw new DOMException(
+ 'The native host returned a non-text clipboard item',
+ 'DataError');
+ }
return new TextDecoder().decode(result.bytes);
},
async read() {
@@ -4433,6 +4442,11 @@ struct v8_dom_runtime::implementation final {
'Clipboard.read', 'partially-supported',
'one bounded native clipboard item');
const result = await __webSceneReadClipboard('*/*');
+ if (!WebSceneClipboardItem.supports(result.type)) {
+ throw new DOMException(
+ `The native host returned unsupported type ${result.type}`,
+ 'NotSupportedError');
+ }
return [new WebSceneClipboardItem({
[result.type]: new Blob([result.bytes], { type: result.type })
})];
@@ -4465,6 +4479,7 @@ struct v8_dom_runtime::implementation final {
};
const createClipboardData = () => {
const values = Object.create(null);
+ const files = [];
const normalize = type => String(type).toLowerCase();
const createStringItem = type => Object.freeze({
kind: 'string',
@@ -4476,21 +4491,32 @@ struct v8_dom_runtime::implementation final {
Promise.resolve().then(() => callback(value));
}
});
+ const createFileItem = file => Object.freeze({
+ kind: 'file',
+ type: file.type,
+ getAsFile() { return file; },
+ getAsString() {}
+ });
+ const allItems = () => [
+ ...Object.keys(values).map(createStringItem),
+ ...files.map(createFileItem)
+ ];
const items = Object.freeze({
- get length() { return Object.keys(values).length; },
+ get length() { return allItems().length; },
item(index) {
- const type = Object.keys(values)[Number(index)];
- return type === undefined ? null : createStringItem(type);
+ return allItems()[Number(index)] || null;
},
*[Symbol.iterator]() {
- for (const type of Object.keys(values)) {
- yield createStringItem(type);
- }
+ yield* allItems();
}
});
- return Object.freeze({
- get types() { return Object.keys(values); },
- files: Object.freeze([]),
+ const clipboardData = Object.freeze({
+ get types() {
+ const result = Object.keys(values);
+ if (files.length !== 0) result.push('Files');
+ return result;
+ },
+ get files() { return Object.freeze(files.slice()); },
items,
getData(type) { return values[normalize(type)] || ''; },
setData(type, value) {
@@ -4504,6 +4530,20 @@ struct v8_dom_runtime::implementation final {
}
}
});
+ const extensionForType = type => ({
+ 'image/png': 'png',
+ 'image/jpeg': 'jpg',
+ 'image/tiff': 'tiff'
+ })[type] || 'bin';
+ return Object.freeze({
+ clipboardData,
+ addFile(type, blob) {
+ type = normalize(type);
+ const bytes = blob instanceof Blob ? blob._bytes : blob;
+ files.push(new File(
+ [bytes], `clipboard.${extensionForType(type)}`, { type }));
+ }
+ });
};
const dispatchClipboardEvent = (type, target, clipboardData) => {
const event = new Event(type, {
@@ -4518,10 +4558,20 @@ struct v8_dom_runtime::implementation final {
configurable: true,
value(type, target) {
if (!target || typeof target.dispatchEvent !== 'function') return false;
- const clipboardData = createClipboardData();
+ const transfer = createClipboardData();
+ const clipboardData = transfer.clipboardData;
if (type === 'paste') {
- clipboard.readText().then(text => {
- clipboardData.setData('text/plain', text);
+ clipboard.read().then(async sourceItems => {
+ for (const sourceItem of sourceItems) {
+ for (const itemType of sourceItem.types) {
+ const blob = await sourceItem.getType(itemType);
+ if (itemType.startsWith('text/')) {
+ clipboardData.setData(itemType, await blob.text());
+ } else {
+ transfer.addFile(itemType, blob);
+ }
+ }
+ }
dispatchClipboardEvent(type, target, clipboardData);
}).catch(() => {});
return true;
diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
index b66a36f07..bc2a22b73 100644
--- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
+++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc
@@ -3015,6 +3015,30 @@ void test_clipboard_read_host_completion(webscene_engine* engine)
text, sizeof(text), nullptr) != 0,
"duplicate stale clipboard completion was not safely admitted");
+ execute(engine, R"JS(
+ globalThis.__clipboardUnsupportedState = 'pending';
+ navigator.clipboard.read().then(
+ () => { __clipboardUnsupportedState = 'fulfilled'; },
+ error => { __clipboardUnsupportedState = error.name; });
+ )JS", "native-clipboard-read-unsupported.js");
+ require(evaluate(engine, "true",
+ "native-clipboard-read-unsupported-barrier.js") == "true",
+ "unsupported Clipboard.read did not drain through the engine worker");
+ request = take_typed_host_request(engine);
+ require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1
+ && request.content_type == "*/*",
+ "Clipboard.read did not emit its wildcard typed host request");
+ constexpr uint8_t unsupported[] = {1U};
+ require(webscene_engine_complete_host_request_v1(
+ engine, request.id, 0U, "application/octet-stream",
+ unsupported, sizeof(unsupported), nullptr) != 0,
+ "unsupported clipboard host completion was rejected before delivery");
+ require(evaluate(engine,
+ "__clipboardUnsupportedState",
+ "native-clipboard-read-unsupported-result.js")
+ == R"("NotSupportedError")",
+ "Clipboard.read admitted an unsupported host MIME type");
+
execute(engine, R"JS(
globalThis.__clipboardCancelState = 'pending';
navigator.clipboard.readText().then(
@@ -3114,15 +3138,28 @@ void test_native_clipboard_shortcut_events(webscene_engine*)
event.type, event.clipboardData.getData('text/plain')]);
const items = Array.from(event.clipboardData.items);
let synchronous = true;
- Promise.all(items.map(item => new Promise(resolve => {
- item.getAsString(value => resolve([
- item.kind, item.type, value, item.getAsFile(), synchronous]));
- }))).then(values => {
+ Promise.all(items.map(item => {
+ if (item.kind === 'file') {
+ const file = item.getAsFile();
+ return file.arrayBuffer().then(buffer => [
+ item.kind, item.type, file.name, file.size,
+ Array.from(new Uint8Array(buffer)).join(','), synchronous
+ ]);
+ }
+ return new Promise(resolve => {
+ item.getAsString(value => resolve([
+ item.kind, item.type, value, item.getAsFile(), synchronous]));
+ });
+ })).then(values => {
__clipboardPasteItems = {
values,
length: event.clipboardData.items.length,
first: event.clipboardData.items.item(0)?.type,
- missing: event.clipboardData.items.item(1)
+ missing: event.clipboardData.items.item(items.length),
+ types: Array.from(event.clipboardData.types),
+ files: Array.from(event.clipboardData.files).map(file => [
+ file.name, file.type, file.size
+ ])
};
});
synchronous = false;
@@ -3191,8 +3228,8 @@ void test_native_clipboard_shortcut_events(webscene_engine*)
"native paste shortcut was not consumed");
request = take_typed_host_request(engine);
require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1
- && request.content_type == "text/plain",
- "native paste event did not request plain text from the host");
+ && request.content_type == "*/*",
+ "native paste event did not request a supported item from the host");
constexpr uint8_t paste[] = {'s','h','o','r','t','c','u','t',' ','p','a','s','t','e'};
require(webscene_engine_complete_host_request_v1(
engine, request.id, 0U, "text/plain",
@@ -3219,13 +3256,51 @@ void test_native_clipboard_shortcut_events(webscene_engine*)
"__clipboardPasteItems",
"native-clipboard-paste-items-result.js");
require(paste_items
- == R"JSON({"values":[["string","text/plain","shortcut paste",null,false]],"length":1,"first":"text/plain","missing":null})JSON",
+ == R"JSON({"values":[["string","text/plain","shortcut paste",null,false]],"length":1,"first":"text/plain","missing":null,"types":["text/plain"],"files":[]})JSON",
"native paste did not expose a Code OSS-compatible DataTransferItemList: "
+ paste_items);
+
+ execute(engine, "__clipboardPasteItems = 'pending'",
+ "native-clipboard-image-paste-reset.js");
+ keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'V', 9764U, modifier);
+ webscene_engine_get_metrics(engine, &metrics);
+ wait_for_consumed_inputs(engine, metrics.enqueued_inputs,
+ "native image paste shortcut was not consumed");
+ request = take_typed_host_request(engine);
+ require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1
+ && request.content_type == "*/*",
+ "native image paste did not request a supported item from the host");
+ constexpr uint8_t png[] = {137U, 80U, 78U, 71U, 13U, 10U, 26U, 10U};
+ require(webscene_engine_complete_host_request_v1(
+ engine, request.id, 0U, "image/png",
+ png, sizeof(png), nullptr) != 0,
+ "native image paste completion was rejected");
+ for (auto attempt = 0; attempt < 250; ++attempt) {
+ if (evaluate(engine,
+ "__clipboardShortcutEvents.length === 4"
+ " && __clipboardExecCommandResults.length === 4"
+ " && __clipboardPasteItems !== 'pending'",
+ "native-clipboard-image-completion.js") == "true") {
+ break;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
+ }
+ const auto image_paste_items = evaluate(engine,
+ "__clipboardPasteItems",
+ "native-clipboard-image-paste-items-result.js");
+ require(image_paste_items
+ == R"JSON({"values":[["file","image/png","clipboard.png",8,"137,80,78,71,13,10,26,10",false]],"length":1,"first":"image/png","missing":null,"types":["Files"],"files":[["clipboard.png","image/png",8]]})JSON",
+ "native image paste did not expose a bounded file item to Code OSS: "
+ + image_paste_items);
+ require(evaluate(engine,
+ "__clipboardShortcutEvents.at(-1)[0] === 'paste'"
+ " && __clipboardShortcutEvents.at(-1)[1] === ''",
+ "native-clipboard-image-event-result.js") == "true",
+ "native image paste exposed non-text bytes through getData");
const auto exec_command_results = evaluate(engine,
"__clipboardExecCommandResults",
"native-clipboard-exec-command-results.js");
- require(exec_command_results == "[true,true,true]",
+ require(exec_command_results == "[true,true,true,true]",
"prevented native shortcuts did not execute document clipboard commands: "
+ exec_command_results);
require(evaluate(engine, "document.execCommand('unsupported-command')",
@@ -3236,6 +3311,96 @@ void test_native_clipboard_shortcut_events(webscene_engine*)
webscene_engine_destroy(engine);
}
+void test_native_image_clipboard_maximum_payload(webscene_engine*)
+{
+ auto* engine = webscene_engine_create(0);
+ require(engine != nullptr,
+ "maximum image clipboard engine creation failed");
+ execute_and_wait(engine, R"JS(
+ document.body.innerHTML = '';
+ const editor = document.getElementById('image-paste');
+ globalThis.__maximumImagePaste = 'pending';
+ editor.addEventListener('keydown', event => {
+ if (event.metaKey && event.key.toLowerCase() === 'v') {
+ event.preventDefault();
+ document.execCommand('paste');
+ }
+ });
+ editor.addEventListener('paste', event => {
+ event.preventDefault();
+ const item = event.clipboardData.items.item(0);
+ const file = item?.getAsFile();
+ if (!file) {
+ __maximumImagePaste = 'missing-file';
+ return;
+ }
+ file.arrayBuffer().then(buffer => {
+ const bytes = new Uint8Array(buffer);
+ __maximumImagePaste = {
+ kind: item.kind,
+ type: item.type,
+ name: file.name,
+ sizeExact: file.size === 16 * 1024 * 1024,
+ lengthExact: bytes.byteLength === 16 * 1024 * 1024,
+ first: bytes[0],
+ last: bytes[bytes.length - 1],
+ files: event.clipboardData.files.length,
+ types: Array.from(event.clipboardData.types)
+ };
+ });
+ });
+ editor.focus();
+ )JS", "native-maximum-image-paste-setup.js");
+
+ keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'V', 9771U,
+ WEBSCENE_INPUT_MODIFIER_META);
+ webscene_engine_metrics metrics{};
+ webscene_engine_get_metrics(engine, &metrics);
+ wait_for_consumed_inputs(engine, metrics.enqueued_inputs,
+ "maximum image paste shortcut was not consumed");
+ const auto request = take_typed_host_request(engine);
+ require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1
+ && request.content_type == "*/*",
+ "maximum image paste did not request a supported host item");
+
+ constexpr size_t maximum_bytes = 16U * 1024U * 1024U;
+ std::vector png(maximum_bytes, 97U);
+ png.front() = 137U;
+ png.back() = 10U;
+ require(webscene_engine_complete_host_request_v1(
+ engine, request.id, 0U, "image/png",
+ png.data(), maximum_bytes + 1U, nullptr) == 0,
+ "image clipboard completion admitted a payload above 16 MiB");
+ const auto started = std::chrono::steady_clock::now();
+ require(webscene_engine_complete_host_request_v1(
+ engine, request.id, 0U, "image/png",
+ png.data(), png.size(), nullptr) != 0,
+ "maximum image clipboard completion was rejected");
+ for (auto attempt = 0; attempt < 500; ++attempt) {
+ if (evaluate(engine,
+ "__maximumImagePaste !== 'pending'",
+ "native-maximum-image-paste-completion.js") == "true") {
+ break;
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(2));
+ }
+ const auto result = evaluate(engine,
+ "__maximumImagePaste",
+ "native-maximum-image-paste-result.js");
+ require(result
+ == R"JSON({"kind":"file","type":"image/png","name":"clipboard.png","sizeExact":true,"lengthExact":true,"first":137,"last":10,"files":1,"types":["Files"]})JSON",
+ "maximum image paste changed bytes or transfer metadata: " + result);
+ require(!take_typed_host_request(engine),
+ "maximum image paste retained a host request");
+ const auto elapsed = std::chrono::duration(
+ std::chrono::steady_clock::now() - started).count();
+ require(elapsed < 5.0,
+ "maximum image clipboard paste exceeded five seconds");
+ std::cout << "Image clipboard maximum-payload gate: bytes="
+ << maximum_bytes << " elapsed=" << elapsed << "s\n";
+ webscene_engine_destroy(engine);
+}
+
void test_native_legacy_clipboard_completion_stress(webscene_engine*)
{
auto* engine = webscene_engine_create(0);
diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
index 690d51b41..5c1a351a3 100644
--- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
+++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp
@@ -1058,6 +1058,7 @@ int main()
test_clipboard_write_text_host_handoff(engine);
test_clipboard_read_host_completion(engine);
test_native_clipboard_shortcut_events(engine);
+ test_native_image_clipboard_maximum_payload(engine);
test_native_legacy_clipboard_completion_stress(engine);
test_native_pending_legacy_clipboard_shutdown(engine);
test_clipboard_maximum_payload_gate(engine);