From 58583ea81d8b940cca1a734975047b1389add57a Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Fri, 25 Sep 2026 23:58:04 -0400 Subject: [PATCH 1/4] feat(classic): load Node-API native addons The classic engine gains a Node-API provider and a native-addon loader, so addons built for Node (napi-rs, node-addon-api, ...) load with require('system_lib://name.node') or require('./name.node'). - napi-v8-shim: the napi-android V8 shim already used by windows-v8, compiled against rusty_v8 into nativescript.dll (whole-archive, so every napi_* is exported), plus glue to create an env over the classic context, run a module's init inside the env's call guard, drain deferred finalizers and tear envs down. - runtime::node_api: the Node-specific half of Node-API -- threadsafe functions, async work, env and async cleanup hooks, buffers, callback scopes, napi_make_callback, napi_module_register, fatal errors. JS-thread work goes through one queue woken by a DispatcherQueue work item (never inline in a XAML callout) and drained by runtime_pump_timers. - runtime::native_addons: resolves system_lib:// next to the executable (then the app root), loads with LoadLibraryExW, honours node_api_module_get_api_version_v1, and caches exports per path. runtime_deinit runs addon cleanup hooks and envs down before the isolate goes away. --- Cargo.toml | 2 +- napi-v8-shim/Cargo.toml | 13 + napi-v8-shim/build.rs | 71 +++ napi-v8-shim/csrc/env_ext.cpp | 67 ++ napi-v8-shim/src/lib.rs | 21 + nativescript/src/lib.rs | 10 +- runtime/Cargo.toml | 4 +- runtime/src/global_fns.rs | 7 + runtime/src/lib.rs | 4 + runtime/src/native_addons.rs | 168 +++++ runtime/src/node_api.rs | 1080 +++++++++++++++++++++++++++++++++ runtime/src/ui_dispatcher.rs | 16 + 12 files changed, 1460 insertions(+), 3 deletions(-) create mode 100644 napi-v8-shim/Cargo.toml create mode 100644 napi-v8-shim/build.rs create mode 100644 napi-v8-shim/csrc/env_ext.cpp create mode 100644 napi-v8-shim/src/lib.rs create mode 100644 runtime/src/native_addons.rs create mode 100644 runtime/src/node_api.rs 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/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..0e618f3 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,8 @@ 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(); if runtime::ui_dispatcher::needs_win32_pump() { runtime::pump_messages(); } else { @@ -440,7 +444,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/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/global_fns.rs b/runtime/src/global_fns.rs index 9b16e64..cfa2701 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -3696,6 +3696,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 +5038,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); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 23c18bd..0633800 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -31,6 +31,10 @@ mod name_space; #[cfg(feature = "napi_engine")] pub mod napi_engine; #[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; 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() } From 1f2f1cc49c3bdefdf8023761c6b4bb6bd76358e6 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 26 Sep 2026 00:19:23 -0400 Subject: [PATCH 2/4] fix(napi shim): type tags could never be read back NAPI_PRIVATE_KEY created a new unique v8::Private on every call, so napi_type_tag_object stored the tag under one symbol and napi_check_object_type_tag looked under another: every check failed. napi-rs 3 tags each class instance and checks the tag on every method call and &T argument, so no napi-rs class could be used at all ("Value is not an instance of class ..."). The tag key is now one Private::ForApi symbol cached on the env. Affects both engines built on this shim (classic via napi-v8-shim, and windows-v8). --- packages/windows-v8/vendor/shim/v8-api-internals.h | 6 ++---- packages/windows-v8/vendor/shim/v8-api.cpp | 4 ++-- packages/windows-v8/vendor/shim/v8-api.h | 10 ++++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) 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; From 5215608e7c847062937a0964ea385dd6268d5dc1 Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 26 Sep 2026 02:16:56 -0400 Subject: [PATCH 3/4] fix(classic): run requestAnimationFrame from the frame pump rAF callbacks ran in a microtask that blocked on DwmFlush() for vsync and queued itself again while callbacks kept requesting frames. A continuous rAF loop (any animation, a canvas render loop) therefore never drained the microtask queue: the UI thread stopped returning to the dispatcher, XAML stopped rendering and the window went "Not Responding" while JS kept running at the refresh rate. requestAnimationFrame now only queues the callback and asks for a frame (__nsRequestFrame). runtime_pump_timers, which the app template drives once per compositor frame from CompositionTarget.Rendering (outside the render walk), runs the queued callbacks once and then drains microtasks (runtime::animation_frames::pump). Callbacks requested during a frame run in the next one; nothing waits for vsync on the UI thread. Tests pump frames explicitly, and a new one checks that rAF does not run on a microtask drain alone. --- integration-tests/tests/new_features.rs | 38 ++++++++++-- nativescript/src/lib.rs | 2 + runtime/src/animation_frames.rs | 82 +++++++++++++++++++++++++ runtime/src/global_fns.rs | 43 +++++-------- runtime/src/lib.rs | 3 + 5 files changed, 135 insertions(+), 33 deletions(-) create mode 100644 runtime/src/animation_frames.rs 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/nativescript/src/lib.rs b/nativescript/src/lib.rs index 0e618f3..affa89f 100644 --- a/nativescript/src/lib.rs +++ b/nativescript/src/lib.rs @@ -424,6 +424,8 @@ pub extern "C" fn runtime_pump_timers() { 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 { 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 cfa2701..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 @@ -5087,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 0633800..5e42fe4 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -31,6 +31,8 @@ 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; @@ -8894,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(); From a691fccefcff659ed3b2875e08828b7d689a501b Mon Sep 17 00:00:00 2001 From: Osei Fortune Date: Sat, 26 Sep 2026 03:27:31 -0400 Subject: [PATCH 4/4] fix(sbg): only generate proxies for bases that can be WinRT types The bundle scan (dotnet-tool) is syntactic, so ordinary classes of bundled libraries (babylon.js, phaser: `class X extends e.Foo` compiled to __extends IIFEs, minified bases like `Ua.$`) were captured as WinRT extensions. sbg emitted a proxy deriving from each, which cannot compile (`public class __16 : Ua.$`), so any app bundling such a library failed to build. - sbg drops auto-captured extensions whose base is neither in the WinRT metadata it can read nor a dotted identifier rooted at Windows, Microsoft, System or NativeScript (the Windows App SDK is not in system metadata). Explicitly named proxies (@CSharpProxy) are kept. - sbg removes the *.g.cs an earlier run generated before generating, and the template adds the generated proxies to Compile inside RunStaticBindingGenerator, after sbg ran: a glob evaluated with the project kept compiling proxies for extensions that no longer exist. --- sbg/src/main.rs | 79 +++++++++++++++++++ sbg/src/signature_resolver.rs | 6 ++ .../__PROJECT_NAME__/__PROJECT_NAME__.csproj | 7 +- 3 files changed, 90 insertions(+), 2 deletions(-) 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 @@ - -