From 1ae35068f7148f33c6c1fbe79630fd6cb5d5e1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wies=C5=82aw=20=C5=A0olt=C3=A9s?= Date: Sat, 19 Sep 2026 14:21:06 +0200 Subject: [PATCH] Add bounded PRIMARY selection publication (#657) --- docs/validation/code-oss-web-api-ledger.md | 2 +- .../primary-selection-publication-657.md | 75 ++++++++++ .../native/webscene_native_engine.h | 13 +- .../native/webscene_v8_runtime.cpp | 130 +++++++++++++++++- .../native/webscene_v8_runtime.h | 1 + .../webscene_v8_runtime_css_cascade.inc | 2 + .../native/webscene_v8_runtime_dom_core.inc | 3 + .../webscene_v8_runtime_dom_properties.inc | 6 + .../native/webscene_v8_runtime_navigation.inc | 6 +- .../native/webscene_v8_runtime_semantics.inc | 1 + .../native/webscene_v8_runtime_state.inc | 1 + .../native/webscene_v8_runtime_tasks.inc | 5 + .../native_v8_runtime_browser_dom_tests.inc | 122 ++++++++++++++++ .../tests/native_v8_runtime_tests.cpp | 1 + 14 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 docs/validation/primary-selection-publication-657.md diff --git a/docs/validation/code-oss-web-api-ledger.md b/docs/validation/code-oss-web-api-ledger.md index db337cb4..56f6398c 100644 --- a/docs/validation/code-oss-web-api-ledger.md +++ b/docs/validation/code-oss-web-api-ledger.md @@ -18,7 +18,7 @@ Static matches establish source reachability only. Runtime claims require the ev | `broadcast-channel` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#81 | 0 | Cross-realm ordering, clone, teardown, queue, and product evidence are incomplete. | | `cache-storage` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#266 | 0 | CacheStorage is required by webview resources and remains scheduled in the webview stack. | | `canvas-2d` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#227 | 0 | Broad engine coverage exists, but the accepted Code OSS visual scenarios have not yet supplied revision-pinned product evidence here. | -| `clipboard` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#227 | 0 | The host ABI exists; editor, terminal, webview, denial, focus, and large-payload product qualification is incomplete. | +| `clipboard` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#227, WebScene#657 | 0 | The bounded host ABI and focused input/textarea PRIMARY publication source contract exist. AppScene#275 integration plus editor, terminal, webview, denial, focus, large-payload and packaged-product qualification remain incomplete. | | `crypto` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#102 | 0 | The vetted-provider runtime surface is complete; configured unchanged-package MCP secret-input and browser connection-secret persistence/reopen evidence remains missing across the release RIDs. Stock Code OSS omits proprietary VSDA assets, and bundled Copilot HMAC executes in its Node extension host. | | `dom-core` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#227, WebScene#260 | 0 | DOM coverage is extensive but no exhaustive reachable-surface and accepted-scenario reconciliation has been promoted. | | `drag-drop` | yes | blocked | linux-x64, macos-arm64, windows-x64 | WebScene#260 | 0 | Explorer/editor drag data, cancellation, focus, native input, and teardown evidence is incomplete. | diff --git a/docs/validation/primary-selection-publication-657.md b/docs/validation/primary-selection-publication-657.md new file mode 100644 index 00000000..7ae668ae --- /dev/null +++ b/docs/validation/primary-selection-publication-657.md @@ -0,0 +1,75 @@ +# Bounded PRIMARY selected-text publication source contract + +WebScene issue #657 adds the provider half of automatic Linux PRIMARY +publication. The exact baseline is WebScene +`dabd1435c8e6415163067ad7ad5befb498a77126`; AppScene #275 is the dependent +native consumer. The provider is a product-neutral native-host signal and has +no X11, browser-process, Electron, CEF, WebView or VS Code dependency. + +## Provider boundary + +A settled nonempty selection in the currently focused, connected, writable +`input` or `textarea` emits the existing `webscene_host_request_v1` with this +exact shape: + +- kind `WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1`; +- request ID and target node ID zero; +- flags `WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 | + WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1`; +- content type `text/plain`, no URL, and valid UTF-8 selected bytes; +- a maximum payload of 64 KiB, with oversize selections refused rather than + truncated. + +The zero request ID makes the update one-way. It creates no JavaScript API, +Promise, completion binding or user-activation requirement. Ordinary Clipboard +API and shortcut reads/writes retain their nonzero request IDs, existing flags, +16-operation completion cap and CLIPBOARD meaning. + +Password, readonly, disabled, inert, disconnected and unfocused controls are +ineligible. A collapsed or empty selection, an invalid UTF-8 slice, a UTF-16 +boundary inside a surrogate pair, and an oversize slice emit nothing. These +cases do not clear a previously published or pending PRIMARY value. + +## Coalescing and lifecycle + +Selection and value mutations mark only the active text control dirty. The +runtime extracts its final selected slice after the current script, event and +microtask work reaches a stable checkpoint. Extraction checks byte offsets and +the 64 KiB cap before allocating the request payload. + +The mutex-protected typed-host FIFO recognizes only the exact zero-ID PRIMARY +publication shape. If one is already pending, the runtime replaces that lease +in place with the newest eligible payload. Otherwise it appends one only while +the aggregate 1,024-request queue has capacity. Publication therefore uses at +most one pending slot and cannot evict or consume capacity reserved by an +existing completion-bearing request. Saturation drops the update without +changing clipboard state or exposing selected bytes in diagnostics. + +Top-level navigation discards every publication still in WebScene's queue and +clears its dirty observation. A queued child-frame publication carries a +private frame-owner identity, outside the public ABI, so frame navigation or +detach can remove it before retiring that realm. Engine teardown destroys the +same bounded state. Once a host has taken a stable one-way lease, it represents +the prior selection and may remain PRIMARY across document navigation, +matching desktop selection ownership. Collapse, blur and teardown never send +a clearing write. + +## Authored contracts and remaining acceptance + +`test_primary_selection_publication` covers the exact ABI, UTF-8 text, +same-task and pending-request coalescing, retention on collapse, ineligible +controls, detach, refusal above 64 KiB and admission at the cap. Existing +clipboard contracts continue to cover ordinary Clipboard API flags, request +IDs, completion and the PRIMARY middle-paste read path. + +Unchanged Code OSS mirrors short Monaco selections into its writable hidden +textarea, so those exact selections reach this provider without a source +patch. Monaco can elide large or multipage screen-reader textarea content and +does not represent all multicursor model selections there. Exact large, +multipage and multicursor Monaco publication, readonly controls, +contenteditable and document `Selection` remain outside #657. + +Only `git diff --check` is run for this fast implementation slice. The authored +native contract, compiler/build, browser-oracle comparison, 10,000-change CPU +and allocation measurements, AppScene #275 integration, physical X11 clients, +packaged unchanged Code OSS, VM and computer-use gates remain unexecuted. diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index cefca1b7..da451488 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -2689,8 +2689,9 @@ WEBSCENE_API void webscene_file_grant_release_request_release_v2( const webscene_file_grant_release_request_v2* request); /* 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. */ + * valid until release. Byte payloads are capped at 16 MiB, with one-way + * selected-text publication capped at 64 KiB. 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, @@ -2704,9 +2705,11 @@ enum { }; enum { WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 = 1U << 0U, - /* Selects the platform's primary/selection clipboard for a text/plain - * READ request. Ordinary Clipboard API and shortcut requests leave this - * clear and continue to address the normal clipboard. */ + /* Selects the platform's primary/selection clipboard. It is valid either + * alone for a completion-bearing text/plain READ request or together with + * REPLACE for the engine's zero-ID, one-way selected-text WRITE. Ordinary + * Clipboard API and shortcut requests leave this clear and continue to + * address the normal clipboard. */ WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1 = 1U << 1U, WEBSCENE_HOST_REQUEST_NAVIGATION_REPLACE_V1 = 1U << 0U, WEBSCENE_HOST_REQUEST_EXTERNAL_NEW_CONTEXT_V1 = 1U << 0U, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index 383ea014..93c7f2f3 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -6526,6 +6526,126 @@ struct v8_dom_runtime::implementation final { return true; } + bool enqueue_primary_selection_publication( + std::unique_ptr request) + { + if (!request) return false; + auto notify = false; + { + std::lock_guard lock(host_request_mutex); + const auto pending = std::find_if( + typed_host_requests.begin(), + typed_host_requests.end(), + [](const auto& candidate) { + return candidate != nullptr + && candidate->view.request_id == 0U + && candidate->view.kind + == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && candidate->view.flags + == (WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + | WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1); + }); + if (pending != typed_host_requests.end()) { + *pending = std::move(request); + return true; + } + if (host_requests.size() + typed_host_requests.size() + + reserved_host_request_count + >= maximum_host_request_count) return false; + typed_host_requests.push_back(std::move(request)); + notify = true; + } + if (notify && host_request_available) host_request_available(); + return true; + } + + void discard_primary_selection_publication_for_frame(uint32_t frame_id) + { + if (frame_id == 0U) return; + std::lock_guard lock(host_request_mutex); + std::erase_if(typed_host_requests, [&](const auto& request) { + return request != nullptr + && request->source_frame_owner_id == frame_id + && request->view.request_id == 0U + && request->view.kind + == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request->view.flags + == (WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + | WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1); + }); + } + + void mark_primary_selection_publication_dirty( + const dom_node* candidate) noexcept + { + if (candidate != nullptr && candidate == active_element) + primary_selection_publication_dirty = true; + } + + void flush_primary_selection_publication() + { + if (!primary_selection_publication_dirty) return; + primary_selection_publication_dirty = false; + constexpr size_t maximum_primary_selection_bytes = 64U * 1024U; + auto* selected = active_element; + if (!is_writable_text_control(selected) + || !is_connected(*selected) + || !selected->form_control().input_focused + || forms::input_type_is(*selected, "password") + || focus_document_owner(selected) != focused_document_owner_id) { + return; + } + ensure_form_value(*selected); + const auto& control = selected->form_control(); + const auto start = std::min(control.selection_start, control.value.size()); + const auto end = std::min(control.selection_end, control.value.size()); + if (start >= end || control.selection_start_utf16_suboffset != 0U + || control.selection_end_utf16_suboffset != 0U + || end - start > maximum_primary_selection_bytes) { + return; + } + const auto text = std::string_view(control.value).substr(start, end - start); + const auto valid_utf8 = [](std::string_view value) noexcept { + for (size_t offset = 0U; offset < value.size();) { + const auto first = static_cast(value[offset]); + size_t length{}; + uint32_t scalar{}; + if (first <= 0x7fU) { length = 1U; scalar = first; } + else if (first >= 0xc2U && first <= 0xdfU) { + length = 2U; scalar = first & 0x1fU; + } else if (first >= 0xe0U && first <= 0xefU) { + length = 3U; scalar = first & 0x0fU; + } else if (first >= 0xf0U && first <= 0xf4U) { + length = 4U; scalar = first & 0x07U; + } else return false; + if (offset + length > value.size()) return false; + for (size_t index = 1U; index < length; ++index) { + const auto continuation = + static_cast(value[offset + index]); + if ((continuation & 0xc0U) != 0x80U) return false; + scalar = (scalar << 6U) | (continuation & 0x3fU); + } + if ((length == 2U && scalar < 0x80U) + || (length == 3U && scalar < 0x800U) + || (length == 4U && scalar < 0x10000U) + || (scalar >= 0xd800U && scalar <= 0xdfffU) + || scalar > 0x10ffffU) return false; + offset += length; + } + return true; + }; + if (!valid_utf8(text)) return; + auto request = std::make_unique(); + request->view.kind = WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1; + request->view.flags = WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + | WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1; + request->source_frame_owner_id = focus_document_owner(selected); + request->content_type = "text/plain"; + request->bytes.assign(text.begin(), text.end()); + static_cast( + enqueue_primary_selection_publication(std::move(request))); + } + bool enqueue_reserved_typed_host_request( std::unique_ptr request) { @@ -7481,14 +7601,16 @@ uint64_t v8_dom_runtime::last_resize_observers_nanoseconds() const noexcept bool v8_dom_runtime::dispatch_input(const webscene_input_event& event, bool defer_cursor_update) { - return impl_->dispatch_input(event, defer_cursor_update) - && impl_->promote_pending_promise_error(); + const auto dispatched = impl_->dispatch_input(event, defer_cursor_update); + impl_->flush_primary_selection_publication(); + return dispatched && impl_->promote_pending_promise_error(); } bool v8_dom_runtime::dispatch_drag(native_drag_event& event) { - return impl_->dispatch_drag(event) - && impl_->promote_pending_promise_error(); + const auto dispatched = impl_->dispatch_drag(event); + impl_->flush_primary_selection_publication(); + return dispatched && impl_->promote_pending_promise_error(); } void v8_dom_runtime::refresh_pointer_cursor_after_layout() diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 4475fab5..67e52acc 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -55,6 +55,7 @@ struct native_host_completion { }; struct native_host_request { webscene_host_request_v1 view{}; + uint32_t source_frame_owner_id{}; std::string content_type; std::vector bytes; std::string url; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc index 2281ad3e..4b66aa11 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_css_cascade.inc @@ -2409,6 +2409,8 @@ std::optional user_validity_interacted = std::nullopt, std::optional value_changed_by_user = std::nullopt) { + if (previous_value != current_value) + mark_primary_selection_publication_dirty(&subject); document.mark_dirty(); const auto empty_state_changed=previous_value.empty()!=current_value.empty(); const auto previous_range=forms::range_state(subject,previous_value); 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 b6737933..c6467299 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 @@ -2825,6 +2825,7 @@ || (target != nullptr && subtree_contains(frame, target)); }); } + discard_primary_selection_publication_for_frame(frame.id); { std::lock_guard lock(document_generation_mutex); ++frame_navigation_generations.try_emplace(frame.id, 0U) @@ -2894,6 +2895,7 @@ void retire_iframe_navigation(uint32_t frame_id) { + discard_primary_selection_publication_for_frame(frame_id); { std::lock_guard lock(document_generation_mutex); frame_navigation_generations.erase(frame_id); @@ -5404,6 +5406,7 @@ control.selection_end_utf16_suboffset = end_offset.utf16_suboffset; control.selection_direction = text_selection_direction::none; control.selection_explicitly_set = true; + self->mark_primary_selection_publication_dirty(node); if(self->is_connected(*node)) { self->recascade_form_value_selector_transition( *node,previous_value,control.value,std::nullopt,false); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc index 9d9af7f1..cdd6c0bd 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_dom_properties.inc @@ -3883,6 +3883,7 @@ v8::Local value, const v8::PropertyCallbackInfo& info) { + auto* self = current(info.GetIsolate()); auto* node = unwrap_node(info.Holder()); if (node == nullptr) return; const auto index = value->Int32Value(info.GetIsolate()->GetCurrentContext()).FromMaybe(0); @@ -3908,6 +3909,7 @@ node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } + self->mark_primary_selection_publication_dirty(node); } static void get_selection_end( @@ -3930,6 +3932,7 @@ v8::Local value, const v8::PropertyCallbackInfo& info) { + auto* self = current(info.GetIsolate()); auto* node = unwrap_node(info.Holder()); if (node == nullptr) return; const auto index = value->Int32Value(info.GetIsolate()->GetCurrentContext()).FromMaybe(0); @@ -3955,6 +3958,7 @@ node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } + self->mark_primary_selection_publication_dirty(node); } static void get_selection_direction( @@ -3979,6 +3983,7 @@ v8::Local value, const v8::PropertyCallbackInfo& info) { + auto* self = current(info.GetIsolate()); auto* node = unwrap_node(info.Holder()); if (node == nullptr) return; const auto direction = to_utf8(info.GetIsolate(), value); @@ -3995,6 +4000,7 @@ node->mutable_form_control().selection_end_utf16_suboffset})) { node->mutable_form_control().selection_direction = text_selection_direction::none; } + self->mark_primary_selection_publication_dirty(node); } static void get_boolean_attribute( 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 cbe8ed84..b668affd 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -143,7 +143,11 @@ 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(); } + { + std::lock_guard lock(host_request_mutex); + typed_host_requests.clear(); + primary_selection_publication_dirty = false; + } { std::lock_guard lock(host_request_mutex); download_requests.clear(); } { std::lock_guard lock(host_request_mutex); outbound_drag_requests.clear(); } object_url_file_data.clear(); diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc index 4eb7c53e..64a7573b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_semantics.inc @@ -1110,6 +1110,7 @@ node->mutable_form_control().selection_direction = text_selection_direction::none; node->mutable_form_control().selection_explicitly_set = true; + mark_primary_selection_publication_dirty(node); document.mark_dirty(); break; } 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 f24a4b5b..18174840 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -285,6 +285,7 @@ std::mutex host_request_mutex; std::deque host_requests; std::deque> typed_host_requests; + bool primary_selection_publication_dirty{}; std::deque> download_requests; std::deque> outbound_drag_requests; 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 a27b9676..6d0c6af7 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_tasks.inc @@ -37,6 +37,7 @@ if (work_metrics_enabled.load(std::memory_order_relaxed)) { ++work.microtask_checkpoints; } + if (!dispatching_user_input) flush_primary_selection_publication(); } bool execute_timer_task( @@ -2658,6 +2659,7 @@ text_selection_direction::none; next->mutable_form_control().selection_explicitly_set = true; } + mark_primary_selection_publication_dirty(next); } // Focus styles are observable synchronously from focus listeners. // Recompute them before dispatching focus/focusin, not only after the @@ -2857,6 +2859,7 @@ caret.utf16_suboffset; target->mutable_form_control().selection_direction = text_selection_direction::none; target->mutable_form_control().selection_explicitly_set = true; + mark_primary_selection_publication_dirty(target); target->mutable_form_control().caret_visible = true; caret_blink_epoch_ms = last_animation_frame_timestamp_ms; recascade_form_value_selector_transition( @@ -3012,6 +3015,7 @@ target->mutable_form_control().selection_direction = text_selection_direction::none; target->mutable_form_control().selection_explicitly_set = true; + mark_primary_selection_publication_dirty(target); target->mutable_form_control().caret_visible = true; caret_blink_epoch_ms = last_animation_frame_timestamp_ms; document.mark_dirty(); @@ -3119,6 +3123,7 @@ if (key_code == 37 || key_code == 38 || key_code == 39 || key_code == 40 || key_code == 36 || key_code == 35) { target->mutable_form_control().selection_explicitly_set = true; + mark_primary_selection_publication_dirty(target); } target->mutable_form_control().caret_visible = true; caret_blink_epoch_ms = last_animation_frame_timestamp_ms; 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 b3a8b99b..8cd10a56 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 @@ -6281,6 +6281,128 @@ void test_primary_selection_middle_paste(webscene_engine*) webscene_engine_destroy(engine); } +void test_primary_selection_publication(webscene_engine*) +{ + auto* engine = webscene_engine_create(0); + require(engine != nullptr, "PRIMARY publication engine creation failed"); + execute_and_wait(engine, R"JS( + document.body.innerHTML = ` + + `; + const source = document.getElementById('primary-source'); + source.value = 'zero alpha βeta omega'; + source.focus(); + source.setSelectionRange(5, 15); + )JS", "native-primary-publication-setup.js"); + + auto request = take_typed_host_request(engine); + require(request.id == 0U + && request.kind == WEBSCENE_HOST_REQUEST_CLIPBOARD_WRITE_V1 + && request.flags + == (WEBSCENE_HOST_REQUEST_CLIPBOARD_REPLACE_V1 + | WEBSCENE_HOST_REQUEST_CLIPBOARD_PRIMARY_V1) + && request.target_node_id == 0U + && request.content_type == "text/plain" + && request.url.empty() + && std::string(request.bytes.begin(), request.bytes.end()) + == "alpha βeta", + "focused selection did not emit the exact one-way PRIMARY write"); + require(!take_typed_host_request(engine), + "one settled selection emitted more than one PRIMARY write"); + + execute_and_wait(engine, R"JS( + const source = document.getElementById('primary-source'); + for (let index = 0; index < 10000; ++index) { + const start = index % 4; + source.setSelectionRange(start, start + 4); + } + source.setSelectionRange(16, 21); + )JS", "native-primary-publication-in-task-coalescing.js"); + execute_and_wait(engine, R"JS( + document.getElementById('primary-source').setSelectionRange(11, 15); + )JS", "native-primary-publication-pending-replacement.js"); + request = take_typed_host_request(engine); + require(std::string(request.bytes.begin(), request.bytes.end()) == "βeta" + && !take_typed_host_request(engine), + "rapid selection changes did not keep one newest pending publication"); + + execute_and_wait(engine, R"JS( + const source = document.getElementById('primary-source'); + source.setSelectionRange(5, 10); + )JS", "native-primary-publication-retained-setup.js"); + execute_and_wait(engine, R"JS( + const source = document.getElementById('primary-source'); + source.setSelectionRange(10, 10); + )JS", "native-primary-publication-collapse.js"); + request = take_typed_host_request(engine); + require(std::string(request.bytes.begin(), request.bytes.end()) == "alpha" + && !take_typed_host_request(engine), + "collapsed selection cleared or replaced the pending PRIMARY value"); + + execute_and_wait(engine, R"JS( + const unfocused = document.getElementById('unfocused-source'); + unfocused.value = 'must not publish'; + unfocused.setSelectionRange(0, unfocused.value.length); + )JS", "native-primary-publication-unfocused.js"); + require(!take_typed_host_request(engine), + "unfocused text selection reached the PRIMARY host boundary"); + + execute_and_wait(engine, R"JS( + const source = document.getElementById('primary-source'); + source.readOnly = true; + source.setSelectionRange(0, 4); + )JS", "native-primary-publication-readonly.js"); + require(!take_typed_host_request(engine), + "readonly text selection reached the PRIMARY host boundary"); + + execute_and_wait(engine, R"JS( + const password = document.createElement('input'); + password.type = 'password'; + password.value = 'secret'; + document.body.appendChild(password); + password.focus(); + password.setSelectionRange(0, password.value.length); + )JS", "native-primary-publication-password.js"); + require(!take_typed_host_request(engine), + "password selection reached the PRIMARY host boundary"); + + execute_and_wait(engine, R"JS( + const detached = document.createElement('textarea'); + detached.value = 'detached'; + document.body.appendChild(detached); + detached.focus(); + detached.setSelectionRange(0, detached.value.length); + detached.remove(); + )JS", "native-primary-publication-detached.js"); + require(!take_typed_host_request(engine), + "detached selection reached the PRIMARY host boundary"); + + execute_and_wait(engine, R"JS( + const oversized = document.createElement('textarea'); + oversized.value = 'x'.repeat(65537); + document.body.appendChild(oversized); + oversized.focus(); + oversized.setSelectionRange(0, oversized.value.length); + )JS", "native-primary-publication-oversize.js"); + require(!take_typed_host_request(engine), + "oversize selection was truncated or reached the PRIMARY host boundary"); + + execute_and_wait(engine, R"JS( + const maximum = document.createElement('textarea'); + maximum.value = 'y'.repeat(65536); + document.body.appendChild(maximum); + maximum.focus(); + maximum.setSelectionRange(0, maximum.value.length); + )JS", "native-primary-publication-maximum.js"); + request = take_typed_host_request(engine); + require(request.bytes.size() == 65536U + && request.bytes.front() == 'y' && request.bytes.back() == 'y' + && !take_typed_host_request(engine), + "64 KiB PRIMARY selection cap changed"); + + webscene_engine_destroy(engine); +} + void test_native_image_clipboard_maximum_payload(webscene_engine*) { auto* engine = webscene_engine_create(0); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index f39acdfd..c264f6d1 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -1530,6 +1530,7 @@ int main() test_clipboard_read_host_completion(engine); test_native_clipboard_shortcut_events(engine); test_primary_selection_middle_paste(engine); + test_primary_selection_publication(engine); test_native_image_clipboard_maximum_payload(engine); test_native_legacy_clipboard_completion_stress(engine); test_native_pending_legacy_clipboard_shutdown(engine);