From b6d0f49b2f9e0333ffa6993624096ed1ba4e1ef4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 22:54:22 +0200 Subject: [PATCH] Expose native clipboard images as file items --- .../native/webscene_v8_runtime.cpp | 76 ++++++-- .../native_v8_runtime_browser_dom_tests.inc | 183 +++++++++++++++++- .../tests/native_v8_runtime_tests.cpp | 1 + 3 files changed, 238 insertions(+), 22 deletions(-) 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);