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 81527bc51..095a376ca 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: 65954cdff2f92cf111e965bd3fcf2617570cd74a3ec7a728e2dee2e4a20c27fb +// Exposure manifest SHA-256: d1bbef499809baf809fcf8c449dffe107d5e0a7da99a144d8225e88c2ac2f4d9 // Inputs: @webref/idl 3.82.1, webidl2 24.5.0. enum class generated_dom_interface : uint8_t { @@ -1800,6 +1800,29 @@ static void generated_HTMLElement_href_set( set_element_href(info.Data().As(), value, info); } +static void generated_HTMLElement_accept_get( + v8::Local, + const v8::PropertyCallbackInfo& info) +{ + if (!generated_dom_receiver_is(info.GetIsolate(), info.Holder(), generated_dom_interface::HTMLElement)) { + throw_generated_illegal_invocation(info.GetIsolate()); + return; + } + get_reflected_string_attribute(info.Data().As(), info); +} + +static void generated_HTMLElement_accept_set( + v8::Local, + v8::Local value, + const v8::PropertyCallbackInfo& info) +{ + if (!generated_dom_receiver_is(info.GetIsolate(), info.Holder(), generated_dom_interface::HTMLElement)) { + throw_generated_illegal_invocation(info.GetIsolate()); + return; + } + set_reflected_string_attribute(info.Data().As(), value, info); +} + static void generated_HTMLElement_download_get( v8::Local, const v8::PropertyCallbackInfo& info) @@ -4004,6 +4027,21 @@ void install_generated_dom_templates(v8::Local) generated_HTMLElement_signature, 0), v8::FunctionTemplate::New(isolate, generated_dom_prototype_attribute_set, generated_HTMLElement_href_symbol, generated_HTMLElement_signature, 1), v8::PropertyAttribute::None); + auto generated_HTMLElement_accept_symbol = v8::Symbol::New( + isolate, js_string(isolate, "WebScene.HTMLElement.accept")); + generated_HTMLElement_template->InstanceTemplate()->SetNativeDataProperty( + generated_HTMLElement_accept_symbol, + generated_HTMLElement_accept_get, + generated_HTMLElement_accept_set, + js_string(isolate, "accept"), + v8::PropertyAttribute::None); + generated_HTMLElement_template->PrototypeTemplate()->SetAccessorProperty( + js_string(isolate, "accept"), + v8::FunctionTemplate::New( + isolate, generated_dom_prototype_attribute_get, generated_HTMLElement_accept_symbol, + generated_HTMLElement_signature, 0), + v8::FunctionTemplate::New(isolate, generated_dom_prototype_attribute_set, generated_HTMLElement_accept_symbol, generated_HTMLElement_signature, 1), + v8::PropertyAttribute::None); auto generated_HTMLElement_download_symbol = v8::Symbol::New( isolate, js_string(isolate, "WebScene.HTMLElement.download")); generated_HTMLElement_template->InstanceTemplate()->SetNativeDataProperty( diff --git a/experiments/WebScene.NativeEngine.Probe/native/graphics/graphics_service.h b/experiments/WebScene.NativeEngine.Probe/native/graphics/graphics_service.h index 25457fbb8..436ce02fe 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/graphics/graphics_service.h +++ b/experiments/WebScene.NativeEngine.Probe/native/graphics/graphics_service.h @@ -305,7 +305,7 @@ class graphics_service { if (!commands_) commands_=std::make_shared(capacity,upload_limit,wake_); return commands_; } - std::shared_ptr release_endpoint(size_t capacity=256) { + std::shared_ptr release_endpoint(size_t capacity=4096) { check_open(); if (!releases_) releases_=std::make_shared(capacity,command_endpoint(),wake_); return releases_; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.cpp index 262359a08..8477ebba6 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.cpp @@ -766,7 +766,33 @@ void serialize_svg_subtree(const dom_node& node, std::string& output, bool root) bool has_color = false; const auto css_fill = node.style.textual().svg_fill; const auto css_stroke = node.style.textual().svg_stroke; + // Stylesheets outside the detached SVG are unavailable to the host SVG + // renderer. Project cascaded typography as presentation attributes, keeping + // local attributes and SVG inheritance when no CSS declaration overrides them. + std::vector> typography; + const auto collect_typography = [&](const dom_node& source, bool inherited) { + const auto add = [&](const char* name, std::string value) { + if (value.empty()) return; + if (inherited && node.attributes.contains(name)) return; + if (std::any_of(typography.begin(), typography.end(), + [&](const auto& entry) { return entry.first == name; })) return; + typography.emplace_back(name, std::move(value)); + }; + if (source.style.font_size >= 0) add("font-size", std::to_string(source.style.font_size)); + if (source.style.font_weight > 0) add("font-weight", std::to_string(source.style.font_weight)); + add("font-family", source.style.textual().font_family); + if (source.style.letter_spacing_specified) add("letter-spacing", std::to_string(source.style.letter_spacing)); + if (source.style.word_spacing_specified) add("word-spacing", std::to_string(source.style.word_spacing)); + add("text-anchor", source.style.textual().svg_text_anchor); + }; + collect_typography(node, false); + if (root) { + for (auto* ancestor = node.parent; ancestor; ancestor = ancestor->parent) + collect_typography(*ancestor, true); + } for (const auto& [name, value] : node.attributes) { + if (std::any_of(typography.begin(), typography.end(), + [&](const auto& entry) { return entry.first == name; })) continue; if (name == "xmlns") has_xmlns = true; else if (name == "id") has_id = true; else if (name == "class") has_class = true; @@ -791,6 +817,11 @@ void serialize_svg_subtree(const dom_node& node, std::string& output, bool root) } output.push_back('"'); } + for (const auto& [name, value] : typography) { + output += " " + name + "=\""; + append_xml_escaped(value, output, true); + output.push_back('"'); + } if (root && !has_xmlns) output += " xmlns=\"http://www.w3.org/2000/svg\""; if (!has_id && !node.id_attribute.empty()) { output += " id=\""; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h index 80fd7cb80..054675a4a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom.h @@ -584,6 +584,7 @@ struct node_style final { // the live inherited foreground when the scene is serialized. std::string svg_fill; std::string svg_stroke; + std::string svg_text_anchor; std::string list_style_position; std::string list_style_type; // Vertical corner radii are cold: circular radii use the four hot diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc index 77dd13b1a..1425bf759 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_metrics.inc @@ -238,6 +238,7 @@ native_document::allocation_metrics native_document::read_allocation_metrics() c + textual->cursor.capacity() + 1U + textual->svg_fill.capacity() + 1U + textual->svg_stroke.capacity() + 1U + + textual->svg_text_anchor.capacity() + 1U + textual->list_style_position.capacity() + 1U + textual->list_style_type.capacity() + 1U; } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc index 2f9da7945..5574ff996 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_dom_scene.inc @@ -601,7 +601,7 @@ void native_document::append_scene( [](unsigned char character) { return static_cast(std::tolower(character)); }); - if (pseudo_background_image_lower.starts_with("linear-gradient(") + if ((pseudo_background_image_lower.starts_with("linear-gradient(") || pseudo_background_image_lower.starts_with("radial-gradient(")) && width > 0 && height > 0) { auto gradient_resource = std::string{"webscene-bg-v2\t"}; gradient_resource += pseudo.background_image.image_value; @@ -934,7 +934,7 @@ void native_document::append_scene( return static_cast(std::tolower(character)); }); if (paint_self - && background_image_value_lower.starts_with("linear-gradient(") + && (background_image_value_lower.starts_with("linear-gradient(") || background_image_value_lower.starts_with("radial-gradient(")) && node.layout.width > 0 && node.layout.height > 0) { auto gradient_resource = std::string{"webscene-bg-v2\t"}; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp index 2917bdd0b..4d63255b1 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.cpp @@ -282,6 +282,7 @@ struct canvas_checkpoint_request { struct url_request final { std::string url; std::vector document_start_scripts; + std::optional initial_viewport; }; #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) @@ -430,6 +431,9 @@ struct webscene_engine final { webscene_native::runtime_diagnostics diagnostics_; #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) std::unique_ptr runtime_; + std::atomic file_service_enabled_{false}; + std::mutex file_runtime_mutex_; + bool file_runtime_ready_{false}; #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) std::atomic inspector_runtime_{nullptr}; std::atomic inspector_state_{nullptr}; @@ -971,6 +975,13 @@ uint8_t webscene_engine_load_url_with_options( : 0U; } +uint8_t webscene_engine_load_url_with_viewport( + webscene_engine* engine, const char* url, size_t length, + const webscene_input_event* viewport) +{ + return engine && viewport && engine->load_url_with_viewport(url, length, *viewport) ? 1U : 0U; +} + uint8_t webscene_engine_enqueue(webscene_engine* engine, const webscene_input_event* event) { return engine != nullptr && event != nullptr && engine->enqueue(*event) ? 1U : 0U; @@ -1731,3 +1742,36 @@ uint8_t webscene_engine_get_memory_metrics( #if defined(WEBSCENE_GRAPHICS_SCENE_TESTS) #include "../tests/graphics_scene_lease_tests.inc" #endif + +uint8_t webscene_engine_enable_file_service_v1(webscene_engine* engine, uint8_t enabled) { + return engine && engine->enable_file_service(enabled != 0); +} +const webscene_file_request_v1* webscene_engine_take_file_request_v1(webscene_engine* engine) { + if (!engine) return nullptr; + auto request=engine->take_file_request(); + if (!request) return nullptr; + request->bind(); + return &request.release()->view; +} +void webscene_file_request_release_v1(const webscene_file_request_v1* request) { + delete reinterpret_cast(request); +} +uint8_t webscene_engine_complete_file_request_v1(webscene_engine* engine, + uint64_t id, uint32_t status, const webscene_file_data_v1* files, + size_t count, const char* error) { + if (!engine || !id || status>2 || count>64 || (count && !files) || (status && count)) return 0; + webscene_native::native_file_completion completion; completion.id=id; completion.status=status; + completion.error=error ? error : ""; + if (completion.error.size()>4096) return 0; + size_t total=0; + for(size_t i=0;i64u*1024u*1024u-total) return 0; + total+=f.byte_count; + webscene_native::native_file_data value; value.name=f.name; value.mime=f.mime_type; + if(value.name.size()>4096 || value.mime.size()>256) return 0; + if(f.byte_count) value.bytes.assign(f.bytes,f.bytes+f.byte_count); + completion.files.push_back(std::move(value)); + } + return engine->complete_file_request(std::move(completion)); +} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports index ccca42fa0..2930bf3b8 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.exports @@ -45,6 +45,7 @@ _webscene_engine_observe_compositor_frame _webscene_engine_requires_animation_frame _webscene_engine_load_url _webscene_engine_load_url_with_options +_webscene_engine_load_url_with_viewport _webscene_engine_prewarm _webscene_engine_request_low_memory _webscene_engine_release_canvas_export @@ -87,3 +88,7 @@ _webscene_gpu_d3d11_seal_v3 _webscene_gpu_d3d11_poll_v3 _webscene_gpu_d3d11_destroy_v3 _webscene_engine_submit_canvas_checkpoint_v3 +_webscene_engine_enable_file_service_v1 +_webscene_engine_take_file_request_v1 +_webscene_file_request_release_v1 +_webscene_engine_complete_file_request_v1 diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h index 64b25327f..502fb254b 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine.h @@ -1155,6 +1155,11 @@ WEBSCENE_API uint8_t webscene_engine_load_url( webscene_engine* engine, const char* url, size_t url_length); +/* Set the initial viewport on the worker immediately before document scripts. + * This avoids publishing a canvas initialized against default dimensions. */ +WEBSCENE_API uint8_t webscene_engine_load_url_with_viewport( + webscene_engine* engine, const char* url, size_t url_length, + const webscene_input_event* viewport); WEBSCENE_API uint8_t webscene_engine_load_url_with_options( webscene_engine* engine, const char* url, @@ -1299,6 +1304,33 @@ WEBSCENE_API uint8_t webscene_engine_get_interop_pool_metrics_v3( * payload is UTF-8 JSON. A too-small/null destination reports the required * size without consuming the request; a successful full copy consumes it. */ +/* Native file service v1. Explicit opt-in permits script-triggered native + * dialogs. Requests own immutable UTF-8 metadata and bytes until release. + * Hosts must return only user-selected bytes, never script-supplied paths. + * kind: 1 Open, 2 Save; status: 0 completed, 1 cancelled, 2 failed. + * Completion copies its inputs before returning and is delivered on the JS + * worker. Maximum 64 MiB per operation, 64 files, 16 pending requests. + * The existing host_request_available callback also signals this queue. */ +typedef struct webscene_file_data_v1 { + const char* name; + const char* mime_type; + const uint8_t* bytes; + size_t byte_count; +} webscene_file_data_v1; +typedef struct webscene_file_request_v1 { + uint32_t struct_size, version; + uint64_t request_id; + uint32_t kind, multiple; + const char* accept; + webscene_file_data_v1 file; +} webscene_file_request_v1; +WEBSCENE_API uint8_t webscene_engine_enable_file_service_v1(webscene_engine* engine, uint8_t enabled); +WEBSCENE_API const webscene_file_request_v1* webscene_engine_take_file_request_v1(webscene_engine* engine); +WEBSCENE_API void webscene_file_request_release_v1(const webscene_file_request_v1* request); +WEBSCENE_API uint8_t webscene_engine_complete_file_request_v1(webscene_engine* engine, + uint64_t request_id, uint32_t status, const webscene_file_data_v1* files, + size_t file_count, const char* error_message); + WEBSCENE_API size_t webscene_engine_take_host_request( webscene_engine* engine, char* destination, diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_interop_types.inc index 8381db7ad..480828744 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 @@ -412,6 +412,7 @@ private: using script_work_request = std::variant< script_request, + webscene_native::native_file_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 d19ecac86..260bd01da 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_lifecycle.inc @@ -94,6 +94,37 @@ } #endif + bool complete_file_request(webscene_native::native_file_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; + } + void retire_file_runtime() { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + std::unique_ptr retired; + { + std::lock_guard lock(file_runtime_mutex_); + file_runtime_ready_=false; + retired=std::move(runtime_); + } + // Destruction stays on the V8 worker and outside the host queue lock. +#endif + } + bool enable_file_service(bool enabled) { +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + file_service_enabled_.store(enabled); signal_worker(); return true; +#endif + return false; + } + std::unique_ptr take_file_request() { +#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 {}; + } bool enqueue(const webscene_input_event& event) { if (event.kind == WEBSCENE_INPUT_RESIZE) { @@ -455,6 +486,15 @@ return enqueue_url(value, length, {}); } + bool load_url_with_viewport(const char* value, size_t length, const webscene_input_event& viewport) + { + if (viewport.kind != WEBSCENE_INPUT_RESIZE || + !std::isfinite(viewport.x) || !std::isfinite(viewport.y) || + !std::isfinite(viewport.delta_x) || viewport.x <= 0 || viewport.y <= 0 || viewport.delta_x <= 0) + return false; + return enqueue_url(value, length, {}, viewport); + } + bool load_url( const char* value, size_t length, @@ -531,7 +571,8 @@ bool enqueue_url( const char* value, size_t length, - std::vector scripts) + std::vector scripts, + std::optional viewport = {}) { #if !defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) static_cast(value); @@ -557,7 +598,7 @@ } script_work_.emplace_back(url_request{ std::string(value, length), - std::move(scripts)}); + std::move(scripts), viewport}); signal_worker(); return true; #endif 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 07249dc4e..3ebb669f9 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_native_engine_worker.inc @@ -22,7 +22,7 @@ inspector_runtime_.store(nullptr, std::memory_order_release); #endif // V8 cleanup must stay on its owning worker even after a terminal throw. - runtime_.reset(); + retire_file_runtime(); #endif } @@ -226,8 +226,9 @@ failure.stage = "bootstrap"; failure.message = runtime_->last_error(); diagnostics_.publish(std::move(failure)); - runtime_.reset(); + retire_file_runtime(); } else { + { std::lock_guard lock(file_runtime_mutex_); file_runtime_ready_=true; } #if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8_INSPECTOR) inspector_runtime_.store(runtime_.get(), std::memory_order_release); #endif @@ -849,6 +850,9 @@ if (runtime_ != nullptr) { std::lock_guard lock(configuration_mutex_); runtime_->set_resource_root(resource_root_); +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + runtime_->enable_file_service(file_service_enabled_.load()); +#endif } std::deque script_work; { @@ -888,8 +892,16 @@ } continue; } +#endif +#if defined(WEBSCENE_NATIVE_ENGINE_WITH_V8) + if (auto* file = std::get_if(&request)) { + if (runtime_) runtime_->complete_file_request(*file); + changed = true; + continue; + } #endif if (auto* url = std::get_if(&request)) { + if (url->initial_viewport) apply(*url->initial_viewport); if (runtime_ != nullptr && runtime_->load_url( url->url, @@ -1243,7 +1255,7 @@ #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_GRAPHICS) if (runtime_) runtime_->shutdown_graphics(); #endif - runtime_.reset(); + retire_file_runtime(); #endif frame_trace_.dump(); } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp index ddb579904..89d3f6c2a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp @@ -473,6 +473,10 @@ struct v8_dom_runtime::implementation final { js_string(isolate, "tabIndex"), get_tab_index, set_tab_index); element->InstanceTemplate()->SetNativeDataProperty(js_string(isolate, "src"), get_element_url, set_element_src); element->InstanceTemplate()->SetNativeDataProperty(js_string(isolate, "href"), get_element_url, set_element_href); + element->InstanceTemplate()->SetNativeDataProperty( + js_string(isolate, "accept"), + get_reflected_string_attribute, + set_reflected_string_attribute); element->InstanceTemplate()->SetNativeDataProperty( js_string(isolate, "download"), get_reflected_string_attribute, @@ -3105,6 +3109,8 @@ struct v8_dom_runtime::implementation final { local_context, js_string(isolate, "__webSceneCreateObjectUrl"), v8::Function::New(local_context, create_object_url).ToLocalChecked()).Check(); + global->Set(local_context, js_string(isolate,"__webSceneRevokeObjectUrl"), + v8::Function::New(local_context,revoke_object_url).ToLocalChecked()).Check(); global->Set( local_context, js_string(isolate, "__webSceneResolveUrl"), @@ -3169,6 +3175,8 @@ struct v8_dom_runtime::implementation final { local_context, js_string(isolate, "__webSceneCreateObjectUrl"), v8::Function::New(local_context, create_object_url).ToLocalChecked()).Check(); + global->Set(local_context, js_string(isolate,"__webSceneRevokeObjectUrl"), + v8::Function::New(local_context,revoke_object_url).ToLocalChecked()).Check(); global->Set(local_context, js_string(isolate, "Image"), v8::FunctionTemplate::New(isolate, image_constructor) ->GetFunction(local_context).ToLocalChecked()).Check(); @@ -3244,6 +3252,13 @@ struct v8_dom_runtime::implementation final { arrayBuffer() { return Promise.resolve(this._bytes.slice().buffer); } slice(start=0,end=this.size,type='') {return new WebSceneBlob([this._bytes.slice(start,end)],{type});} } + globalThis.File = class File extends WebSceneBlob { + constructor(parts, name, options={}) { + super(parts,options); + this.name=String(name).replace(/[\/]/g, ':'); + this.lastModified=Number(options.lastModified ?? Date.now()); + } + }; class WebSceneURLSearchParams { constructor(init = null) { this._owner = init && typeof init === 'object' @@ -4002,8 +4017,12 @@ struct v8_dom_runtime::implementation final { return true; } +#include "webscene_v8_runtime_files.inc" + bool queue_external_navigation(dom_node& target) { + if (file_service_enabled.load() && target.tag == "input" + && target.attributes["type"] == "file") return queue_file_request(target, false); auto* anchor = ⌖ while (anchor != nullptr && anchor->tag != "a") anchor = anchor->parent; if (anchor == nullptr) return true; @@ -4011,6 +4030,7 @@ struct v8_dom_runtime::implementation final { const auto authored = anchor->attributes.find("href"); if (authored == anchor->attributes.end() || authored->second.empty()) return true; if (anchor->attributes.contains("download")) { + if (file_service_enabled.load()) return queue_file_request(*anchor, true); auto local_context = frame_context.IsEmpty() ? context.Get(isolate) : frame_context.Get(isolate); @@ -5909,3 +5929,25 @@ const std::string& v8_dom_runtime::frame_last_error() const noexcept } } // namespace webscene_native + +namespace webscene_native { +void v8_dom_runtime::enable_file_service(bool enabled) { impl_->file_service_enabled.store(enabled); } +std::unique_ptr v8_dom_runtime::take_file_request() { + std::lock_guard lock(impl_->file_requests_mutex); + if(impl_->file_requests.empty()) return {}; + auto result=std::move(impl_->file_requests.front()); impl_->file_requests.pop_front(); return result; +} +void v8_dom_runtime::complete_file_request(native_file_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_file(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 file completion: "+impl_->last_error); + } +} +} diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h index 73ba5a1b4..772f70aed 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.h @@ -23,6 +23,27 @@ struct webscene_gpu_image_lease_v3; namespace webscene_native { +struct native_file_data { + std::string name, mime; + std::vector bytes; +}; +struct native_file_request { + webscene_file_request_v1 view{}; + std::string accept; + native_file_data file; + void bind() { + view.struct_size=sizeof(view); view.version=1; + view.accept=accept.c_str(); + view.file={file.name.c_str(),file.mime.c_str(),file.bytes.data(),file.bytes.size()}; + } +}; +struct native_file_completion { + uint64_t id{}; + uint32_t status{}; + std::vector files; + std::string error; +}; + class native_document; struct document_start_script final { @@ -257,6 +278,9 @@ class v8_dom_runtime final { interop_callback_completion_data_v3& completion); void cancel_callback_v3(uint64_t call_id); uint64_t pending_callback_promises() const noexcept; + void enable_file_service(bool enabled); + std::unique_ptr take_file_request(); + void complete_file_request(native_file_completion& completion); bool try_take_host_request(std::string& request); bool try_take_console_message(std::string& message); bool inspector_available() const noexcept; 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 1a0a907f7..29fdc8e9a 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 @@ -1932,7 +1932,7 @@ auto* self=current(info.GetIsolate());if(!self||!info.Length())return; auto url=to_utf8(info.GetIsolate(),info[0]);auto found=self->object_url_binary.find(url); if(found!=self->object_url_binary.end()&&found->second.origin==resource_origin(self->current_base_address())){ - self->object_url_binary.erase(found);self->object_urls.erase(url);self->object_url_download_payloads.erase(url);self->object_url_canvas_node_ids.erase(url); + self->object_url_file_data.erase(url);self->object_url_binary.erase(found);self->object_urls.erase(url);self->object_url_download_payloads.erase(url);self->object_url_canvas_node_ids.erase(url); } } static void create_object_url(const v8::FunctionCallbackInfo& info) @@ -1940,6 +1940,8 @@ auto* self = current(info.GetIsolate()); auto payload = info.Length() > 0 ? to_utf8(info.GetIsolate(), info[0]) : std::string{}; std::string download_payload; + native_file_data file_bytes; + bool has_file_bytes=false; std::string binary_payload; uint32_t canvas_node_id = 0; if (info.Length() > 0 && info[0]->IsObject()) { @@ -1968,6 +1970,11 @@ js_string(info.GetIsolate(), "type")).ToLocal(&type_value) ? to_utf8(info.GetIsolate(), type_value) : std::string{}; + if (view->ByteLength() <= 64u*1024u*1024u) { + has_file_bytes=true; + file_bytes.mime=type; + if(view->ByteLength()) file_bytes.bytes.assign(bytes,bytes+view->ByteLength()); + } if(view->ByteLength())binary_payload.assign(reinterpret_cast(bytes),view->ByteLength()); const auto is_textual = type.starts_with("text/") || type == "application/xhtml+xml" @@ -1984,6 +1991,7 @@ } } const auto url = "blob:webscene-native/" + std::to_string(self->next_object_url_id++); + if(has_file_bytes) self->object_url_file_data[url]=std::move(file_bytes); self->object_urls[url] = std::move(payload); self->object_url_binary[url]={std::move(binary_payload),resource_origin(self->current_base_address())}; if (!download_payload.empty()) { 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 bf92eeaf2..c70f1b506 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 @@ -1370,6 +1370,8 @@ local_context, js_string(isolate, "__webSceneCreateObjectUrl"), v8::Function::New(local_context, create_object_url).ToLocalChecked()).Check(); + global->Set(local_context, js_string(isolate,"__webSceneRevokeObjectUrl"), + v8::Function::New(local_context,revoke_object_url).ToLocalChecked()).Check(); global->Set( local_context, js_string(isolate, "__webSceneResolveUrl"), @@ -1473,7 +1475,9 @@ let size = 0; for (const part of parts) { let bytes; - if (part instanceof ArrayBuffer) { + if (part instanceof WebSceneBlob) { + bytes = part._bytes; + } else if (part instanceof ArrayBuffer) { bytes = new Uint8Array(part); } else if (ArrayBuffer.isView(part)) { bytes = new Uint8Array(part.buffer, part.byteOffset, part.byteLength); @@ -1494,8 +1498,20 @@ this._text = Array.from(parts, String).join(''); } toString() { return this._text; } + async text() { return new TextDecoder().decode(this._bytes); } + async arrayBuffer() { return this._bytes.slice().buffer; } + slice(start=0, end=this.size, type='') { + return new WebSceneBlob([this._bytes.slice(start,end)], {type}); } - class WebSceneURLSearchParams { + } + globalThis.File = class File extends WebSceneBlob { + constructor(parts, name, options={}) { + super(parts,options); + this.name=String(name).replace(/[\/]/g, ':'); + this.lastModified=Number(options.lastModified ?? Date.now()); + } + }; + class WebSceneURLSearchParams { constructor(init = null) { this._owner = init && typeof init === 'object' && 'href' in init ? init : null; @@ -1733,7 +1749,7 @@ } toJSON() { return this.toString(); } static createObjectURL(blob) { return __webSceneCreateObjectUrl(blob); } - static revokeObjectURL() {} + static revokeObjectURL(url) { __webSceneRevokeObjectUrl(String(url)); } } class WebSceneDOMException extends Error { constructor(message = '', name = 'Error') { 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 0a8a4ef5e..7d6b5b38c 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 @@ -761,7 +761,7 @@ pseudo.background_image.image_value = value; pseudo.background_image.image_markup.clear(); pseudo.background_image.image_view_box.clear(); - if (value != "none" && !lower_html_name(value).starts_with("linear-gradient(")) { + if (value != "none" && !(lower_html_name(value).starts_with("linear-gradient(") || lower_html_name(value).starts_with("radial-gradient("))) { decision.classification = "unsupported"; decision.semantic_slice = "single linear-gradient layer"; } @@ -778,7 +778,7 @@ : native_document::parse_color(value); if (name == "background") { const auto lower = lower_html_name(value); - const auto gradient_start = lower.find("linear-gradient("); + const auto gradient_start = std::min(lower.find("linear-gradient("), lower.find("radial-gradient(")); if (gradient_start == std::string::npos) { pseudo.background_image.image_value = "none"; } else { @@ -2670,7 +2670,7 @@ background.image_markup.clear(); background.image_view_box.clear(); if (value == "none") return; - if (lower_html_name(value).starts_with("linear-gradient(")) { + if (lower_html_name(value).starts_with("linear-gradient(") || lower_html_name(value).starts_with("radial-gradient(")) { decision.classification = "supported"; decision.semantic_slice = "linear-gradient color stops, percentages, angles, and side/corner directions"; @@ -2743,7 +2743,7 @@ } if (name == "background" && !is_inline(inline_background_image)) { const auto lower = lower_html_name(value); - const auto gradient = lower.find("linear-gradient("); + const auto gradient = std::min(lower.find("linear-gradient("), lower.find("radial-gradient(")); if (gradient != std::string::npos) { auto& background = node.style.mutable_background_image(); background.image_value = value.substr(gradient); @@ -2822,6 +2822,14 @@ ? std::string{} : value; decision.classification = "partially-supported"; decision.semantic_slice = "solid SVG paint, none, and currentColor"; + } else if (name == "text-anchor" && !is_inline(inline_svg_text_anchor)) { + if (value == "start" || value == "middle" || value == "end") { + node.style.mutable_textual().svg_text_anchor = value; + } else if (value == "initial") { + node.style.mutable_textual().svg_text_anchor = "start"; + } else if (value == "inherit" || value == "unset") { + node.style.mutable_textual().svg_text_anchor.clear(); + } } else if (name == "cursor" && !is_inline(inline_cursor)) { node.style.mutable_textual().cursor = value == "initial" ? "auto" : value; diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc new file mode 100644 index 000000000..c8bd7afb0 --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_files.inc @@ -0,0 +1,87 @@ + bool queue_file_request(dom_node& node, bool save) { + auto local=context_for_node(node); + // The first service slice is for the active top-level application. + if (local != context.Get(isolate)) { + isolate->ThrowException(v8::Exception::Error(js_string(isolate,"Native file service does not support iframe requests"))); + return false; + } + std::erase_if(file_targets, [&](auto& item) { return item.second.context.Get(isolate)!=local; }); + if(file_targets.size()>=16) { + isolate->ThrowException(v8::Exception::Error(js_string(isolate,"Too many pending native file requests"))); + return false; + } + auto request=std::make_unique(); + request->view.request_id=++next_host_request_id; + request->view.kind=save ? 2 : 1; + request->view.multiple=node.attributes.contains("multiple"); + request->accept=node.attributes.contains("accept") ? node.attributes.at("accept") : ""; + if(save) { + const auto found=object_url_file_data.find(node.attributes["href"]); + if(found==object_url_file_data.end() || object_url_canvas_node_ids.contains(node.attributes["href"])) { + isolate->ThrowException(v8::Exception::Error(js_string(isolate,"Native Save requires a Blob with encoded file bytes"))); + return false; + } + request->file=found->second; + request->file.name=node.attributes["download"]; + // A suggestion is a filename, never a path or a chosen destination. + const auto slash=request->file.name.find_last_of("/\\"); + if(slash!=std::string::npos) request->file.name.erase(0,slash+1); + std::erase_if(request->file.name,[](unsigned char c){return c<32 || c==127;}); + if(request->file.name.empty() || request->file.name=="." || request->file.name=="..") request->file.name="Export"; + } + file_target target; target.node_id=node.id; target.context.Reset(isolate,local); + file_targets.emplace(request->view.request_id,std::move(target)); + { + std::lock_guard lock(file_requests_mutex); + file_requests.push_back(std::move(request)); + } + if(host_request_available) host_request_available(); + return true; + } + + void complete_native_file(native_file_completion& completion) { + const auto found=file_targets.find(completion.id); + if(found==file_targets.end()) return; + auto local=found->second.context.Get(isolate); + const auto node_id=found->second.node_id; + file_targets.erase(found); + if(local!=context.Get(isolate)) return; // navigation invalidates old authority + v8::Context::Scope context_scope(local); + auto* node=document.find_by_native_id(node_id); + // Detached save anchors are deliberately allowed; their bytes were + // captured before the app revoked its URL. Completion has no DOM event. + if(!node) return; + webscene_input_event event{}; + if(completion.status) { + dispatch_input_event_type(event,completion.status==1 ? "cancel" : "error",*node); + return; + } + if(node->tag!="input") return; + auto files=v8::Array::New(isolate,static_cast(completion.files.size())); + v8::Local constructor; + if(!local->Global()->Get(local,js_string(isolate,"File")).ToLocal(&constructor) || !constructor->IsFunction()) return; + for(uint32_t i=0;iGetBackingStore()->Data(),file.bytes.data(),file.bytes.size()); + auto parts=v8::Array::New(isolate,1); + parts->Set(local,0,buffer).Check(); + auto options=v8::Object::New(isolate); + options->Set(local,js_string(isolate,"type"),js_string(isolate,file.mime.c_str())).Check(); + v8::Local args[]={parts,js_string(isolate,file.name.c_str()),options}; + v8::Local value; + if(!constructor.As()->NewInstance(local,3,args).ToLocal(&value)) return; + files->Set(local,i,value).Check(); + } + // FileList's indexed access, length and item are the supported slice. + files->Set(local,js_string(isolate,"item"),v8::Function::New(local,[](const v8::FunctionCallbackInfo& info){ + auto ctx=info.GetIsolate()->GetCurrentContext(); + auto index=info.Length() ? info[0]->Uint32Value(ctx).FromMaybe(0) : 0; + v8::Local value; + if(info.This()->Get(ctx,index).ToLocal(&value)) info.GetReturnValue().Set(value->IsUndefined() ? v8::Null(info.GetIsolate()).As() : value); + }).ToLocalChecked()).Check(); + wrap_node(*node)->Set(local,js_string(isolate,"files"),files).Check(); + dispatch_input_event_type(event,"input",*node); + dispatch_input_event_type(event,"change",*node); + isolate->PerformMicrotaskCheckpoint(); + } diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc index ea181dab1..0b73ef099 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_html.inc @@ -242,6 +242,7 @@ if (name == "color") return inline_color; if (name == "fill") return inline_svg_fill; if (name == "stroke") return inline_svg_stroke; + if (name == "text-anchor") return inline_svg_text_anchor; if (name == "cursor") return inline_cursor; if (name == "font-size") return inline_font_size; if (name == "font-family") return inline_font_family; 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 6ecf6e9fb..361299f6a 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_navigation.inc @@ -98,6 +98,13 @@ retire_document_graphics(); #endif stop_workers(); + file_targets.clear(); + { std::lock_guard lock(file_requests_mutex); file_requests.clear(); } + object_url_file_data.clear(); + object_url_binary.clear(); + object_urls.clear(); + object_url_download_payloads.clear(); + object_url_canvas_node_ids.clear(); #if defined(WEBSCENE_NATIVE_ENGINE_ENABLE_MEDIA) clear_media_bindings(); #endif 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 92bc3706a..f83b9e331 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_state.inc @@ -137,6 +137,13 @@ std::mutex console_message_mutex; std::deque console_messages; uint64_t next_host_request_id{0}; + std::atomic file_service_enabled{false}; + std::mutex file_requests_mutex; + std::deque> file_requests; + struct file_target { uint32_t node_id{}; v8::Global context; }; + std::unordered_map file_targets; + std::unordered_map object_url_file_data; + std::vector> resize_observers; bool resize_observers_pending{false}; // DOM mutations stay on the native fast path until a realm registers its diff --git a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc index 450f9a087..e5093aced 100644 --- a/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc +++ b/experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime_support.inc @@ -377,6 +377,7 @@ enum inline_style_property : uint64_t { inline_font_smoothing = 1ULL << 54U, inline_background_image = 1ULL << 55U, inline_contain = 1ULL << 56U, + inline_svg_text_anchor = 1ULL << 57U, inline_transition = inline_transition_property | inline_transition_duration | inline_transition_delay | inline_transition_timing }; diff --git a/experiments/WebScene.NativeEngine.Probe/tests/graphics_dawn_event_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/graphics_dawn_event_tests.cpp index 6e0ce08a5..622920a55 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/graphics_dawn_event_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/graphics_dawn_event_tests.cpp @@ -237,6 +237,20 @@ void test_canvas_storage(dawn_event_service& service,const wgpu::Device& device, int main() try { test_dxgi_fence_ownership(); auto wake=std::make_shared(); + // Real editors retain hundreds of wrappers across resize/compute work; + // release reservations must not exhaust before individual resource tables. + graphics_service editor_releases(wake); + auto editor_channel=editor_releases.release_endpoint(); + std::vector editor_tickets; + for (int i=0;i<1024;++i) { + auto ticket=editor_channel->reserve(graphics_command{ + [](graphics_service&,std::span,const graphics_command::arguments&) noexcept {},{}}); + if (!ticket) throw std::runtime_error("editor release capacity exhausted"); + editor_tickets.push_back(*ticket); + } + for (auto ticket:editor_tickets) if (!editor_channel->publish(ticket)) + throw std::runtime_error("editor release publication failed"); + editor_releases.close(); graphics_service root(wake),other_root(wake); auto& service=root.dawn(); auto mailbox=service.completions(); diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_file_service_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_file_service_tests.inc new file mode 100644 index 000000000..afc9502ab --- /dev/null +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_file_service_tests.inc @@ -0,0 +1,71 @@ +void test_native_file_service(webscene_engine* engine) { + execute(engine,"void 0","file-bootstrap.js"); + require(webscene_engine_enable_file_service_v1(engine,1)!=0,"file service opt-in failed"); + execute(engine,R"JS( + document.body.innerHTML=''; + globalThis.fileEvents=[]; + const input=document.getElementById('native-file'); + input.oncancel=()=>fileEvents.push('cancel'); + input.onchange=async()=>{ + fileEvents.push('change'); + globalThis.openedFiles=await Promise.all(Array.from(input.files, async f=>({name:f.name,text:await f.text(),bytes:Array.from(new Uint8Array(await f.arrayBuffer())),isFile:f instanceof File}))); + }; + input.accept='.dxf'; + input.click(); + )JS","native-file-open.js"); + const auto take=[&] { + const webscene_file_request_v1* request=nullptr; + for(int i=0;i<200 && !request;++i) { request=webscene_engine_take_file_request_v1(engine); if(!request) std::this_thread::sleep_for(std::chrono::milliseconds(2)); } + require(request!=nullptr,"native file request missing"); return request; + }; + auto* request=take(); + require(request->kind==1 && request->multiple && std::string(request->accept)==".dxf","Open metadata mismatch"); + const auto id=request->request_id; + webscene_file_request_release_v1(request); + const uint8_t bytes[]={0,1,255,65}; + webscene_file_data_v1 files[]={{"café.kcad","application/json",bytes,sizeof(bytes)},{"empty.json","application/json",nullptr,0}}; + require(webscene_engine_complete_file_request_v1(engine,id,0,files,2,nullptr),"Open completion rejected"); + std::string opened; + for(int i=0;i<100;++i) {opened=evaluate(engine,"JSON.stringify(globalThis.openedFiles)","opened-files.js");if(opened.find("café.kcad")!=std::string::npos)break;std::this_thread::sleep_for(std::chrono::milliseconds(2));} + require(opened.find("[0,1,255,65]")!=std::string::npos && opened.find("empty.json")!=std::string::npos && evaluate(engine,"openedFiles.every(f=>f.isFile)","is-file.js")=="true","Open bytes/File semantics mismatch: "+opened); + execute(engine,"document.getElementById('native-file').click()","cancel-open.js"); + request=take(); const auto cancel_id=request->request_id;webscene_file_request_release_v1(request); + require(webscene_engine_complete_file_request_v1(engine,cancel_id,1,nullptr,0,nullptr),"Cancel completion failed"); + require(evaluate(engine,"fileEvents.join(',')","file-events.js").find("change,cancel")!=std::string::npos,"Cancellation delivered a change or was lost"); + execute(engine,R"JS( + const bytes=new Uint8Array([99,0,1,255,65,99]); + const blob=new Blob([bytes.subarray(1,5)],{type:'application/octet-stream'}); + const url=URL.createObjectURL(blob); + // A Blob can back both a native Save request and a binary fetch. Revoking + // its URL must invalidate future fetches without losing either byte copy. + fetch(url).then(r=>r.arrayBuffer()).then(b=>globalThis.savedBlobFetch=Array.from(new Uint8Array(b))).catch(e=>globalThis.savedBlobFetch=String(e)); + const a=document.createElement('a');a.href=url;a.download='../café.bin';a.click();URL.revokeObjectURL(url); + fetch(url).then(()=>globalThis.revokedBlobRejected=false,()=>globalThis.revokedBlobRejected=true); + )JS","native-file-save.js"); + request=take(); + require(request->kind==2 && std::string(request->file.name)=="café.bin" && request->file.byte_count==4 && std::memcmp(request->file.bytes,bytes,4)==0,"Save bytes, typed array slice or name sanitization failed"); + const auto save_id=request->request_id;webscene_file_request_release_v1(request); + require(evaluate(engine,"JSON.stringify(globalThis.savedBlobFetch)==='[0,1,255,65]'","saved-blob-fetch.js")=="true", + "Native Save lost the binary Blob fetch payload"); + require(evaluate(engine,"revokedBlobRejected","revoked-blob-fetch.js")=="true", + "Revoked native Save Blob URL remained fetchable"); + require(webscene_engine_complete_file_request_v1(engine,save_id,1,nullptr,0,nullptr),"Save cancellation failed"); + require(!webscene_engine_complete_file_request_v1(engine,0,0,nullptr,0,nullptr),"Invalid completion accepted"); + execute(engine,"(()=>{const a=document.createElement('a');a.href=URL.createObjectURL(new Blob([]));a.download='empty.bin';a.click();URL.revokeObjectURL(a.href);})()","empty-save.js"); + request=take();require(request->file.byte_count==0,"Empty export changed bytes"); + const auto empty_id=request->request_id;webscene_file_request_release_v1(request); + require(webscene_engine_complete_file_request_v1(engine,empty_id,2,nullptr,0,"write denied"),"Save failure completion rejected"); + // A completion from a previous document must never populate the next one. + execute(engine,"document.getElementById('native-file').click()","before-navigation.js"); + request=take();const auto old_id=request->request_id;webscene_file_request_release_v1(request); + const auto path=std::filesystem::temp_directory_path()/("webscene-file-navigation-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".html"); + {std::ofstream file(path);file<<"";} + const auto root=path.parent_path().string();webscene_engine_set_resource_root(engine,root.data(),root.size()); + const auto url="file://"+path.string(); + require(webscene_engine_load_url(engine,url.data(),url.size()),"File navigation failed"); + for(int i=0;i<100 && evaluate(engine,"globalThis.navigationReady===true","navigation-ready.js")!="true";++i) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + require(webscene_engine_complete_file_request_v1(engine,old_id,0,files,2,nullptr),"Stale completion could not be queued"); + require(evaluate(engine,"globalThis.unexpectedChange","after-navigation.js")=="false","Old file completion reached the new document"); + std::filesystem::remove(path); + webscene_engine_enable_file_service_v1(engine,0); +} 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 d29057813..de05d0dec 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 @@ -1744,6 +1744,30 @@ void test_pointer_cursor_and_external_anchor_host_handoff(webscene_engine* engin void test_css_linear_gradient_reaches_the_retained_scene(webscene_engine* engine) { + { + const auto path=std::filesystem::temp_directory_path()/( + "webscene-initial-viewport-"+std::to_string(std::chrono::steady_clock::now().time_since_epoch().count())+".html"); + { std::ofstream file(path); file << ""; } + auto* fresh=webscene_engine_create(0); + require(fresh!=nullptr,"initial viewport engine creation failed"); + const auto root=path.parent_path().string(); + webscene_engine_set_resource_root(fresh,root.data(),root.size()); + const auto url="file://"+path.string(); + webscene_input_event viewport{WEBSCENE_INPUT_RESIZE,0,1,1357,811,1.5,0}; + require(webscene_engine_load_url_with_viewport(fresh,url.data(),url.size(),&viewport)!=0, + "initial viewport navigation was rejected"); + std::string observed; + for (int attempt=0;attempt<100;++attempt) { + observed=evaluate(fresh,"JSON.stringify(globalThis.initialViewport)","initial-viewport.js"); + if (observed.find("1357,811,1.5")!=std::string::npos) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(observed.find("1357,811,1.5")!=std::string::npos, + "document-start JS observed unexpected viewport: "+observed); + webscene_engine_destroy(fresh); + std::filesystem::remove(path); + } + execute(engine, R"JS( (() => { document.body.innerHTML = ` +
`; + )JS", "native-radial-gradient.js"); + webscene_engine_request_scene_checkpoint(engine); + bool radial_found=false; + std::string radial_observed; + for (int attempt=0;attempt<100 && !radial_found;++attempt) { + if (const auto* scene=webscene_engine_acquire_latest_scene(engine)) { + for (uint32_t i=0;iheader.command_count;++i) { + const auto& command=scene->commands[i]; + if ((command.kind!=21 && command.kind!=22) || command.flags>=scene->string_count) continue; + const auto& ref=scene->strings[command.flags]; + const std::string resource(scene->string_bytes+ref.byte_offset,ref.byte_length); + radial_observed=resource+" radius="+std::to_string(command.radius_top_left); + radial_found=resource.find("radial-gradient")!=std::string::npos && + resource.find("#ffffffa0")!=std::string::npos && resource.find("rgb(255, 68, 0)")!=std::string::npos && + command.radius_top_left>0; + if (radial_found) break; + } + webscene_scene_acknowledge(scene); webscene_scene_release(scene); + } + if (!radial_found) std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(radial_found,"layered radial backgrounds with custom properties and rounded clipping were not retained: "+radial_observed); } diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc index b24cad9a1..2056c894d 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_rendering_metrics_tests.inc @@ -285,6 +285,50 @@ void test_svg_current_color_is_resolved_before_scene_serialization(webscene_engi "inherited SVG currentColor did not resolve to an explicit immutable-scene color"); } +void test_svg_stylesheet_typography_reaches_scene(webscene_engine* engine) +{ + execute(engine, R"JS( + document.body.innerHTML = ` + TOP + FRONT + RIGHT + `; + )JS", "native-svg-typography.js"); + webscene_engine_request_scene_checkpoint(engine); + auto found_resolved_svg = false; + for (auto attempt = 0; attempt < 100; ++attempt) { + const auto* scene = webscene_engine_acquire_latest_scene(engine); + if (scene != nullptr) { + for (uint32_t index = 0; index < scene->header.command_count; ++index) { + const auto& command = scene->commands[index]; + if (command.kind != 6U || command.flags >= scene->string_count) continue; + const auto resource = scene->strings[command.flags]; + const std::string_view bytes( + scene->string_bytes + resource.byte_offset, + resource.byte_length); + found_resolved_svg = bytes.find("font-size=\"8.000000\"") != std::string_view::npos + && bytes.find("font-weight=\"600\"") != std::string_view::npos + && bytes.find("font-family=\"Helvetica\"") != std::string_view::npos + && bytes.find("letter-spacing=\"0.700000\"") != std::string_view::npos + && bytes.find("text-anchor=\"middle\"") != std::string_view::npos + && bytes.find("rotate(-29 76 64)") != std::string_view::npos + && bytes.find("font-size=\"30\"") == std::string_view::npos + && bytes.find("text-anchor=\"end\"") == std::string_view::npos; + if (found_resolved_svg) break; + } + webscene_scene_acknowledge(scene); + webscene_scene_release(scene); + } + if (found_resolved_svg) break; + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } + require(found_resolved_svg, + "SVG stylesheet typography was lost or failed to override presentation attributes"); +} + void test_svg_preserve_aspect_ratio_reaches_scene_serialization(webscene_engine* engine) { execute(engine, R"JS( diff --git a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp index 24f31d183..c03a6d049 100644 --- a/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp +++ b/experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp @@ -74,6 +74,7 @@ uint8_t measure_baseline_fixture_text( #endif #include "native_v8_runtime_interop_tests.inc" #include "native_v8_runtime_input_tests.inc" +#include "native_file_service_tests.inc" #include "native_table_cell_copy_tests.inc" #include "native_v8_runtime_resource_tests.inc" #include "native_v8_runtime_diagnostics_tests.inc" @@ -261,6 +262,13 @@ int main() webscene_engine_destroy(focused_engine); return 0; } + if (selected == "native-file-service") { + auto* engine=webscene_engine_create(0); + require(engine!=nullptr,"file engine creation failed"); + test_native_file_service(engine); + webscene_engine_destroy(engine); + return 0; + } if (selected == "tradingview-opacity-border") { auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, "focused engine creation failed"); @@ -386,6 +394,7 @@ int main() auto* focused_engine = webscene_engine_create(0); require(focused_engine != nullptr, "focused engine creation failed"); test_svg_dom_parser_preserves_fill_rule(focused_engine); + test_svg_stylesheet_typography_reaches_scene(focused_engine); webscene_engine_destroy(focused_engine); return 0; } @@ -674,6 +683,12 @@ int main() test_pointer_cursor_and_external_anchor_host_handoff(engine); test_enter_dispatches_browser_keypress_for_interval_commit(engine); test_css_linear_gradient_reaches_the_retained_scene(engine); + { + auto* file_engine=webscene_engine_create(0); + require(file_engine!=nullptr,"file service fixture creation failed"); + test_native_file_service(file_engine); + webscene_engine_destroy(file_engine); + } test_z_index_orders_positioned_siblings_in_scene(engine); test_popup_portal_tooltip_escapes_non_stacking_positioned_wrapper(engine); test_fixed_portal_descendant_stays_in_ancestor_stacking_context(engine); @@ -767,6 +782,7 @@ int main() test_tradingview_split_color_swatch_uses_pseudo_border_triangle(engine); test_negative_z_after_paints_behind_svg_content(engine); test_svg_current_color_is_resolved_before_scene_serialization(engine); + test_svg_stylesheet_typography_reaches_scene(engine); test_svg_preserve_aspect_ratio_reaches_scene_serialization(engine); test_svg_view_box_keeps_foreign_attribute_case_and_origin(engine); test_positive_z_before_paints_above_lower_z_child(engine); diff --git a/tooling/webscene/tests/native-binary-interop.test.mjs b/tooling/webscene/tests/native-binary-interop.test.mjs index e15686194..4aefd6d41 100644 --- a/tooling/webscene/tests/native-binary-interop.test.mjs +++ b/tooling/webscene/tests/native-binary-interop.test.mjs @@ -41,7 +41,23 @@ test('native engine publishes only the versioned leased interop surface', async assert.doesNotMatch(header, /\bwebscene_engine_evaluate_json\b/); assert.doesNotMatch(exports, /_webscene_engine_evaluate_json\b/); - assert.doesNotMatch(header, /\bwebscene_(?:engine|interop)_[a-z0-9_]+_v[12]\b/); + // File services have their own versioned ABI; they are not legacy interop. + const fileServiceSymbols = new Set([ + 'webscene_engine_enable_file_service_v1', + 'webscene_engine_take_file_request_v1', + 'webscene_engine_complete_file_request_v1', + 'webscene_file_request_release_v1' + ]); + for (const symbol of fileServiceSymbols) { + assert.match(header, new RegExp(`\\b${symbol}\\b`)); + assert.match(exports, new RegExp(`_${symbol}\\b`)); + } + for (const [name, source] of [['header', header], ['exports', exports]]) { + const legacySymbols = [...source.matchAll( + /\b_?(webscene_(?:engine|interop)_[a-z0-9_]+_v[12])\b/g + )].map(match => match[1]).filter(symbol => !fileServiceSymbols.has(symbol)); + assert.deepEqual(legacySymbols, [], `${name} must not expose legacy interop`); + } assert.match( header, /webscene_interop_result_release_v3\s*\([^)]*uint64_t lease_id\s*\)/s); diff --git a/tools/webidl-v8-bindings/dom-exposure.json b/tools/webidl-v8-bindings/dom-exposure.json index 40e4e2e33..cd3fa7c18 100644 --- a/tools/webidl-v8-bindings/dom-exposure.json +++ b/tools/webidl-v8-bindings/dom-exposure.json @@ -283,6 +283,7 @@ { "name": "tabIndex", "getter": "get_tab_index", "setter": "set_tab_index" }, { "name": "src", "getter": "get_element_url", "setter": "set_element_src" }, { "name": "href", "getter": "get_element_url", "setter": "set_element_href" }, + { "name": "accept", "getter": "get_reflected_string_attribute", "setter": "set_reflected_string_attribute" }, { "name": "download", "getter": "get_reflected_string_attribute", "setter": "set_reflected_string_attribute" }, { "name": "hash", "getter": "get_anchor_hash", "setter": "set_anchor_hash" }, { "name": "contentWindow", "getter": "get_content_window" },