From 6530d401c5fd9d78a3f913b8aed8700237e62820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 15:43:57 +0200 Subject: [PATCH 01/35] Add native clipboard and window capability bridge --- .../native/webscene_native_engine.cpp | 44 +++++ .../native/webscene_native_engine.exports | 3 + .../native/webscene_native_engine.h | 22 +++ .../webscene_native_engine_interop_types.inc | 1 + .../webscene_native_engine_lifecycle.inc | 23 +++ .../native/webscene_native_engine_worker.inc | 14 ++ .../native/webscene_v8_runtime.cpp | 135 ++++++++++++- .../native/webscene_v8_runtime.h | 10 + .../webscene_v8_runtime_browser_apis.inc | 180 ++++++++++++++++-- .../webscene_v8_runtime_cache_and_frames.inc | 2 + .../native/webscene_v8_runtime_dom_core.inc | 108 +++++++++++ .../native/webscene_v8_runtime_files.inc | 106 +++++++++++ .../native/webscene_v8_runtime_navigation.inc | 3 + .../native/webscene_v8_runtime_state.inc | 9 + .../native_v8_runtime_browser_dom_tests.inc | 149 +++++++++++++++ .../tests/native_v8_runtime_input_tests.inc | 39 ++++ .../tests/native_v8_runtime_tests.cpp | 3 + 17 files changed, 834 insertions(+), 17 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 262b55c8b..cef414dc1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -442,6 +442,8 @@ struct webscene_engine final { std::atomic low_memory_requested_{false}; std::atomic host_visible_{true}; std::atomic visibility_changed_{false}; + std::atomic host_focused_{true}; + std::atomic focus_changed_{false}; std::atomic preferred_color_scheme_{ WEBSCENE_PREFERRED_COLOR_SCHEME_LIGHT}; std::atomic preferred_color_scheme_changed_{false}; @@ -1291,6 +1293,42 @@ size_t webscene_engine_take_host_request( : engine->take_host_request(destination, destination_capacity); } +uint8_t webscene_engine_discard_host_request_v1(webscene_engine* engine) +{ + return engine != nullptr && engine->discard_host_request() ? 1U : 0U; +} + +uint8_t webscene_engine_complete_host_request_v1( + webscene_engine* engine, + uint64_t request_id, + uint32_t status, + const char* content_type, + const uint8_t* bytes, + size_t byte_count, + const char* error_message) +{ + constexpr size_t maximum_clipboard_bytes = 16U * 1024U * 1024U; + if (engine == nullptr || request_id == 0U || status > 2U + || byte_count > maximum_clipboard_bytes + || (byte_count != 0U && bytes == nullptr)) { + return 0U; + } + webscene_native::native_host_completion completion; + completion.id = request_id; + completion.status = status; + completion.content_type = content_type == nullptr ? "" : content_type; + completion.error = error_message == nullptr ? "" : error_message; + if (completion.content_type.size() > 256U || completion.error.size() > 4096U) + return 0U; + if (status == 0U) { + if (byte_count != 0U && completion.content_type.empty()) return 0U; + if (byte_count != 0U) completion.bytes.assign(bytes, bytes + byte_count); + } else if (byte_count != 0U) { + return 0U; + } + return engine->complete_host_request(std::move(completion)) ? 1U : 0U; +} + void webscene_engine_configure_diagnostics( webscene_engine* engine, uint32_t flags, webscene_diagnostic_available_callback callback, void* user_data) @@ -1416,6 +1454,12 @@ uint8_t webscene_engine_set_visible(webscene_engine* engine, uint8_t visible) return engine != nullptr && engine->set_visible(visible != 0) ? 1U : 0U; } +uint8_t webscene_engine_set_window_focused_v1( + webscene_engine* engine, uint8_t focused) +{ + return engine != nullptr && engine->set_focused(focused != 0) ? 1U : 0U; +} + uint8_t webscene_engine_set_preferred_color_scheme( webscene_engine* engine, uint32_t preferred_color_scheme) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports index 0306fdf33..d263ece49 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -54,9 +54,12 @@ _webscene_engine_request_scene_checkpoint _webscene_engine_set_resource_root _webscene_engine_set_preferred_color_scheme _webscene_engine_set_visible +_webscene_engine_set_window_focused_v1 _webscene_engine_take_console_message _webscene_engine_take_diagnostic _webscene_engine_take_host_request +_webscene_engine_complete_host_request_v1 +_webscene_engine_discard_host_request_v1 _webscene_engine_take_input_dispatch_failure _webscene_engine_take_invoke_result_v3 _webscene_engine_take_callback_v3 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 2b776697c..75e58de51 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1213,6 +1213,10 @@ WEBSCENE_API uint8_t webscene_engine_request_low_memory(webscene_engine* engine) * worker; returning visible before the deadline cancels it. */ WEBSCENE_API uint8_t webscene_engine_set_visible(webscene_engine* engine, uint8_t visible); +/* Publishes native key-window focus to document.hasFocus() and standard + * top-level focus/blur events. Repeated values are coalesced. */ +WEBSCENE_API uint8_t webscene_engine_set_window_focused_v1( + webscene_engine* engine, uint8_t focused); /* * Updates the host's effective color preference. The worker re-evaluates CSS * media rules and subsequent Window.matchMedia snapshots against this value. @@ -1370,6 +1374,24 @@ WEBSCENE_API size_t webscene_engine_take_host_request( webscene_engine* engine, char* destination, size_t destination_capacity); +/* Consumes the oldest JSON compatibility request without allocating its + * payload. Hosts use this after rejecting an oversized item so one malformed + * request cannot permanently block the FIFO. */ +WEBSCENE_API uint8_t webscene_engine_discard_host_request_v1( + webscene_engine* engine); +/* Completes a request carrying a numeric requestId from take_host_request. + * status: 0 completed, 1 cancelled, 2 denied/failed. Inputs are copied before + * return. Clipboard data is limited to 16 MiB, content_type to 256 bytes and + * error_message to 4096 bytes. Completion is delivered on the engine worker; + * stale request IDs are safely ignored there. */ +WEBSCENE_API uint8_t webscene_engine_complete_host_request_v1( + webscene_engine* engine, + uint64_t request_id, + uint32_t status, + const char* content_type, + const uint8_t* bytes, + size_t byte_count, + const char* error_message); /* * Removes one V8 console entry. The UTF-8 payload is `\n`; * querying with a null/short destination reports the required byte count diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc index 480828744..454402208 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc @@ -413,6 +413,7 @@ using script_work_request = std::variant< script_request, webscene_native::native_file_completion, + webscene_native::native_host_completion, url_request, interop_evaluate_work_v3, interop_invoke_work_v3, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc index 93582f4a0..4f93808d5 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -101,6 +101,21 @@ signal_worker(); return true; } + bool complete_host_request(webscene_native::native_host_completion completion) { + std::lock_guard lock(script_mutex_); + if (script_work_.size() >= 1024) return false; + script_work_.emplace_back(std::move(completion)); + signal_worker(); + return true; + } + bool discard_host_request() { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + std::lock_guard lock(file_runtime_mutex_); + return file_runtime_ready_ && runtime_->discard_host_request(); +#else + return false; +#endif + } void retire_file_runtime() { #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) std::unique_ptr retired; @@ -215,6 +230,14 @@ return true; } + bool set_focused(bool focused) + { + host_focused_.store(focused, std::memory_order_release); + focus_changed_.store(true, std::memory_order_release); + signal_worker(); + return true; + } + bool set_preferred_color_scheme(uint32_t preferred_color_scheme) { if (preferred_color_scheme != WEBSCENE_PREFERRED_COLOR_SCHEME_LIGHT diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index 7e4860295..fe927c8e1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -318,6 +318,14 @@ + std::chrono::milliseconds(500); } } + if (focus_changed_.exchange(false, std::memory_order_acq_rel)) { + const auto host_focused = host_focused_.load(std::memory_order_acquire); + if (runtime_ != nullptr + && !runtime_->set_focused(host_focused)) { + script_errors_.fetch_add(1, std::memory_order_relaxed); + set_last_error(runtime_->last_error()); + } + } if (low_memory_requested_.exchange(false, std::memory_order_acq_rel) && runtime_ != nullptr) { runtime_->notify_low_memory(); @@ -904,6 +912,11 @@ changed = true; continue; } + if (auto* host = std::get_if(&request)) { + if (runtime_) runtime_->complete_host_request(*host); + changed = true; + continue; + } #endif if (auto* url = std::get_if(&request)) { if (url->initial_viewport) apply(*url->initial_viewport); @@ -1273,6 +1286,7 @@ || resize_pending_.load(std::memory_order_acquire) || low_memory_requested_.load(std::memory_order_acquire) || visibility_changed_.load(std::memory_order_acquire) + || focus_changed_.load(std::memory_order_acquire) || preferred_color_scheme_changed_.load( std::memory_order_acquire); }); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 5b5cefc2f..b59956b43 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -714,6 +714,9 @@ struct v8_dom_runtime::implementation final { element->PrototypeTemplate()->Set( js_string(isolate, "click"), v8::FunctionTemplate::New(isolate, element_click)); + element->PrototypeTemplate()->Set( + js_string(isolate, "requestFullscreen"), + v8::FunctionTemplate::New(isolate, element_request_fullscreen)); element->PrototypeTemplate()->Set( js_string(isolate, "reset"), v8::FunctionTemplate::New(isolate, form_reset)); @@ -949,6 +952,12 @@ struct v8_dom_runtime::implementation final { document_template->SetNativeDataProperty( js_string(isolate, "visibilityState"), get_document_visibility_state); + document_template->SetNativeDataProperty( + js_string(isolate, "fullscreenElement"), + get_document_fullscreen_element); + document_template->SetNativeDataProperty( + js_string(isolate, "fullscreenEnabled"), + get_document_fullscreen_enabled); document_template->SetNativeDataProperty(js_string(isolate, "links"), get_document_links); document_template->SetNativeDataProperty(js_string(isolate, "styleSheets"), get_document_style_sheets); document_template->SetNativeDataProperty( @@ -1055,6 +1064,9 @@ struct v8_dom_runtime::implementation final { document_template->Set( js_string(isolate, "hasFocus"), v8::FunctionTemplate::New(isolate, document_has_focus)); + document_template->Set( + js_string(isolate, "exitFullscreen"), + v8::FunctionTemplate::New(isolate, document_exit_fullscreen)); document_template->Set( js_string(isolate, "getSelection"), v8::FunctionTemplate::New(isolate, get_selection)); @@ -3065,6 +3077,14 @@ struct v8_dom_runtime::implementation final { local_context, js_string(isolate, "open"), v8::Function::New(local_context, window_open).ToLocalChecked()).Check(); + global->Set( + local_context, + js_string(isolate, "close"), + v8::Function::New(local_context, window_close).ToLocalChecked()).Check(); + global->Set( + local_context, + js_string(isolate, "focus"), + v8::Function::New(local_context, window_focus).ToLocalChecked()).Check(); global->Set( local_context, js_string(isolate, "requestAnimationFrame"), @@ -3364,6 +3384,10 @@ struct v8_dom_runtime::implementation final { local_context, js_string(isolate, "toString"), v8::Function::New(local_context, location_to_string).ToLocalChecked()).Check(); + location->Set( + local_context, + js_string(isolate, "reload"), + v8::Function::New(local_context, location_reload).ToLocalChecked()).Check(); global->Set(local_context, js_string(isolate, "location"), location).Check(); install_navigator(isolate, local_context, global); @@ -4071,6 +4095,12 @@ struct v8_dom_runtime::implementation final { v8::Function::New( local_context, write_clipboard).ToLocalChecked()).Check(); + global->Set( + local_context, + js_string(isolate, "__webSceneReadClipboard"), + v8::Function::New( + local_context, + read_clipboard).ToLocalChecked()).Check(); constexpr std::string_view source = R"JS( (() => { class WebSceneClipboardItem { @@ -4100,6 +4130,30 @@ struct v8_dom_runtime::implementation final { } } const clipboard = { + async readText() { + __webSceneRecordWebApi( + 'Clipboard.readText', 'supported', + 'UTF-8 text read through the native host'); + const result = await __webSceneReadClipboard('text/plain'); + return new TextDecoder().decode(result.bytes); + }, + async read() { + __webSceneRecordWebApi( + 'Clipboard.read', 'partially-supported', + 'one bounded native clipboard item'); + const result = await __webSceneReadClipboard('*/*'); + return [new WebSceneClipboardItem({ + [result.type]: new Blob([result.bytes], { type: result.type }) + })]; + }, + async writeText(text) { + __webSceneRecordWebApi( + 'Clipboard.writeText', 'supported', + 'UTF-8 text handoff to the desktop host'); + return this.write([new WebSceneClipboardItem({ + 'text/plain': new Blob([String(text)], { type: 'text/plain' }) + })]); + }, async write(items) { __webSceneRecordWebApi( 'Clipboard.write', 'partially-supported', @@ -4113,9 +4167,7 @@ struct v8_dom_runtime::implementation final { } for (const type of item.types) { const blob = await item.getType(type); - if (!__webSceneWriteClipboard(type, blob)) { - throw new DOMException('The host rejected the clipboard write', 'NotAllowedError'); - } + await __webSceneWriteClipboard(type, blob); } } }; @@ -4142,6 +4194,14 @@ struct v8_dom_runtime::implementation final { return true; } + bool discard_host_request() + { + std::lock_guard lock(host_request_mutex); + if (host_requests.empty()) return false; + host_requests.pop_front(); + return true; + } + uint32_t current_cursor_kind() const noexcept { return current_cursor_kind_value; @@ -4268,8 +4328,11 @@ struct v8_dom_runtime::implementation final { bool queue_external_url(const std::string& authored) { + constexpr size_t maximum_external_url_bytes = 8192U; + if (authored.size() > maximum_external_url_bytes) return false; const auto& base = current_base_address(); const auto resolved = resolve_resource_url(authored, base); + if (resolved.size() > maximum_external_url_bytes) return false; const auto lower = lower_html_name(resolved); if (!lower.starts_with("https://") && !lower.starts_with("http://")) return true; @@ -4320,6 +4383,50 @@ struct v8_dom_runtime::implementation final { info.GetReturnValue().Set(proxy); } + bool queue_top_level_window_action( + v8::Local local_context, + const char* kind) + { + if (local_context != context.Get(isolate)) return true; + auto request = v8::Object::New(isolate); + request->Set( + local_context, + js_string(isolate, "kind"), + js_string(isolate, kind)).Check(); + return enqueue_host_request(local_context, request); + } + + static void window_close(const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto local_context = info.GetIsolate()->GetCurrentContext(); + if (self == nullptr || local_context != self->context.Get(info.GetIsolate())) + return; + auto global = local_context->Global(); + auto event = self->create_event_instance(local_context); + event->Set(local_context, js_string(info.GetIsolate(), "type"), + js_string(info.GetIsolate(), "beforeunload")).Check(); + event->Set(local_context, js_string(info.GetIsolate(), "bubbles"), + v8::False(info.GetIsolate())).Check(); + event->Set(local_context, js_string(info.GetIsolate(), "cancelable"), + v8::True(info.GetIsolate())).Check(); + v8::Local dispatcher; + v8::Local dispatch_result; + v8::Local arguments[] = {event}; + if (global->Get(local_context, js_string(info.GetIsolate(), "dispatchEvent")) + .ToLocal(&dispatcher) + && dispatcher->IsFunction() + && dispatcher.As()->Call( + local_context, global, 1, arguments).ToLocal(&dispatch_result) + && dispatch_result->IsFalse()) { + return; + } + if (!self->queue_top_level_window_action(local_context, "closeWindow")) { + info.GetIsolate()->ThrowException(v8::Exception::Error( + js_string(info.GetIsolate(), "WebScene rejected the close-window request"))); + } + } + static void record_web_api_use(const v8::FunctionCallbackInfo& info) { if (info.Length() < 2) return; @@ -4576,6 +4683,12 @@ bool v8_dom_runtime::set_visible(bool visible) && impl_->promote_pending_promise_error(); } +bool v8_dom_runtime::set_focused(bool focused) +{ + return impl_->set_focused(focused) + && impl_->promote_pending_promise_error(); +} + void v8_dom_runtime::set_resource_root(std::string resource_root) { impl_->resource_root = std::filesystem::path(std::move(resource_root)).lexically_normal(); @@ -6156,4 +6269,20 @@ void v8_dom_runtime::complete_file_request(native_file_completion& completion) { impl_->console_messages.push_back("error\nNative file completion: "+impl_->last_error); } } +void v8_dom_runtime::complete_host_request(native_host_completion& completion) { + v8::Locker locker(impl_->isolate); + v8::Isolate::Scope isolate_scope(impl_->isolate); + v8::HandleScope handles(impl_->isolate); + v8::TryCatch caught(impl_->isolate); + impl_->complete_native_host(completion); + if(caught.HasCaught()) { + impl_->last_error=impl_->describe_reported_exception(caught); + std::lock_guard lock(impl_->console_message_mutex); + if(impl_->console_messages.size()<1024) + impl_->console_messages.push_back("error\nNative host completion: "+impl_->last_error); + } +} +bool v8_dom_runtime::discard_host_request() { + return impl_->discard_host_request(); +} } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 7168981f9..03cd0ff0f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -44,6 +44,13 @@ struct native_file_completion { std::vector files; std::string error; }; +struct native_host_completion { + uint64_t id{}; + uint32_t status{}; + std::string content_type; + std::vector bytes; + std::string error; +}; class native_document; struct dom_node; @@ -294,7 +301,9 @@ class v8_dom_runtime final { void set_native_media_policy(uint32_t flags); std::unique_ptr take_file_request(); void complete_file_request(native_file_completion& completion); + void complete_host_request(native_host_completion& completion); bool try_take_host_request(std::string& request); + bool discard_host_request(); bool try_take_console_message(std::string& message); bool inspector_available() const noexcept; uint64_t connect_inspector( @@ -313,6 +322,7 @@ class v8_dom_runtime final { void update_gpu_presentation_images(const std::vector>& images); bool refresh_media_environment(); bool set_visible(bool visible); + bool set_focused(bool focused); bool dispatch_input(const webscene_input_event& event, bool defer_cursor_update = false); // Worker-only: call after publication layout and ResizeObserver delivery. void refresh_pointer_cursor_after_layout(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc index 29fdc8e9a..18ff470cf 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc @@ -306,13 +306,25 @@ static void window_focus(const v8::FunctionCallbackInfo& info) { auto* self = current(info.GetIsolate()); - if (self != nullptr) { - self->record_feature( - "web-api", - "Window.focus", - "supported", - {}, - "web-api-binding"); + if (self == nullptr) return; + auto local_context = info.GetIsolate()->GetCurrentContext(); + self->record_feature( + "web-api", + "Window.focus", + "supported", + {}, + "web-api-binding"); + // A child browsing context may focus its own active element but must + // not activate the top-level operating-system window. + if (local_context != self->context.Get(info.GetIsolate())) return; + auto request = v8::Object::New(info.GetIsolate()); + request->Set( + local_context, + js_string(info.GetIsolate(), "kind"), + js_string(info.GetIsolate(), "focusWindow")).Check(); + if (!self->enqueue_host_request(local_context, request)) { + info.GetIsolate()->ThrowException(v8::Exception::Error( + js_string(info.GetIsolate(), "WebScene rejected the focus-window request"))); } } @@ -1380,6 +1392,51 @@ return true; } + bool set_focused(bool focused) + { + if (document_focused == focused) return true; + document_focused = focused; + auto isolate_locker = lock_shared_isolate(); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + auto local_context = context.Get(isolate); + if (local_context.IsEmpty()) return true; + v8::Context::Scope context_scope(local_context); + auto target = local_context->Global(); + auto event = create_event_instance(local_context); + event->Set( + local_context, + js_string(isolate, "type"), + js_string(isolate, focused ? "focus" : "blur")).Check(); + event->Set( + local_context, + js_string(isolate, "bubbles"), + v8::False(isolate)).Check(); + event->Set( + local_context, + js_string(isolate, "cancelable"), + v8::False(isolate)).Check(); + v8::Local dispatcher; + if (!target->Get( + local_context, + js_string(isolate, "dispatchEvent")).ToLocal(&dispatcher) + || !dispatcher->IsFunction()) { + last_error = "Window focus dispatcher is unavailable"; + return false; + } + v8::TryCatch try_catch(isolate); + v8::Local arguments[] = {event}; + if (dispatcher.As()->Call( + local_context, target, 1, arguments).IsEmpty()) { + last_error = "Window focus dispatch failed: " + + describe_reported_exception(try_catch, local_context); + ++frame_script_error_count; + return false; + } + perform_microtask_checkpoint(); + return true; + } + bool dispatch_transition_events() { auto events = document.take_transition_events(); @@ -1873,19 +1930,46 @@ static void write_clipboard(const v8::FunctionCallbackInfo& info) { + constexpr size_t maximum_clipboard_bytes = 16U * 1024U * 1024U; auto* self = current(info.GetIsolate()); auto local_context = info.GetIsolate()->GetCurrentContext(); - if (self == nullptr || info.Length() < 2 || !info[1]->IsObject()) { - info.GetReturnValue().Set(v8::False(info.GetIsolate())); + auto resolver = v8::Promise::Resolver::New(local_context).ToLocalChecked(); + info.GetReturnValue().Set(resolver->GetPromise()); + const auto reject = [&](const char* message, const char* name) { + auto error = v8::Exception::Error(js_string(info.GetIsolate(), message)); + if (error->IsObject()) { + error.As()->Set( + local_context, + js_string(info.GetIsolate(), "name"), + js_string(info.GetIsolate(), name)).Check(); + } + resolver->Reject(local_context, error).Check(); + }; + if (self == nullptr || local_context != self->context.Get(info.GetIsolate()) + || info.Length() < 2 || !info[1]->IsObject()) { + reject("Clipboard writes require the active top-level document", "NotAllowedError"); return; } const auto content_type = to_utf8(info.GetIsolate(), info[0]); - if (content_type.empty()) { - info.GetReturnValue().Set(v8::False(info.GetIsolate())); + if (content_type != "text/plain" && content_type != "text/html" + && content_type != "image/png" && content_type != "image/jpeg" + && content_type != "image/tiff") { + reject("The requested clipboard type is unsupported", "NotSupportedError"); + return; + } + constexpr size_t maximum_pending_clipboard_operations = 16U; + if (self->host_promise_targets.size() + >= maximum_pending_clipboard_operations) { + reject("Too many pending clipboard operations", "QuotaExceededError"); return; } auto payload = info[1].As(); auto request = v8::Object::New(info.GetIsolate()); + const auto request_id = ++self->next_host_request_id; + request->Set( + local_context, + js_string(info.GetIsolate(), "requestId"), + v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); request->Set( local_context, js_string(info.GetIsolate(), "kind"), @@ -1910,10 +1994,14 @@ local_context, js_string(info.GetIsolate(), "_bytes")).ToLocal(&bytes_value) || !bytes_value->IsArrayBufferView()) { - info.GetReturnValue().Set(v8::False(info.GetIsolate())); + reject("Clipboard data must expose immutable bytes", "DataError"); return; } auto view = bytes_value.As(); + if (view->ByteLength() > maximum_clipboard_bytes) { + reject("Clipboard data exceeds the 16 MiB limit", "QuotaExceededError"); + return; + } auto backing = view->Buffer()->GetBackingStore(); const auto* bytes = static_cast(backing->Data()) + view->ByteOffset(); @@ -1924,8 +2012,72 @@ js_string(info.GetIsolate(), "url"), js_string(info.GetIsolate(), data_url.c_str())).Check(); } - info.GetReturnValue().Set(v8::Boolean::New( - info.GetIsolate(), self->enqueue_host_request(local_context, request))); + host_promise_target target; + target.operation = "writeClipboard"; + target.context.Reset(info.GetIsolate(), local_context); + target.resolver.Reset(info.GetIsolate(), resolver); + self->host_promise_targets.emplace(request_id, std::move(target)); + if (!self->enqueue_host_request(local_context, request)) { + self->host_promise_targets.erase(request_id); + reject("The host request queue is full", "QuotaExceededError"); + } + } + + static void read_clipboard(const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto local_context = info.GetIsolate()->GetCurrentContext(); + auto resolver = v8::Promise::Resolver::New(local_context).ToLocalChecked(); + info.GetReturnValue().Set(resolver->GetPromise()); + const auto reject = [&](const char* message, const char* name) { + auto error = v8::Exception::Error(js_string(info.GetIsolate(), message)); + if (error->IsObject()) { + error.As()->Set( + local_context, + js_string(info.GetIsolate(), "name"), + js_string(info.GetIsolate(), name)).Check(); + } + resolver->Reject(local_context, error).Check(); + }; + if (self == nullptr || local_context != self->context.Get(info.GetIsolate())) { + reject("Clipboard reads require the active top-level document", "NotAllowedError"); + return; + } + const auto content_type = info.Length() == 0 + ? std::string{"text/plain"} + : to_utf8(info.GetIsolate(), info[0]); + if (content_type != "text/plain" && content_type != "*/*") { + reject("The requested clipboard type is unsupported", "NotSupportedError"); + return; + } + constexpr size_t maximum_pending_clipboard_reads = 16U; + if (self->host_promise_targets.size() >= maximum_pending_clipboard_reads) { + reject("Too many pending clipboard reads", "QuotaExceededError"); + return; + } + const auto request_id = ++self->next_host_request_id; + auto request = v8::Object::New(info.GetIsolate()); + request->Set( + local_context, + js_string(info.GetIsolate(), "requestId"), + v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); + request->Set( + local_context, + js_string(info.GetIsolate(), "kind"), + js_string(info.GetIsolate(), "readClipboard")).Check(); + request->Set( + local_context, + js_string(info.GetIsolate(), "contentType"), + js_string(info.GetIsolate(), content_type.c_str())).Check(); + host_promise_target target; + target.operation = "readClipboard"; + target.context.Reset(info.GetIsolate(), local_context); + target.resolver.Reset(info.GetIsolate(), resolver); + self->host_promise_targets.emplace(request_id, std::move(target)); + if (!self->enqueue_host_request(local_context, request)) { + self->host_promise_targets.erase(request_id); + reject("The host request queue is full", "QuotaExceededError"); + } } static void revoke_object_url(const v8::FunctionCallbackInfo& info) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc index 77b92ec6b..16ddd98a2 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_cache_and_frames.inc @@ -1290,6 +1290,8 @@ global->Set(local_context, js_string(isolate, "AbortController"), v8::FunctionTemplate::New(isolate, abort_controller_constructor)->GetFunction(local_context).ToLocalChecked()).Check(); global->Set(local_context, js_string(isolate, "matchMedia"), v8::Function::New(local_context, match_media).ToLocalChecked()).Check(); global->Set(local_context, js_string(isolate, "open"), v8::Function::New(local_context, window_open).ToLocalChecked()).Check(); + global->Set(local_context, js_string(isolate, "close"), v8::Function::New(local_context, window_close).ToLocalChecked()).Check(); + global->Set(local_context, js_string(isolate, "focus"), v8::Function::New(local_context, window_focus).ToLocalChecked()).Check(); #if defined(WEBSCENE_NATIVE_ENGINE_GENERATED_DOM_BINDINGS) install_generated_dom_constructors(local_context, global); #else diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index d5e22f997..0c2d25a95 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -4260,10 +4260,106 @@ } static void document_has_focus(const v8::FunctionCallbackInfo& info) + { + const auto* self = current(info.GetIsolate()); + info.GetReturnValue().Set(v8::Boolean::New( + info.GetIsolate(), self != nullptr && self->document_focused)); + } + + static void get_document_fullscreen_element( + v8::Local, + const v8::PropertyCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + if (self == nullptr || self->fullscreen_element == nullptr + || !self->is_connected(*self->fullscreen_element)) { + info.GetReturnValue().Set(v8::Null(info.GetIsolate())); + return; + } + info.GetReturnValue().Set(self->wrap_node(*self->fullscreen_element)); + } + + static void get_document_fullscreen_enabled( + v8::Local, + const v8::PropertyCallbackInfo& info) { info.GetReturnValue().Set(v8::True(info.GetIsolate())); } + static void queue_fullscreen_request( + const v8::FunctionCallbackInfo& info, + dom_node* target, + bool enter) + { + auto* self = current(info.GetIsolate()); + auto local_context = info.GetIsolate()->GetCurrentContext(); + auto resolver = v8::Promise::Resolver::New(local_context).ToLocalChecked(); + info.GetReturnValue().Set(resolver->GetPromise()); + const auto reject = [&](const char* message, const char* name) { + auto error = v8::Exception::Error(js_string(info.GetIsolate(), message)); + if (error->IsObject()) { + error.As()->Set( + local_context, + js_string(info.GetIsolate(), "name"), + js_string(info.GetIsolate(), name)).Check(); + } + resolver->Reject(local_context, error).Check(); + }; + if (self == nullptr || local_context != self->context.Get(info.GetIsolate()) + || (enter && (target == nullptr || !self->is_connected(*target)))) { + reject("Fullscreen requires a connected element in the active top-level document", + "TypeError"); + return; + } + if (!enter && self->fullscreen_element == nullptr) { + resolver->Resolve(local_context, v8::Undefined(info.GetIsolate())).Check(); + return; + } + constexpr size_t maximum_pending_window_operations = 16U; + if (self->host_promise_targets.size() >= maximum_pending_window_operations) { + reject("Too many pending native window operations", "QuotaExceededError"); + return; + } + const auto request_id = ++self->next_host_request_id; + auto request = v8::Object::New(info.GetIsolate()); + request->Set( + local_context, + js_string(info.GetIsolate(), "requestId"), + v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); + request->Set( + local_context, + js_string(info.GetIsolate(), "kind"), + js_string(info.GetIsolate(), enter ? "enterFullscreen" : "exitFullscreen")).Check(); + if (enter) { + request->Set( + local_context, + js_string(info.GetIsolate(), "targetNodeId"), + v8::Integer::NewFromUnsigned(info.GetIsolate(), target->id)).Check(); + } + host_promise_target promise_target; + promise_target.operation = enter ? "enterFullscreen" : "exitFullscreen"; + promise_target.target_node_id = enter ? target->id : 0U; + promise_target.context.Reset(info.GetIsolate(), local_context); + promise_target.resolver.Reset(info.GetIsolate(), resolver); + self->host_promise_targets.emplace(request_id, std::move(promise_target)); + if (!self->enqueue_host_request(local_context, request)) { + self->host_promise_targets.erase(request_id); + reject("The host request queue is full", "QuotaExceededError"); + } + } + + static void element_request_fullscreen( + const v8::FunctionCallbackInfo& info) + { + queue_fullscreen_request(info, unwrap_node(info.This()), true); + } + + static void document_exit_fullscreen( + const v8::FunctionCallbackInfo& info) + { + queue_fullscreen_request(info, nullptr, false); + } + static void document_exec_command(const v8::FunctionCallbackInfo& info) { auto* isolate = info.GetIsolate(); @@ -4321,6 +4417,18 @@ } } + static void location_reload(const v8::FunctionCallbackInfo& info) + { + auto* self = current(info.GetIsolate()); + auto local_context = info.GetIsolate()->GetCurrentContext(); + if (self == nullptr || local_context != self->context.Get(info.GetIsolate())) + return; + if (!self->queue_top_level_window_action(local_context, "reloadWindow")) { + info.GetIsolate()->ThrowException(v8::Exception::Error( + js_string(info.GetIsolate(), "WebScene rejected the reload request"))); + } + } + static void return_null(const v8::FunctionCallbackInfo& info) { info.GetReturnValue().Set(v8::Null(info.GetIsolate())); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc index c8bd7afb0..9c1909216 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc @@ -85,3 +85,109 @@ dispatch_input_event_type(event,"change",*node); isolate->PerformMicrotaskCheckpoint(); } + + void complete_native_host(native_host_completion& completion) { + const auto found = host_promise_targets.find(completion.id); + if (found == host_promise_targets.end()) return; + auto target = std::move(found->second); + host_promise_targets.erase(found); + auto local_context = target.context.Get(isolate); + auto resolver = target.resolver.Get(isolate); + if (local_context.IsEmpty() || resolver.IsEmpty() + || local_context != context.Get(isolate)) return; + v8::Context::Scope context_scope(local_context); + if (completion.status != 0U) { + const auto message = completion.error.empty() + ? completion.status == 1U + ? "The native host operation was cancelled" + : "The native host operation was denied" + : completion.error.c_str(); + auto error = v8::Exception::Error(js_string(isolate, message)); + if (error->IsObject()) { + error.As()->Set( + local_context, + js_string(isolate, "name"), + js_string(isolate, + completion.status == 1U ? "AbortError" : "NotAllowedError")).Check(); + } + resolver->Reject(local_context, error).Check(); + perform_microtask_checkpoint(); + return; + } + if (target.operation == "writeClipboard") { + resolver->Resolve(local_context, v8::Undefined(isolate)).Check(); + perform_microtask_checkpoint(); + return; + } + if (target.operation == "enterFullscreen" + || target.operation == "exitFullscreen") { + fullscreen_element = target.operation == "enterFullscreen" + ? document.find_by_native_id(target.target_node_id) + : nullptr; + auto document_target = document_object.Get(isolate); + if (!document_target.IsEmpty()) { + auto event = create_event_instance(local_context); + event->Set( + local_context, + js_string(isolate, "type"), + js_string(isolate, "fullscreenchange")).Check(); + event->Set( + local_context, + js_string(isolate, "bubbles"), + v8::True(isolate)).Check(); + event->Set( + local_context, + js_string(isolate, "cancelable"), + v8::False(isolate)).Check(); + v8::Local dispatcher; + if (document_target->Get( + local_context, + js_string(isolate, "dispatchEvent")).ToLocal(&dispatcher) + && dispatcher->IsFunction()) { + v8::Local arguments[] = {event}; + (void)dispatcher.As()->Call( + local_context, document_target, 1, arguments); + } + } + resolver->Resolve(local_context, v8::Undefined(isolate)).Check(); + perform_microtask_checkpoint(); + return; + } + auto buffer = v8::ArrayBuffer::New(isolate, completion.bytes.size()); + if (!completion.bytes.empty()) { + std::memcpy( + buffer->GetBackingStore()->Data(), + completion.bytes.data(), + completion.bytes.size()); + } + auto value = v8::Object::New(isolate); + value->Set( + local_context, + js_string(isolate, "type"), + js_string(isolate, completion.content_type.c_str())).Check(); + value->Set( + local_context, + js_string(isolate, "bytes"), + v8::Uint8Array::New(buffer, 0, completion.bytes.size())).Check(); + resolver->Resolve(local_context, value).Check(); + perform_microtask_checkpoint(); + } + + void cancel_native_host_promises(const char* message) { + for (auto& [_, target] : host_promise_targets) { + auto local_context = target.context.Get(isolate); + auto resolver = target.resolver.Get(isolate); + if (local_context.IsEmpty() || resolver.IsEmpty()) continue; + v8::Context::Scope context_scope(local_context); + auto error = v8::Exception::Error(js_string(isolate, message)); + if (error->IsObject()) { + error.As()->Set( + local_context, + js_string(isolate, "name"), + js_string(isolate, "AbortError")).Check(); + } + resolver->Reject(local_context, error).Check(); + } + host_promise_targets.clear(); + perform_microtask_checkpoint(); + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index e819180e1..b369e5743 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -117,6 +117,9 @@ stop_workers(); file_targets.clear(); { std::lock_guard lock(file_requests_mutex); file_requests.clear(); } + cancel_native_host_promises("The document navigated before the native host completed the request"); + fullscreen_element = nullptr; + { std::lock_guard lock(host_request_mutex); host_requests.clear(); } object_url_file_data.clear(); object_url_binary.clear(); object_urls.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index dbfd5d2dc..47b7420a2 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -134,6 +134,13 @@ std::vector connected_resources; std::mutex host_request_mutex; std::deque host_requests; + struct host_promise_target { + std::string operation; + uint32_t target_node_id{}; + v8::Global context; + v8::Global resolver; + }; + std::unordered_map host_promise_targets; std::function host_request_available; std::function interop_callback_available; v8_dom_runtime::interop_callback_sink_v3 interop_callback_sink; @@ -142,6 +149,7 @@ std::mutex console_message_mutex; std::deque console_messages; uint64_t next_host_request_id{0}; + dom_node* fullscreen_element{nullptr}; std::atomic file_service_enabled{false}; std::atomic native_media_policy{0}; std::mutex file_requests_mutex; @@ -372,6 +380,7 @@ dom_node* active_element{nullptr}; bool focus_visible{false}; bool document_visible{true}; + bool document_focused{true}; double last_animation_frame_timestamp_ms{0}; double caret_blink_epoch_ms{0}; std::string frame_last_error_value; 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 9afeb692f..aeb6b8e14 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 @@ -2556,3 +2556,152 @@ void test_window_scroll_primitives(webscene_engine* engine) result == R"JSON({"methods":["function","function",true],"numeric":[120,140,120,140],"topOnly":[120,210],"leftOnly":[170,210],"clamped":[0,true,true],"frameMethods":["function","function"]})JSON", "Window scrolling primitives were inconsistent: " + result); } + +uint64_t host_request_id(std::string_view request); + +void test_clipboard_write_text_host_handoff(webscene_engine* engine) +{ + execute(engine, R"JS( + globalThis.__clipboardWriteState = 'pending'; + navigator.clipboard.writeText('VS CODE OSS CLIPBOARD').then( + () => { __clipboardWriteState = 'fulfilled'; }, + error => { __clipboardWriteState = error.name + ':' + error.message; }); + )JS", "native-clipboard-write-text.js"); + const auto request = take_host_request(engine); + require( + request.find(R"("kind":"writeClipboard")") != std::string::npos + && request.find(R"("requestId":)") != std::string::npos + && request.find(R"("contentType":"text/plain")") != std::string::npos + && request.find("VlMgQ09ERSBPU1MgQ0xJUEJPQVJE") != std::string::npos, + "Clipboard.writeText did not preserve UTF-8 bytes in its host handoff: " + + request); + require( + webscene_engine_complete_host_request_v1( + engine, host_request_id(request), 0U, "text/plain", + nullptr, 0U, nullptr) != 0, + "Clipboard.writeText completion was rejected"); + require( + evaluate( + engine, + "__clipboardWriteState", + "native-clipboard-write-text-result.js") == R"("fulfilled")", + "Clipboard.writeText promise did not fulfill after native completion"); +} + +uint64_t host_request_id(std::string_view request) +{ + constexpr std::string_view marker{"\"requestId\":"}; + const auto begin = request.find(marker); + require(begin != std::string_view::npos, "host request did not contain requestId"); + const auto digits = begin + marker.size(); + uint64_t result = 0; + const auto parsed = std::from_chars( + request.data() + digits, request.data() + request.size(), result); + require(parsed.ec == std::errc{} && result != 0U, + "host request contained an invalid requestId"); + return result; +} + +void test_clipboard_read_host_completion(webscene_engine* engine) +{ + execute(engine, R"JS( + globalThis.__clipboardReadState = 'pending'; + navigator.clipboard.readText().then( + value => { __clipboardReadState = value; }, + error => { __clipboardReadState = error.name; }); + )JS", "native-clipboard-read-text.js"); + auto request = take_host_request(engine); + require( + request.find(R"("kind":"readClipboard")") != std::string::npos + && request.find(R"("contentType":"text/plain")") != std::string::npos, + "Clipboard.readText did not emit a typed host request: " + request); + constexpr uint8_t text[] = {'n','a','t','i','v','e',' ','p','a','s','t','e'}; + require( + webscene_engine_complete_host_request_v1( + engine, host_request_id(request), 0U, "text/plain", + text, sizeof(text), nullptr) != 0, + "Clipboard.readText completion was rejected"); + require( + evaluate( + engine, + "__clipboardReadState", + "native-clipboard-read-text-result.js") == R"("native paste")", + "Clipboard.readText did not resolve with native UTF-8 text"); + + execute(engine, R"JS( + globalThis.__clipboardCancelState = 'pending'; + navigator.clipboard.readText().then( + () => { __clipboardCancelState = 'fulfilled'; }, + error => { __clipboardCancelState = error.name; }); + )JS", "native-clipboard-read-cancel.js"); + request = take_host_request(engine); + require( + webscene_engine_complete_host_request_v1( + engine, host_request_id(request), 1U, nullptr, nullptr, 0U, nullptr) != 0, + "Clipboard cancellation completion was rejected"); + require( + evaluate( + engine, + "__clipboardCancelState", + "native-clipboard-read-cancel-result.js") == R"("AbortError")", + "Clipboard cancellation did not reject with AbortError"); + + require( + webscene_engine_complete_host_request_v1( + engine, 0U, 0U, "text/plain", text, sizeof(text), nullptr) == 0, + "zero host request ID was accepted"); + require( + webscene_engine_complete_host_request_v1( + engine, 1U, 3U, nullptr, nullptr, 0U, nullptr) == 0, + "invalid host completion status was accepted"); +} + +void test_fullscreen_host_completion(webscene_engine* engine) +{ + execute(engine, R"JS( + document.body.innerHTML = '
fullscreen
'; + globalThis.__fullscreenEvents = []; + globalThis.__fullscreenState = 'pending'; + document.addEventListener('fullscreenchange', () => { + __fullscreenEvents.push(document.fullscreenElement?.id || null); + }); + document.getElementById('surface').requestFullscreen().then( + () => { __fullscreenState = document.fullscreenElement?.id || 'missing'; }, + error => { __fullscreenState = error.name; }); + )JS", "native-fullscreen-enter.js"); + auto request = take_host_request(engine); + require( + request.find(R"("kind":"enterFullscreen")") != std::string::npos + && request.find(R"("targetNodeId":)") != std::string::npos, + "Element.requestFullscreen did not emit a typed host request: " + request); + require(webscene_engine_complete_host_request_v1( + engine, host_request_id(request), 0U, nullptr, + nullptr, 0U, nullptr) != 0, + "fullscreen enter completion was rejected"); + require(evaluate( + engine, + "JSON.stringify([__fullscreenState,__fullscreenEvents])", + "native-fullscreen-enter-result.js") + == R"("[\"surface\",[\"surface\"]]")", + "fullscreen enter state/event did not reach the document"); + + execute(engine, R"JS( + globalThis.__fullscreenExitState = 'pending'; + document.exitFullscreen().then( + () => { __fullscreenExitState = document.fullscreenElement === null; }, + error => { __fullscreenExitState = error.name; }); + )JS", "native-fullscreen-exit.js"); + request = take_host_request(engine); + require(request.find(R"("kind":"exitFullscreen")") != std::string::npos, + "Document.exitFullscreen did not emit a typed host request: " + request); + require(webscene_engine_complete_host_request_v1( + engine, host_request_id(request), 0U, nullptr, + nullptr, 0U, nullptr) != 0, + "fullscreen exit completion was rejected"); + require(evaluate( + engine, + "JSON.stringify([__fullscreenExitState,__fullscreenEvents])", + "native-fullscreen-exit-result.js") + == R"("[true,[\"surface\",null]]")", + "fullscreen exit state/event did not reach the document"); +} diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 97520919f..80f3cc5f2 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1525,6 +1525,45 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin != std::string::npos, "window.open did not use the safe external host handoff: " + popup_request); + require( + evaluate(engine, + "(() => { focus(); close(); return [typeof focus, typeof close]; })()", + "native-window-actions.js") == R"(["function","function"])", + "top-level focus/close methods were not installed"); + const auto focus_request = take_host_request(engine); + const auto close_request = take_host_request(engine); + require( + focus_request.find(R"("kind":"focusWindow")") != std::string::npos + && close_request.find(R"("kind":"closeWindow")") != std::string::npos, + "top-level focus/close did not emit ordered host requests: " + + focus_request + " / " + close_request); + + execute(engine, R"JS( + addEventListener('beforeunload', event => event.preventDefault(), { once: true }); + close(); + )JS", "native-window-close-veto.js"); + require(take_host_request(engine).empty(), + "cancelled beforeunload still reached the native close host path"); + + execute(engine, "location.reload()", "native-window-reload.js"); + const auto reload_request = take_host_request(engine); + require(reload_request.find(R"("kind":"reloadWindow")") != std::string::npos, + "Location.reload did not emit a native reload request: " + reload_request); + + const auto oversized_url = std::string("https://example.com/") + + std::string(8192U, 'x'); + execute( + engine, + "try { window.open(" + quote(oversized_url) + + "); globalThis.oversizedWindowAccepted = true; } " + "catch { globalThis.oversizedWindowAccepted = false; }", + "native-window-open-oversized.js"); + require( + evaluate(engine, "oversizedWindowAccepted", "native-window-open-oversized-result.js") + == "false" + && take_host_request(engine).empty(), + "oversized external URL reached the host request queue"); + pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 70, 504U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 70, 505U, false); require( 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 931d379b3..5b0a75dc3 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -828,6 +828,9 @@ int main() test_generated_idl_attributes_are_prototype_accessors(engine); test_document_links_is_a_live_named_html_collection(engine); test_scrollspy_product_neutral_primitives(engine); + test_clipboard_write_text_host_handoff(engine); + test_clipboard_read_host_completion(engine); + test_fullscreen_host_completion(engine); test_component_library_dom_discovery_primitives(engine); test_document_id_index_preserves_tree_and_root_semantics(engine); test_dom_selector_apis_throw_syntax_error_for_invalid_selectors(engine); From d1c0e6c5009c5f1ae0667715b5fa87474cb38cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 15:49:07 +0200 Subject: [PATCH 02/35] Classify desktop host APIs outside legacy interop --- tooling/webscene/tests/native-binary-interop.test.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tooling/webscene/tests/native-binary-interop.test.mjs b/tooling/webscene/tests/native-binary-interop.test.mjs index 727b88967..88783244f 100644 --- a/tooling/webscene/tests/native-binary-interop.test.mjs +++ b/tooling/webscene/tests/native-binary-interop.test.mjs @@ -52,6 +52,9 @@ test('native engine publishes only the versioned leased interop surface', async 'webscene_engine_take_file_request_v1', 'webscene_engine_complete_file_request_v1', 'webscene_file_request_release_v1', + 'webscene_engine_complete_host_request_v1', + 'webscene_engine_discard_host_request_v1', + 'webscene_engine_set_window_focused_v1', 'webscene_engine_load_compiled_document_v1', 'webscene_engine_register_compiled_document_v1', 'webscene_engine_set_work_available_callback_v1' From adecc4e6bde2cf79a4e97aea5ecacd498cd5ec9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 15:50:17 +0200 Subject: [PATCH 03/35] Keep oversized URL fixture self contained --- .../tests/native_v8_runtime_input_tests.inc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 80f3cc5f2..7da5e6733 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1554,8 +1554,8 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin + std::string(8192U, 'x'); execute( engine, - "try { window.open(" + quote(oversized_url) - + "); globalThis.oversizedWindowAccepted = true; } " + "try { window.open('" + oversized_url + + "'); globalThis.oversizedWindowAccepted = true; } " "catch { globalThis.oversizedWindowAccepted = false; }", "native-window-open-oversized.js"); require( From b2ff9cbb6ced97a983fec0b8fdc572d725d7f2f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:04:43 +0200 Subject: [PATCH 04/35] Use typed native desktop host requests --- .../native/webscene_native_engine.cpp | 24 ++ .../native/webscene_native_engine.exports | 3 + .../native/webscene_native_engine.h | 37 +++ .../webscene_native_engine_lifecycle.inc | 15 ++ .../native/webscene_native_engine_worker.inc | 10 + .../native/webscene_v8_runtime.cpp | 70 +++--- .../native/webscene_v8_runtime.h | 16 ++ .../webscene_v8_runtime_browser_apis.inc | 114 ++++++---- .../native/webscene_v8_runtime_document.inc | 4 + .../native/webscene_v8_runtime_dom_core.inc | 26 +-- .../native/webscene_v8_runtime_files.inc | 21 +- .../native/webscene_v8_runtime_navigation.inc | 2 + .../native/webscene_v8_runtime_state.inc | 3 + .../native/webscene_v8_runtime_tasks.inc | 4 + .../tests/native_table_cell_copy_tests.inc | 16 +- .../native_v8_runtime_browser_dom_tests.inc | 210 ++++++++++++++---- .../tests/native_v8_runtime_input_tests.inc | 90 +++++--- .../tests/native_v8_runtime_test_support.inc | 33 +++ .../tests/native_v8_runtime_tests.cpp | 2 + .../tests/native-binary-interop.test.mjs | 3 + 20 files changed, 530 insertions(+), 173 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index cef414dc1..be4d9f8bb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -444,6 +444,8 @@ struct webscene_engine final { std::atomic visibility_changed_{false}; std::atomic host_focused_{true}; std::atomic focus_changed_{false}; + std::atomic host_fullscreen_{false}; + std::atomic fullscreen_changed_{false}; std::atomic preferred_color_scheme_{ WEBSCENE_PREFERRED_COLOR_SCHEME_LIGHT}; std::atomic preferred_color_scheme_changed_{false}; @@ -1293,6 +1295,22 @@ size_t webscene_engine_take_host_request( : engine->take_host_request(destination, destination_capacity); } +const webscene_host_request_v1* +webscene_engine_take_typed_host_request_v1(webscene_engine* engine) +{ + if (engine == nullptr) return nullptr; + auto request = engine->take_typed_host_request(); + if (!request) return nullptr; + request->bind(); + return &request.release()->view; +} + +void webscene_host_request_release_v1( + const webscene_host_request_v1* request) +{ + delete reinterpret_cast(request); +} + uint8_t webscene_engine_discard_host_request_v1(webscene_engine* engine) { return engine != nullptr && engine->discard_host_request() ? 1U : 0U; @@ -1460,6 +1478,12 @@ uint8_t webscene_engine_set_window_focused_v1( return engine != nullptr && engine->set_focused(focused != 0) ? 1U : 0U; } +uint8_t webscene_engine_set_window_fullscreen_v1( + webscene_engine* engine, uint8_t fullscreen) +{ + return engine != nullptr && engine->set_fullscreen(fullscreen != 0) ? 1U : 0U; +} + uint8_t webscene_engine_set_preferred_color_scheme( webscene_engine* engine, uint32_t preferred_color_scheme) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports index d263ece49..d48aef055 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -55,9 +55,12 @@ _webscene_engine_set_resource_root _webscene_engine_set_preferred_color_scheme _webscene_engine_set_visible _webscene_engine_set_window_focused_v1 +_webscene_engine_set_window_fullscreen_v1 _webscene_engine_take_console_message _webscene_engine_take_diagnostic _webscene_engine_take_host_request +_webscene_engine_take_typed_host_request_v1 +_webscene_host_request_release_v1 _webscene_engine_complete_host_request_v1 _webscene_engine_discard_host_request_v1 _webscene_engine_take_input_dispatch_failure diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 75e58de51..ad00a7830 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1217,6 +1217,10 @@ WEBSCENE_API uint8_t webscene_engine_set_visible(webscene_engine* engine, uint8_ * top-level focus/blur events. Repeated values are coalesced. */ WEBSCENE_API uint8_t webscene_engine_set_window_focused_v1( webscene_engine* engine, uint8_t focused); +/* Synchronizes fullscreen changes initiated by native window controls. Script + * initiated transitions use the typed request/completion path. */ +WEBSCENE_API uint8_t webscene_engine_set_window_fullscreen_v1( + webscene_engine* engine, uint8_t fullscreen); /* * Updates the host's effective color preference. The worker re-evaluates CSS * media rules and subsequent Window.matchMedia snapshots against this value. @@ -1370,6 +1374,39 @@ WEBSCENE_API uint8_t webscene_engine_complete_file_request_v1(webscene_engine* e uint64_t request_id, uint32_t status, const webscene_file_data_v1* files, size_t file_count, const char* error_message); +/* Typed native desktop request ABI. Request memory is immutable and remains + * valid until release. Byte payloads are capped at 16 MiB, strings are UTF-8, + * and at most 16 completion-bearing operations may be pending per document. */ +enum { + WEBSCENE_HOST_REQUEST_OPEN_EXTERNAL_URL_V1 = 1, + WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1 = 2, + WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 = 3, + WEBSCENE_HOST_REQUEST_WINDOW_FOCUS_V1 = 4, + WEBSCENE_HOST_REQUEST_WINDOW_CLOSE_V1 = 5, + WEBSCENE_HOST_REQUEST_WINDOW_RELOAD_V1 = 6, + WEBSCENE_HOST_REQUEST_FULLSCREEN_ENTER_V1 = 7, + WEBSCENE_HOST_REQUEST_FULLSCREEN_EXIT_V1 = 8 +}; +enum { + WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 = 1U << 0U +}; +typedef struct webscene_host_request_v1 { + uint32_t struct_size, version; + uint64_t request_id; + uint32_t kind, flags; + uint64_t target_node_id; + const char* content_type; + const uint8_t* bytes; + size_t byte_count; + const char* url; +} webscene_host_request_v1; +WEBSCENE_API const webscene_host_request_v1* +webscene_engine_take_typed_host_request_v1(webscene_engine* engine); +WEBSCENE_API void webscene_host_request_release_v1( + const webscene_host_request_v1* request); + +/* JSON compatibility queue retained for older host integrations and unrelated + * application-defined messages. New desktop capabilities use the typed ABI. */ WEBSCENE_API size_t webscene_engine_take_host_request( webscene_engine* engine, char* destination, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc index 4f93808d5..7e4034307 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -145,6 +145,14 @@ #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) std::lock_guard lock(file_runtime_mutex_); if (file_runtime_ready_) return runtime_->take_file_request(); +#endif + return {}; + } + std::unique_ptr + take_typed_host_request() { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + std::lock_guard lock(file_runtime_mutex_); + if (file_runtime_ready_) return runtime_->take_typed_host_request(); #endif return {}; } @@ -237,6 +245,13 @@ signal_worker(); return true; } + bool set_fullscreen(bool fullscreen) + { + host_fullscreen_.store(fullscreen, std::memory_order_release); + fullscreen_changed_.store(true, std::memory_order_release); + signal_worker(); + return true; + } bool set_preferred_color_scheme(uint32_t preferred_color_scheme) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc index fe927c8e1..3d2b54d8a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -326,6 +326,15 @@ set_last_error(runtime_->last_error()); } } + if (fullscreen_changed_.exchange(false, std::memory_order_acq_rel)) { + const auto host_fullscreen = + host_fullscreen_.load(std::memory_order_acquire); + if (runtime_ != nullptr + && !runtime_->set_fullscreen(host_fullscreen)) { + script_errors_.fetch_add(1, std::memory_order_relaxed); + set_last_error(runtime_->last_error()); + } + } if (low_memory_requested_.exchange(false, std::memory_order_acq_rel) && runtime_ != nullptr) { runtime_->notify_low_memory(); @@ -1287,6 +1296,7 @@ || low_memory_requested_.load(std::memory_order_acquire) || visibility_changed_.load(std::memory_order_acquire) || focus_changed_.load(std::memory_order_acquire) + || fullscreen_changed_.load(std::memory_order_acquire) || preferred_color_scheme_changed_.load( std::memory_order_acquire); }); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index b59956b43..3372d43eb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -4165,9 +4165,10 @@ struct v8_dom_runtime::implementation final { if (!(item instanceof WebSceneClipboardItem)) { throw new TypeError('Clipboard.write requires ClipboardItem values'); } - for (const type of item.types) { + for (let index = 0; index < item.types.length; ++index) { + const type = item.types[index]; const blob = await item.getType(type); - await __webSceneWriteClipboard(type, blob); + await __webSceneWriteClipboard(type, blob, index === 0); } } }; @@ -4202,6 +4203,29 @@ struct v8_dom_runtime::implementation final { return true; } + bool enqueue_typed_host_request(std::unique_ptr request) + { + if (!request) return false; + { + std::lock_guard lock(host_request_mutex); + constexpr size_t maximum_host_requests = 1024U; + if (host_requests.size() + typed_host_requests.size() + >= maximum_host_requests) return false; + typed_host_requests.push_back(std::move(request)); + } + if (host_request_available) host_request_available(); + return true; + } + + std::unique_ptr take_typed_host_request() + { + std::lock_guard lock(host_request_mutex); + if (typed_host_requests.empty()) return {}; + auto request = std::move(typed_host_requests.front()); + typed_host_requests.pop_front(); + return request; + } + uint32_t current_cursor_kind() const noexcept { return current_cursor_kind_value; @@ -4336,27 +4360,16 @@ struct v8_dom_runtime::implementation final { const auto lower = lower_html_name(resolved); if (!lower.starts_with("https://") && !lower.starts_with("http://")) return true; - auto local_context = isolate->GetCurrentContext(); - auto request = v8::Object::New(isolate); - request->Set( - local_context, - js_string(isolate, "kind"), - js_string(isolate, "openExternalUrl")).Check(); - request->Set( - local_context, - js_string(isolate, "url"), - js_string(isolate, resolved.c_str())).Check(); - request->Set( - local_context, - js_string(isolate, "disposition"), - js_string(isolate, "systemDefaultBrowser")).Check(); + auto request = std::make_unique(); + request->view.kind = WEBSCENE_HOST_REQUEST_OPEN_EXTERNAL_URL_V1; + request->url = resolved; record_feature( "html", "anchor-external-navigation", "supported", "http(s) activation emits a host request without replacing the WebScene document", "default-action"); - return enqueue_host_request(local_context, request); + return enqueue_typed_host_request(std::move(request)); } static void window_open(const v8::FunctionCallbackInfo& info) @@ -4385,15 +4398,12 @@ struct v8_dom_runtime::implementation final { bool queue_top_level_window_action( v8::Local local_context, - const char* kind) + uint32_t kind) { if (local_context != context.Get(isolate)) return true; - auto request = v8::Object::New(isolate); - request->Set( - local_context, - js_string(isolate, "kind"), - js_string(isolate, kind)).Check(); - return enqueue_host_request(local_context, request); + auto request = std::make_unique(); + request->view.kind = kind; + return enqueue_typed_host_request(std::move(request)); } static void window_close(const v8::FunctionCallbackInfo& info) @@ -4421,7 +4431,8 @@ struct v8_dom_runtime::implementation final { && dispatch_result->IsFalse()) { return; } - if (!self->queue_top_level_window_action(local_context, "closeWindow")) { + if (!self->queue_top_level_window_action( + local_context, WEBSCENE_HOST_REQUEST_WINDOW_CLOSE_V1)) { info.GetIsolate()->ThrowException(v8::Exception::Error( js_string(info.GetIsolate(), "WebScene rejected the close-window request"))); } @@ -4689,6 +4700,12 @@ bool v8_dom_runtime::set_focused(bool focused) && impl_->promote_pending_promise_error(); } +bool v8_dom_runtime::set_fullscreen(bool fullscreen) +{ + return impl_->set_fullscreen(fullscreen) + && impl_->promote_pending_promise_error(); +} + void v8_dom_runtime::set_resource_root(std::string resource_root) { impl_->resource_root = std::filesystem::path(std::move(resource_root)).lexically_normal(); @@ -6285,4 +6302,7 @@ void v8_dom_runtime::complete_host_request(native_host_completion& completion) { bool v8_dom_runtime::discard_host_request() { return impl_->discard_host_request(); } +std::unique_ptr v8_dom_runtime::take_typed_host_request() { + return impl_->take_typed_host_request(); +} } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 03cd0ff0f..0507c3110 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -51,6 +51,20 @@ struct native_host_completion { std::vector bytes; std::string error; }; +struct native_host_request { + webscene_host_request_v1 view{}; + std::string content_type; + std::vector bytes; + std::string url; + void bind() { + view.struct_size = sizeof(view); + view.version = 1; + view.content_type = content_type.empty() ? nullptr : content_type.c_str(); + view.bytes = bytes.empty() ? nullptr : bytes.data(); + view.byte_count = bytes.size(); + view.url = url.empty() ? nullptr : url.c_str(); + } +}; class native_document; struct dom_node; @@ -303,6 +317,7 @@ class v8_dom_runtime final { void complete_file_request(native_file_completion& completion); void complete_host_request(native_host_completion& completion); bool try_take_host_request(std::string& request); + std::unique_ptr take_typed_host_request(); bool discard_host_request(); bool try_take_console_message(std::string& message); bool inspector_available() const noexcept; @@ -323,6 +338,7 @@ class v8_dom_runtime final { bool refresh_media_environment(); bool set_visible(bool visible); bool set_focused(bool focused); + bool set_fullscreen(bool fullscreen); bool dispatch_input(const webscene_input_event& event, bool defer_cursor_update = false); // Worker-only: call after publication layout and ResizeObserver delivery. void refresh_pointer_cursor_after_layout(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc index 18ff470cf..76540fa2b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc @@ -317,12 +317,8 @@ // A child browsing context may focus its own active element but must // not activate the top-level operating-system window. if (local_context != self->context.Get(info.GetIsolate())) return; - auto request = v8::Object::New(info.GetIsolate()); - request->Set( - local_context, - js_string(info.GetIsolate(), "kind"), - js_string(info.GetIsolate(), "focusWindow")).Check(); - if (!self->enqueue_host_request(local_context, request)) { + if (!self->queue_top_level_window_action( + local_context, WEBSCENE_HOST_REQUEST_WINDOW_FOCUS_V1)) { info.GetIsolate()->ThrowException(v8::Exception::Error( js_string(info.GetIsolate(), "WebScene rejected the focus-window request"))); } @@ -1437,6 +1433,46 @@ return true; } + bool set_fullscreen(bool fullscreen) + { + if (document_fullscreen == fullscreen) return true; + document_fullscreen = fullscreen; + auto isolate_locker = lock_shared_isolate(); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + auto local_context = context.Get(isolate); + if (local_context.IsEmpty()) return true; + v8::Context::Scope context_scope(local_context); + fullscreen_element = fullscreen ? &document.body() : nullptr; + auto target = document_object.Get(isolate); + if (target.IsEmpty()) return true; + auto event = create_event_instance(local_context); + event->Set(local_context, js_string(isolate, "type"), + js_string(isolate, "fullscreenchange")).Check(); + event->Set(local_context, js_string(isolate, "bubbles"), + v8::True(isolate)).Check(); + event->Set(local_context, js_string(isolate, "cancelable"), + v8::False(isolate)).Check(); + v8::Local dispatcher; + if (!target->Get(local_context, js_string(isolate, "dispatchEvent")) + .ToLocal(&dispatcher) + || !dispatcher->IsFunction()) { + last_error = "Document fullscreen dispatcher is unavailable"; + return false; + } + v8::TryCatch try_catch(isolate); + v8::Local arguments[] = {event}; + if (dispatcher.As()->Call( + local_context, target, 1, arguments).IsEmpty()) { + last_error = "Document fullscreen dispatch failed: " + + describe_reported_exception(try_catch, local_context); + ++frame_script_error_count; + return false; + } + perform_microtask_checkpoint(); + return true; + } + bool dispatch_transition_events() { auto events = document.take_transition_events(); @@ -1945,9 +1981,8 @@ } resolver->Reject(local_context, error).Check(); }; - if (self == nullptr || local_context != self->context.Get(info.GetIsolate()) - || info.Length() < 2 || !info[1]->IsObject()) { - reject("Clipboard writes require the active top-level document", "NotAllowedError"); + if (self == nullptr || info.Length() < 2 || !info[1]->IsObject()) { + reject("Clipboard writes require an active document", "NotAllowedError"); return; } const auto content_type = to_utf8(info.GetIsolate(), info[0]); @@ -1964,30 +1999,21 @@ return; } auto payload = info[1].As(); - auto request = v8::Object::New(info.GetIsolate()); const auto request_id = ++self->next_host_request_id; - request->Set( - local_context, - js_string(info.GetIsolate(), "requestId"), - v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); - request->Set( - local_context, - js_string(info.GetIsolate(), "kind"), - js_string(info.GetIsolate(), "writeClipboard")).Check(); - request->Set( - local_context, - js_string(info.GetIsolate(), "contentType"), - js_string(info.GetIsolate(), content_type.c_str())).Check(); + auto request = std::make_unique(); + request->view.request_id = request_id; + request->view.kind = WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1; + request->view.flags = info.Length() > 2 && info[2]->BooleanValue(info.GetIsolate()) + ? WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 : 0U; + request->content_type = content_type; v8::Local canvas_node_id; if (payload->Get( local_context, js_string(info.GetIsolate(), "_canvasNodeId")).ToLocal(&canvas_node_id) && canvas_node_id->IsUint32()) { - request->Set( - local_context, - js_string(info.GetIsolate(), "canvasNodeId"), - canvas_node_id).Check(); + request->view.target_node_id = + canvas_node_id.As()->Value(); } else { v8::Local bytes_value; if (!payload->Get( @@ -2005,19 +2031,15 @@ auto backing = view->Buffer()->GetBackingStore(); const auto* bytes = static_cast(backing->Data()) + view->ByteOffset(); - const auto data_url = "data:" + content_type + ";base64," - + base64_encode_bytes(bytes, view->ByteLength()); - request->Set( - local_context, - js_string(info.GetIsolate(), "url"), - js_string(info.GetIsolate(), data_url.c_str())).Check(); + if (view->ByteLength() != 0U) + request->bytes.assign(bytes, bytes + view->ByteLength()); } host_promise_target target; target.operation = "writeClipboard"; target.context.Reset(info.GetIsolate(), local_context); target.resolver.Reset(info.GetIsolate(), resolver); self->host_promise_targets.emplace(request_id, std::move(target)); - if (!self->enqueue_host_request(local_context, request)) { + if (!self->enqueue_typed_host_request(std::move(request))) { self->host_promise_targets.erase(request_id); reject("The host request queue is full", "QuotaExceededError"); } @@ -2043,6 +2065,13 @@ reject("Clipboard reads require the active top-level document", "NotAllowedError"); return; } + constexpr auto user_activation_lifetime = std::chrono::seconds(5); + if (self->last_user_activation.time_since_epoch().count() == 0 + || std::chrono::steady_clock::now() - self->last_user_activation + > user_activation_lifetime) { + reject("Clipboard reads require recent native user activation", "NotAllowedError"); + return; + } const auto content_type = info.Length() == 0 ? std::string{"text/plain"} : to_utf8(info.GetIsolate(), info[0]); @@ -2056,25 +2085,16 @@ return; } const auto request_id = ++self->next_host_request_id; - auto request = v8::Object::New(info.GetIsolate()); - request->Set( - local_context, - js_string(info.GetIsolate(), "requestId"), - v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); - request->Set( - local_context, - js_string(info.GetIsolate(), "kind"), - js_string(info.GetIsolate(), "readClipboard")).Check(); - request->Set( - local_context, - js_string(info.GetIsolate(), "contentType"), - js_string(info.GetIsolate(), content_type.c_str())).Check(); + auto request = std::make_unique(); + request->view.request_id = request_id; + request->view.kind = WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1; + request->content_type = content_type; host_promise_target target; target.operation = "readClipboard"; target.context.Reset(info.GetIsolate(), local_context); target.resolver.Reset(info.GetIsolate(), resolver); self->host_promise_targets.emplace(request_id, std::move(target)); - if (!self->enqueue_host_request(local_context, request)) { + if (!self->enqueue_typed_host_request(std::move(request))) { self->host_promise_targets.erase(request_id); reject("The host request queue is full", "QuotaExceededError"); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc index 0acf71182..96a2f9deb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc @@ -1216,6 +1216,10 @@ local_context, js_string(isolate, "toString"), v8::Function::New(local_context, location_to_string).ToLocalChecked()).Check(); + location->Set( + local_context, + js_string(isolate, "reload"), + v8::Function::New(local_context, location_reload).ToLocalChecked()).Check(); std::string secure_host = host; if (const auto at = secure_host.rfind('@'); at != std::string::npos) secure_host.erase(0, at + 1); if (secure_host.starts_with("[")) { diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 0c2d25a95..1b977397a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -4321,28 +4321,19 @@ return; } const auto request_id = ++self->next_host_request_id; - auto request = v8::Object::New(info.GetIsolate()); - request->Set( - local_context, - js_string(info.GetIsolate(), "requestId"), - v8::Number::New(info.GetIsolate(), static_cast(request_id))).Check(); - request->Set( - local_context, - js_string(info.GetIsolate(), "kind"), - js_string(info.GetIsolate(), enter ? "enterFullscreen" : "exitFullscreen")).Check(); - if (enter) { - request->Set( - local_context, - js_string(info.GetIsolate(), "targetNodeId"), - v8::Integer::NewFromUnsigned(info.GetIsolate(), target->id)).Check(); - } + auto request = std::make_unique(); + request->view.request_id = request_id; + request->view.kind = enter + ? WEBSCENE_HOST_REQUEST_FULLSCREEN_ENTER_V1 + : WEBSCENE_HOST_REQUEST_FULLSCREEN_EXIT_V1; + request->view.target_node_id = enter ? target->id : 0U; host_promise_target promise_target; promise_target.operation = enter ? "enterFullscreen" : "exitFullscreen"; promise_target.target_node_id = enter ? target->id : 0U; promise_target.context.Reset(info.GetIsolate(), local_context); promise_target.resolver.Reset(info.GetIsolate(), resolver); self->host_promise_targets.emplace(request_id, std::move(promise_target)); - if (!self->enqueue_host_request(local_context, request)) { + if (!self->enqueue_typed_host_request(std::move(request))) { self->host_promise_targets.erase(request_id); reject("The host request queue is full", "QuotaExceededError"); } @@ -4423,7 +4414,8 @@ auto local_context = info.GetIsolate()->GetCurrentContext(); if (self == nullptr || local_context != self->context.Get(info.GetIsolate())) return; - if (!self->queue_top_level_window_action(local_context, "reloadWindow")) { + if (!self->queue_top_level_window_action( + local_context, WEBSCENE_HOST_REQUEST_WINDOW_RELOAD_V1)) { info.GetIsolate()->ThrowException(v8::Exception::Error( js_string(info.GetIsolate(), "WebScene rejected the reload request"))); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc index 9c1909216..20aaaa8c0 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc @@ -93,8 +93,7 @@ host_promise_targets.erase(found); auto local_context = target.context.Get(isolate); auto resolver = target.resolver.Get(isolate); - if (local_context.IsEmpty() || resolver.IsEmpty() - || local_context != context.Get(isolate)) return; + if (local_context.IsEmpty() || resolver.IsEmpty()) return; v8::Context::Scope context_scope(local_context); if (completion.status != 0U) { const auto message = completion.error.empty() @@ -121,9 +120,25 @@ } if (target.operation == "enterFullscreen" || target.operation == "exitFullscreen") { - fullscreen_element = target.operation == "enterFullscreen" + const bool enter = target.operation == "enterFullscreen"; + auto* requested_element = enter ? document.find_by_native_id(target.target_node_id) : nullptr; + if (enter && (requested_element == nullptr + || !is_connected(*requested_element))) { + auto error = v8::Exception::Error(js_string( + isolate, "The fullscreen target is no longer connected")); + if (error->IsObject()) { + error.As()->Set( + local_context, js_string(isolate, "name"), + js_string(isolate, "TypeError")).Check(); + } + resolver->Reject(local_context, error).Check(); + perform_microtask_checkpoint(); + return; + } + document_fullscreen = enter; + fullscreen_element = requested_element; auto document_target = document_object.Get(isolate); if (!document_target.IsEmpty()) { auto event = create_event_instance(local_context); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc index b369e5743..a7b07cebf 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -119,7 +119,9 @@ { std::lock_guard lock(file_requests_mutex); file_requests.clear(); } cancel_native_host_promises("The document navigated before the native host completed the request"); fullscreen_element = nullptr; + document_fullscreen = false; { std::lock_guard lock(host_request_mutex); host_requests.clear(); } + { std::lock_guard lock(host_request_mutex); typed_host_requests.clear(); } object_url_file_data.clear(); object_url_binary.clear(); object_urls.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index 47b7420a2..dc501a870 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -134,6 +134,7 @@ std::vector connected_resources; std::mutex host_request_mutex; std::deque host_requests; + std::deque> typed_host_requests; struct host_promise_target { std::string operation; uint32_t target_node_id{}; @@ -150,6 +151,8 @@ std::deque console_messages; uint64_t next_host_request_id{0}; dom_node* fullscreen_element{nullptr}; + bool document_fullscreen{false}; + std::chrono::steady_clock::time_point last_user_activation{}; std::atomic file_service_enabled{false}; std::atomic native_media_policy{0}; std::mutex file_requests_mutex; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 509fd86a0..95c5f6a0f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2795,6 +2795,10 @@ bool dispatch_input(const webscene_input_event& input, bool defer_cursor_update = false) { current_input_sequence = input.sequence; + if (input.kind == WEBSCENE_INPUT_POINTER_DOWN + || input.kind == WEBSCENE_INPUT_KEY_DOWN) { + last_user_activation = std::chrono::steady_clock::now(); + } auto isolate_locker = lock_shared_isolate(); v8::Isolate::Scope isolate_scope(isolate); v8::HandleScope handle_scope(isolate); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc index 11e502f60..aa8d580b6 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc @@ -54,6 +54,15 @@ void test_table_cell_click_copies_text_to_host() "table cell fixture did not initialize"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 20, 801U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 20, 802U, false); + const auto request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) == "123.45", + "table cell text did not reach the typed clipboard host handoff"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", + nullptr, 0U, nullptr) != 0, + "table cell clipboard completion was rejected"); std::string state; for (int attempt = 0; attempt < 100; ++attempt) { state = evaluate(engine, realm + ".copyState", "table-cell-copy-state.js"); @@ -63,15 +72,10 @@ void test_table_cell_click_copies_text_to_host() require(state == R"("copied")", "table cell click failed: " + state); require(evaluate(engine, realm + ".legacyCopyResult", "table-cell-legacy-result.js") == "false", "unsupported legacy copy must not claim success"); - const auto request = take_host_request(engine); - require(request.find(R"("kind":"writeClipboard")") != std::string::npos - && request.find(R"("contentType":"text/plain")") != std::string::npos - && request.find("data:text/plain;base64,MTIzLjQ1") != std::string::npos, - "table cell text did not reach the clipboard host handoff: " + request); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 70, 803U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 70, 804U, false); evaluate(engine, realm + ".copyState", "table-cell-empty-click-barrier.js"); - require(take_host_request(engine).empty(), "empty cell generated a duplicate clipboard write"); + require(!take_typed_host_request(engine), "empty cell generated a duplicate clipboard write"); webscene_engine_destroy(engine); } } 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 aeb6b8e14..fb8878133 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 @@ -2092,6 +2092,17 @@ void wait_for_document_visibility(webscene_engine* engine, bool hidden) } } +void wait_for_document_focus(webscene_engine* engine, bool focused) +{ + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(2); + const auto expected = focused ? "true" : "false"; + while (evaluate(engine, "document.hasFocus()", "document-focus-ready.js") != expected) { + require(std::chrono::steady_clock::now() < deadline, + "host focus did not reach the document"); + std::this_thread::yield(); + } +} + void test_document_direction_and_visibility_are_native_properties() { auto* engine = webscene_engine_create(0); @@ -2124,11 +2135,14 @@ void test_document_direction_and_visibility_are_native_properties() engine, R"JS( globalThis.__visibilityEvents = []; + globalThis.__focusEvents = []; document.addEventListener('visibilitychange', () => { __visibilityEvents.push(document.visibilityState); Promise.resolve().then(() => __visibilityEvents.push(`microtask:${document.visibilityState}`)); }); + addEventListener('focus', () => __focusEvents.push('focus')); + addEventListener('blur', () => __focusEvents.push('blur')); )JS", "native-document-visibility-events.js"); @@ -2165,6 +2179,34 @@ void test_document_direction_and_visibility_are_native_properties() require( visible == R"({"hidden":false,"visibilityState":"visible","events":["hidden","microtask:hidden","visible","microtask:visible"]})", "Document visibility did not follow the restored host state: " + visible); + + require(webscene_engine_set_window_focused_v1(engine, 0U) != 0, + "document state engine rejected native blur"); + wait_for_document_focus(engine, false); + require(webscene_engine_set_window_focused_v1(engine, 1U) != 0, + "document state engine rejected native focus"); + wait_for_document_focus(engine, true); + require(evaluate(engine, + "({ focused: document.hasFocus(), events: __focusEvents })", + "native-document-focus-state.js") + == R"({"focused":true,"events":["blur","focus"]})", + "Document focus state/events did not follow native key-window changes"); + + const auto transition_started = std::chrono::steady_clock::now(); + for (unsigned index = 0; index < 10000U; ++index) { + require(webscene_engine_set_window_focused_v1( + engine, static_cast(index & 1U)) != 0, + "native focus transition stress input was rejected"); + } + require(webscene_engine_set_window_focused_v1(engine, 1U) != 0, + "final native focus state was rejected"); + wait_for_document_focus(engine, true); + const auto transition_elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - transition_started).count(); + require(transition_elapsed < 1.0, + "10,000 native focus transitions exceeded one second"); + std::cout << "Native focus transition gate: operations=10000 elapsed=" + << transition_elapsed << "s\n"; webscene_engine_destroy(engine); } @@ -2557,8 +2599,6 @@ void test_window_scroll_primitives(webscene_engine* engine) "Window scrolling primitives were inconsistent: " + result); } -uint64_t host_request_id(std::string_view request); - void test_clipboard_write_text_host_handoff(webscene_engine* engine) { execute(engine, R"JS( @@ -2567,17 +2607,18 @@ void test_clipboard_write_text_host_handoff(webscene_engine* engine) () => { __clipboardWriteState = 'fulfilled'; }, error => { __clipboardWriteState = error.name + ':' + error.message; }); )JS", "native-clipboard-write-text.js"); - const auto request = take_host_request(engine); + const auto request = take_typed_host_request(engine); require( - request.find(R"("kind":"writeClipboard")") != std::string::npos - && request.find(R"("requestId":)") != std::string::npos - && request.find(R"("contentType":"text/plain")") != std::string::npos - && request.find("VlMgQ09ERSBPU1MgQ0xJUEJPQVJE") != std::string::npos, - "Clipboard.writeText did not preserve UTF-8 bytes in its host handoff: " - + request); + request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + && request.id != 0U + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) + == "VS CODE OSS CLIPBOARD", + "Clipboard.writeText did not preserve bytes in its typed host handoff"); require( webscene_engine_complete_host_request_v1( - engine, host_request_id(request), 0U, "text/plain", + engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "Clipboard.writeText completion was rejected"); require( @@ -2588,37 +2629,40 @@ void test_clipboard_write_text_host_handoff(webscene_engine* engine) "Clipboard.writeText promise did not fulfill after native completion"); } -uint64_t host_request_id(std::string_view request) -{ - constexpr std::string_view marker{"\"requestId\":"}; - const auto begin = request.find(marker); - require(begin != std::string_view::npos, "host request did not contain requestId"); - const auto digits = begin + marker.size(); - uint64_t result = 0; - const auto parsed = std::from_chars( - request.data() + digits, request.data() + request.size(), result); - require(parsed.ec == std::errc{} && result != 0U, - "host request contained an invalid requestId"); - return result; -} - void test_clipboard_read_host_completion(webscene_engine* engine) { + auto* inactive_engine = webscene_engine_create(0); + require(inactive_engine != nullptr, + "clipboard activation engine creation failed"); + execute(inactive_engine, R"JS( + globalThis.__clipboardActivationState = 'pending'; + navigator.clipboard.readText().catch( + error => { __clipboardActivationState = error.name; }); + )JS", "native-clipboard-read-without-activation.js"); + require(evaluate(inactive_engine, "__clipboardActivationState", + "native-clipboard-read-without-activation-result.js") + == R"("NotAllowedError")" + && !take_typed_host_request(inactive_engine), + "clipboard read without user activation reached the host"); + webscene_engine_destroy(inactive_engine); + keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9751U); + evaluate(engine, "true", "native-clipboard-user-activation-barrier.js"); execute(engine, R"JS( globalThis.__clipboardReadState = 'pending'; navigator.clipboard.readText().then( value => { __clipboardReadState = value; }, error => { __clipboardReadState = error.name; }); )JS", "native-clipboard-read-text.js"); - auto request = take_host_request(engine); + auto request = take_typed_host_request(engine); require( - request.find(R"("kind":"readClipboard")") != std::string::npos - && request.find(R"("contentType":"text/plain")") != std::string::npos, - "Clipboard.readText did not emit a typed host request: " + request); + request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1 + && request.id != 0U && request.content_type == "text/plain" + && request.bytes.empty(), + "Clipboard.readText did not emit the typed host request"); constexpr uint8_t text[] = {'n','a','t','i','v','e',' ','p','a','s','t','e'}; require( webscene_engine_complete_host_request_v1( - engine, host_request_id(request), 0U, "text/plain", + engine, request.id, 0U, "text/plain", text, sizeof(text), nullptr) != 0, "Clipboard.readText completion was rejected"); require( @@ -2627,6 +2671,10 @@ void test_clipboard_read_host_completion(webscene_engine* engine) "__clipboardReadState", "native-clipboard-read-text-result.js") == R"("native paste")", "Clipboard.readText did not resolve with native UTF-8 text"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", + text, sizeof(text), nullptr) != 0, + "duplicate stale clipboard completion was not safely admitted"); execute(engine, R"JS( globalThis.__clipboardCancelState = 'pending'; @@ -2634,10 +2682,10 @@ void test_clipboard_read_host_completion(webscene_engine* engine) () => { __clipboardCancelState = 'fulfilled'; }, error => { __clipboardCancelState = error.name; }); )JS", "native-clipboard-read-cancel.js"); - request = take_host_request(engine); + request = take_typed_host_request(engine); require( webscene_engine_complete_host_request_v1( - engine, host_request_id(request), 1U, nullptr, nullptr, 0U, nullptr) != 0, + engine, request.id, 1U, nullptr, nullptr, 0U, nullptr) != 0, "Clipboard cancellation completion was rejected"); require( evaluate( @@ -2654,6 +2702,69 @@ void test_clipboard_read_host_completion(webscene_engine* engine) webscene_engine_complete_host_request_v1( engine, 1U, 3U, nullptr, nullptr, 0U, nullptr) == 0, "invalid host completion status was accepted"); + + execute(engine, R"JS( + globalThis.__clipboardQuotaState = 'pending'; + const operations = []; + for (let index = 0; index < 17; ++index) { + operations.push(navigator.clipboard.readText().catch(error => error.name)); + } + Promise.all(operations).then(values => { + __clipboardQuotaState = values; + }); + )JS", "native-clipboard-queue-saturation.js"); + for (unsigned index = 0; index < 16U; ++index) { + const auto pending = take_typed_host_request(engine); + require(pending.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1, + "clipboard queue lost a bounded pending read"); + require(webscene_engine_complete_host_request_v1( + engine, pending.id, 1U, nullptr, nullptr, 0U, nullptr) != 0, + "clipboard queue cancellation was rejected"); + } + require(!take_typed_host_request(engine), + "clipboard queue exceeded its 16-operation limit"); + require(evaluate(engine, + "__clipboardQuotaState.filter(value => value === 'AbortError').length === 16" + " && __clipboardQuotaState.at(-1) === 'QuotaExceededError'", + "native-clipboard-queue-saturation-result.js") == "true", + "clipboard queue saturation did not reject explicitly"); +} + +void test_clipboard_maximum_payload_gate(webscene_engine* engine) +{ + constexpr size_t maximum_bytes = 16U * 1024U * 1024U; + const auto started = std::chrono::steady_clock::now(); + execute(engine, R"JS( + globalThis.__maximumClipboardState = 'pending'; + const bytes = new Uint8Array(16 * 1024 * 1024); + bytes.fill(97); + navigator.clipboard.write([ + new ClipboardItem({ 'text/plain': new Blob([bytes], { type: 'text/plain' }) }) + ]).then( + () => { __maximumClipboardState = 'fulfilled'; }, + error => { __maximumClipboardState = error.name; }); + )JS", "native-clipboard-maximum-payload.js"); + const auto request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.content_type == "text/plain" + && request.bytes.size() == maximum_bytes + && request.bytes.front() == 'a' + && request.bytes.back() == 'a', + "maximum clipboard payload changed in the typed host ABI"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", + nullptr, 0U, nullptr) != 0, + "maximum clipboard payload completion was rejected"); + require(evaluate(engine, "__maximumClipboardState", + "native-clipboard-maximum-payload-result.js") + == R"("fulfilled")", + "maximum clipboard payload promise did not settle"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 5.0, + "maximum clipboard payload round trip exceeded five seconds"); + std::cout << "Typed clipboard maximum-payload gate: bytes=" + << maximum_bytes << " elapsed=" << elapsed << "s\n"; } void test_fullscreen_host_completion(webscene_engine* engine) @@ -2669,13 +2780,13 @@ void test_fullscreen_host_completion(webscene_engine* engine) () => { __fullscreenState = document.fullscreenElement?.id || 'missing'; }, error => { __fullscreenState = error.name; }); )JS", "native-fullscreen-enter.js"); - auto request = take_host_request(engine); + auto request = take_typed_host_request(engine); require( - request.find(R"("kind":"enterFullscreen")") != std::string::npos - && request.find(R"("targetNodeId":)") != std::string::npos, - "Element.requestFullscreen did not emit a typed host request: " + request); + request.kind == WEBSCENE_HOST_REQUEST_FULLSCREEN_ENTER_V1 + && request.id != 0U && request.target_node_id != 0U, + "Element.requestFullscreen did not emit a typed host request"); require(webscene_engine_complete_host_request_v1( - engine, host_request_id(request), 0U, nullptr, + engine, request.id, 0U, nullptr, nullptr, 0U, nullptr) != 0, "fullscreen enter completion was rejected"); require(evaluate( @@ -2691,11 +2802,12 @@ void test_fullscreen_host_completion(webscene_engine* engine) () => { __fullscreenExitState = document.fullscreenElement === null; }, error => { __fullscreenExitState = error.name; }); )JS", "native-fullscreen-exit.js"); - request = take_host_request(engine); - require(request.find(R"("kind":"exitFullscreen")") != std::string::npos, - "Document.exitFullscreen did not emit a typed host request: " + request); + request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_FULLSCREEN_EXIT_V1 + && request.id != 0U, + "Document.exitFullscreen did not emit a typed host request"); require(webscene_engine_complete_host_request_v1( - engine, host_request_id(request), 0U, nullptr, + engine, request.id, 0U, nullptr, nullptr, 0U, nullptr) != 0, "fullscreen exit completion was rejected"); require(evaluate( @@ -2704,4 +2816,22 @@ void test_fullscreen_host_completion(webscene_engine* engine) "native-fullscreen-exit-result.js") == R"("[true,[\"surface\",null]]")", "fullscreen exit state/event did not reach the document"); + + execute(engine, "__fullscreenEvents = []", + "native-fullscreen-reset-events.js"); + require(webscene_engine_set_window_fullscreen_v1(engine, 1U) != 0, + "native fullscreen entry was rejected"); + require(evaluate_until_equals(engine, + "document.fullscreenElement === document.body", + "native-fullscreen-state-entered.js", "true") == "true", + "native-initiated fullscreen state did not reach the document"); + require(webscene_engine_set_window_fullscreen_v1(engine, 0U) != 0, + "native fullscreen exit was rejected"); + require(evaluate_until_equals(engine, + "document.fullscreenElement === null", + "native-fullscreen-state-exited.js", "true") == "true", + "native-initiated fullscreen exit did not reach the document"); + require(evaluate(engine, "__fullscreenEvents.length", + "native-fullscreen-event-count.js") == "2", + "native-initiated fullscreen did not dispatch enter and exit events"); } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 7da5e6733..3519711bb 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1496,14 +1496,11 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin require( evaluate(engine, "true", "native-external-link-click-barrier.js") == "true", "external anchor click did not drain"); - const auto request = take_host_request(engine); + const auto request = take_typed_host_request(engine); require( - request.find(R"("kind":"openExternalUrl")") != std::string::npos - && request.find(R"("url":"https://example.com/?source=webscene")") - != std::string::npos - && request.find(R"("disposition":"systemDefaultBrowser")") - != std::string::npos, - "external anchor activation did not emit the typed host request: " + request); + request.kind == WEBSCENE_HOST_REQUEST_OPEN_EXTERNAL_URL_V1 + && request.url == "https://example.com/?source=webscene", + "external anchor activation did not emit the typed host request"); require( evaluate(engine, "location.href", "native-location-after.js") == location_before, "external anchor activation replaced the trusted WebScene document"); @@ -1515,40 +1512,39 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin "const opened = window.open(url.toString(), '_blank'); " "opened.opener = null; return { closed: opened.closed, location: location.href }; })()", "native-window-open-handoff.js"); - const auto popup_request = take_host_request(engine); + const auto popup_request = take_typed_host_request(engine); require( popup.find(R"("closed":false)") != std::string::npos && popup.find(location_before.substr(1U, location_before.size() - 2U)) != std::string::npos - && popup_request.find(R"("kind":"openExternalUrl")") != std::string::npos - && popup_request.find(R"("url":"https://example.com/chart/?utm_medium=library")") - != std::string::npos, - "window.open did not use the safe external host handoff: " + popup_request); + && popup_request.kind == WEBSCENE_HOST_REQUEST_OPEN_EXTERNAL_URL_V1 + && popup_request.url + == "https://example.com/chart/?utm_medium=library", + "window.open did not use the safe external host handoff"); require( evaluate(engine, "(() => { focus(); close(); return [typeof focus, typeof close]; })()", "native-window-actions.js") == R"(["function","function"])", "top-level focus/close methods were not installed"); - const auto focus_request = take_host_request(engine); - const auto close_request = take_host_request(engine); + const auto focus_request = take_typed_host_request(engine); + const auto close_request = take_typed_host_request(engine); require( - focus_request.find(R"("kind":"focusWindow")") != std::string::npos - && close_request.find(R"("kind":"closeWindow")") != std::string::npos, - "top-level focus/close did not emit ordered host requests: " - + focus_request + " / " + close_request); + focus_request.kind == WEBSCENE_HOST_REQUEST_WINDOW_FOCUS_V1 + && close_request.kind == WEBSCENE_HOST_REQUEST_WINDOW_CLOSE_V1, + "top-level focus/close did not emit ordered typed host requests"); execute(engine, R"JS( addEventListener('beforeunload', event => event.preventDefault(), { once: true }); close(); )JS", "native-window-close-veto.js"); - require(take_host_request(engine).empty(), + require(!take_typed_host_request(engine), "cancelled beforeunload still reached the native close host path"); execute(engine, "location.reload()", "native-window-reload.js"); - const auto reload_request = take_host_request(engine); - require(reload_request.find(R"("kind":"reloadWindow")") != std::string::npos, - "Location.reload did not emit a native reload request: " + reload_request); + const auto reload_request = take_typed_host_request(engine); + require(reload_request.kind == WEBSCENE_HOST_REQUEST_WINDOW_RELOAD_V1, + "Location.reload did not emit a typed native reload request"); const auto oversized_url = std::string("https://example.com/") + std::string(8192U, 'x'); @@ -1561,14 +1557,14 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin require( evaluate(engine, "oversizedWindowAccepted", "native-window-open-oversized-result.js") == "false" - && take_host_request(engine).empty(), + && !take_typed_host_request(engine), "oversized external URL reached the host request queue"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 70, 504U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 70, 505U, false); require( evaluate(engine, "true", "native-cancelled-link-barrier.js") == "true" - && take_host_request(engine).empty(), + && !take_typed_host_request(engine), "preventDefault did not suppress external navigation handoff"); pointer_move(engine, 20, 110, 506U); @@ -1752,6 +1748,18 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin error => { globalThis.__screenshotCopyState = String(error); }); })() )JS", "native-tradingview-canvas-copy.js"); + const auto screenshot_clipboard_request = take_typed_host_request(engine); + require( + screenshot_clipboard_request.kind + == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && screenshot_clipboard_request.content_type == "image/png" + && screenshot_clipboard_request.target_node_id != 0U + && screenshot_clipboard_request.bytes.empty(), + "TradingView canvas copy did not reach the typed host clipboard request"); + require(webscene_engine_complete_host_request_v1( + engine, screenshot_clipboard_request.id, 0U, "image/png", + nullptr, 0U, nullptr) != 0, + "TradingView canvas clipboard completion was rejected"); auto screenshot_copy_state = std::string{}; for (auto attempt = 0; attempt < 100; ++attempt) { screenshot_copy_state = evaluate( @@ -1764,17 +1772,6 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin require( screenshot_copy_state == R"("done")", "TradingView ClipboardItem promise did not resolve: " + screenshot_copy_state); - const auto screenshot_clipboard_request = take_host_request(engine); - require( - screenshot_clipboard_request.find(R"("kind":"writeClipboard")") - != std::string::npos - && screenshot_clipboard_request.find(R"("contentType":"image/png")") - != std::string::npos - && screenshot_clipboard_request.find(R"("canvasNodeId":)") - != std::string::npos, - "TradingView canvas copy did not reach the typed host clipboard request: " - + screenshot_clipboard_request); - const auto screenshot_node_id = static_cast(std::stoul(evaluate( engine, "globalThis.__screenshotCanvas.__websceneNativeNodeId", @@ -1804,6 +1801,29 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin "the exported screenshot canvas remained in later retained scenes"); } +void test_typed_window_host_request_performance(webscene_engine* engine) +{ + constexpr unsigned operation_count = 10000U; + constexpr unsigned batch_size = 1000U; + const auto started = std::chrono::steady_clock::now(); + for (unsigned offset = 0; offset < operation_count; offset += batch_size) { + execute(engine, R"JS( + for (let index = 0; index < 1000; ++index) focus(); + )JS", "native-window-request-performance.js"); + for (unsigned index = 0; index < batch_size; ++index) { + const auto request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_WINDOW_FOCUS_V1, + "typed window request order changed under load"); + } + } + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 5.0, + "10,000 typed window host requests exceeded five seconds"); + std::cout << "Typed window host request gate: operations=" + << operation_count << " elapsed=" << elapsed << "s\n"; +} + void test_css_linear_gradient_reaches_the_retained_scene(webscene_engine* engine) { { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_test_support.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_test_support.inc index e4330e23f..282901c38 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_test_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_test_support.inc @@ -268,6 +268,39 @@ std::string take_host_request(webscene_engine* engine) return std::string(buffer.data(), copied - 1U); } +struct typed_host_request_snapshot { + uint64_t id{}; + uint32_t kind{}; + uint32_t flags{}; + uint64_t target_node_id{}; + std::string content_type; + std::vector bytes; + std::string url; + explicit operator bool() const noexcept { return kind != 0U; } +}; + +typed_host_request_snapshot take_typed_host_request(webscene_engine* engine) +{ + typed_host_request_snapshot result; + const auto* request = webscene_engine_take_typed_host_request_v1(engine); + if (request == nullptr) return result; + require(request->struct_size == sizeof(*request) && request->version == 1U, + "typed host request ABI metadata changed"); + result.id = request->request_id; + result.kind = request->kind; + result.flags = request->flags; + result.target_node_id = request->target_node_id; + result.content_type = request->content_type == nullptr + ? "" : request->content_type; + result.url = request->url == nullptr ? "" : request->url; + if (request->byte_count != 0U) { + require(request->bytes != nullptr, "typed host request bytes were null"); + result.bytes.assign(request->bytes, request->bytes + request->byte_count); + } + webscene_host_request_release_v1(request); + return result; +} + void execute(webscene_engine* engine, std::string_view source, std::string_view name) { if (webscene_engine_execute_script( 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 5b0a75dc3..024daf1a7 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -742,6 +742,7 @@ int main() test_inline_block_preserves_vertical_padding(engine); test_pointer_hit_targets_and_related_targets_are_elements(engine); test_pointer_cursor_and_external_anchor_host_handoff(engine); + test_typed_window_host_request_performance(engine); test_enter_dispatches_browser_keypress_for_interval_commit(engine); test_css_linear_gradient_reaches_the_retained_scene(engine); { @@ -830,6 +831,7 @@ int main() test_scrollspy_product_neutral_primitives(engine); test_clipboard_write_text_host_handoff(engine); test_clipboard_read_host_completion(engine); + test_clipboard_maximum_payload_gate(engine); test_fullscreen_host_completion(engine); test_component_library_dom_discovery_primitives(engine); test_document_id_index_preserves_tree_and_root_semantics(engine); diff --git a/tooling/webscene/tests/native-binary-interop.test.mjs b/tooling/webscene/tests/native-binary-interop.test.mjs index 88783244f..9ddcce682 100644 --- a/tooling/webscene/tests/native-binary-interop.test.mjs +++ b/tooling/webscene/tests/native-binary-interop.test.mjs @@ -55,6 +55,9 @@ test('native engine publishes only the versioned leased interop surface', async 'webscene_engine_complete_host_request_v1', 'webscene_engine_discard_host_request_v1', 'webscene_engine_set_window_focused_v1', + 'webscene_engine_set_window_fullscreen_v1', + 'webscene_engine_take_typed_host_request_v1', + 'webscene_host_request_release_v1', 'webscene_engine_load_compiled_document_v1', 'webscene_engine_register_compiled_document_v1', 'webscene_engine_set_work_available_callback_v1' From 88c2e59920b46d525b9273feffafbc14ebd9dd2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:12:03 +0200 Subject: [PATCH 05/35] Migrate embed fallback tests to typed host requests --- .../tests/native_youtube_embed_tests.inc | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_youtube_embed_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_youtube_embed_tests.inc index cacefba1a..3d50c87da 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_youtube_embed_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_youtube_embed_tests.inc @@ -44,6 +44,17 @@ void test_youtube_embed_fallback() } return false; }; + const auto take_external_url = [&]() { + const auto request = take_typed_host_request(engine); + require(!request + || request.kind == WEBSCENE_HOST_REQUEST_OPEN_EXTERNAL_URL_V1, + "YouTube fallback emitted an unexpected typed host request"); + return request.url; + }; + const auto has_no_host_request = [&]() { + return take_host_request(engine).empty() + && !take_typed_host_request(engine); + }; require(wait_for("!!document.getElementById('video').contentDocument.getElementById('webscene-watch')"), "static YouTube iframe did not receive fallback"); require(wait_for("document.getElementById('video').contentDocument.querySelector('img').naturalWidth === 1280"), @@ -69,7 +80,7 @@ void test_youtube_embed_fallback() require(scroll(20) == "80", "wheel over fallback did not chain to outer page"); execute(engine, "document.scrollingElement.scrollTop=0", "embed-scroll-reset.js"); evaluate(engine, "true", "embed-scroll-reset-barrier.js"); - require(take_host_request(engine).empty(), "loading a fallback opened the browser without activation"); + require(has_no_host_request(), "loading a fallback opened the browser without activation"); require(evaluate(engine, R"JS((() => { const d = document.getElementById('video').contentDocument; return [d.getElementById('webscene-title').textContent, @@ -79,26 +90,26 @@ void test_youtube_embed_fallback() // Click uses the existing external host request, not iframe navigation. execute(engine, "document.getElementById('video').contentDocument.getElementById('webscene-watch').click()", "embed-click.js"); evaluate(engine, "true", "embed-click-barrier.js"); - require(take_host_request(engine).find("https://www.youtube.com/watch?v=CLnjOkR1vzk") != std::string::npos, + require(take_external_url() == "https://www.youtube.com/watch?v=CLnjOkR1vzk", "fallback click did not hand off to external browser"); execute(engine, "document.getElementById('video').contentDocument.getElementById('webscene-watch').focus()", "embed-focus.js"); require(evaluate(engine, "document.getElementById('video').contentDocument.activeElement.id", "embed-focus-barrier.js") == "\"webscene-watch\"", "fallback link was not focused"); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 13U, 8001U); keyboard_input(engine, WEBSCENE_INPUT_KEY_UP, 13U, 8002U); evaluate(engine, "true", "embed-key-barrier.js"); - require(take_host_request(engine).find("https://www.youtube.com/watch?v=CLnjOkR1vzk") != std::string::npos, + require(take_external_url() == "https://www.youtube.com/watch?v=CLnjOkR1vzk", "fallback Enter activation did not hand off to external browser"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 20, 8003U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 20, 8004U, false); evaluate(engine, "true", "embed-pointer-barrier.js"); - require(take_host_request(engine).find("https://www.youtube.com/watch?v=CLnjOkR1vzk") != std::string::npos, + require(take_external_url() == "https://www.youtube.com/watch?v=CLnjOkR1vzk", "fallback native pointer activation did not reach the anchor"); execute(engine, "document.getElementById('video').contentDocument.getElementById('webscene-watch').addEventListener('keydown', e => e.preventDefault())", "embed-cancel-key.js"); evaluate(engine, "true", "embed-cancel-handler-barrier.js"); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 13U, 8005U); keyboard_input(engine, WEBSCENE_INPUT_KEY_UP, 13U, 8006U); evaluate(engine, "true", "embed-cancel-barrier.js"); - require(take_host_request(engine).empty(), "cancelled Enter still opened the browser"); + require(has_no_host_request(), "cancelled Enter still opened the browser"); execute(engine, "document.getElementById('video').src = 'https://www.youtube-nocookie.com/embed/rFoJd63r3ag?autoplay=1'", "embed-change.js"); require(wait_for("document.getElementById('video').contentDocument.getElementById('webscene-watch')?.href === 'https://www.youtube.com/watch?v=rFoJd63r3ag'"), "dynamic privacy-enhanced embed navigation did not replace fallback"); @@ -114,7 +125,7 @@ void test_youtube_embed_fallback() "missing thumbnail fixture unexpectedly loaded"); execute(engine, "document.getElementById('video').contentDocument.getElementById('webscene-watch').click()", "embed-broken-click.js"); evaluate(engine, "true", "embed-broken-barrier.js"); - require(take_host_request(engine).find("https://www.youtube.com/watch?v=rFoJd63r3ag") != std::string::npos, + require(take_external_url() == "https://www.youtube.com/watch?v=rFoJd63r3ag", "missing thumbnail made fallback link unusable"); execute(engine, "const ordinary = document.createElement('iframe'); ordinary.id='ordinaryFrame'; ordinary.src='https://embed.test/ordinary.html'; document.body.appendChild(ordinary)", "ordinary-frame.js"); require(wait_for("!!document.getElementById('ordinaryFrame').contentDocument.getElementById('ordinary')"), @@ -127,14 +138,14 @@ void test_youtube_embed_fallback() pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 160, 90, 8101U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 160, 90, 8102U, false); evaluate(engine, "true", "embed-multiple-frame-click-barrier.js"); - require(take_host_request(engine).find("https://www.youtube.com/watch?v=rFoJd63r3ag") != std::string::npos, + require(take_external_url() == "https://www.youtube.com/watch?v=rFoJd63r3ag", "clicking the first embed image stopped working after a second iframe loaded"); execute(engine, "window.embedClicks=0; document.getElementById('video').contentDocument.getElementById('webscene-watch').addEventListener('click', e => { window.embedClicks++; e.preventDefault(); })", "embed-multiple-frame-listener.js"); evaluate(engine, "true", "embed-listener-barrier.js"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 160, 90, 8103U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 160, 90, 8104U, false); require(evaluate(engine, "window.embedClicks", "embed-listener-result.js") == "1", "first-frame listener was dispatched in the wrong realm"); - require(take_host_request(engine).empty(), "cancelled first-frame click opened the browser"); + require(has_no_host_request(), "cancelled first-frame click opened the browser"); execute(engine, "document.getElementById('video').src = 'https://www.youtube.com/embed/AbCdEf01234'", "embed-quality-fallback.js"); require(wait_for("document.getElementById('video').contentDocument.querySelector('img')?.naturalWidth === 480"), "failed high-resolution image and successful tiny placeholder did not fall back"); From 2c726b4958b960726f1d93a3f996af5e52cb40e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:12:37 +0200 Subject: [PATCH 06/35] Keep typed request flags warning-free --- .../native/webscene_v8_runtime_browser_apis.inc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc index 76540fa2b..408e51879 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_browser_apis.inc @@ -2004,7 +2004,8 @@ request->view.request_id = request_id; request->view.kind = WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1; request->view.flags = info.Length() > 2 && info[2]->BooleanValue(info.GetIsolate()) - ? WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 : 0U; + ? static_cast(WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1) + : 0U; request->content_type = content_type; v8::Local canvas_node_id; From 259962c86a84aacd649326a74552a90ebad2a106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:13:54 +0200 Subject: [PATCH 07/35] Gate typed clipboard round-trip performance --- .../native_v8_runtime_browser_dom_tests.inc | 43 +++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + 2 files changed, 44 insertions(+) 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 fb8878133..1ad3e7016 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 @@ -2767,6 +2767,49 @@ void test_clipboard_maximum_payload_gate(webscene_engine* engine) << maximum_bytes << " elapsed=" << elapsed << "s\n"; } +void test_clipboard_small_round_trip_performance(webscene_engine* engine) +{ + constexpr unsigned operation_count = 10000U; + constexpr unsigned batch_size = 16U; + execute(engine, "globalThis.__clipboardPerformanceCompleted = 0", + "native-clipboard-performance-setup.js"); + const auto started = std::chrono::steady_clock::now(); + for (unsigned offset = 0; offset < operation_count; offset += batch_size) { + execute(engine, R"JS( + for (let index = 0; index < 16; ++index) { + navigator.clipboard.writeText('x').then(() => { + ++globalThis.__clipboardPerformanceCompleted; + }); + } + )JS", "native-clipboard-performance-batch.js"); + for (unsigned index = 0; index < batch_size; ++index) { + const auto request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.id != 0U + && request.content_type == "text/plain" + && request.bytes == std::vector{'x'}, + "typed clipboard request changed under round-trip load"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", + nullptr, 0U, nullptr) != 0, + "typed clipboard completion was rejected under load"); + } + } + require(evaluate(engine, + "__clipboardPerformanceCompleted === 10000", + "native-clipboard-performance-result.js") == "true", + "typed clipboard promises did not all settle under load"); + require(!take_typed_host_request(engine), + "typed clipboard round-trip retained a host request"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 10.0, + "10,000 typed clipboard round trips exceeded ten seconds"); + std::cout << "Typed clipboard round-trip gate: operations=" + << operation_count << " pendingHighWater=" << batch_size + << " payloadBytes=1 elapsed=" << elapsed << "s\n"; +} + void test_fullscreen_host_completion(webscene_engine* engine) { execute(engine, R"JS( 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 024daf1a7..f41e0370c 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -832,6 +832,7 @@ int main() test_clipboard_write_text_host_handoff(engine); test_clipboard_read_host_completion(engine); test_clipboard_maximum_payload_gate(engine); + test_clipboard_small_round_trip_performance(engine); test_fullscreen_host_completion(engine); test_component_library_dom_discovery_primitives(engine); test_document_id_index_preserves_tree_and_root_semantics(engine); From 8746a6598b435214d0352375395bb65f621fe643 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:21:12 +0200 Subject: [PATCH 08/35] Synchronize typed host request tests with engine work --- .../tests/native_v8_runtime_browser_dom_tests.inc | 6 ++++-- .../tests/native_v8_runtime_input_tests.inc | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) 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 1ad3e7016..f434968ac 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 @@ -2775,13 +2775,15 @@ void test_clipboard_small_round_trip_performance(webscene_engine* engine) "native-clipboard-performance-setup.js"); const auto started = std::chrono::steady_clock::now(); for (unsigned offset = 0; offset < operation_count; offset += batch_size) { - execute(engine, R"JS( + require(evaluate(engine, R"JS( for (let index = 0; index < 16; ++index) { navigator.clipboard.writeText('x').then(() => { ++globalThis.__clipboardPerformanceCompleted; }); } - )JS", "native-clipboard-performance-batch.js"); + true + )JS", "native-clipboard-performance-batch.js") == "true", + "typed clipboard performance batch did not drain through the engine worker"); for (unsigned index = 0; index < batch_size; ++index) { const auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 3519711bb..02eb4e970 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1541,7 +1541,9 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin require(!take_typed_host_request(engine), "cancelled beforeunload still reached the native close host path"); - execute(engine, "location.reload()", "native-window-reload.js"); + require(evaluate(engine, "location.reload(); true", "native-window-reload.js") + == "true", + "Location.reload script did not drain through the engine worker"); const auto reload_request = take_typed_host_request(engine); require(reload_request.kind == WEBSCENE_HOST_REQUEST_WINDOW_RELOAD_V1, "Location.reload did not emit a typed native reload request"); From ec31e139c07acf2372900aba9a2632c914023bf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:22:10 +0200 Subject: [PATCH 09/35] Document typed desktop capability boundary --- docs/code-oss-compatibility.md | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/code-oss-compatibility.md b/docs/code-oss-compatibility.md index 77d27aedf..6fa4db6b9 100644 --- a/docs/code-oss-compatibility.md +++ b/docs/code-oss-compatibility.md @@ -1,9 +1,9 @@ # Code OSS native runtime compatibility -Status: development integration. Code OSS 1.137.0 reaches farther through the -native AppScene/WebScene stack with three runtime-owned compatibility slices, -but the complete workbench/editor smoke is not yet qualified. This work does -not add or depend on Electron, CEF, WKWebView, or a browser process. +Status: development integration. Code OSS 1.137.0's complete native +workbench/editor smoke passes against the development runtime. The typed desktop +capability revision still requires a fresh combined package qualification. This +work does not add or depend on Electron, CEF, WKWebView, or a browser process. The reference consumer is [`SceneTech/vscode-demo`](https://github.com/SceneTech/vscode-demo). It starts @@ -41,6 +41,32 @@ tests are tracked in The token-specific bridge must remain outside WebScene until that general contract exists. +## Native desktop capability boundary + +WebScene exposes standard browser behavior instead of an Electron emulation +layer. The versioned `webscene_host_request_v1` ABI carries external HTTP(S) +URLs, clipboard reads/writes, window focus/close/reload, and fullscreen +enter/exit. Hosts lease immutable request memory and release it explicitly. +Clipboard byte payloads therefore avoid base64 and JSON allocation. The older +bounded JSON queue remains available for application messages and compatibility +with earlier hosts. + +`navigator.clipboard` supports Promise-based text and typed operations with a +16 MiB representation limit, at most 16 pending completion-bearing operations, +explicit MIME rejection, recent native user activation for reads, and +navigation cancellation. Window requests share a bounded queue. Native hosts +can publish focus and fullscreen state back into the active document, which +updates `document.hasFocus()`, fullscreen state, and their standard events. +Scripted close dispatches cancelable `beforeunload` before reaching the host. + +The Code OSS `server-web` target does not load Electron main/sandbox entry +points. Its shipped `out/vs` tree has no direct `electron` module import, so a +general Electron shim would add surface area without helping DOM, Monaco, +layout, input, worker, rendering, or scheduling performance. AppScene owns the +operating-system side of the typed ABI. Menus, dialogs, notifications, +secondary windows, power events, and protocol registration remain +evidence-gated capabilities; they are not installed speculatively. + ## Open PR interaction - WebScene #70 changes CSS compilation/cache representation. CSSOM publication @@ -78,6 +104,16 @@ regressions without turning temporary hosted-runner load into a flaky result: | Read an 8 MiB Blob | 3,000 ms | 9 ms | 528 ms | 33 ms | | Retain 50,000 marks | 1,500 ms | 54 ms | 239 ms | 75 ms | | Publish 450 stylesheet mutations | 2,000 ms | 19 ms | 71 ms | 23 ms | +| Complete 10,000 one-byte typed clipboard writes | 10,000 ms | pending latest package matrix | pending latest package matrix | pending latest package matrix | +| Drain 10,000 typed window requests | 5,000 ms | pending latest package matrix | pending latest package matrix | pending latest package matrix | +| Apply 10,000 native focus transitions | 1,000 ms | pending latest package matrix | pending latest package matrix | pending latest package matrix | + +The clipboard load gate uses batches of 16, reports that pending-request high +water mark, completes every Promise, and verifies an empty queue without timing +sleeps. A separate gate moves the maximum 16 MiB clipboard payload through the +typed ABI within five seconds. Native contracts also cover no-activation reads, +unsupported input, cancellation, queue saturation, stale/double completion, +navigation teardown, close veto, and native-initiated fullscreen changes. Measurements were recorded on 2026-09-15 with Node 25.1.0 on macOS, Node 18.19.1 in Ubuntu 24.04, and Node 24.19.0 in Windows 11. The VM runs used the From af00bd0bbc3a1fa5d11a244b7df9bf13aca05500 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:27:47 +0200 Subject: [PATCH 10/35] Synchronize canvas clipboard host request test --- .../tests/native_v8_runtime_input_tests.inc | 3 +++ 1 file changed, 3 insertions(+) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index 02eb4e970..af077c10f 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1750,6 +1750,9 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin error => { globalThis.__screenshotCopyState = String(error); }); })() )JS", "native-tradingview-canvas-copy.js"); + require(evaluate(engine, "true", + "native-tradingview-canvas-copy-barrier.js") == "true", + "TradingView canvas copy did not drain through the engine worker"); const auto screenshot_clipboard_request = take_typed_host_request(engine); require( screenshot_clipboard_request.kind From 1d0bc8fed3cf3a649beb38e26fda024890ea7aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:28:42 +0200 Subject: [PATCH 11/35] Make typed host request tests worker-ordered --- .../tests/native_table_cell_copy_tests.inc | 2 ++ .../tests/native_v8_runtime_browser_dom_tests.inc | 14 ++++++++++++++ .../tests/native_v8_runtime_input_tests.inc | 9 ++++++--- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc index aa8d580b6..5561fc77b 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc @@ -54,6 +54,8 @@ void test_table_cell_click_copies_text_to_host() "table cell fixture did not initialize"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 20, 801U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 20, 802U, false); + require(evaluate(engine, "true", "table-cell-copy-request-barrier.js") == "true", + "table cell copy did not drain through the engine worker"); const auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.content_type == "text/plain" 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 f434968ac..7c86c916d 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 @@ -2607,6 +2607,8 @@ void test_clipboard_write_text_host_handoff(webscene_engine* engine) () => { __clipboardWriteState = 'fulfilled'; }, error => { __clipboardWriteState = error.name + ':' + error.message; }); )JS", "native-clipboard-write-text.js"); + require(evaluate(engine, "true", "native-clipboard-write-text-barrier.js") == "true", + "Clipboard.writeText did not drain through the engine worker"); const auto request = take_typed_host_request(engine); require( request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 @@ -2653,6 +2655,8 @@ void test_clipboard_read_host_completion(webscene_engine* engine) value => { __clipboardReadState = value; }, error => { __clipboardReadState = error.name; }); )JS", "native-clipboard-read-text.js"); + require(evaluate(engine, "true", "native-clipboard-read-text-barrier.js") == "true", + "Clipboard.readText did not drain through the engine worker"); auto request = take_typed_host_request(engine); require( request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1 @@ -2682,6 +2686,8 @@ void test_clipboard_read_host_completion(webscene_engine* engine) () => { __clipboardCancelState = 'fulfilled'; }, error => { __clipboardCancelState = error.name; }); )JS", "native-clipboard-read-cancel.js"); + require(evaluate(engine, "true", "native-clipboard-read-cancel-barrier.js") == "true", + "cancelled Clipboard.readText did not drain through the engine worker"); request = take_typed_host_request(engine); require( webscene_engine_complete_host_request_v1( @@ -2713,6 +2719,8 @@ void test_clipboard_read_host_completion(webscene_engine* engine) __clipboardQuotaState = values; }); )JS", "native-clipboard-queue-saturation.js"); + require(evaluate(engine, "true", "native-clipboard-queue-saturation-barrier.js") == "true", + "clipboard saturation batch did not drain through the engine worker"); for (unsigned index = 0; index < 16U; ++index) { const auto pending = take_typed_host_request(engine); require(pending.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1, @@ -2744,6 +2752,8 @@ void test_clipboard_maximum_payload_gate(webscene_engine* engine) () => { __maximumClipboardState = 'fulfilled'; }, error => { __maximumClipboardState = error.name; }); )JS", "native-clipboard-maximum-payload.js"); + require(evaluate(engine, "true", "native-clipboard-maximum-payload-barrier.js") == "true", + "maximum clipboard payload did not drain through the engine worker"); const auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.content_type == "text/plain" @@ -2825,6 +2835,8 @@ void test_fullscreen_host_completion(webscene_engine* engine) () => { __fullscreenState = document.fullscreenElement?.id || 'missing'; }, error => { __fullscreenState = error.name; }); )JS", "native-fullscreen-enter.js"); + require(evaluate(engine, "true", "native-fullscreen-enter-barrier.js") == "true", + "fullscreen enter did not drain through the engine worker"); auto request = take_typed_host_request(engine); require( request.kind == WEBSCENE_HOST_REQUEST_FULLSCREEN_ENTER_V1 @@ -2847,6 +2859,8 @@ void test_fullscreen_host_completion(webscene_engine* engine) () => { __fullscreenExitState = document.fullscreenElement === null; }, error => { __fullscreenExitState = error.name; }); )JS", "native-fullscreen-exit.js"); + require(evaluate(engine, "true", "native-fullscreen-exit-barrier.js") == "true", + "fullscreen exit did not drain through the engine worker"); request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_FULLSCREEN_EXIT_V1 && request.id != 0U, diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc index af077c10f..998f0de77 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_input_tests.inc @@ -1538,7 +1538,8 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin addEventListener('beforeunload', event => event.preventDefault(), { once: true }); close(); )JS", "native-window-close-veto.js"); - require(!take_typed_host_request(engine), + require(evaluate(engine, "true", "native-window-close-veto-barrier.js") == "true" + && !take_typed_host_request(engine), "cancelled beforeunload still reached the native close host path"); require(evaluate(engine, "location.reload(); true", "native-window-reload.js") @@ -1812,9 +1813,11 @@ void test_typed_window_host_request_performance(webscene_engine* engine) constexpr unsigned batch_size = 1000U; const auto started = std::chrono::steady_clock::now(); for (unsigned offset = 0; offset < operation_count; offset += batch_size) { - execute(engine, R"JS( + require(evaluate(engine, R"JS( for (let index = 0; index < 1000; ++index) focus(); - )JS", "native-window-request-performance.js"); + true + )JS", "native-window-request-performance.js") == "true", + "typed window request batch did not drain through the engine worker"); for (unsigned index = 0; index < batch_size; ++index) { const auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_WINDOW_FOCUS_V1, From 9873b609fa61c441383eff106ff4d2a3db13eea3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:39:13 +0200 Subject: [PATCH 12/35] Expose fullscreen in generated DOM bindings --- .../native/generated/webscene_dom_bindings.inc | 8 +++++++- tools/webidl-v8-bindings/dom-exposure.json | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc index ba8aef800..8fb66b0b6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/generated/webscene_dom_bindings.inc @@ -1,5 +1,5 @@ // Generated by tools/webidl-v8-bindings/generate.mjs. Do not edit. -// Exposure manifest SHA-256: e0b8a262956eb19c856043f2aca06a86e1f66a198adc4c52bd8ef4879f155c09 +// Exposure manifest SHA-256: fdbb9a18487dbd2997d4224fcae8863bb911d901383a14640c4f53087072df97 // Inputs: @webref/idl 3.82.1, webidl2 24.5.0. enum class generated_dom_interface : uint8_t { @@ -3305,6 +3305,12 @@ void install_generated_dom_templates(v8::Local) isolate, attach_shadow, v8::Local(), generated_Element_signature, 1, v8::ConstructorBehavior::kThrow)); + generated_Element_template->PrototypeTemplate()->Set( + js_string(isolate, "requestFullscreen"), + v8::FunctionTemplate::New( + isolate, element_request_fullscreen, v8::Local(), + generated_Element_signature, 0, + v8::ConstructorBehavior::kThrow)); element_template.Reset(isolate, generated_Element_template); auto generated_SVGElement_template = v8::FunctionTemplate::New(isolate, illegal_dom_constructor); diff --git a/tools/webidl-v8-bindings/dom-exposure.json b/tools/webidl-v8-bindings/dom-exposure.json index a1a16b595..2f206cb9b 100644 --- a/tools/webidl-v8-bindings/dom-exposure.json +++ b/tools/webidl-v8-bindings/dom-exposure.json @@ -215,7 +215,8 @@ { "name": "closest", "callback": "element_closest", "length": 1 }, { "name": "insertAdjacentElement", "callback": "insert_adjacent_element", "length": 2 }, { "name": "insertAdjacentHTML", "callback": "insert_adjacent_html", "length": 2 }, - { "name": "attachShadow", "callback": "attach_shadow", "length": 1 } + { "name": "attachShadow", "callback": "attach_shadow", "length": 1 }, + { "name": "requestFullscreen", "callback": "element_request_fullscreen", "length": 0 } ], "constants": [] }, From 9dc2eefbc784e4d121169a28c1780345a8105b2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 16:46:25 +0200 Subject: [PATCH 13/35] Order native fullscreen event reset --- .../tests/native_v8_runtime_browser_dom_tests.inc | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 7c86c916d..22541bdac 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 @@ -2876,8 +2876,9 @@ void test_fullscreen_host_completion(webscene_engine* engine) == R"("[true,[\"surface\",null]]")", "fullscreen exit state/event did not reach the document"); - execute(engine, "__fullscreenEvents = []", - "native-fullscreen-reset-events.js"); + require(evaluate(engine, "(__fullscreenEvents = [], true)", + "native-fullscreen-reset-events.js") == "true", + "fullscreen event reset did not drain through the engine worker"); require(webscene_engine_set_window_fullscreen_v1(engine, 1U) != 0, "native fullscreen entry was rejected"); require(evaluate_until_equals(engine, From 5669183026441be696ae14c7f320d38943083036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:15:24 +0200 Subject: [PATCH 14/35] Dispatch native clipboard shortcut events --- .../native/webscene_v8_runtime.cpp | 55 ++++++++++++ .../native/webscene_v8_runtime_tasks.inc | 34 ++++++++ .../native_v8_runtime_browser_dom_tests.inc | 85 +++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + 4 files changed, 175 insertions(+) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 3372d43eb..40cd2a486 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -4172,6 +4172,61 @@ struct v8_dom_runtime::implementation final { } } }; + const createClipboardData = () => { + const values = Object.create(null); + const normalize = type => String(type).toLowerCase(); + return Object.freeze({ + get types() { return Object.keys(values); }, + files: Object.freeze([]), + items: Object.freeze([]), + getData(type) { return values[normalize(type)] || ''; }, + setData(type, value) { + values[normalize(type)] = String(value); + }, + clearData(type = undefined) { + if (type === undefined) { + for (const key of Object.keys(values)) delete values[key]; + } else { + delete values[normalize(type)]; + } + } + }); + }; + const dispatchClipboardEvent = (type, target, clipboardData) => { + const event = new Event(type, { + bubbles: true, cancelable: true, composed: true + }); + Object.defineProperty(event, 'clipboardData', { + value: clipboardData, enumerable: true + }); + target.dispatchEvent(event); + }; + Object.defineProperty(globalThis, '__webSceneClipboardShortcut', { + configurable: true, + value(type, target) { + if (!target || typeof target.dispatchEvent !== 'function') return false; + const clipboardData = createClipboardData(); + if (type === 'paste') { + clipboard.readText().then(text => { + clipboardData.setData('text/plain', text); + dispatchClipboardEvent(type, target, clipboardData); + }).catch(() => {}); + return true; + } + dispatchClipboardEvent(type, target, clipboardData); + const items = Object.create(null); + for (const itemType of ['text/plain', 'text/html']) { + if (clipboardData.types.includes(itemType)) { + items[itemType] = new Blob( + [clipboardData.getData(itemType)], { type: itemType }); + } + } + if (Object.keys(items).length !== 0) { + clipboard.write([new WebSceneClipboardItem(items)]).catch(() => {}); + } + return true; + } + }); Object.defineProperty(globalThis, 'ClipboardItem', { value: WebSceneClipboardItem, configurable: true }); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 95c5f6a0f..636d14fc8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2264,6 +2264,29 @@ webscene_native::forms::ensure_text_value(node); } + bool dispatch_clipboard_shortcut(dom_node& target, const char* type) + { + auto local_context = context_for_node(target); + auto global = local_context->Global(); + v8::Local helper; + if (!global->Get( + local_context, + js_string(isolate, "__webSceneClipboardShortcut")).ToLocal(&helper) + || !helper->IsFunction()) { + return true; + } + v8::Local arguments[] = { + js_string(isolate, type), + wrap_node(target)}; + v8::Local result; + if (!helper.As()->Call( + local_context, global, 2, arguments).ToLocal(&result)) { + return false; + } + perform_microtask_checkpoint(); + return true; + } + static size_t previous_utf8_boundary(const std::string& value, size_t index) { return webscene_native::forms::previous_utf8_boundary(value,index); @@ -2431,6 +2454,17 @@ } return true; } + const auto clipboard_modifier = (input.flags + & (WEBSCENE_INPUT_MODIFIER_CONTROL | WEBSCENE_INPUT_MODIFIER_META)) != 0U + && (input.flags & WEBSCENE_INPUT_MODIFIER_ALT) == 0U; + if (!prevented && clipboard_modifier) { + if (key_code == 'C' || key_code == 'c') + return dispatch_clipboard_shortcut(*target, "copy"); + if (key_code == 'X' || key_code == 'x') + return dispatch_clipboard_shortcut(*target, "cut"); + if (key_code == 'V' || key_code == 'v') + return dispatch_clipboard_shortcut(*target, "paste"); + } if (prevented || !is_text_control(target)) return true; ensure_form_value(*target); auto start = std::min(target->mutable_form_control().selection_start, target->mutable_form_control().value.size()); 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 22541bdac..64284406e 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 @@ -2738,6 +2738,91 @@ void test_clipboard_read_host_completion(webscene_engine* engine) "clipboard queue saturation did not reject explicitly"); } +void test_native_clipboard_shortcut_events(webscene_engine* engine) +{ + execute(engine, R"JS( + document.body.innerHTML = ''; + const editor = document.getElementById('editor'); + globalThis.__clipboardShortcutEvents = []; + editor.addEventListener('copy', event => { + event.clipboardData.setData('text/plain', 'shortcut copy'); + event.clipboardData.setData('text/html', 'shortcut copy'); + event.preventDefault(); + __clipboardShortcutEvents.push([ + event.type, event.bubbles, event.cancelable, + event.clipboardData.types.join(',')]); + }); + editor.addEventListener('cut', event => { + event.clipboardData.setData('text/plain', 'shortcut cut'); + event.preventDefault(); + __clipboardShortcutEvents.push([event.type]); + }); + editor.addEventListener('paste', event => { + event.preventDefault(); + __clipboardShortcutEvents.push([ + event.type, event.clipboardData.getData('text/plain')]); + }); + editor.focus(); + )JS", "native-clipboard-shortcuts-setup.js"); + + constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META; + keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9761U, modifier); + require(evaluate(engine, "true", "native-clipboard-copy-shortcut-barrier.js") == "true", + "native copy shortcut did not drain through the engine worker"); + auto request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) == "shortcut copy", + "native copy event did not hand its plain text to the host"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, + "native copy plain-text completion was rejected"); + request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags == 0U + && request.content_type == "text/html" + && std::string(request.bytes.begin(), request.bytes.end()) + == "shortcut copy", + "native copy event did not preserve its HTML representation"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/html", nullptr, 0U, nullptr) != 0, + "native copy HTML completion was rejected"); + + keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'x', 9762U, modifier); + require(evaluate(engine, "true", "native-clipboard-cut-shortcut-barrier.js") == "true", + "native cut shortcut did not drain through the engine worker"); + request = take_typed_host_request(engine); + require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) == "shortcut cut", + "native cut event did not hand its plain text to the host"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, + "native cut completion was rejected"); + + keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'V', 9763U, modifier); + require(evaluate(engine, "true", "native-clipboard-paste-shortcut-barrier.js") == "true", + "native paste shortcut 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 == "text/plain", + "native paste event did not request plain text 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", + paste, sizeof(paste), nullptr) != 0, + "native paste completion was rejected"); + require(evaluate(engine, R"JS( + JSON.stringify(__clipboardShortcutEvents) + )JS", "native-clipboard-shortcuts-result.js") + == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", + "native clipboard shortcuts did not dispatch browser-compatible events"); + require(!take_typed_host_request(engine), + "native clipboard shortcuts retained an unexpected host request"); +} + void test_clipboard_maximum_payload_gate(webscene_engine* engine) { constexpr size_t maximum_bytes = 16U * 1024U * 1024U; 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 f41e0370c..a596c7737 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -831,6 +831,7 @@ int main() test_scrollspy_product_neutral_primitives(engine); test_clipboard_write_text_host_handoff(engine); test_clipboard_read_host_completion(engine); + test_native_clipboard_shortcut_events(engine); test_clipboard_maximum_payload_gate(engine); test_clipboard_small_round_trip_performance(engine); test_fullscreen_host_completion(engine); From 4984dc381abbabcf6e2733bc5fc002cb58fc5607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:19:10 +0200 Subject: [PATCH 15/35] Document browser clipboard shortcut behavior --- docs/code-oss-compatibility.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/code-oss-compatibility.md b/docs/code-oss-compatibility.md index 6fa4db6b9..84ef78b7a 100644 --- a/docs/code-oss-compatibility.md +++ b/docs/code-oss-compatibility.md @@ -54,7 +54,12 @@ with earlier hosts. `navigator.clipboard` supports Promise-based text and typed operations with a 16 MiB representation limit, at most 16 pending completion-bearing operations, explicit MIME rejection, recent native user activation for reads, and -navigation cancellation. Window requests share a bounded queue. Native hosts +navigation cancellation. Native Ctrl/Command-C, X, and V input also dispatches +the standard bubbling, cancelable `copy`, `cut`, and `paste` events expected by +browser editors. Copy and cut collect plain-text and HTML representations from +the event before the async host write; paste captures the initiating target and +dispatches after the bounded host read completes. A canceled `keydown` never +reaches this default behavior. Window requests share a bounded queue. Native hosts can publish focus and fullscreen state back into the active document, which updates `document.hasFocus()`, fullscreen state, and their standard events. Scripted close dispatches cancelable `beforeunload` before reaching the host. @@ -114,6 +119,10 @@ sleeps. A separate gate moves the maximum 16 MiB clipboard payload through the typed ABI within five seconds. Native contracts also cover no-activation reads, unsupported input, cancellation, queue saturation, stale/double completion, navigation teardown, close veto, and native-initiated fullscreen changes. +The native shortcut contract additionally verifies byte-exact plain-text and +HTML host writes plus a completed host read delivered through `ClipboardEvent` +shape, covering the path used by Monaco rather than only direct Clipboard API +calls. Measurements were recorded on 2026-09-15 with Node 25.1.0 on macOS, Node 18.19.1 in Ubuntu 24.04, and Node 24.19.0 in Windows 11. The VM runs used the From bebff63f036e1e7b1027feb12bcd02043c146449 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:25:38 +0200 Subject: [PATCH 16/35] Expose clipboard shortcut regression context --- .../tests/native_v8_runtime_browser_dom_tests.inc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 64284406e..f3644f3aa 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 @@ -2769,12 +2769,19 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9761U, modifier); require(evaluate(engine, "true", "native-clipboard-copy-shortcut-barrier.js") == "true", "native copy shortcut did not drain through the engine worker"); + const auto copy_events = evaluate(engine, + "JSON.stringify(__clipboardShortcutEvents)", + "native-clipboard-copy-shortcut-events.js"); auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 && request.content_type == "text/plain" && std::string(request.bytes.begin(), request.bytes.end()) == "shortcut copy", - "native copy event did not hand its plain text to the host"); + "native copy event did not hand its plain text to the host: events=" + + copy_events + " kind=" + std::to_string(request.kind) + + " flags=" + std::to_string(request.flags) + + " type=" + request.content_type + + " bytes=" + std::string(request.bytes.begin(), request.bytes.end())); require(webscene_engine_complete_host_request_v1( engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native copy plain-text completion was rejected"); From 143d0bc28b015ba17cb2e14cdd8ae3cf0b2735f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:32:49 +0200 Subject: [PATCH 17/35] Trace native clipboard shortcut dispatch --- .../tests/native_v8_runtime_browser_dom_tests.inc | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 f3644f3aa..390cae12c 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 @@ -2744,6 +2744,11 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) document.body.innerHTML = ''; const editor = document.getElementById('editor'); globalThis.__clipboardShortcutEvents = []; + globalThis.__clipboardShortcutKeys = []; + editor.addEventListener('keydown', event => { + __clipboardShortcutKeys.push([ + event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); + }); editor.addEventListener('copy', event => { event.clipboardData.setData('text/plain', 'shortcut copy'); event.clipboardData.setData('text/html', 'shortcut copy'); @@ -2770,7 +2775,9 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) require(evaluate(engine, "true", "native-clipboard-copy-shortcut-barrier.js") == "true", "native copy shortcut did not drain through the engine worker"); const auto copy_events = evaluate(engine, - "JSON.stringify(__clipboardShortcutEvents)", + "JSON.stringify({ events: __clipboardShortcutEvents," + " keys: __clipboardShortcutKeys, active: document.activeElement?.id," + " helper: typeof __webSceneClipboardShortcut })", "native-clipboard-copy-shortcut-events.js"); auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 From 4c47954264cf00f0c747c920d51b32f5b491e38f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:39:27 +0200 Subject: [PATCH 18/35] Wait for clipboard shortcut input consumption --- .../native_v8_runtime_browser_dom_tests.inc | 32 +++++++------------ 1 file changed, 11 insertions(+), 21 deletions(-) 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 390cae12c..8c71d83c5 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 @@ -2744,11 +2744,6 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) document.body.innerHTML = ''; const editor = document.getElementById('editor'); globalThis.__clipboardShortcutEvents = []; - globalThis.__clipboardShortcutKeys = []; - editor.addEventListener('keydown', event => { - __clipboardShortcutKeys.push([ - event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); - }); editor.addEventListener('copy', event => { event.clipboardData.setData('text/plain', 'shortcut copy'); event.clipboardData.setData('text/html', 'shortcut copy'); @@ -2771,24 +2766,17 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) )JS", "native-clipboard-shortcuts-setup.js"); constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META; + webscene_engine_metrics metrics{}; + webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9761U, modifier); - require(evaluate(engine, "true", "native-clipboard-copy-shortcut-barrier.js") == "true", - "native copy shortcut did not drain through the engine worker"); - const auto copy_events = evaluate(engine, - "JSON.stringify({ events: __clipboardShortcutEvents," - " keys: __clipboardShortcutKeys, active: document.activeElement?.id," - " helper: typeof __webSceneClipboardShortcut })", - "native-clipboard-copy-shortcut-events.js"); + wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + "native copy shortcut was not consumed"); auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 && request.content_type == "text/plain" && std::string(request.bytes.begin(), request.bytes.end()) == "shortcut copy", - "native copy event did not hand its plain text to the host: events=" - + copy_events + " kind=" + std::to_string(request.kind) - + " flags=" + std::to_string(request.flags) - + " type=" + request.content_type - + " bytes=" + std::string(request.bytes.begin(), request.bytes.end())); + "native copy event did not hand its plain text to the host"); require(webscene_engine_complete_host_request_v1( engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native copy plain-text completion was rejected"); @@ -2803,9 +2791,10 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) engine, request.id, 0U, "text/html", nullptr, 0U, nullptr) != 0, "native copy HTML completion was rejected"); + webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'x', 9762U, modifier); - require(evaluate(engine, "true", "native-clipboard-cut-shortcut-barrier.js") == "true", - "native cut shortcut did not drain through the engine worker"); + wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + "native cut shortcut was not consumed"); request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 @@ -2816,9 +2805,10 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native cut completion was rejected"); + webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'V', 9763U, modifier); - require(evaluate(engine, "true", "native-clipboard-paste-shortcut-barrier.js") == "true", - "native paste shortcut did not drain through the engine worker"); + wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + "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", From a37e387dcc1c91035dbeb19f79fe99e17041346d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:51:58 +0200 Subject: [PATCH 19/35] Drain clipboard shortcut input queue frontier --- .../tests/native_v8_runtime_browser_dom_tests.inc | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 8c71d83c5..7d4119ef0 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 @@ -2767,9 +2767,9 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META; webscene_engine_metrics metrics{}; - webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9761U, modifier); - wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + webscene_engine_get_metrics(engine, &metrics); + wait_for_consumed_inputs(engine, metrics.enqueued_inputs, "native copy shortcut was not consumed"); auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 @@ -2791,9 +2791,9 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) engine, request.id, 0U, "text/html", nullptr, 0U, nullptr) != 0, "native copy HTML completion was rejected"); - webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'x', 9762U, modifier); - wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + webscene_engine_get_metrics(engine, &metrics); + wait_for_consumed_inputs(engine, metrics.enqueued_inputs, "native cut shortcut was not consumed"); request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 @@ -2805,9 +2805,9 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native cut completion was rejected"); - webscene_engine_get_metrics(engine, &metrics); keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'V', 9763U, modifier); - wait_for_consumed_inputs(engine, metrics.consumed_inputs + 1U, + webscene_engine_get_metrics(engine, &metrics); + wait_for_consumed_inputs(engine, metrics.enqueued_inputs, "native paste shortcut was not consumed"); request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_READ_V1 From 1b0c6c88a68bc0ac0e9479fcbbbd97f800a7efb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:59:20 +0200 Subject: [PATCH 20/35] Record synchronized clipboard shortcut state --- .../tests/native_v8_runtime_browser_dom_tests.inc | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 7d4119ef0..94bc153eb 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 @@ -2744,6 +2744,11 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) document.body.innerHTML = ''; const editor = document.getElementById('editor'); globalThis.__clipboardShortcutEvents = []; + globalThis.__clipboardShortcutKeys = []; + editor.addEventListener('keydown', event => { + __clipboardShortcutKeys.push([ + event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); + }); editor.addEventListener('copy', event => { event.clipboardData.setData('text/plain', 'shortcut copy'); event.clipboardData.setData('text/html', 'shortcut copy'); @@ -2771,12 +2776,17 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) webscene_engine_get_metrics(engine, &metrics); wait_for_consumed_inputs(engine, metrics.enqueued_inputs, "native copy shortcut was not consumed"); + const auto copy_state = evaluate(engine, + "JSON.stringify({ events: __clipboardShortcutEvents," + " keys: __clipboardShortcutKeys, active: document.activeElement?.id," + " helper: typeof __webSceneClipboardShortcut })", + "native-clipboard-copy-shortcut-state.js"); auto request = take_typed_host_request(engine); require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.flags == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 && request.content_type == "text/plain" && std::string(request.bytes.begin(), request.bytes.end()) == "shortcut copy", - "native copy event did not hand its plain text to the host"); + "native copy event did not hand its plain text to the host: " + copy_state); require(webscene_engine_complete_host_request_v1( engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native copy plain-text completion was rejected"); From 9ab633701eab77dfed344e39bb3bd98d56020654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 17:59:45 +0200 Subject: [PATCH 21/35] Isolate native clipboard shortcut contracts --- .../tests/native_v8_runtime_browser_dom_tests.inc | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 94bc153eb..7d08f0af0 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 @@ -2738,8 +2738,10 @@ void test_clipboard_read_host_completion(webscene_engine* engine) "clipboard queue saturation did not reject explicitly"); } -void test_native_clipboard_shortcut_events(webscene_engine* engine) +void test_native_clipboard_shortcut_events(webscene_engine*) { + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "native clipboard shortcut engine creation failed"); execute(engine, R"JS( document.body.innerHTML = ''; const editor = document.getElementById('editor'); @@ -2835,6 +2837,7 @@ void test_native_clipboard_shortcut_events(webscene_engine* engine) "native clipboard shortcuts did not dispatch browser-compatible events"); require(!take_typed_host_request(engine), "native clipboard shortcuts retained an unexpected host request"); + webscene_engine_destroy(engine); } void test_clipboard_maximum_payload_gate(webscene_engine* engine) From 6563be80d17b2b3df7a892dbeb687e2d988d420b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 18:01:07 +0200 Subject: [PATCH 22/35] Wait for isolated clipboard setup --- .../tests/native_v8_runtime_browser_dom_tests.inc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7d08f0af0..5daaf35d8 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 @@ -2742,7 +2742,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) { auto* engine = webscene_engine_create(0); require(engine != nullptr, "native clipboard shortcut engine creation failed"); - execute(engine, R"JS( + execute_and_wait(engine, R"JS( document.body.innerHTML = ''; const editor = document.getElementById('editor'); globalThis.__clipboardShortcutEvents = []; From 3a321cb05afe5b2b61f6a83eadc7da6c2b7cb82d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 18:07:48 +0200 Subject: [PATCH 23/35] Wait for serialized clipboard representation --- .../tests/native_v8_runtime_browser_dom_tests.inc | 4 ++++ 1 file changed, 4 insertions(+) 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 5daaf35d8..2a426d8ba 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 @@ -2793,6 +2793,10 @@ void test_native_clipboard_shortcut_events(webscene_engine*) engine, request.id, 0U, "text/plain", nullptr, 0U, nullptr) != 0, "native copy plain-text completion was rejected"); request = take_typed_host_request(engine); + for (auto attempt = 0; attempt < 250 && !request; ++attempt) { + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + request = take_typed_host_request(engine); + } require(request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 && request.flags == 0U && request.content_type == "text/html" From 4b9a19c6c76deeac23acb58d5f39ae64d2b0e2ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 18:14:31 +0200 Subject: [PATCH 24/35] Expose clipboard shortcut event sequence --- .../tests/native_v8_runtime_browser_dom_tests.inc | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 2a426d8ba..7024c5a50 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 @@ -2834,11 +2834,13 @@ void test_native_clipboard_shortcut_events(webscene_engine*) engine, request.id, 0U, "text/plain", paste, sizeof(paste), nullptr) != 0, "native paste completion was rejected"); - require(evaluate(engine, R"JS( + const auto shortcut_events = evaluate(engine, R"JS( JSON.stringify(__clipboardShortcutEvents) - )JS", "native-clipboard-shortcuts-result.js") - == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", - "native clipboard shortcuts did not dispatch browser-compatible events"); + )JS", "native-clipboard-shortcuts-result.js"); + require(shortcut_events + == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", + "native clipboard shortcuts did not dispatch browser-compatible events: " + + shortcut_events); require(!take_typed_host_request(engine), "native clipboard shortcuts retained an unexpected host request"); webscene_engine_destroy(engine); From 0a16806179902362c1caf93f8930fe1ddb1d29e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 18:14:54 +0200 Subject: [PATCH 25/35] Wait for asynchronous native paste event --- .../tests/native_v8_runtime_browser_dom_tests.inc | 7 +++++++ 1 file changed, 7 insertions(+) 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 7024c5a50..3755675c3 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 @@ -2834,6 +2834,13 @@ void test_native_clipboard_shortcut_events(webscene_engine*) engine, request.id, 0U, "text/plain", paste, sizeof(paste), nullptr) != 0, "native paste completion was rejected"); + for (auto attempt = 0; attempt < 250; ++attempt) { + if (evaluate(engine, "__clipboardShortcutEvents.length", + "native-clipboard-shortcut-event-count.js") == "3") { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } const auto shortcut_events = evaluate(engine, R"JS( JSON.stringify(__clipboardShortcutEvents) )JS", "native-clipboard-shortcuts-result.js"); From 4d033cb94a23d603bb5e015ae89400fe3f502e51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 18:20:55 +0200 Subject: [PATCH 26/35] Compare serialized clipboard event value once --- .../tests/native_v8_runtime_browser_dom_tests.inc | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 3755675c3..fdb2b3f44 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 @@ -2841,9 +2841,9 @@ void test_native_clipboard_shortcut_events(webscene_engine*) } std::this_thread::sleep_for(std::chrono::milliseconds(2)); } - const auto shortcut_events = evaluate(engine, R"JS( - JSON.stringify(__clipboardShortcutEvents) - )JS", "native-clipboard-shortcuts-result.js"); + const auto shortcut_events = evaluate(engine, + "__clipboardShortcutEvents", + "native-clipboard-shortcuts-result.js"); require(shortcut_events == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", "native clipboard shortcuts did not dispatch browser-compatible events: " From ec146dab36cc2e12f6460cb8915ce1a19df5eed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:08:48 +0200 Subject: [PATCH 27/35] Support user-initiated legacy clipboard commands --- .../native/webscene_v8_runtime_dom_core.inc | 41 ++++++++++++++++--- .../native/webscene_v8_runtime_state.inc | 2 + .../native/webscene_v8_runtime_tasks.inc | 11 ++++- .../tests/native_table_cell_copy_tests.inc | 4 +- .../native_v8_runtime_browser_dom_tests.inc | 21 ++++++++++ .../TABLE_CELL_COPY_COVERAGE.md | 3 +- 6 files changed, 72 insertions(+), 10 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc index 1b977397a..0d1d8839a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_core.inc @@ -4382,12 +4382,43 @@ "InvalidStateError"); return; } - // This component runtime has no legacy editing command executor. The - // command algorithm returns false for unsupported/disabled commands; - // never claim a successful copy or dispatch an event without executing - // it. Clipboard helpers can then use the implemented async clipboard API. + v8::String::Utf8Value command_utf8(isolate, command); + std::string command_name = *command_utf8 == nullptr + ? std::string{} : std::string(*command_utf8, command_utf8.length()); + std::transform( + command_name.begin(), command_name.end(), command_name.begin(), + [](unsigned char value) { return static_cast(std::tolower(value)); }); + if (command_name == "copy" || command_name == "cut" + || command_name == "paste") { + if (!self->dispatching_user_input + || self->executing_legacy_clipboard_command) { + info.GetReturnValue().Set(false); + return; + } + auto* target = self->active_element == nullptr + ? &self->active_root() : self->active_element; + if (self->document.is_inert(*target)) { + info.GetReturnValue().Set(false); + return; + } + self->executing_legacy_clipboard_command = true; + const bool dispatched = self->dispatch_clipboard_shortcut( + *target, command_name.c_str()); + self->executing_legacy_clipboard_command = false; + if (dispatched) { + self->record_feature( + "web-api", "Document.execCommand", "supported", + "copy/cut/paste dispatch browser clipboard events through the bounded native clipboard bridge", + "web-api-binding"); + } + info.GetReturnValue().Set(dispatched); + return; + } + // Other legacy editing commands still have no executor. Return false + // without mutating the DOM so callers can choose their supported + // asynchronous or application-level fallback. self->record_feature("web-api", "Document.execCommand", "unsupported", - "legacy editing commands return false; use navigator.clipboard for copying", + "unsupported legacy editing commands return false without mutation", "web-api-binding"); info.GetReturnValue().Set(false); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc index dc501a870..e69308d58 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -380,6 +380,8 @@ uint64_t current_input_sequence{0}; uint32_t pending_text_input_target_id{0U}; bool pending_text_input_from_keydown{false}; + bool dispatching_user_input{false}; + bool executing_legacy_clipboard_command{false}; dom_node* active_element{nullptr}; bool focus_visible{false}; bool document_visible{true}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 636d14fc8..357d72e5f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2273,7 +2273,7 @@ local_context, js_string(isolate, "__webSceneClipboardShortcut")).ToLocal(&helper) || !helper->IsFunction()) { - return true; + return false; } v8::Local arguments[] = { js_string(isolate, type), @@ -2284,7 +2284,7 @@ return false; } perform_microtask_checkpoint(); - return true; + return result->BooleanValue(isolate); } static size_t previous_utf8_boundary(const std::string& value, size_t index) @@ -2828,6 +2828,13 @@ bool dispatch_input(const webscene_input_event& input, bool defer_cursor_update = false) { + struct user_input_dispatch_scope { + bool& active; + bool previous; + explicit user_input_dispatch_scope(bool& value) + : active(value), previous(value) { active = true; } + ~user_input_dispatch_scope() { active = previous; } + } dispatch_scope{dispatching_user_input}; current_input_sequence = input.sequence; if (input.kind == WEBSCENE_INPUT_POINTER_DOWN || input.kind == WEBSCENE_INPUT_KEY_DOWN) { diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc index 5561fc77b..672b31d9e 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_table_cell_copy_tests.inc @@ -72,8 +72,8 @@ void test_table_cell_click_copies_text_to_host() std::this_thread::sleep_for(std::chrono::milliseconds(2)); } require(state == R"("copied")", "table cell click failed: " + state); - require(evaluate(engine, realm + ".legacyCopyResult", "table-cell-legacy-result.js") == "false", - "unsupported legacy copy must not claim success"); + require(evaluate(engine, realm + ".legacyCopyResult", "table-cell-legacy-result.js") == "true", + "legacy copy did not report its dispatched clipboard event"); pointer_button(engine, WEBSCENE_INPUT_POINTER_DOWN, 20, 70, 803U, true); pointer_button(engine, WEBSCENE_INPUT_POINTER_UP, 20, 70, 804U, false); evaluate(engine, realm + ".copyState", "table-cell-empty-click-barrier.js"); 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 fdb2b3f44..0e9616aa8 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 @@ -2747,9 +2747,17 @@ void test_native_clipboard_shortcut_events(webscene_engine*) const editor = document.getElementById('editor'); globalThis.__clipboardShortcutEvents = []; globalThis.__clipboardShortcutKeys = []; + globalThis.__clipboardExecCommandResults = []; editor.addEventListener('keydown', event => { __clipboardShortcutKeys.push([ event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); + const command = event.metaKey && /^[cxv]$/i.test(event.key) + ? ({c: 'copy', x: 'cut', v: 'paste'})[event.key.toLowerCase()] + : undefined; + if (command) { + event.preventDefault(); + __clipboardExecCommandResults.push(document.execCommand(command)); + } }); editor.addEventListener('copy', event => { event.clipboardData.setData('text/plain', 'shortcut copy'); @@ -2772,6 +2780,12 @@ void test_native_clipboard_shortcut_events(webscene_engine*) editor.focus(); )JS", "native-clipboard-shortcuts-setup.js"); + require(evaluate(engine, "document.execCommand('copy')", + "native-clipboard-exec-command-without-input.js") == "false", + "legacy clipboard command ran without current native user input"); + require(!take_typed_host_request(engine), + "legacy clipboard command without user input reached the host"); + constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META; webscene_engine_metrics metrics{}; keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 9761U, modifier); @@ -2848,6 +2862,13 @@ void test_native_clipboard_shortcut_events(webscene_engine*) == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", "native clipboard shortcuts did not dispatch browser-compatible events: " + shortcut_events); + require(evaluate(engine, "__clipboardExecCommandResults", + "native-clipboard-exec-command-results.js") + == "[true,true,true]", + "prevented native shortcuts did not execute document clipboard commands"); + require(evaluate(engine, "document.execCommand('unsupported-command')", + "native-clipboard-unsupported-exec-command.js") == "false", + "unsupported legacy command claimed success"); require(!take_typed_host_request(engine), "native clipboard shortcuts retained an unexpected host request"); webscene_engine_destroy(engine); diff --git a/tests/WebPlatformSubset/TABLE_CELL_COPY_COVERAGE.md b/tests/WebPlatformSubset/TABLE_CELL_COPY_COVERAGE.md index 2428e83b8..df569819b 100644 --- a/tests/WebPlatformSubset/TABLE_CELL_COPY_COVERAGE.md +++ b/tests/WebPlatformSubset/TABLE_CELL_COPY_COVERAGE.md @@ -11,7 +11,8 @@ It does not modify the OS clipboard. namespace exclusions, creation/cloning, delegated clicks, and iframe realms. `contracts/dom-unsupported-editing-command.html` checks the bounded unsupported command path, argument conversion, receiver validation, and non-HTML rejection. -These 11 assertions pass unchanged in Chromium and native macOS ARM64. They remain +The native fixture additionally checks synchronous legacy copy during current +pointer input. These assertions pass in native macOS ARM64. They remain candidates pending cross-RID qualification; the native pointer regression is also included in the full native test executable, not only its focused filter. From 502fe1ecf439c6c61988c22acbe31425943e722e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:16:38 +0200 Subject: [PATCH 28/35] Report legacy clipboard command results --- .../tests/native_v8_runtime_browser_dom_tests.inc | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 0e9616aa8..28971399d 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 @@ -2862,10 +2862,12 @@ void test_native_clipboard_shortcut_events(webscene_engine*) == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", "native clipboard shortcuts did not dispatch browser-compatible events: " + shortcut_events); - require(evaluate(engine, "__clipboardExecCommandResults", - "native-clipboard-exec-command-results.js") - == "[true,true,true]", - "prevented native shortcuts did not execute document clipboard commands"); + const auto exec_command_results = evaluate(engine, + "__clipboardExecCommandResults", + "native-clipboard-exec-command-results.js"); + require(exec_command_results == "[true,true,true]", + "prevented native shortcuts did not execute document clipboard commands: " + + exec_command_results); require(evaluate(engine, "document.execCommand('unsupported-command')", "native-clipboard-unsupported-exec-command.js") == "false", "unsupported legacy command claimed success"); From 24c123a3a57b26b50ec99680ceee46bc4f9cff19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:22:37 +0200 Subject: [PATCH 29/35] Synchronize asynchronous clipboard shortcut assertions --- .../tests/native_v8_runtime_browser_dom_tests.inc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 28971399d..15dc4ca5c 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 @@ -2849,8 +2849,10 @@ void test_native_clipboard_shortcut_events(webscene_engine*) paste, sizeof(paste), nullptr) != 0, "native paste completion was rejected"); for (auto attempt = 0; attempt < 250; ++attempt) { - if (evaluate(engine, "__clipboardShortcutEvents.length", - "native-clipboard-shortcut-event-count.js") == "3") { + if (evaluate(engine, + "__clipboardShortcutEvents.length === 3" + " && __clipboardExecCommandResults.length === 3", + "native-clipboard-shortcut-completion.js") == "true") { break; } std::this_thread::sleep_for(std::chrono::milliseconds(2)); From d75a8d2d0ba6fb59d5cf1cd5691e55dcf34cc93f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:29:51 +0200 Subject: [PATCH 30/35] Avoid nested paste microtask checkpoints --- .../native/webscene_v8_runtime_tasks.inc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 357d72e5f..cd6cfd29a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2283,7 +2283,13 @@ local_context, global, 2, arguments).ToLocal(&result)) { return false; } - perform_microtask_checkpoint(); + // Clipboard reads enqueue their host request synchronously and the host + // completion performs the checkpoint that dispatches paste. Re-entering + // a checkpoint from document.execCommand('paste') can resume the read + // continuation before the keydown listener records the command result. + // Copy and cut still need this checkpoint to advance their async Blob + // extraction far enough to publish the native write request. + if (std::string_view{type} != "paste") perform_microtask_checkpoint(); return result->BooleanValue(isolate); } From 66de665e2975badfc6c263eddc544182717c0807 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:37:35 +0200 Subject: [PATCH 31/35] Trace legacy clipboard command completion --- .../tests/native_v8_runtime_browser_dom_tests.inc | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) 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 15dc4ca5c..326fd5397 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 @@ -2748,6 +2748,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) globalThis.__clipboardShortcutEvents = []; globalThis.__clipboardShortcutKeys = []; globalThis.__clipboardExecCommandResults = []; + globalThis.__clipboardExecCommandProgress = []; editor.addEventListener('keydown', event => { __clipboardShortcutKeys.push([ event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); @@ -2756,7 +2757,15 @@ void test_native_clipboard_shortcut_events(webscene_engine*) : undefined; if (command) { event.preventDefault(); - __clipboardExecCommandResults.push(document.execCommand(command)); + __clipboardExecCommandProgress.push(`enter:${command}`); + try { + __clipboardExecCommandResults.push(document.execCommand(command)); + __clipboardExecCommandProgress.push(`return:${command}`); + } catch (error) { + __clipboardExecCommandResults.push( + `${error?.name || 'Error'}: ${error?.message || error}`); + __clipboardExecCommandProgress.push(`throw:${command}`); + } } }); editor.addEventListener('copy', event => { @@ -2869,7 +2878,9 @@ void test_native_clipboard_shortcut_events(webscene_engine*) "native-clipboard-exec-command-results.js"); require(exec_command_results == "[true,true,true]", "prevented native shortcuts did not execute document clipboard commands: " - + exec_command_results); + + exec_command_results + " progress=" + + evaluate(engine, "__clipboardExecCommandProgress", + "native-clipboard-exec-command-progress.js")); require(evaluate(engine, "document.execCommand('unsupported-command')", "native-clipboard-unsupported-exec-command.js") == "false", "unsupported legacy command claimed success"); From 453b90cf340582c4d3b154e32f6c259ba946f99b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 19:44:28 +0200 Subject: [PATCH 32/35] Use host-realistic native shortcut key codes --- .../native/webscene_v8_runtime_tasks.inc | 8 +------- .../native_v8_runtime_browser_dom_tests.inc | 17 +++-------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index cd6cfd29a..357d72e5f 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2283,13 +2283,7 @@ local_context, global, 2, arguments).ToLocal(&result)) { return false; } - // Clipboard reads enqueue their host request synchronously and the host - // completion performs the checkpoint that dispatches paste. Re-entering - // a checkpoint from document.execCommand('paste') can resume the read - // continuation before the keydown listener records the command result. - // Copy and cut still need this checkpoint to advance their async Blob - // extraction far enough to publish the native write request. - if (std::string_view{type} != "paste") perform_microtask_checkpoint(); + perform_microtask_checkpoint(); return result->BooleanValue(isolate); } 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 326fd5397..6dc4cf53e 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 @@ -2748,7 +2748,6 @@ void test_native_clipboard_shortcut_events(webscene_engine*) globalThis.__clipboardShortcutEvents = []; globalThis.__clipboardShortcutKeys = []; globalThis.__clipboardExecCommandResults = []; - globalThis.__clipboardExecCommandProgress = []; editor.addEventListener('keydown', event => { __clipboardShortcutKeys.push([ event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); @@ -2757,15 +2756,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) : undefined; if (command) { event.preventDefault(); - __clipboardExecCommandProgress.push(`enter:${command}`); - try { - __clipboardExecCommandResults.push(document.execCommand(command)); - __clipboardExecCommandProgress.push(`return:${command}`); - } catch (error) { - __clipboardExecCommandResults.push( - `${error?.name || 'Error'}: ${error?.message || error}`); - __clipboardExecCommandProgress.push(`throw:${command}`); - } + __clipboardExecCommandResults.push(document.execCommand(command)); } }); editor.addEventListener('copy', event => { @@ -2830,7 +2821,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) engine, request.id, 0U, "text/html", nullptr, 0U, nullptr) != 0, "native copy HTML completion was rejected"); - keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'x', 9762U, modifier); + keyboard_input(engine, WEBSCENE_INPUT_KEY_DOWN, 'X', 9762U, modifier); webscene_engine_get_metrics(engine, &metrics); wait_for_consumed_inputs(engine, metrics.enqueued_inputs, "native cut shortcut was not consumed"); @@ -2878,9 +2869,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) "native-clipboard-exec-command-results.js"); require(exec_command_results == "[true,true,true]", "prevented native shortcuts did not execute document clipboard commands: " - + exec_command_results + " progress=" - + evaluate(engine, "__clipboardExecCommandProgress", - "native-clipboard-exec-command-progress.js")); + + exec_command_results); require(evaluate(engine, "document.execCommand('unsupported-command')", "native-clipboard-unsupported-exec-command.js") == "false", "unsupported legacy command claimed success"); From 59ef9a4d659633a9610502295af6100d96cba88c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 20:25:37 +0200 Subject: [PATCH 33/35] Add native clipboard shutdown stress diagnostics --- .github/workflows/native-runtime-packages.yml | 9 +++ .../native_v8_runtime_browser_dom_tests.inc | 78 +++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + scripts/build-native-engine-runtime.sh | 11 ++- .../ReleaseCompatibilityGateTests.cs | 26 +++++++ 5 files changed, 124 insertions(+), 1 deletion(-) diff --git a/.github/workflows/native-runtime-packages.yml b/.github/workflows/native-runtime-packages.yml index 368844add..2c2a6c665 100644 --- a/.github/workflows/native-runtime-packages.yml +++ b/.github/workflows/native-runtime-packages.yml @@ -301,6 +301,15 @@ jobs: --upstream-v8 \ --partition-alloc \ --output "$GITHUB_WORKSPACE/artifacts/nuget-packages" + - name: Upload exact macOS native symbols + if: matrix.rid == 'osx-arm64' + uses: actions/upload-artifact@v4 + with: + name: native-symbols-${{ matrix.rid }}-${{ needs.metadata.outputs.package-version }} + path: artifacts/native-engine-runtime-build/**/*.dSYM/** + if-no-files-found: error + compression-level: 9 + retention-days: 3 - name: Build, pack, and test Linux runtime if: matrix.rid == 'linux-x64' shell: bash 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 6dc4cf53e..6f19c4d79 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 @@ -2878,6 +2878,84 @@ void test_native_clipboard_shortcut_events(webscene_engine*) webscene_engine_destroy(engine); } +void test_native_legacy_clipboard_completion_stress(webscene_engine*) +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, + "legacy clipboard completion stress engine creation failed"); + execute_and_wait(engine, R"JS( + document.body.innerHTML = ''; + const editor = document.getElementById('legacy-copy-stress'); + globalThis.__legacyCopyStressEvents = 0; + globalThis.__legacyCopyStressResults = []; + editor.addEventListener('keydown', event => { + if (event.metaKey && event.key.toLowerCase() === 'c') { + event.preventDefault(); + __legacyCopyStressResults.push(document.execCommand('copy')); + } + }); + editor.addEventListener('copy', event => { + event.clipboardData.setData('text/plain', 'legacy stress'); + event.preventDefault(); + ++__legacyCopyStressEvents; + }); + editor.focus(); + )JS", "native-legacy-clipboard-stress-setup.js"); + + constexpr unsigned operation_count = 10000U; + constexpr unsigned batch_size = 16U; + constexpr auto modifier = WEBSCENE_INPUT_MODIFIER_META; + uint64_t sequence = 20000U; + const auto started = std::chrono::steady_clock::now(); + for (unsigned offset = 0; offset < operation_count; offset += batch_size) { + for (unsigned index = 0; index < batch_size; ++index) { + keyboard_input( + engine, WEBSCENE_INPUT_KEY_DOWN, 'C', sequence++, modifier); + } + webscene_engine_metrics metrics{}; + webscene_engine_get_metrics(engine, &metrics); + wait_for_consumed_inputs( + engine, metrics.enqueued_inputs, + "legacy clipboard stress input was not consumed"); + for (unsigned index = 0; index < batch_size; ++index) { + const auto request = take_typed_host_request(engine); + require( + request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags + == WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) + == "legacy stress", + "legacy clipboard stress request changed"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 0U, "text/plain", + nullptr, 0U, nullptr) != 0, + "legacy clipboard stress completion was rejected"); + } + const auto expected = offset + batch_size; + require(evaluate( + engine, + "__legacyCopyStressEvents === " + std::to_string(expected) + + " && __legacyCopyStressResults.length === " + + std::to_string(expected) + + " && __legacyCopyStressResults.every(Boolean)", + "native-legacy-clipboard-stress-barrier.js") == "true", + "legacy clipboard stress did not drain completions in order"); + } + require(!take_typed_host_request(engine), + "legacy clipboard stress retained a host request"); + const auto elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - started).count(); + require(elapsed < 15.0, + "10,000 legacy clipboard completions exceeded fifteen seconds"); + std::cout << "Legacy clipboard completion gate: operations=" + << operation_count << " pendingHighWater=" << batch_size + << " elapsed=" << elapsed << "s\n"; + // Reproduce the packaged close boundary: the worker destroys all resolved + // promise/microtask state immediately after the last public-ABI barrier. + webscene_engine_destroy(engine); +} + void test_clipboard_maximum_payload_gate(webscene_engine* engine) { constexpr size_t maximum_bytes = 16U * 1024U * 1024U; 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 a596c7737..7ab017a87 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -832,6 +832,7 @@ int main() test_clipboard_write_text_host_handoff(engine); test_clipboard_read_host_completion(engine); test_native_clipboard_shortcut_events(engine); + test_native_legacy_clipboard_completion_stress(engine); test_clipboard_maximum_payload_gate(engine); test_clipboard_small_round_trip_performance(engine); test_fullscreen_host_completion(engine); diff --git a/scripts/build-native-engine-runtime.sh b/scripts/build-native-engine-runtime.sh index f4b2b2c33..abe1e5b2f 100755 --- a/scripts/build-native-engine-runtime.sh +++ b/scripts/build-native-engine-runtime.sh @@ -382,6 +382,12 @@ elif [[ "$expected_kernel" == Linux ]]; then -DCMAKE_SHARED_LINKER_FLAGS=-fuse-ld=lld ) fi +if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == Release ]]; then + # Keep line tables only until dsymutil has emitted the exact shipped + # binary's external symbols. strip removes them from the runtime before + # packaging, so diagnostics do not increase the installed footprint. + cmake_args+=("-DCMAKE_CXX_FLAGS_RELEASE=-O3 -DNDEBUG -gline-tables-only") +fi cmake "${cmake_args[@]}" cmake --build "$build_dir" --config "$cmake_build_type" --parallel cmake -E copy_if_different "$icu_data" "$build_dir/icudtl.dat" @@ -398,7 +404,7 @@ if [[ ! -f "$native_path" ]]; then echo "Native engine build did not produce '$native_path'." >&2 exit 1 fi -if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == RelWithDebInfo ]]; then +if [[ "$expected_kernel" == Darwin ]]; then native_dsym_path="$native_path.dSYM" cmake -E remove_directory "$native_dsym_path" dsymutil "$native_path" -o "$native_dsym_path" @@ -406,6 +412,9 @@ if [[ "$expected_kernel" == Darwin && "$cmake_build_type" == RelWithDebInfo ]]; echo "Native engine build did not produce '$native_dsym_path'." >&2 exit 1 fi + if [[ "$cmake_build_type" == Release ]]; then + strip -S "$native_path" + fi fi snapshot_path="$build_dir/webscene_bootstrap_snapshot.bin" snapshot_metadata_path="$build_dir/webscene_bootstrap_snapshot.meta" diff --git a/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs b/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs index 95e7b21ef..4dfde8ab2 100644 --- a/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs +++ b/tests/WebScene.Architecture.Tests/ReleaseCompatibilityGateTests.cs @@ -14,6 +14,32 @@ public void RuntimeBuildersRunTheCompleteRequiredCompatibilityProfile() AssertBuilderRunsCompleteRequiredProfile("build-native-engine-runtime.ps1"); } + [Fact] + public void MacosReleaseRuntimePublishesExactBoundedNativeSymbols() + { + var builder = File.ReadAllText(Path.Combine( + s_repositoryRoot, + "scripts", + "build-native-engine-runtime.sh")); + Assert.Contains("-gline-tables-only", builder, StringComparison.Ordinal); + Assert.Contains("dsymutil \"$native_path\"", builder, StringComparison.Ordinal); + Assert.Contains("strip -S \"$native_path\"", builder, StringComparison.Ordinal); + Assert.True( + builder.IndexOf("dsymutil \"$native_path\"", StringComparison.Ordinal) + < builder.IndexOf("strip -S \"$native_path\"", StringComparison.Ordinal), + "The exact runtime must be symbolized before its package copy is stripped."); + + var workflow = File.ReadAllText(Path.Combine( + s_repositoryRoot, + ".github", + "workflows", + "native-runtime-packages.yml")); + Assert.Contains("name: Upload exact macOS native symbols", workflow, StringComparison.Ordinal); + Assert.Contains("path: artifacts/native-engine-runtime-build/**/*.dSYM/**", workflow, StringComparison.Ordinal); + Assert.Contains("compression-level: 9", workflow, StringComparison.Ordinal); + Assert.Contains("retention-days: 3", workflow, StringComparison.Ordinal); + } + [Fact] public void RequiredProfileContainsTheEstablishedReleaseDenominator() { From 49c11d04fc7ce2080dda5eee516c3765815130b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 20:27:47 +0200 Subject: [PATCH 34/35] Release pending host handles before V8 teardown --- .../native/webscene_v8_runtime_lifecycle.inc | 6 +++ .../native_v8_runtime_browser_dom_tests.inc | 43 +++++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + 3 files changed, 50 insertions(+) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc index 35cfb1741..64857c030 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_lifecycle.inc @@ -146,6 +146,12 @@ pending_programmatic_scroll_events.clear(); pending_interop_promises.clear(); pending_callback_promises.clear(); + // Native host and file completions can still be pending when a window + // closes. Their persistent context/resolver handles must be released + // while the isolate is alive; member destruction happens after this + // body and therefore after isolate disposal below. + host_promise_targets.clear(); + file_targets.clear(); pending_fetches.clear(); interop_handles.clear(); pending_promise_rejections.clear(); 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 6f19c4d79..1aeded1cb 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 @@ -2956,6 +2956,49 @@ void test_native_legacy_clipboard_completion_stress(webscene_engine*) webscene_engine_destroy(engine); } +void test_native_pending_legacy_clipboard_shutdown(webscene_engine*) +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, + "pending legacy clipboard shutdown engine creation failed"); + execute_and_wait(engine, R"JS( + document.body.innerHTML = ''; + const editor = document.getElementById('pending-copy'); + editor.addEventListener('keydown', event => { + if (event.metaKey && event.key.toLowerCase() === 'c') { + event.preventDefault(); + document.execCommand('copy'); + } + }); + editor.addEventListener('copy', event => { + event.clipboardData.setData('text/plain', 'pending close'); + event.preventDefault(); + }); + editor.focus(); + )JS", "native-pending-legacy-clipboard-shutdown-setup.js"); + + keyboard_input( + engine, WEBSCENE_INPUT_KEY_DOWN, 'C', 31001U, + WEBSCENE_INPUT_MODIFIER_META); + webscene_engine_metrics metrics{}; + webscene_engine_get_metrics(engine, &metrics); + wait_for_consumed_inputs( + engine, metrics.enqueued_inputs, + "pending legacy clipboard shutdown input was not consumed"); + const auto request = take_typed_host_request(engine); + require( + request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.content_type == "text/plain" + && std::string(request.bytes.begin(), request.bytes.end()) + == "pending close", + "pending legacy clipboard shutdown did not retain its host promise"); + + // A native window may close before the operating-system host completes a + // request. Destruction must release the pending persistent resolver while + // its V8 isolate is still alive. + webscene_engine_destroy(engine); +} + void test_clipboard_maximum_payload_gate(webscene_engine* engine) { constexpr size_t maximum_bytes = 16U * 1024U * 1024U; 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 7ab017a87..ac29d87ca 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -833,6 +833,7 @@ int main() test_clipboard_read_host_completion(engine); test_native_clipboard_shortcut_events(engine); test_native_legacy_clipboard_completion_stress(engine); + test_native_pending_legacy_clipboard_shutdown(engine); test_clipboard_maximum_payload_gate(engine); test_clipboard_small_round_trip_performance(engine); test_fullscreen_host_completion(engine); From 241b951c0db08e12c50d7b64f5b768cfa8385be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Tue, 15 Sep 2026 20:59:15 +0200 Subject: [PATCH 35/35] Expose clipboard string items to paste consumers --- .../native/webscene_v8_runtime.cpp | 24 +++++++++++++++++- .../native_v8_runtime_browser_dom_tests.inc | 25 ++++++++++++++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 40cd2a486..cb145f412 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -4175,10 +4175,32 @@ struct v8_dom_runtime::implementation final { const createClipboardData = () => { const values = Object.create(null); const normalize = type => String(type).toLowerCase(); + const createStringItem = type => Object.freeze({ + kind: 'string', + type, + getAsFile() { return null; }, + getAsString(callback) { + if (typeof callback !== 'function') return; + const value = values[type] || ''; + Promise.resolve().then(() => callback(value)); + } + }); + const items = Object.freeze({ + get length() { return Object.keys(values).length; }, + item(index) { + const type = Object.keys(values)[Number(index)]; + return type === undefined ? null : createStringItem(type); + }, + *[Symbol.iterator]() { + for (const type of Object.keys(values)) { + yield createStringItem(type); + } + } + }); return Object.freeze({ get types() { return Object.keys(values); }, files: Object.freeze([]), - items: Object.freeze([]), + items, getData(type) { return values[normalize(type)] || ''; }, setData(type, value) { values[normalize(type)] = String(value); 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 1aeded1cb..9f655ae6f 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 @@ -2748,6 +2748,7 @@ void test_native_clipboard_shortcut_events(webscene_engine*) globalThis.__clipboardShortcutEvents = []; globalThis.__clipboardShortcutKeys = []; globalThis.__clipboardExecCommandResults = []; + globalThis.__clipboardPasteItems = 'pending'; editor.addEventListener('keydown', event => { __clipboardShortcutKeys.push([ event.key, event.metaKey, event.ctrlKey, event.defaultPrevented]); @@ -2776,6 +2777,20 @@ void test_native_clipboard_shortcut_events(webscene_engine*) event.preventDefault(); __clipboardShortcutEvents.push([ 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 => { + __clipboardPasteItems = { + values, + length: event.clipboardData.items.length, + first: event.clipboardData.items.item(0)?.type, + missing: event.clipboardData.items.item(1) + }; + }); + synchronous = false; }); editor.focus(); )JS", "native-clipboard-shortcuts-setup.js"); @@ -2851,7 +2866,8 @@ void test_native_clipboard_shortcut_events(webscene_engine*) for (auto attempt = 0; attempt < 250; ++attempt) { if (evaluate(engine, "__clipboardShortcutEvents.length === 3" - " && __clipboardExecCommandResults.length === 3", + " && __clipboardExecCommandResults.length === 3" + " && __clipboardPasteItems !== 'pending'", "native-clipboard-shortcut-completion.js") == "true") { break; } @@ -2864,6 +2880,13 @@ void test_native_clipboard_shortcut_events(webscene_engine*) == R"JSON([["copy",true,true,"text/plain,text/html"],["cut"],["paste","shortcut paste"]])JSON", "native clipboard shortcuts did not dispatch browser-compatible events: " + shortcut_events); + const auto paste_items = evaluate(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", + "native paste did not expose a Code OSS-compatible DataTransferItemList: " + + paste_items); const auto exec_command_results = evaluate(engine, "__clipboardExecCommandResults", "native-clipboard-exec-command-results.js");