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/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 262b55c8b..be4d9f8bb 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -442,6 +442,10 @@ 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 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}; @@ -1291,6 +1295,58 @@ 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; +} + +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 +1472,18 @@ 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_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 d38a594d1..c116f7371 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -54,9 +54,15 @@ _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_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 _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..ad00a7830 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1213,6 +1213,14 @@ 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); +/* 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. @@ -1366,10 +1374,61 @@ 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, 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 324f3d18d..76caea007 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -106,6 +106,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; @@ -135,6 +150,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 {}; } @@ -220,6 +243,21 @@ 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_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) { 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..3d2b54d8a 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,23 @@ + 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 (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(); @@ -904,6 +921,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 +1295,8 @@ || 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) + || 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 84a552a28..69a717d31 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -723,6 +723,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)); @@ -958,6 +961,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( @@ -1064,6 +1073,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)); @@ -3240,6 +3252,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"), @@ -3526,6 +3546,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); @@ -4215,6 +4239,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 { @@ -4244,6 +4274,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', @@ -4255,14 +4309,90 @@ 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); - if (!__webSceneWriteClipboard(type, blob)) { - throw new DOMException('The host rejected the clipboard write', 'NotAllowedError'); - } + await __webSceneWriteClipboard(type, blob, index === 0); } } }; + 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, + 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 }); @@ -4286,6 +4416,37 @@ 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; + } + + 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; @@ -4412,32 +4573,24 @@ 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; - 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) @@ -4464,6 +4617,48 @@ struct v8_dom_runtime::implementation final { info.GetReturnValue().Set(proxy); } + bool queue_top_level_window_action( + v8::Local local_context, + uint32_t kind) + { + if (local_context != context.Get(isolate)) return true; + 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) + { + 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, WEBSCENE_HOST_REQUEST_WINDOW_CLOSE_V1)) { + 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; @@ -4720,6 +4915,18 @@ 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(); +} + +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(); @@ -6301,4 +6508,23 @@ 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(); +} +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 7168981f9..0507c3110 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -44,6 +44,27 @@ 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; +}; +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; @@ -294,7 +315,10 @@ 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); + std::unique_ptr take_typed_host_request(); + bool discard_host_request(); bool try_take_console_message(std::string& message); bool inspector_available() const noexcept; uint64_t connect_inspector( @@ -313,6 +337,8 @@ 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 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 779400d95..bb4ce5f40 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 @@ -343,13 +343,21 @@ 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; + 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"))); } } @@ -1417,6 +1425,91 @@ 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 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(); @@ -1910,59 +2003,139 @@ 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(); + 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 || info.Length() < 2 || !info[1]->IsObject()) { - info.GetReturnValue().Set(v8::False(info.GetIsolate())); + reject("Clipboard writes require an active 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()); - 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(); + const auto request_id = ++self->next_host_request_id; + 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()) + ? static_cast(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( 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(); - 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_typed_host_request(std::move(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; + } + 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]); + 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 = 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_typed_host_request(std::move(request))) { + self->host_promise_targets.erase(request_id); + reject("The host request queue is full", "QuotaExceededError"); } - info.GetReturnValue().Set(v8::Boolean::New( - info.GetIsolate(), self->enqueue_host_request(local_context, request))); } 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 f01ad5001..e44c0fd89 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 @@ -1308,6 +1308,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_document.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_document.inc index c89d68569..f714852b3 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 de244e121..ba096a2c7 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 @@ -4243,10 +4243,97 @@ } 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 = 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_typed_host_request(std::move(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(); @@ -4278,12 +4365,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); } @@ -4304,6 +4422,19 @@ } } + 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, WEBSCENE_HOST_REQUEST_WINDOW_RELOAD_V1)) { + 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..20aaaa8c0 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,124 @@ 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()) 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") { + 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); + 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 736dc7336..5038a62ba 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -119,6 +119,11 @@ reset_message_ports_for_navigation(); 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; + 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 4ba59d983..3ad26c164 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -145,6 +145,14 @@ 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{}; + 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; @@ -153,6 +161,9 @@ std::mutex console_message_mutex; 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; @@ -380,9 +391,12 @@ 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}; + 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/native/webscene_v8_runtime_tasks.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc index 75b651d10..274eda9de 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -2281,6 +2281,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 false; + } + 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 result->BooleanValue(isolate); + } + static size_t previous_utf8_boundary(const std::string& value, size_t index) { return webscene_native::forms::previous_utf8_boundary(value,index); @@ -2448,6 +2471,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()); @@ -2811,7 +2845,18 @@ 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) { + 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..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 @@ -54,6 +54,17 @@ 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" + && 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"); @@ -61,17 +72,12 @@ 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"); - 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); + 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"); - 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 36057ab83..aa38c37a7 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 @@ -2371,6 +2371,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); @@ -2403,11 +2414,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"); @@ -2444,6 +2458,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); } @@ -2835,3 +2877,463 @@ 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); } + +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"); + 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 + && 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, request.id, 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"); +} + +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"); + 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 + && 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, request.id, 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"); + 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'; + navigator.clipboard.readText().then( + () => { __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( + engine, request.id, 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"); + + 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"); + 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, + "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_native_clipboard_shortcut_events(webscene_engine*) +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "native clipboard shortcut engine creation failed"); + execute_and_wait(engine, R"JS( + document.body.innerHTML = ''; + const editor = document.getElementById('editor'); + globalThis.__clipboardShortcutEvents = []; + globalThis.__clipboardShortcutKeys = []; + globalThis.__clipboardExecCommandResults = []; + globalThis.__clipboardPasteItems = 'pending'; + 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'); + 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')]); + 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"); + + 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); + 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: " + 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"); + 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" + && 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); + 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 + && 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); + 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 + && 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"); + for (auto attempt = 0; attempt < 250; ++attempt) { + if (evaluate(engine, + "__clipboardShortcutEvents.length === 3" + " && __clipboardExecCommandResults.length === 3" + " && __clipboardPasteItems !== 'pending'", + "native-clipboard-shortcut-completion.js") == "true") { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + 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: " + + 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"); + 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"); + 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) +{ + 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"); + 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" + && 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_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) { + require(evaluate(engine, R"JS( + for (let index = 0; index < 16; ++index) { + navigator.clipboard.writeText('x').then(() => { + ++globalThis.__clipboardPerformanceCompleted; + }); + } + 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 + && 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( + 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"); + 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 + && 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, request.id, 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"); + 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, + "Document.exitFullscreen did not emit a typed host request"); + require(webscene_engine_complete_host_request_v1( + engine, request.id, 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"); + + 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, + "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 1dbcf2020..d316f6a5e 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 @@ -1497,14 +1497,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"); @@ -1516,21 +1513,62 @@ 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_typed_host_request(engine); + const auto close_request = take_typed_host_request(engine); + require( + 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(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") + == "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"); + + const auto oversized_url = std::string("https://example.com/") + + std::string(8192U, 'x'); + execute( + engine, + "try { window.open('" + 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_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); @@ -1714,6 +1752,21 @@ 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 + == 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( @@ -1726,17 +1779,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", @@ -1766,6 +1808,31 @@ 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) { + require(evaluate(engine, R"JS( + for (let index = 0; index < 1000; ++index) focus(); + 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, + "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 64ba59d01..62477c9f2 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 5db5be8a0..e4313e059 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -268,6 +268,24 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "desktop-host-capabilities") { + auto* focused_engine = webscene_engine_create(0); + require(focused_engine != nullptr, + "desktop host capability engine creation failed"); + test_document_direction_and_visibility_are_native_properties(); + test_pointer_cursor_and_external_anchor_host_handoff(focused_engine); + test_table_cell_click_copies_text_to_host(); + test_youtube_embed_fallback(); + test_typed_window_host_request_performance(focused_engine); + test_clipboard_write_text_host_handoff(focused_engine); + test_clipboard_read_host_completion(focused_engine); + test_native_clipboard_shortcut_events(focused_engine); + test_clipboard_maximum_payload_gate(focused_engine); + test_clipboard_small_round_trip_performance(focused_engine); + test_fullscreen_host_completion(focused_engine); + webscene_engine_destroy(focused_engine); + return 0; + } if (selected == "media-query-list") { auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, "focused engine creation failed"); @@ -814,6 +832,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); { @@ -903,6 +922,12 @@ int main() test_scrollspy_product_neutral_primitives(engine); test_native_performance_timeline_identity(engine); test_native_mutable_stylesheet_cssom(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); 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); 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"); 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. diff --git a/tooling/webscene/tests/native-binary-interop.test.mjs b/tooling/webscene/tests/native-binary-interop.test.mjs index 727b88967..9ddcce682 100644 --- a/tooling/webscene/tests/native-binary-interop.test.mjs +++ b/tooling/webscene/tests/native-binary-interop.test.mjs @@ -52,6 +52,12 @@ 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_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' 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": [] },