diff --git a/Cargo.toml b/Cargo.toml index 4bbd7dc..12a0598 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["metadata", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi"] +members = ["metadata", "playground", "runtime", "runtime-binding-gen", "sbg", "nativescript", "typings-generator", "integration-tests", "runtime-devtools", "metadata-generator","tools/dotnet-tool", "windows-napi", "napi-v8-shim"] # Excluded so their C builds / prebuilt-engine links don't run on normal `cargo` invocations. exclude = ["packages/common", "packages/demo", "packages/windows-quickjs", "packages/windows-hermes", "packages/windows-jsc", "packages/windows-v8"] diff --git a/integration-tests/tests/new_features.rs b/integration-tests/tests/new_features.rs index 9ba5e3f..a5d962b 100644 --- a/integration-tests/tests/new_features.rs +++ b/integration-tests/tests/new_features.rs @@ -137,6 +137,8 @@ fn url_relative_resolution() { #[test] fn raf_fires_callback_with_positive_timestamp() { let mut rt = Runtime::new("."); + // What an app host does after creating the runtime; the frame pump finds the isolate here. + rt.register_delegate_isolate_ptr(); // Register the callback. rt.run_script( r#" @@ -150,9 +152,8 @@ fn raf_fires_callback_with_positive_timestamp() { "setup.js", ); - // Drain microtasks: __nsDwmFlush returns immediately on headless (no DWM) - // or waits one VSync on a live display. Either way the callback runs. - rt.run_script("", "pump.js"); + // One frame: the host's pump (runtime_pump_timers) runs requested callbacks. + assert!(runtime::animation_frames::pump(), "no frame was requested"); assert_js(&mut rt, "_rafFired === true", "rAF callback not fired"); assert_js(&mut rt, "_rafTs >= 0", "rAF timestamp negative"); @@ -161,6 +162,8 @@ fn raf_fires_callback_with_positive_timestamp() { #[test] fn raf_callback_receives_increasing_timestamps() { let mut rt = Runtime::new("."); + // What an app host does after creating the runtime; the frame pump finds the isolate here. + rt.register_delegate_isolate_ptr(); rt.run_script( r#" var _ts1 = -1, _ts2 = -1; @@ -171,15 +174,38 @@ fn raf_callback_receives_increasing_timestamps() { "#, "setup.js", ); - rt.run_script("", "pump1.js"); - rt.run_script("", "pump2.js"); + runtime::animation_frames::pump(); + // Requested during the first frame: runs in the second, not the first. + assert_js(&mut rt, "_ts2 === -1", "nested rAF ran in the same frame"); + runtime::animation_frames::pump(); assert_js(&mut rt, "_ts1 >= 0", "first rAF timestamp invalid"); assert_js(&mut rt, "_ts2 >= _ts1", "second rAF not >= first"); } +#[test] +fn raf_does_not_run_without_a_frame() { + let mut rt = Runtime::new("."); + // What an app host does after creating the runtime; the frame pump finds the isolate here. + rt.register_delegate_isolate_ptr(); + rt.run_script( + r#" + var _early = false; + requestAnimationFrame(function() { _early = true; }); + Promise.resolve().then(function() {}); + "#, + "setup.js", + ); + // Microtasks drained, no pump yet: the UI thread was never held for a frame. + assert_js(&mut rt, "_early === false", "rAF ran before the frame"); + runtime::animation_frames::pump(); + assert_js(&mut rt, "_early === true", "rAF did not run on the frame"); +} + #[test] fn cancel_raf_prevents_callback() { let mut rt = Runtime::new("."); + // What an app host does after creating the runtime; the frame pump finds the isolate here. + rt.register_delegate_isolate_ptr(); rt.run_script( r#" var _called = false; @@ -188,7 +214,7 @@ fn cancel_raf_prevents_callback() { "#, "setup.js", ); - rt.run_script("", "pump.js"); + runtime::animation_frames::pump(); assert_js( &mut rt, "_called === false", diff --git a/napi-v8-shim/Cargo.toml b/napi-v8-shim/Cargo.toml new file mode 100644 index 0000000..700125b --- /dev/null +++ b/napi-v8-shim/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "napi-v8-shim" +version = "0.1.0" +edition = "2021" +description = "Node-API over the classic engine's V8: napi-android's v8-api.cpp (vendored in packages/windows-v8) compiled against rusty_v8, plus the env/registration glue the native-addon loader needs." + +[dependencies] +# The shim's C++ calls V8; this keeps rusty_v8's static library (and its headers, which build.rs +# finds in the registry source) in the link. +v8 = "147" + +[build-dependencies] +cc = "1" diff --git a/napi-v8-shim/build.rs b/napi-v8-shim/build.rs new file mode 100644 index 0000000..e7fde92 --- /dev/null +++ b/napi-v8-shim/build.rs @@ -0,0 +1,71 @@ +//! Compiles napi-android's V8 Node-API shim (`packages/windows-v8/vendor/shim/v8-api.cpp`, shared +//! with the `windows-v8` engine package) and `csrc/env_ext.cpp` against the `v8` crate's V8 14.7 +//! headers, with the same settings `packages/windows-v8/build.rs` validated. +//! +//! Linked whole-archive: the shim's `napi_*` functions are `__declspec(dllexport)`, and the +//! native-addon loader needs every one of them in `nativescript.dll`'s export table even though +//! Rust never names most of them. +use std::path::{Path, PathBuf}; + +fn find_v8_include() -> PathBuf { + let cargo_home = std::env::var("CARGO_HOME").map(PathBuf::from).unwrap_or_else(|_| { + let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap(); + Path::new(&home).join(".cargo") + }); + let src = cargo_home.join("registry").join("src"); + if let Ok(indexes) = std::fs::read_dir(&src) { + for idx in indexes.flatten() { + if let Ok(crates) = std::fs::read_dir(idx.path()) { + let mut hits: Vec = crates + .flatten() + .map(|e| e.path()) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("v8-147.")) + .unwrap_or(false) + && p.join("v8/include/v8.h").exists() + }) + .collect(); + hits.sort(); + if let Some(p) = hits.pop() { + return p.join("v8/include"); + } + } + } + } + panic!("could not locate the v8 crate's include dir (v8-147.x/v8/include) under {src:?}"); +} + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let vendor = manifest.join("..").join("packages").join("windows-v8").join("vendor"); + + let mut b = cc::Build::new(); + b.cpp(true) + .include(find_v8_include()) + .include(vendor.join("shim")) + .include(vendor.join("napi")) + .include(vendor.join("compat")) + .file(vendor.join("shim/v8-api.cpp")) + .file(manifest.join("csrc/env_ext.cpp")) + .define("NAPI_VERSION", "8") + // V8 14.7 (>13): the shim's modern code paths (SetAccessorProperty etc.). + .define("__V8_13__", None) + .std("c++20") + .warnings(false) + .link_lib_modifier("+whole-archive"); + if b.get_compiler().is_like_msvc() { + b.flag("/EHsc") + .flag("/Zc:__cplusplus") + .define("NOMINMAX", None) + .define("WIN32_LEAN_AND_MEAN", None) + .define("_SILENCE_CXX17_CODECVT_HEADER_DEPRECATION_WARNING", None) + .define("_CRT_SECURE_NO_WARNINGS", None); + } + b.compile("napi_v8_shim"); + + println!("cargo:rerun-if-changed=csrc/env_ext.cpp"); + println!("cargo:rerun-if-changed=../packages/windows-v8/vendor/shim/v8-api.cpp"); + println!("cargo:rerun-if-changed=../packages/windows-v8/vendor/shim/v8-api.h"); +} diff --git a/napi-v8-shim/csrc/env_ext.cpp b/napi-v8-shim/csrc/env_ext.cpp new file mode 100644 index 0000000..32b15b5 --- /dev/null +++ b/napi-v8-shim/csrc/env_ext.cpp @@ -0,0 +1,67 @@ +// Glue between the classic engine (rusty_v8, Rust) and the vendored Node-API shim (v8-api.cpp): +// create an env over an existing context, run a module's init inside the env's call guard, and +// drain finalizers the way Node does (outside GC, at a point where calling into JS is safe). + +#include + +#include "js_native_api.h" +#include "v8-api.h" + +// rusty_v8 hands us a `v8::Local` as the pointer it wraps; that only round-trips if a Local is a +// single pointer (true without V8_ENABLE_DIRECT_HANDLE, which the v8 crate's default build lacks). +static_assert(sizeof(v8::Local) == sizeof(void*), "v8::Local must be pointer-sized"); + +// From node_api_types.h, which the vendored headers don't carry. +typedef napi_value (*napi_addon_register_func)(napi_env env, napi_value exports); + +extern "C" { + +// `context` is a `v8::Local` (as its underlying pointer) of the isolate current on +// this thread. `module_api_version` is the addon's declared Node-API version. +napi_env ns_napi_env_create(void* context, int32_t module_api_version) { + v8::Local local; + std::memcpy(static_cast(&local), &context, sizeof(void*)); + return new napi_env__(local, module_api_version); +} + +// Calls `init(env, exports)` inside the env's call guard; an exception it throws is left pending +// on the isolate for the caller's TryCatch. Must run in a HandleScope with the env's context entered. +napi_value ns_napi_call_module_init(napi_env env, napi_addon_register_func init, napi_value exports) { + napi_value result = nullptr; + env->CallIntoModule([&](napi_env env) { result = init(env, exports); }); + return result; +} + +// Runs finalizers the GC queued for `env` (module API versions below "experimental" defer them; +// see napi_env__::InvokeFinalizerFromGC). Node's `DrainFinalizerQueue`. +void ns_napi_drain_finalizers(napi_env env) { + if (env->pending_finalizers.empty()) { + return; + } + v8::HandleScope handle_scope(env->isolate); + // env->context() aliases the env's persistent slot; take a real handle for the scope. + v8::Local context = v8::Local::New(env->isolate, env->context()); + v8::Context::Scope context_scope(context); + while (!env->pending_finalizers.empty()) { + v8impl::RefTracker* tracker = *env->pending_finalizers.begin(); + env->pending_finalizers.erase(tracker); + tracker->Finalize(); + } +} + +bool ns_napi_has_pending_finalizers(napi_env env) { + return !env->pending_finalizers.empty(); +} + +// Tears the env down: finalizes remaining references (running their finalizers) and frees it. +void ns_napi_env_teardown(napi_env env) { + v8::Isolate* isolate = env->isolate; + v8::HandleScope handle_scope(isolate); + // A real handle, not env->context(): that aliases the env's persistent slot, which DeleteMe + // frees before this scope exits. + v8::Local context = v8::Local::New(isolate, env->context()); + v8::Context::Scope context_scope(context); + env->DeleteMe(); +} + +} // extern "C" diff --git a/napi-v8-shim/src/lib.rs b/napi-v8-shim/src/lib.rs new file mode 100644 index 0000000..944ace3 --- /dev/null +++ b/napi-v8-shim/src/lib.rs @@ -0,0 +1,21 @@ +//! Node-API over the classic engine's V8. The `napi_*` engine functions (js_native_api) come from +//! the compiled shim and are exported from the final DLL; the Node-specific ones (threadsafe +//! functions, async work, cleanup hooks, buffers, …) live in `runtime::node_api`. + +use std::ffi::c_void; + +// Keeps rusty_v8's static library in the link for the C++ shim. +extern crate v8; + +pub type NapiEnv = *mut c_void; +pub type NapiValue = *mut c_void; +pub type AddonInit = unsafe extern "C" fn(NapiEnv, NapiValue) -> NapiValue; + +extern "C" { + /// `context` is the pointer inside a `v8::Local` of the current isolate. + pub fn ns_napi_env_create(context: *const c_void, module_api_version: i32) -> NapiEnv; + pub fn ns_napi_call_module_init(env: NapiEnv, init: AddonInit, exports: NapiValue) -> NapiValue; + pub fn ns_napi_drain_finalizers(env: NapiEnv); + pub fn ns_napi_has_pending_finalizers(env: NapiEnv) -> bool; + pub fn ns_napi_env_teardown(env: NapiEnv); +} diff --git a/nativescript/src/lib.rs b/nativescript/src/lib.rs index 3766480..affa89f 100644 --- a/nativescript/src/lib.rs +++ b/nativescript/src/lib.rs @@ -240,6 +240,8 @@ pub extern "C" fn runtime_init(app_root: *const c_char) -> i64 { pub extern "C" fn runtime_deinit(runtime: i64) { if runtime != 0 { let _ = std::panic::catch_unwind(|| { + // Native addons' cleanup hooks and envs go first, while the isolate is still alive. + runtime::node_api::teardown(); let runtime: *mut Runtime = runtime as _; let _ = unsafe { Box::from_raw(runtime) }; }); @@ -420,6 +422,10 @@ pub extern "C" fn runtime_devtools_pump(_runtime: i64) { pub extern "C" fn runtime_pump_timers() { let _ = std::panic::catch_unwind(|| { runtime::timers::pump(); + // Native addons: threadsafe-function calls, async-work completions, deferred finalizers. + runtime::node_api::drain(); + // This frame's requestAnimationFrame callbacks, then their microtasks. + runtime::animation_frames::pump(); if runtime::ui_dispatcher::needs_win32_pump() { runtime::pump_messages(); } else { @@ -440,7 +446,11 @@ pub extern "C" fn runtime_pump_timers() { /// Returns `true` if at least one Win32 message was dispatched. #[no_mangle] pub extern "C" fn runtime_pump_messages() -> bool { - std::panic::catch_unwind(|| runtime::pump_messages()).unwrap_or(false) + std::panic::catch_unwind(|| { + runtime::node_api::drain(); + runtime::pump_messages() + }) + .unwrap_or(false) } /// Free a string previously returned by `runtime_devtools_start`. diff --git a/packages/windows-v8/vendor/shim/v8-api-internals.h b/packages/windows-v8/vendor/shim/v8-api-internals.h index f06e2ca..a692117 100644 --- a/packages/windows-v8/vendor/shim/v8-api-internals.h +++ b/packages/windows-v8/vendor/shim/v8-api-internals.h @@ -101,9 +101,7 @@ class PersistentToLocal { #define CHECK_LE(a, b) CHECK((a) <= (b)) #endif -// [BABYLON-NATIVE-ADDITION]: Increase perf by using internal field instead of private property -// [windows port] V8 14.7 removed Context::GetIsolate(); the host has the isolate entered. -#define NAPI_PRIVATE_KEY(context) \ - (v8::Private::New(v8::Isolate::GetCurrent())) +// [windows port] Type tags use napi_env__::TypeTagKey(): a stable private symbol. (This macro used +// to create a new, unique private on every call, so a tag could never be read back.) #endif // SRC_JS_NATIVE_API_V8_INTERNALS_H_ \ No newline at end of file diff --git a/packages/windows-v8/vendor/shim/v8-api.cpp b/packages/windows-v8/vendor/shim/v8-api.cpp index 4835b38..14b258b 100644 --- a/packages/windows-v8/vendor/shim/v8-api.cpp +++ b/packages/windows-v8/vendor/shim/v8-api.cpp @@ -2592,7 +2592,7 @@ napi_status NAPI_CDECL napi_type_tag_object(napi_env env, CHECK_TO_OBJECT_WITH_PREAMBLE(env, context, obj, object); CHECK_ARG_WITH_PREAMBLE(env, type_tag); - auto key = NAPI_PRIVATE_KEY(context); + auto key = env->TypeTagKey(); auto maybe_has = obj->HasPrivate(context, key); CHECK_MAYBE_NOTHING_WITH_PREAMBLE(env, maybe_has, napi_generic_failure); RETURN_STATUS_IF_FALSE_WITH_PREAMBLE( @@ -2622,7 +2622,7 @@ napi_status NAPI_CDECL napi_check_object_type_tag(napi_env env, CHECK_ARG_WITH_PREAMBLE(env, result); auto maybe_value = - obj->GetPrivate(context, NAPI_PRIVATE_KEY(context)); + obj->GetPrivate(context, env->TypeTagKey()); CHECK_MAYBE_EMPTY_WITH_PREAMBLE(env, maybe_value, napi_generic_failure); v8::Local val = maybe_value.ToLocalChecked(); diff --git a/packages/windows-v8/vendor/shim/v8-api.h b/packages/windows-v8/vendor/shim/v8-api.h index b4467b5..2ca8521 100644 --- a/packages/windows-v8/vendor/shim/v8-api.h +++ b/packages/windows-v8/vendor/shim/v8-api.h @@ -195,6 +195,16 @@ struct napi_env__ { v8::Isolate* const isolate; // Shortcut for context()->GetIsolate() v8impl::Persistent context_persistent; + // The private symbol type tags are stored under. It must be the same symbol for tagging and + // checking, so it is created once (Private::ForApi) and kept for the env's lifetime. + v8::Global type_tag_key; + inline v8::Local TypeTagKey() { + if (type_tag_key.IsEmpty()) { + type_tag_key.Reset(isolate, v8::Private::ForApi(isolate, v8::String::NewFromUtf8Literal(isolate, "napi:type_tag"))); + } + return type_tag_key.Get(isolate); + } + v8impl::Persistent last_exception; // Cache the template for NapiHostObject v8::Persistent host_object_template; diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index 99176ab..6e9de04 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -13,6 +13,8 @@ anyhow = "1.0" libffi = "5.1.0" # The classic engine (`classic` feature). The napi engines build without it and never link V8. v8 = { version = "147", optional = true } +# Node-API over the classic engine's V8, for loading native addons (`runtime::native_addons`). +napi-v8-shim = { path = "../napi-v8-shim", optional = true } chrono = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -73,7 +75,7 @@ features = [ default = ["classic"] # The classic engine: WinRT interop written against rusty_v8, which `nativescript.dll` is built on. # The napi engine packages turn default features off, so they don't compile or link V8. -classic = ["dep:v8"] +classic = ["dep:v8", "dep:napi-v8-shim"] devtools = ["classic", "runtime-devtools"] napi_engine = ["dep:napi"] # ES modules for napi engines without a native module loader (QuickJS, Hermes, JSC): modules are diff --git a/runtime/src/animation_frames.rs b/runtime/src/animation_frames.rs new file mode 100644 index 0000000..8cd31a1 --- /dev/null +++ b/runtime/src/animation_frames.rs @@ -0,0 +1,82 @@ +//! `requestAnimationFrame` for the classic engine. +//! +//! JS queues callbacks (the prelude's `requestAnimationFrame`) and asks for a frame with +//! `__nsRequestFrame()`. The host's pump (`runtime_pump_timers`, which the app template drives +//! once per compositor frame from `CompositionTarget.Rendering`, outside the render walk) then +//! runs the queued callbacks once and drains microtasks, which is where rendering work such as +//! canvas presents happens. Nothing waits for vsync on the UI thread, and a continuous rAF loop +//! gives the dispatcher back between frames. + +use std::cell::Cell; + +use crate::DELEGATE_ISOLATE_PTR; + +thread_local! { + static REQUESTED: Cell = const { Cell::new(false) }; +} + +/// `__nsRequestFrame()`: run animation callbacks at the next pump. +pub(crate) fn handle_request_frame( + _scope: &mut v8::PinScope<'_, '_>, + _args: v8::FunctionCallbackArguments, + _retval: v8::ReturnValue, +) { + REQUESTED.with(|r| r.set(true)); +} + +/// Drops a pending request (the runtime on this thread is going away). +pub(crate) fn clear_thread() { + REQUESTED.with(|r| r.set(false)); +} + +fn now_ms() -> f64 { + crate::globals::time::PROCESS_START + .get_or_init(std::time::Instant::now) + .elapsed() + .as_nanos() as f64 + / 1_000_000.0 +} + +/// Runs this thread's pending animation-frame callbacks, if a frame was requested, then drains +/// microtasks. Returns whether callbacks ran. +pub fn pump() -> bool { + if !REQUESTED.with(|r| r.replace(false)) { + return false; + } + let isolate_ptr = DELEGATE_ISOLATE_PTR.with(|c| c.get()); + if isolate_ptr.is_null() { + return false; + } + let isolate: &mut v8::Isolate = unsafe { &mut *isolate_ptr }; + v8::scope!(scope, isolate); + let Some(context) = scope.get_slot::>().cloned() else { + return false; + }; + let context = v8::Local::new(scope, &context); + let scope = &mut v8::ContextScope::new(scope, context); + v8::tc_scope!(tc, scope); + + let global = context.global(tc); + let run = v8::String::new(tc, "__nsRunAnimationFrames") + .and_then(|key| global.get(tc, key.into())) + .and_then(|value| v8::Local::::try_from(value).ok()); + let Some(run) = run else { + return false; + }; + let timestamp = v8::Number::new(tc, now_ms()); + let _ = run.call(tc, global.into(), &[timestamp.into()]); + if tc.has_caught() { + if let Some(message) = tc + .exception() + .and_then(|e| e.to_string(tc)) + .map(|s| s.to_rust_string_lossy(tc)) + { + eprintln!("[NativeScript] requestAnimationFrame error: {message}"); + } + tc.reset(); + } + if !crate::defer_microtask_drain() { + tc.perform_microtask_checkpoint(); + } + true +} diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index 9b16e64..5efc722 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -2922,47 +2922,35 @@ const HELPER_SOURCE: &str = r#" } })(); - // Uses __nsDwmFlush() — the Windows equivalent of Choreographer / - // CADisplayLink. DwmFlush() blocks the calling thread until the next - // monitor VSync, giving frame-perfect timing at any refresh rate - // (60 / 120 / 144 / 240 Hz) with no timer overhead. - // - // On headless systems DwmFlush() returns immediately (composition - // disabled), so rAF callbacks fire as fast as microtasks drain — - // ideal for tests and headless rendering scenarios. + // Callbacks run once per frame from the host's pump (runtime_pump_timers, driven by + // CompositionTarget.Rendering), never by blocking the UI thread on vsync: see + // runtime/src/animation_frames.rs. Callbacks requested during a frame run in the next. (function () { var _nextId = 0; var _pending = new Map(); - var _running = false; - - function _flush() { - if (_pending.size === 0) { _running = false; return; } - // Block until next VSync; returns ms timestamp. - var ts = (typeof __nsDwmFlush === 'function') - ? __nsDwmFlush() - : performance.now(); - var cbs = Array.from(_pending); - _pending.clear(); - for (var i = 0; i < cbs.length; i++) { - try { cbs[i][1](ts); } catch (e) { - console.log('rAF error:', e && e.message || e); - } - } - if (_pending.size > 0) queueMicrotask(_flush); - else _running = false; - } globalThis.requestAnimationFrame = function requestAnimationFrame(callback) { if (typeof callback !== 'function') return 0; var id = ++_nextId; _pending.set(id, callback); - if (!_running) { _running = true; queueMicrotask(_flush); } + if (_pending.size === 1) __nsRequestFrame(); return id; }; globalThis.cancelAnimationFrame = function cancelAnimationFrame(id) { _pending.delete(id); }; + + globalThis.__nsRunAnimationFrames = function (ts) { + if (_pending.size === 0) return; + var cbs = Array.from(_pending.values()); + _pending.clear(); + for (var i = 0; i < cbs.length; i++) { + try { cbs[i](ts); } catch (e) { + console.log('rAF error:', e && e.message || e); + } + } + }; })(); // The native classes are installed by install_url_globals() before this @@ -3696,6 +3684,12 @@ const HELPER_SOURCE: &str = r#" function makeRequire(callerFile) { return function require(specifier) { if (specifier === 'ns:module' && globalThis.__nsModuleBuiltin) return globalThis.__nsModuleBuiltin; + // Node-API addons: `system_lib://name.node` (next to the app executable) or a + // path to a `.node` file. + if (typeof specifier === 'string' && typeof globalThis.__nsLoadNativeAddon === 'function' && + (specifier.indexOf('system_lib://') === 0 || /\.node$/i.test(specifier))) { + return globalThis.__nsLoadNativeAddon(specifier, callerFile || '', globalThis.__nsAppRoot || ''); + } var resolved = resolveSpecifier(specifier, callerFile); if (!resolved) throw new Error('Cannot find module: ' + specifier); @@ -5032,6 +5026,7 @@ pub(crate) fn init_async_helpers( register!("__nsHostWaitForAsync", handle_host_wait_for_async); register!("__nsEnqueueMicrotask", handle_enqueue_microtask); register!("__nsPointerKey", handle_pointer_key); + register!("__nsLoadNativeAddon", crate::native_addons::handle_load_native_addon); register!("__nsBufferToPointer", handle_buffer_to_pointer); register!("__nsArrayBufferFromBuffer", handle_array_buffer_from_buffer); register!("__nsProxyWriteTextFile", handle_proxy_write_text_file); @@ -5080,6 +5075,7 @@ pub(crate) fn init_async_helpers( crate::timers::handle_ns_clear_interval ); register!("__nsDwmFlush", handle_dwm_flush); + register!("__nsRequestFrame", crate::animation_frames::handle_request_frame); register!("__tns_uptime", handle_tns_uptime); register!("__nsUUID", handle_ns_uuid); register!("__nsIsUiThread", handle_is_ui_thread); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 23c18bd..5e42fe4 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -31,6 +31,12 @@ mod name_space; #[cfg(feature = "napi_engine")] pub mod napi_engine; #[cfg(feature = "classic")] +pub mod animation_frames; +#[cfg(feature = "classic")] +mod native_addons; +#[cfg(feature = "classic")] +pub mod node_api; +#[cfg(feature = "classic")] mod ns_proxy; #[cfg(feature = "classic")] mod wrapper_cache; @@ -8890,6 +8896,7 @@ impl Drop for Runtime { DOTNET_JS_CALLBACKS.with(|m| m.borrow_mut().clear()); DOTNET_ONESHOT_JS_CALLBACKS.with(|m| m.borrow_mut().clear()); crate::timers::clear_thread_tasks(); + crate::animation_frames::clear_thread(); crate::websocket::clear_thread_sockets(); crate::globals::url::clear_thread_url_ctor(); crate::inspector::clear_thread_dispatchers(); diff --git a/runtime/src/native_addons.rs b/runtime/src/native_addons.rs new file mode 100644 index 0000000..ac054cc --- /dev/null +++ b/runtime/src/native_addons.rs @@ -0,0 +1,168 @@ +//! Loading native Node-API addons: `require('./x.node')` and `require('system_lib://x.node')` +//! (the form NativeScript plugins use on Android) resolve here instead of being read as JS. +//! +//! Each addon gets its own `napi_env` over the main context with the Node-API version it declares +//! (`node_api_module_get_api_version_v1`, else 8), and is initialised once per process, like Node. + +use std::cell::RefCell; +use std::path::{Path, PathBuf}; + +use windows::core::{PCSTR, PCWSTR}; +use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR, +}; + +const SYSTEM_LIB: &str = "system_lib://"; +const DEFAULT_MODULE_API_VERSION: i32 = 8; + +struct Addon { + key: String, + exports: v8::Global, +} + +thread_local! { + static ADDONS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +/// Where `system_lib://name` looks, in order: next to the host executable (where the app's native +/// libraries are deployed), then the app root. +fn resolve(specifier: &str, caller_file: &str, app_root: &str) -> Option { + let candidates: Vec = if let Some(name) = specifier.strip_prefix(SYSTEM_LIB) { + let mut dirs = Vec::new(); + if let Some(dir) = std::env::current_exe().ok().and_then(|p| p.parent().map(Path::to_path_buf)) { + dirs.push(dir); + } + if !app_root.is_empty() { + dirs.push(PathBuf::from(app_root)); + } + dirs.into_iter().map(|d| d.join(name)).collect() + } else { + let path = Path::new(specifier); + if path.is_absolute() { + vec![path.to_path_buf()] + } else { + let base = Path::new(caller_file) + .parent() + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(app_root)); + vec![base.join(path)] + } + }; + candidates.into_iter().find(|p| p.is_file()) +} + +fn wide(path: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + path.as_os_str().encode_wide().chain(std::iter::once(0)).collect() +} + +fn throw(scope: &mut v8::PinScope, message: &str) { + if let Some(text) = v8::String::new(scope, message) { + let error = v8::Exception::error(scope, text); + scope.throw_exception(error); + } +} + +/// `__nsLoadNativeAddon(specifier, callerFile, appRoot)` → the addon's exports. +pub(crate) fn handle_load_native_addon( + scope: &mut v8::PinScope, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let specifier = args.get(0).to_rust_string_lossy(scope); + let caller = if args.length() > 1 && args.get(1).is_string() { + args.get(1).to_rust_string_lossy(scope) + } else { + String::new() + }; + let app_root = if args.length() > 2 && args.get(2).is_string() { + args.get(2).to_rust_string_lossy(scope) + } else { + String::new() + }; + + let Some(path) = resolve(&specifier, &caller, &app_root) else { + throw(scope, &format!("Cannot find native module: {specifier}")); + return; + }; + let path = path.canonicalize().unwrap_or(path); + let key = path.to_string_lossy().to_lowercase(); + + let cached = ADDONS.with(|a| { + a.borrow() + .iter() + .find(|addon| addon.key == key) + .map(|addon| v8::Local::new(scope, &addon.exports)) + }); + if let Some(exports) = cached { + retval.set(exports); + return; + } + + match load(scope, &path) { + Ok(exports) => { + let global = v8::Global::new(scope, exports); + ADDONS.with(|a| a.borrow_mut().push(Addon { key, exports: global })); + retval.set(exports); + } + Err(message) => throw(scope, &message), + } +} + +fn load<'s>(scope: &mut v8::PinScope<'s, '_>, path: &Path) -> Result, String> { + let display = path.display().to_string(); + let wide_path = wide(path); + + // A static-constructor `napi_module_register` call happens inside LoadLibrary. + let _ = crate::node_api::take_legacy_module(); + let module = unsafe { + LoadLibraryExW( + PCWSTR(wide_path.as_ptr()), + None, + LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS, + ) + } + .map_err(|e| format!("Failed to load native module {display}: {e}"))?; + + let init: napi_v8_shim::AddonInit = match unsafe { GetProcAddress(module, PCSTR(c"napi_register_module_v1".as_ptr() as *const u8)) } { + Some(f) => unsafe { std::mem::transmute(f) }, + None => crate::node_api::take_legacy_module() + .ok_or_else(|| format!("{display} is not a Node-API module (no napi_register_module_v1)"))?, + }; + + let version = match unsafe { GetProcAddress(module, PCSTR(c"node_api_module_get_api_version_v1".as_ptr() as *const u8)) } { + Some(f) => { + let get: unsafe extern "C" fn() -> i32 = unsafe { std::mem::transmute(f) }; + unsafe { get() } + } + None => DEFAULT_MODULE_API_VERSION, + }; + + let context = scope.get_current_context(); + let env = unsafe { napi_v8_shim::ns_napi_env_create(&*context as *const v8::Context as *const _, version) }; + if env.is_null() { + return Err(format!("Failed to create a Node-API environment for {display}")); + } + crate::node_api::register_env(env); + crate::node_api::set_module_file_name(env, &format!("file:///{}", display.replace('\\', "/"))); + + let exports = v8::Object::new(scope); + let exports_value: v8::Local = exports.into(); + v8::tc_scope!(tc, scope); + let result = unsafe { + napi_v8_shim::ns_napi_call_module_init(env, init, &*exports_value as *const v8::Value as *mut _) + }; + if tc.has_caught() { + let message = tc + .exception() + .and_then(|e| e.to_string(tc)) + .map(|s| s.to_rust_string_lossy(tc)) + .unwrap_or_else(|| "exception".into()); + return Err(format!("Initialising native module {display} threw: {message}")); + } + if result.is_null() { + return Ok(exports_value); + } + // A napi_value is the pointer inside a v8::Local in the current handle scope. + Ok(unsafe { std::mem::transmute::<*mut std::ffi::c_void, v8::Local<'s, v8::Value>>(result) }) +} diff --git a/runtime/src/node_api.rs b/runtime/src/node_api.rs new file mode 100644 index 0000000..3e494d0 --- /dev/null +++ b/runtime/src/node_api.rs @@ -0,0 +1,1080 @@ +//! The Node-specific half of Node-API (`node_api.h`) for native addons on the classic engine. +//! +//! The engine half (`js_native_api.h`: values, objects, references, wrapping, …) comes from the +//! V8 shim in `napi-v8-shim`. What Node itself provides on top -- threadsafe functions, async +//! work, env cleanup hooks, buffers, callback scopes, `napi_module_register` -- is implemented here +//! and exported from `nativescript.dll`, so an addon built for Node (napi-rs, node-addon-api, …) +//! resolves every symbol it imports. +//! +//! Anything that must run on the JS thread goes through one job queue. Other threads push jobs and +//! wake the UI thread with a `DispatcherQueue` work item; `runtime_pump_timers` drains it too, so a +//! host without a dispatcher still makes progress. Each job runs in its own V8 scope and ends with +//! a microtask checkpoint, like a timer task. + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::ffi::{c_char, c_void}; +use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc::{channel, Sender}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; + +use crate::DELEGATE_ISOLATE_PTR; + +#[allow(non_camel_case_types)] +pub type napi_env = *mut c_void; +#[allow(non_camel_case_types)] +pub type napi_value = *mut c_void; +#[allow(non_camel_case_types)] +type napi_ref = *mut c_void; +#[allow(non_camel_case_types)] +type napi_status = i32; + +const NAPI_OK: napi_status = 0; +const NAPI_INVALID_ARG: napi_status = 1; +const NAPI_GENERIC_FAILURE: napi_status = 9; +const NAPI_CANCELLED: napi_status = 11; +const NAPI_QUEUE_FULL: napi_status = 15; +const NAPI_CLOSING: napi_status = 16; + +const UINT8_ARRAY: i32 = 1; + +#[allow(non_camel_case_types)] +type napi_finalize = Option; +#[allow(non_camel_case_types)] +type napi_threadsafe_function_call_js = + Option; +#[allow(non_camel_case_types)] +type napi_async_execute_callback = Option; +#[allow(non_camel_case_types)] +type napi_async_complete_callback = Option; +#[allow(non_camel_case_types)] +type napi_cleanup_hook = Option; +#[allow(non_camel_case_types)] +type napi_async_cleanup_hook = Option; + +// The engine half, from the V8 shim linked into the same DLL. +extern "C" { + fn napi_open_handle_scope(env: napi_env, result: *mut *mut c_void) -> napi_status; + fn napi_close_handle_scope(env: napi_env, scope: *mut c_void) -> napi_status; + fn napi_create_reference(env: napi_env, value: napi_value, initial: u32, result: *mut napi_ref) -> napi_status; + fn napi_delete_reference(env: napi_env, reference: napi_ref) -> napi_status; + fn napi_get_reference_value(env: napi_env, reference: napi_ref, result: *mut napi_value) -> napi_status; + fn napi_get_undefined(env: napi_env, result: *mut napi_value) -> napi_status; + fn napi_call_function( + env: napi_env, + recv: napi_value, + func: napi_value, + argc: usize, + argv: *const napi_value, + result: *mut napi_value, + ) -> napi_status; + fn napi_create_arraybuffer(env: napi_env, length: usize, data: *mut *mut c_void, result: *mut napi_value) -> napi_status; + fn napi_create_external_arraybuffer( + env: napi_env, + data: *mut c_void, + length: usize, + finalize_cb: napi_finalize, + finalize_hint: *mut c_void, + result: *mut napi_value, + ) -> napi_status; + fn napi_create_typedarray( + env: napi_env, + kind: i32, + length: usize, + arraybuffer: napi_value, + byte_offset: usize, + result: *mut napi_value, + ) -> napi_status; + fn napi_is_typedarray(env: napi_env, value: napi_value, result: *mut bool) -> napi_status; + fn napi_is_dataview(env: napi_env, value: napi_value, result: *mut bool) -> napi_status; + fn napi_get_typedarray_info( + env: napi_env, + value: napi_value, + kind: *mut i32, + length: *mut usize, + data: *mut *mut c_void, + arraybuffer: *mut napi_value, + byte_offset: *mut usize, + ) -> napi_status; + fn napi_get_dataview_info( + env: napi_env, + value: napi_value, + byte_length: *mut usize, + data: *mut *mut c_void, + arraybuffer: *mut napi_value, + byte_offset: *mut usize, + ) -> napi_status; + fn napi_is_exception_pending(env: napi_env, result: *mut bool) -> napi_status; + fn napi_get_and_clear_last_exception(env: napi_env, result: *mut napi_value) -> napi_status; + fn napi_coerce_to_string(env: napi_env, value: napi_value, result: *mut napi_value) -> napi_status; + fn napi_get_value_string_utf8( + env: napi_env, + value: napi_value, + buf: *mut c_char, + bufsize: usize, + result: *mut usize, + ) -> napi_status; +} + +// --------------------------------------------------------------------------------------------- +// The JS-thread job queue. + +enum Job { + Tsfn(Arc), + AsyncComplete(usize), +} + +// Jobs only carry `Arc` (Send+Sync below) and pointers used on the JS thread. +unsafe impl Send for Job {} + +static QUEUE: Mutex> = Mutex::new(VecDeque::new()); +static WAKE_QUEUED: AtomicBool = AtomicBool::new(false); +/// Set by [`teardown`]: the runtime is going away, so late work (a cleanup hook releasing a +/// threadsafe function, a dispatcher item that runs during shutdown) must not touch V8. +static SHUT_DOWN: AtomicBool = AtomicBool::new(false); + +fn push(job: Job) { + if SHUT_DOWN.load(Ordering::Acquire) { + return; + } + QUEUE.lock().unwrap().push_back(job); + wake(); +} + +fn wake() { + if SHUT_DOWN.load(Ordering::Acquire) || WAKE_QUEUED.swap(true, Ordering::AcqRel) { + return; + } + // A dispatcher work item runs between frames, never inside a XAML callout. Hosts without one + // (console apps, tests) drain from their pump loop instead. + if !crate::ui_dispatcher::enqueue_on_ui_thread(|| { + let _ = std::panic::catch_unwind(drain); + }) { + WAKE_QUEUED.store(false, Ordering::Release); + } +} + +/// Runs every queued JS-thread job and every env's deferred finalizers. Called from the dispatcher +/// wake-up and from `runtime_pump_timers`; a no-op when there is nothing to do. +pub fn drain() { + WAKE_QUEUED.store(false, Ordering::Release); + if SHUT_DOWN.load(Ordering::Acquire) { + return; + } + loop { + let job = QUEUE.lock().unwrap().pop_front(); + let Some(job) = job else { break }; + match job { + Job::Tsfn(tsfn) => in_js_scope(|| unsafe { tsfn.dispatch() }), + Job::AsyncComplete(work) => in_js_scope(|| unsafe { AsyncWork::complete(work as *mut AsyncWork) }), + } + } + drain_finalizers(); +} + +thread_local! { + /// Envs created for native addons on this (the JS) thread. + static ENVS: RefCell> = const { RefCell::new(Vec::new()) }; + /// `napi_open_callback_scope` depth; microtasks run when the outermost scope closes. + static CALLBACK_DEPTH: Cell = const { Cell::new(0) }; +} + +pub(crate) fn register_env(env: napi_env) { + // A new runtime on this thread loads addons again. + SHUT_DOWN.store(false, Ordering::Release); + ENVS.with(|e| e.borrow_mut().push(env)); +} + +fn drain_finalizers() { + let envs: Vec = ENVS.with(|e| e.borrow().clone()); + for env in envs { + if unsafe { napi_v8_shim::ns_napi_has_pending_finalizers(env) } { + in_js_scope(|| unsafe { napi_v8_shim::ns_napi_drain_finalizers(env) }); + } + } +} + +/// Tears down every addon env (runs cleanup hooks first, LIFO, as Node does at exit). +pub fn teardown() { + let envs: Vec = ENVS.with(|e| std::mem::take(&mut *e.borrow_mut())); + if envs.is_empty() { + return; + } + // Deliver what is already queued while everything is still alive, then stop accepting work. + drain(); + SHUT_DOWN.store(true, Ordering::Release); + QUEUE.lock().unwrap().clear(); + for &env in envs.iter().rev() { + run_cleanup_hooks(env); + in_js_scope(|| unsafe { napi_v8_shim::ns_napi_env_teardown(env) }); + } +} + +/// Runs `f` with the main context entered, reports an exception it leaves pending, and ends with a +/// microtask checkpoint (deferred when inside a XAML callout). +fn in_js_scope(f: impl FnOnce()) { + let isolate_ptr = DELEGATE_ISOLATE_PTR.with(|c| c.get()); + if isolate_ptr.is_null() { + return; + } + let isolate: &mut v8::Isolate = unsafe { &mut *isolate_ptr }; + v8::scope!(scope, isolate); + let Some(context) = scope.get_slot::>().cloned() else { return }; + let context = v8::Local::new(scope, &context); + let scope = &mut v8::ContextScope::new(scope, context); + v8::tc_scope!(tc, scope); + + f(); + + if tc.has_caught() { + if let Some(exception) = tc.exception() { + let message = exception + .to_string(tc) + .map(|s| s.to_rust_string_lossy(tc)) + .unwrap_or_else(|| "".into()); + eprintln!("[NativeScript] uncaught exception in a native addon callback: {message}"); + crate::store_last_js_error(message); + } + tc.reset(); + } + if !crate::defer_microtask_drain() { + tc.perform_microtask_checkpoint(); + } +} + +/// Reports an exception a callback left pending on `env` (outside `in_js_scope`'s TryCatch). +unsafe fn report_pending(env: napi_env) { + let mut pending = false; + napi_is_exception_pending(env, &mut pending); + if !pending { + return; + } + let mut error = ptr::null_mut(); + napi_get_and_clear_last_exception(env, &mut error); + let mut text = ptr::null_mut(); + let mut message = String::from(""); + if napi_coerce_to_string(env, error, &mut text) == NAPI_OK { + let mut len = 0usize; + napi_get_value_string_utf8(env, text, ptr::null_mut(), 0, &mut len); + let mut buf = vec![0u8; len + 1]; + napi_get_value_string_utf8(env, text, buf.as_mut_ptr() as *mut c_char, len + 1, &mut len); + buf.truncate(len); + message = String::from_utf8_lossy(&buf).into_owned(); + } + eprintln!("[NativeScript] uncaught exception in a native addon callback: {message}"); + crate::store_last_js_error(message); +} + +struct HandleScope(napi_env, *mut c_void); + +impl HandleScope { + unsafe fn open(env: napi_env) -> HandleScope { + let mut scope = ptr::null_mut(); + napi_open_handle_scope(env, &mut scope); + HandleScope(env, scope) + } +} + +impl Drop for HandleScope { + fn drop(&mut self) { + unsafe { + napi_close_handle_scope(self.0, self.1); + } + } +} + +// --------------------------------------------------------------------------------------------- +// Worker pool for async work. + +type Task = Box; + +fn pool() -> &'static Mutex> { + static POOL: OnceLock>> = OnceLock::new(); + POOL.get_or_init(|| { + let (tx, rx) = channel::(); + let rx = Arc::new(Mutex::new(rx)); + let workers = std::thread::available_parallelism().map_or(2, |n| n.get()).clamp(2, 4); + for i in 0..workers { + let rx = rx.clone(); + let _ = std::thread::Builder::new() + .name(format!("napi-worker-{i}")) + .spawn(move || loop { + let task = match rx.lock() { + Ok(rx) => rx.recv(), + Err(_) => return, + }; + match task { + Ok(task) => { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(task)); + } + Err(_) => return, + } + }); + } + Mutex::new(tx) + }) +} + +// --------------------------------------------------------------------------------------------- +// Threadsafe functions. + +struct TsfnState { + queue: VecDeque<*mut c_void>, + thread_count: usize, + closing: bool, + aborted: bool, + finalize_queued: bool, + finalized: bool, +} + +struct Tsfn { + env: napi_env, + func: napi_ref, + context: *mut c_void, + call_js: napi_threadsafe_function_call_js, + finalize_cb: napi_finalize, + finalize_data: *mut c_void, + max_queue_size: usize, + state: Mutex, + space: Condvar, +} + +// Queued data pointers and env/ref handles are only dereferenced on the JS thread. +unsafe impl Send for Tsfn {} +unsafe impl Sync for Tsfn {} + +impl Tsfn { + fn from_handle(handle: *mut c_void) -> Option<&'static Tsfn> { + (!handle.is_null()).then(|| unsafe { &*(handle as *const Tsfn) }) + } + + unsafe fn arc(handle: *mut c_void) -> Arc { + Arc::increment_strong_count(handle as *const Tsfn); + Arc::from_raw(handle as *const Tsfn) + } + + /// JS thread: deliver queued calls, then finalize if the function is done. + unsafe fn dispatch(self: &Arc) { + loop { + let (data, done) = { + let mut state = self.state.lock().unwrap(); + if state.finalized { + return; + } + if state.aborted { + (None, true) + } else { + let data = state.queue.pop_front(); + self.space.notify_one(); + let done = data.is_none() && state.thread_count == 0; + (data, done) + } + }; + match data { + Some(data) => self.call(data), + None => { + if done { + self.finalize(); + } + return; + } + } + } + } + + unsafe fn call(&self, data: *mut c_void) { + let _scope = HandleScope::open(self.env); + let mut func = ptr::null_mut(); + if !self.func.is_null() { + napi_get_reference_value(self.env, self.func, &mut func); + } + match self.call_js { + Some(call_js) => call_js(self.env, func, self.context, data), + None if !func.is_null() => { + let mut recv = ptr::null_mut(); + napi_get_undefined(self.env, &mut recv); + let mut result = ptr::null_mut(); + napi_call_function(self.env, recv, func, 0, ptr::null(), &mut result); + } + None => {} + } + report_pending(self.env); + } + + unsafe fn finalize(self: &Arc) { + let leftover = { + let mut state = self.state.lock().unwrap(); + if state.finalized { + return; + } + state.finalized = true; + state.closing = true; + self.space.notify_all(); + std::mem::take(&mut state.queue) + }; + // Items never delivered (abort) still go to call_js, with a null env, so the addon can + // free them -- Node's contract. + if let Some(call_js) = self.call_js { + for data in leftover { + call_js(ptr::null_mut(), ptr::null_mut(), self.context, data); + } + } + let _scope = HandleScope::open(self.env); + if let Some(finalize) = self.finalize_cb { + finalize(self.env, self.finalize_data, self.context); + report_pending(self.env); + } + if !self.func.is_null() { + napi_delete_reference(self.env, self.func); + } + // The handle's own reference. + drop(Arc::from_raw(Arc::as_ptr(self))); + } + + fn queue_finalize(self: &Arc, state: &mut TsfnState) { + if !state.finalize_queued { + state.finalize_queued = true; + push(Job::Tsfn(self.clone())); + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn napi_create_threadsafe_function( + env: napi_env, + func: napi_value, + _async_resource: napi_value, + _async_resource_name: napi_value, + max_queue_size: usize, + initial_thread_count: usize, + thread_finalize_data: *mut c_void, + thread_finalize_cb: napi_finalize, + context: *mut c_void, + call_js_cb: napi_threadsafe_function_call_js, + result: *mut *mut c_void, +) -> napi_status { + if env.is_null() || result.is_null() || initial_thread_count == 0 || (func.is_null() && call_js_cb.is_none()) { + return NAPI_INVALID_ARG; + } + let mut reference = ptr::null_mut(); + if !func.is_null() && napi_create_reference(env, func, 1, &mut reference) != NAPI_OK { + return NAPI_GENERIC_FAILURE; + } + let tsfn = Arc::new(Tsfn { + env, + func: reference, + context, + call_js: call_js_cb, + finalize_cb: thread_finalize_cb, + finalize_data: thread_finalize_data, + max_queue_size, + state: Mutex::new(TsfnState { + queue: VecDeque::new(), + thread_count: initial_thread_count, + closing: false, + aborted: false, + finalize_queued: false, + finalized: false, + }), + space: Condvar::new(), + }); + *result = Arc::into_raw(tsfn) as *mut c_void; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_call_threadsafe_function(func: *mut c_void, data: *mut c_void, is_blocking: i32) -> napi_status { + let Some(tsfn) = Tsfn::from_handle(func) else { return NAPI_INVALID_ARG }; + let mut state = tsfn.state.lock().unwrap(); + loop { + if state.closing || state.aborted { + return NAPI_CLOSING; + } + if tsfn.max_queue_size == 0 || state.queue.len() < tsfn.max_queue_size { + break; + } + if is_blocking == 0 { + return NAPI_QUEUE_FULL; + } + state = tsfn.space.wait(state).unwrap(); + } + state.queue.push_back(data); + drop(state); + push(Job::Tsfn(Tsfn::arc(func))); + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_acquire_threadsafe_function(func: *mut c_void) -> napi_status { + let Some(tsfn) = Tsfn::from_handle(func) else { return NAPI_INVALID_ARG }; + let mut state = tsfn.state.lock().unwrap(); + if state.closing || state.aborted { + return NAPI_CLOSING; + } + state.thread_count += 1; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_release_threadsafe_function(func: *mut c_void, mode: i32) -> napi_status { + let Some(tsfn) = Tsfn::from_handle(func) else { return NAPI_INVALID_ARG }; + let arc = Tsfn::arc(func); + let mut state = tsfn.state.lock().unwrap(); + if state.thread_count == 0 { + return NAPI_INVALID_ARG; + } + state.thread_count -= 1; + if mode == 1 { + // napi_tsfn_abort + state.closing = true; + state.aborted = true; + tsfn.space.notify_all(); + } + if state.thread_count == 0 || state.aborted { + state.closing = true; + arc.queue_finalize(&mut state); + } + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_get_threadsafe_function_context(func: *mut c_void, result: *mut *mut c_void) -> napi_status { + let Some(tsfn) = Tsfn::from_handle(func) else { return NAPI_INVALID_ARG }; + if result.is_null() { + return NAPI_INVALID_ARG; + } + *result = tsfn.context; + NAPI_OK +} + +/// The runtime's lifetime is the app's, not an event loop's, so ref/unref have nothing to keep +/// alive; they are accepted for API compatibility. +#[no_mangle] +pub unsafe extern "C" fn napi_ref_threadsafe_function(_env: napi_env, func: *mut c_void) -> napi_status { + if func.is_null() { NAPI_INVALID_ARG } else { NAPI_OK } +} + +#[no_mangle] +pub unsafe extern "C" fn napi_unref_threadsafe_function(_env: napi_env, func: *mut c_void) -> napi_status { + if func.is_null() { NAPI_INVALID_ARG } else { NAPI_OK } +} + +// --------------------------------------------------------------------------------------------- +// Async work. + +const WORK_IDLE: u8 = 0; +const WORK_QUEUED: u8 = 1; +const WORK_RUNNING: u8 = 2; +const WORK_CANCELLED: u8 = 3; + +struct AsyncWork { + env: napi_env, + execute: napi_async_execute_callback, + complete: napi_async_complete_callback, + data: *mut c_void, + state: AtomicU8, +} + +impl AsyncWork { + /// JS thread. + unsafe fn complete(work: *mut AsyncWork) { + let work = &*work; + let status = if work.state.swap(WORK_IDLE, Ordering::AcqRel) == WORK_CANCELLED { + NAPI_CANCELLED + } else { + NAPI_OK + }; + if let Some(complete) = work.complete { + let _scope = HandleScope::open(work.env); + complete(work.env, status, work.data); + report_pending(work.env); + } + } +} + +#[no_mangle] +pub unsafe extern "C" fn napi_create_async_work( + env: napi_env, + _async_resource: napi_value, + _async_resource_name: napi_value, + execute: napi_async_execute_callback, + complete: napi_async_complete_callback, + data: *mut c_void, + result: *mut *mut c_void, +) -> napi_status { + if env.is_null() || execute.is_none() || result.is_null() { + return NAPI_INVALID_ARG; + } + *result = Box::into_raw(Box::new(AsyncWork { + env, + execute, + complete, + data, + state: AtomicU8::new(WORK_IDLE), + })) as *mut c_void; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_delete_async_work(_env: napi_env, work: *mut c_void) -> napi_status { + if work.is_null() { + return NAPI_INVALID_ARG; + } + drop(Box::from_raw(work as *mut AsyncWork)); + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_queue_async_work(_env: napi_env, work: *mut c_void) -> napi_status { + if work.is_null() { + return NAPI_INVALID_ARG; + } + let entry = &*(work as *const AsyncWork); + if entry + .state + .compare_exchange(WORK_IDLE, WORK_QUEUED, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return NAPI_GENERIC_FAILURE; + } + let address = work as usize; + let task: Task = Box::new(move || { + let work = unsafe { &*(address as *const AsyncWork) }; + if work + .state + .compare_exchange(WORK_QUEUED, WORK_RUNNING, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + if let Some(execute) = work.execute { + unsafe { execute(work.env, work.data) }; + } + } + push(Job::AsyncComplete(address)); + }); + match pool().lock() { + Ok(tx) if tx.send(task).is_ok() => NAPI_OK, + _ => NAPI_GENERIC_FAILURE, + } +} + +#[no_mangle] +pub unsafe extern "C" fn napi_cancel_async_work(_env: napi_env, work: *mut c_void) -> napi_status { + if work.is_null() { + return NAPI_INVALID_ARG; + } + let work = &*(work as *const AsyncWork); + match work + .state + .compare_exchange(WORK_QUEUED, WORK_CANCELLED, Ordering::AcqRel, Ordering::Acquire) + { + Ok(_) => NAPI_OK, + Err(_) => NAPI_GENERIC_FAILURE, + } +} + +// --------------------------------------------------------------------------------------------- +// Cleanup hooks. + +#[derive(Clone, Copy, PartialEq)] +enum Hook { + Sync(unsafe extern "C" fn(*mut c_void), usize), + Async(usize), +} + +struct AsyncHook { + hook: unsafe extern "C" fn(*mut c_void, *mut c_void), + arg: *mut c_void, + env: napi_env, +} + +static HOOKS: Mutex>>> = Mutex::new(None); + +fn with_hooks(f: impl FnOnce(&mut HashMap>) -> R) -> R { + let mut guard = HOOKS.lock().unwrap(); + f(guard.get_or_insert_with(HashMap::new)) +} + +#[no_mangle] +pub unsafe extern "C" fn napi_add_env_cleanup_hook(env: napi_env, fun: napi_cleanup_hook, arg: *mut c_void) -> napi_status { + let (Some(fun), false) = (fun, env.is_null()) else { return NAPI_INVALID_ARG }; + with_hooks(|hooks| hooks.entry(env as usize).or_default().push(Hook::Sync(fun, arg as usize))); + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_remove_env_cleanup_hook(env: napi_env, fun: napi_cleanup_hook, arg: *mut c_void) -> napi_status { + let (Some(fun), false) = (fun, env.is_null()) else { return NAPI_INVALID_ARG }; + with_hooks(|hooks| { + if let Some(list) = hooks.get_mut(&(env as usize)) { + if let Some(i) = list.iter().rposition(|h| *h == Hook::Sync(fun, arg as usize)) { + list.remove(i); + } + } + }); + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_add_async_cleanup_hook( + env: napi_env, + hook: napi_async_cleanup_hook, + arg: *mut c_void, + remove_handle: *mut *mut c_void, +) -> napi_status { + let (Some(hook), false) = (hook, env.is_null()) else { return NAPI_INVALID_ARG }; + let handle = Box::into_raw(Box::new(AsyncHook { hook, arg, env })); + with_hooks(|hooks| hooks.entry(env as usize).or_default().push(Hook::Async(handle as usize))); + if !remove_handle.is_null() { + *remove_handle = handle as *mut c_void; + } + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_remove_async_cleanup_hook(remove_handle: *mut c_void) -> napi_status { + if remove_handle.is_null() { + return NAPI_INVALID_ARG; + } + let handle = Box::from_raw(remove_handle as *mut AsyncHook); + with_hooks(|hooks| { + if let Some(list) = hooks.get_mut(&(handle.env as usize)) { + list.retain(|h| *h != Hook::Async(remove_handle as usize)); + } + }); + NAPI_OK +} + +fn run_cleanup_hooks(env: napi_env) { + let hooks = with_hooks(|hooks| hooks.remove(&(env as usize))).unwrap_or_default(); + for hook in hooks.into_iter().rev() { + match hook { + Hook::Sync(fun, arg) => unsafe { fun(arg as *mut c_void) }, + // The hook calls napi_remove_async_cleanup_hook (freeing the handle) when it is done. + Hook::Async(handle) => unsafe { + let entry = &*(handle as *const AsyncHook); + (entry.hook)(handle as *mut c_void, entry.arg) + }, + } + } +} + +// --------------------------------------------------------------------------------------------- +// Buffers (Node's Buffer is a Uint8Array). + +#[no_mangle] +pub unsafe extern "C" fn napi_create_buffer(env: napi_env, size: usize, data: *mut *mut c_void, result: *mut napi_value) -> napi_status { + let mut buffer = ptr::null_mut(); + let status = napi_create_arraybuffer(env, size, data, &mut buffer); + if status != NAPI_OK { + return status; + } + napi_create_typedarray(env, UINT8_ARRAY, size, buffer, 0, result) +} + +#[no_mangle] +pub unsafe extern "C" fn napi_create_buffer_copy( + env: napi_env, + length: usize, + data: *const c_void, + result_data: *mut *mut c_void, + result: *mut napi_value, +) -> napi_status { + let mut out = ptr::null_mut(); + let status = napi_create_buffer(env, length, &mut out, result); + if status != NAPI_OK { + return status; + } + if length > 0 && !data.is_null() { + ptr::copy_nonoverlapping(data as *const u8, out as *mut u8, length); + } + if !result_data.is_null() { + *result_data = out; + } + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_create_external_buffer( + env: napi_env, + length: usize, + data: *mut c_void, + finalize_cb: napi_finalize, + finalize_hint: *mut c_void, + result: *mut napi_value, +) -> napi_status { + let mut buffer = ptr::null_mut(); + let status = napi_create_external_arraybuffer(env, data, length, finalize_cb, finalize_hint, &mut buffer); + if status != NAPI_OK { + return status; + } + napi_create_typedarray(env, UINT8_ARRAY, length, buffer, 0, result) +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_create_buffer_from_arraybuffer( + env: napi_env, + arraybuffer: napi_value, + byte_offset: usize, + byte_length: usize, + result: *mut napi_value, +) -> napi_status { + napi_create_typedarray(env, UINT8_ARRAY, byte_length, arraybuffer, byte_offset, result) +} + +fn element_size(kind: i32) -> usize { + match kind { + 0..=2 => 1, + 3 | 4 => 2, + 5..=7 => 4, + _ => 8, + } +} + +#[no_mangle] +pub unsafe extern "C" fn napi_get_buffer_info(env: napi_env, value: napi_value, data: *mut *mut c_void, length: *mut usize) -> napi_status { + let mut is_typed = false; + napi_is_typedarray(env, value, &mut is_typed); + let (mut out_data, mut out_len) = (ptr::null_mut(), 0usize); + let mut arraybuffer = ptr::null_mut(); + let mut offset = 0usize; + if is_typed { + let mut kind = 0; + let status = napi_get_typedarray_info(env, value, &mut kind, &mut out_len, &mut out_data, &mut arraybuffer, &mut offset); + if status != NAPI_OK { + return status; + } + out_len *= element_size(kind); + } else { + let status = napi_get_dataview_info(env, value, &mut out_len, &mut out_data, &mut arraybuffer, &mut offset); + if status != NAPI_OK { + return status; + } + } + if !data.is_null() { + *data = out_data; + } + if !length.is_null() { + *length = out_len; + } + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_is_buffer(env: napi_env, value: napi_value, result: *mut bool) -> napi_status { + if result.is_null() { + return NAPI_INVALID_ARG; + } + let mut typed = false; + let mut view = false; + napi_is_typedarray(env, value, &mut typed); + if !typed { + napi_is_dataview(env, value, &mut view); + } + *result = typed || view; + NAPI_OK +} + +// --------------------------------------------------------------------------------------------- +// Callbacks, async context, versions, errors, legacy registration. + +fn microtask_checkpoint() { + let isolate_ptr = DELEGATE_ISOLATE_PTR.with(|c| c.get()); + if isolate_ptr.is_null() || crate::defer_microtask_drain() { + return; + } + let isolate: &mut v8::Isolate = unsafe { &mut *isolate_ptr }; + isolate.perform_microtask_checkpoint(); +} + +#[no_mangle] +pub unsafe extern "C" fn napi_make_callback( + env: napi_env, + _async_context: *mut c_void, + recv: napi_value, + func: napi_value, + argc: usize, + argv: *const napi_value, + result: *mut napi_value, +) -> napi_status { + let mut ignored = ptr::null_mut(); + let result = if result.is_null() { &mut ignored } else { result }; + let status = napi_call_function(env, recv, func, argc, argv, result); + if CALLBACK_DEPTH.with(|d| d.get()) == 0 { + microtask_checkpoint(); + } + status +} + +#[no_mangle] +pub unsafe extern "C" fn napi_open_callback_scope( + _env: napi_env, + _resource: napi_value, + _context: *mut c_void, + result: *mut *mut c_void, +) -> napi_status { + if result.is_null() { + return NAPI_INVALID_ARG; + } + let depth = CALLBACK_DEPTH.with(|d| { + d.set(d.get() + 1); + d.get() + }); + *result = depth as usize as *mut c_void; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_close_callback_scope(_env: napi_env, scope: *mut c_void) -> napi_status { + if scope.is_null() { + return NAPI_INVALID_ARG; + } + let depth = CALLBACK_DEPTH.with(|d| { + d.set(d.get().saturating_sub(1)); + d.get() + }); + if depth == 0 { + microtask_checkpoint(); + } + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_async_init( + _env: napi_env, + _async_resource: napi_value, + _async_resource_name: napi_value, + result: *mut *mut c_void, +) -> napi_status { + if result.is_null() { + return NAPI_INVALID_ARG; + } + // No async_hooks: the context is an opaque non-null token. + *result = ptr::NonNull::::dangling().as_ptr() as *mut c_void; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_async_destroy(_env: napi_env, _async_context: *mut c_void) -> napi_status { + NAPI_OK +} + +#[repr(C)] +pub struct NapiNodeVersion { + major: u32, + minor: u32, + patch: u32, + release: *const c_char, +} + +unsafe impl Sync for NapiNodeVersion {} + +/// Addons use this for feature checks; report the Node-API level of a current Node LTS. +static NODE_VERSION: NapiNodeVersion = NapiNodeVersion { + major: 22, + minor: 0, + patch: 0, + release: c"nativescript".as_ptr(), +}; + +#[no_mangle] +pub unsafe extern "C" fn napi_get_node_version(_env: napi_env, result: *mut *const NapiNodeVersion) -> napi_status { + if result.is_null() { + return NAPI_INVALID_ARG; + } + *result = &NODE_VERSION; + NAPI_OK +} + +#[no_mangle] +pub unsafe extern "C" fn napi_get_uv_event_loop(_env: napi_env, result: *mut *mut c_void) -> napi_status { + if !result.is_null() { + *result = ptr::null_mut(); + } + NAPI_GENERIC_FAILURE +} + +unsafe fn lossy(text: *const c_char, len: usize) -> String { + if text.is_null() { + return String::new(); + } + let bytes = if len == usize::MAX { + std::ffi::CStr::from_ptr(text).to_bytes() + } else { + std::slice::from_raw_parts(text as *const u8, len) + }; + String::from_utf8_lossy(bytes).into_owned() +} + +#[no_mangle] +pub unsafe extern "C" fn napi_fatal_error(location: *const c_char, location_len: usize, message: *const c_char, message_len: usize) -> ! { + let report = format!( + "FATAL ERROR: {} {}", + lossy(location, location_len), + lossy(message, message_len) + ); + eprintln!("[NativeScript] {report}"); + crate::store_last_js_error(report); + std::process::abort(); +} + +#[no_mangle] +pub unsafe extern "C" fn napi_fatal_exception(env: napi_env, error: napi_value) -> napi_status { + let mut text = ptr::null_mut(); + let mut message = String::from(""); + if napi_coerce_to_string(env, error, &mut text) == NAPI_OK { + let mut len = 0usize; + napi_get_value_string_utf8(env, text, ptr::null_mut(), 0, &mut len); + let mut buf = vec![0u8; len + 1]; + napi_get_value_string_utf8(env, text, buf.as_mut_ptr() as *mut c_char, len + 1, &mut len); + buf.truncate(len); + message = String::from_utf8_lossy(&buf).into_owned(); + } + eprintln!("[NativeScript] uncaught exception from a native addon: {message}"); + crate::store_last_js_error(message); + NAPI_OK +} + +/// `NAPI_MODULE` registration from a static constructor, as older addons do. The loader reads it +/// right after `LoadLibrary` returns. +#[repr(C)] +pub struct NapiModule { + pub nm_version: i32, + pub nm_flags: u32, + pub nm_filename: *const c_char, + pub nm_register_func: Option, + pub nm_modname: *const c_char, + pub nm_priv: *mut c_void, + pub reserved: [*mut c_void; 4], +} + +static LEGACY_MODULE: Mutex = Mutex::new(0); + +#[no_mangle] +pub unsafe extern "C" fn napi_module_register(module: *mut NapiModule) { + *LEGACY_MODULE.lock().unwrap() = module as usize; +} + +pub(crate) fn take_legacy_module() -> Option { + let module = std::mem::take(&mut *LEGACY_MODULE.lock().unwrap()); + (module != 0).then(|| unsafe { (*(module as *const NapiModule)).nm_register_func }).flatten() +} + +thread_local! { + static MODULE_FILE_NAMES: RefCell> = RefCell::new(HashMap::new()); +} + +pub(crate) fn set_module_file_name(env: napi_env, url: &str) { + if let Ok(name) = std::ffi::CString::new(url) { + MODULE_FILE_NAMES.with(|m| m.borrow_mut().insert(env as usize, name)); + } +} + +#[no_mangle] +pub unsafe extern "C" fn node_api_get_module_file_name(env: napi_env, result: *mut *const c_char) -> napi_status { + if result.is_null() { + return NAPI_INVALID_ARG; + } + *result = MODULE_FILE_NAMES.with(|m| m.borrow().get(&(env as usize)).map_or(ptr::null(), |s| s.as_ptr())); + if (*result).is_null() { NAPI_GENERIC_FAILURE } else { NAPI_OK } +} diff --git a/runtime/src/ui_dispatcher.rs b/runtime/src/ui_dispatcher.rs index b85f4f1..4b7015b 100644 --- a/runtime/src/ui_dispatcher.rs +++ b/runtime/src/ui_dispatcher.rs @@ -102,6 +102,22 @@ pub fn defer_on_ui_thread(f: impl FnOnce() + Send + 'static) -> bool { dq.TryEnqueue(&handler).unwrap_or(false) } +/// Queues `f` on the UI thread's dispatcher -- always as a separate work item, never inline, even +/// from the UI thread (so it can't run inside a XAML callout). `false` if there is no dispatcher. +pub fn enqueue_on_ui_thread(f: impl FnOnce() + Send + 'static) -> bool { + let Some(dq) = UI_QUEUE.get() else { + return false; + }; + let cell = std::sync::Mutex::new(Some(f)); + let handler = DispatcherQueueHandler::new(move || { + if let Some(f) = cell.lock().unwrap().take() { + f(); + } + Ok(()) + }); + dq.TryEnqueue(&handler).unwrap_or(false) +} + pub fn is_initialized() -> bool { UI_QUEUE.get().is_some() } diff --git a/sbg/src/main.rs b/sbg/src/main.rs index c52bc19..ca7bc5d 100644 --- a/sbg/src/main.rs +++ b/sbg/src/main.rs @@ -90,6 +90,12 @@ impl StaticBindingGenerator { extensions_metadata.len() ); + let extensions_metadata = retain_extendable(extensions_metadata); + + // Proxies from an earlier run whose extension is gone (or now skipped) must not stay in + // the build: the app compiles whatever is in the directory. + remove_generated_proxies(&self.config.output_dir.join("NSWinRTProxies")); + if extensions_metadata.is_empty() { println!("[SBG] No extensions to generate, skipping C# compilation"); return Ok(ProxyManifest::default()); @@ -349,3 +355,76 @@ fn main() -> Result<()> { Ok(()) } + +/// Deletes the `*.g.cs` files an earlier run generated (hand-written app sources are not `.g.cs`). +fn remove_generated_proxies(project_dir: &Path) { + let Ok(entries) = fs::read_dir(project_dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.ends_with(".g.cs")) { + let _ = fs::remove_file(&path); + } + } +} + +/// Root namespaces of types a JS extension can derive from without WinRT metadata sbg can read +/// (the Windows App SDK's `Microsoft.UI.*` lives in a framework package, not system metadata). +const EXTENDABLE_ROOTS: &[&str] = &["Windows", "Microsoft", "System", "NativeScript"]; + +fn is_identifier(segment: &str) -> bool { + let mut chars = segment.chars(); + matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +/// Whether an auto-captured extension's base can be a real WinRT/.NET type. +fn is_extendable_base(base: &str) -> bool { + let generic_root = base.split('`').next().unwrap_or(base); + let segments: Vec<&str> = generic_root.split('.').collect(); + if segments.len() < 2 || !segments.iter().all(|s| is_identifier(s)) { + return false; + } + EXTENDABLE_ROOTS.contains(&segments[0]) || signature_resolver::is_known_type(base) +} + +/// The bundle scan (dotnet-tool) is syntactic: any class extending a dotted name looks like an +/// extension, including ordinary classes of bundled libraries (minified `e.Foo`, `Ua.$`). A proxy +/// for such a "base" cannot compile, so auto-captured ones are dropped; explicitly named ones +/// (`@CSharpProxy`) are kept as asked. +fn retain_extendable(extensions: Vec) -> Vec { + let (kept, dropped): (Vec<_>, Vec<_>) = extensions.into_iter().partition(|ext| { + let base = ext.base_class.as_deref().map(str::trim).unwrap_or(""); + !ext.is_auto_generated_name || base.is_empty() || base == "object" || base == "Object" || is_extendable_base(base) + }); + if !dropped.is_empty() { + let names: Vec<&str> = dropped.iter().filter_map(|e| e.base_class.as_deref()).take(8).collect(); + println!( + "[SBG] Skipped {} captured extension(s) whose base is not a WinRT type ({}{})", + dropped.len(), + names.join(", "), + if dropped.len() > names.len() { ", ..." } else { "" } + ); + } + kept +} + +#[cfg(test)] +mod tests { + use super::is_extendable_base; + + #[test] + fn winrt_bases_are_extendable() { + assert!(is_extendable_base("Microsoft.UI.Xaml.Controls.Button")); + assert!(is_extendable_base("Windows.UI.Xaml.Controls.Panel")); + assert!(is_extendable_base("NativeScript.Mason.View")); + } + + #[test] + fn bundled_library_classes_are_not() { + for base in ["Ua.$", "i.X", "Sa.ThinEngine", "Phaser.Utils", "u.MaterialDefines", "Button"] { + assert!(!is_extendable_base(base), "{base}"); + } + } +} diff --git a/sbg/src/signature_resolver.rs b/sbg/src/signature_resolver.rs index 56a931c..d400e10 100644 --- a/sbg/src/signature_resolver.rs +++ b/sbg/src/signature_resolver.rs @@ -173,6 +173,12 @@ fn known_override_signature(base_type: &str, method: &str) -> Option bool { + MetadataReader::find_by_name_or_generic(type_name).is_some() +} + /// Resolves one method's real signature given the type name it's declared/overridden on /// (base class or interface) and the method's name. `None` when the type or method can't be /// found — callers should skip the member and warn, not guess a signature. diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj index f6b4353..02bf895 100644 --- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj +++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj @@ -199,6 +199,11 @@ Command="cargo run --quiet --manifest-path "$(WindowsRuntimeRepoRoot)\Cargo.toml" -p sbg" EnvironmentVariables="NSWINRT_AUTO_METADATA_PATH=$(SBGMetadataSource);SBG_OUTPUT_DIR=$(SBG_OUTPUT_DIR);SBG_METADATA_SOURCE=$(SBGMetadataSource);SBG_TARGET_FRAMEWORK=$(TargetFramework);SBG_TARGET_PLATFORM_MIN_VERSION=$(TargetPlatformMinVersion);SBG_USE_UWP=false;SBG_APP_CS_SOURCES_DIR=$(MSBuildProjectDirectory)" Condition="'$(SbgExe)' == '' and Exists('$(SBGMetadataSource)') and Exists('$(WindowsRuntimeRepoRoot)\Cargo.toml')" /> + + + + @@ -206,8 +211,6 @@ - -