diff --git a/integration-tests/tests/boxed_structs.rs b/integration-tests/tests/boxed_structs.rs new file mode 100644 index 0000000..f82b135 --- /dev/null +++ b/integration-tests/tests/boxed_structs.rs @@ -0,0 +1,57 @@ +use runtime::Runtime; + +fn eval(rt: &mut Runtime, expr: &str) -> String { + rt.eval_script_to_string(expr) + .unwrap_or_else(|| "".to_string()) +} + +#[test] +fn boxed_structs_unbox_and_box_by_shape() { + let mut rt = Runtime::new("."); + rt.run_script( + "const PV = Windows.Foundation.PropertyValue; + globalThis.ps = new Windows.Foundation.Collections.PropertySet(); + ps.Insert('size', PV.CreateSize({ Width: 9, Height: 8 })); + ps.Insert('point', { X: 1, Y: 2 }); + ps.Insert('rect', { X: 1, Y: 2, Width: 3, Height: 4 }); + ps.Insert('span', { Duration: 10000000 });", + "setup.js", + ); + assert_eq!(eval(&mut rt, "JSON.stringify(ps.Lookup('size'))"), r#"{"Width":9,"Height":8}"#); + assert_eq!(eval(&mut rt, "JSON.stringify(ps.Lookup('point'))"), r#"{"X":1,"Y":2}"#); + assert_eq!( + eval(&mut rt, "JSON.stringify(ps.Lookup('rect'))"), + r#"{"X":1,"Y":2,"Width":3,"Height":4}"# + ); + assert_eq!(eval(&mut rt, "JSON.stringify(ps.Lookup('span'))"), r#"{"Duration":10000000}"#); + assert_eq!( + eval(&mut rt, "JSON.stringify(Windows.Foundation.PropertyValue.CreatePoint({ X: 1.5, Y: 2.5 }))"), + r#"{"X":1.5,"Y":2.5}"# + ); +} + +#[test] +fn by_value_point_round_trips() { + let mut rt = Runtime::new("."); + assert_eq!( + eval( + &mut rt, + "JSON.stringify(new Windows.UI.Input.Inking.InkPoint({ X: 3, Y: 4 }, 0.5).Position)" + ), + r#"{"X":3,"Y":4}"# + ); +} + +#[test] +fn missing_struct_fields_serialize_as_zero() { + let mut rt = Runtime::new("."); + let v = eval( + &mut rt, + "(() => { + const r = new Windows.Media.Core.TimedTextRegion(); + r.Padding = { Before: 1, Unit: 1 }; + return JSON.stringify(r.Padding); + })()", + ); + assert_eq!(v, r#"{"Before":1,"After":0,"Start":0,"End":0,"Unit":1}"#); +} diff --git a/runtime/src/napi_engine/ns_proxy.rs b/runtime/src/napi_engine/ns_proxy.rs index 85e18e9..6d83134 100644 --- a/runtime/src/napi_engine/ns_proxy.rs +++ b/runtime/src/napi_engine/ns_proxy.rs @@ -618,11 +618,49 @@ fn resolve_member(env: &Env, full_name: &str) -> napi::Result { create_ctor_proxy(env, full_name, declaration)?, )), DeclarationKind::Enum => Ok(as_unknown(env, create_enum_object(env, &declaration)?)), - // Interfaces / structs arrive with later slices. + DeclarationKind::Struct => Ok(as_unknown(env, create_struct_ctor(env, full_name, declaration)?)), + // Interfaces arrive with later slices. _ => undefined_js(env), } } +/// `new Rect({ X, Y, Width, Height })`, `new Rect(x, y, w, h)` or `new Rect()`: a plain struct +/// value, round-tripped through the struct's bytes so missing fields are zero and values match +/// what native receives. +fn create_struct_ctor(env: &Env, full_name: &str, declaration: Decl) -> napi::Result { + use metadata::declarations::struct_declaration::StructDeclaration; + + let simple_name = full_name.rsplit('.').next().unwrap_or(full_name); + env.create_function_from_closure(simple_name, move |ctx: CallContext| { + let env = &ctx.env; + let first = if ctx.length > 0 { Some(ctx.get::(0)?) } else { None }; + let named = ctx.length == 1 + && first.as_ref().is_some_and(|v| matches!(v.get_type(), Ok(ValueType::Object))); + let mut buf = Vec::new(); + { + let lock = declaration.read(); + let Some(sd) = lock.as_any().downcast_ref::() else { + return undefined_js(env); + }; + let source: JsObject = if named { + unsafe { first.unwrap().cast() } + } else { + let mut positional = env.create_object()?; + for (i, field) in sd.fields().iter().enumerate().take(ctx.length) { + positional.set_named_property(field.name(), ctx.get::(i)?)?; + } + positional + }; + crate::property_call::append_struct_object_bytes_napi(env, &mut buf, &source, sd); + } + if buf.is_empty() { + buf.push(0); + } + let value = create_struct_object_from_raw(env, &declaration, buf.as_ptr())?; + Ok(as_unknown(env, value)) + }) +} + /// A WinRT enum as a plain JS object of name → numeric value (Int32/UInt32, same /// representation the v8 interceptor produced). fn create_enum_object(env: &Env, declaration: &Decl) -> napi::Result { diff --git a/runtime/src/napi_engine/value.rs b/runtime/src/napi_engine/value.rs index d93d31f..8dbf8e9 100644 --- a/runtime/src/napi_engine/value.rs +++ b/runtime/src/napi_engine/value.rs @@ -892,6 +892,43 @@ pub fn box_as_ireference( // +/// Boxes a plain object shaped like one of the struct types `IPropertyValue` supports +/// (`{X,Y}`, `{Width,Height}`, `{X,Y,Width,Height}`, `{Duration}`, `{UniversalTime}`). +fn box_struct_shape(env: &Env, obj: &JsObject) -> Option { + use windows::Foundation::{DateTime, Point, Rect, Size, TimeSpan}; + let names = obj.get_property_names().ok()?; + let len = names.get_array_length().ok()?; + let mut keys: Vec = Vec::with_capacity(len as usize); + for i in 0..len { + let k: JsString = names.get_element(i).ok()?; + keys.push(k.into_utf8().ok()?.as_str().ok()?.to_owned()); + } + keys.sort(); + let get = |name: &str| obj.get_named_property::(name).ok(); + let f = |name: &str| { + get(name) + .and_then(|v| coerce_f64(env, &v)) + .filter(|n| !n.is_nan()) + .unwrap_or(0.0) as f32 + }; + let i = |name: &str| get(name).map(|v| js_to_i64(env, &v)).unwrap_or(0); + let keys: Vec<&str> = keys.iter().map(String::as_str).collect(); + let boxed = match keys.as_slice() { + ["X", "Y"] => PropertyValue::CreatePoint(Point { X: f("X"), Y: f("Y") }), + ["Height", "Width"] => PropertyValue::CreateSize(Size { Width: f("Width"), Height: f("Height") }), + ["Height", "Width", "X", "Y"] => PropertyValue::CreateRect(Rect { + X: f("X"), + Y: f("Y"), + Width: f("Width"), + Height: f("Height"), + }), + ["Duration"] => PropertyValue::CreateTimeSpan(TimeSpan { Duration: i("Duration") }), + ["UniversalTime"] => PropertyValue::CreateDateTime(DateTime { UniversalTime: i("UniversalTime") }), + _ => return None, + }; + boxed.ok()?.cast().ok() +} + pub fn napi_parse_pointer(env: &Env, arg: &JsUnknown) -> Result { let vt = value_type(arg)?; @@ -941,6 +978,15 @@ pub fn napi_parse_pointer(env: &Env, arg: &JsUnknown) -> Result 0.0, + _ => coerce_f64(env, value).unwrap_or(0.0), + }; match native_type { NativeType::F64 => buf.extend_from_slice(&num.to_le_bytes()), NativeType::F32 => buf.extend_from_slice(&(num as f32).to_le_bytes()), @@ -1157,6 +1206,12 @@ pub fn append_struct_field_bytes( NativeType::I8 => buf.extend_from_slice(&(num as i8).to_le_bytes()), NativeType::U8 => buf.push(num as u8), NativeType::Bool => buf.push(if coerce_bool(env, value) { 1u8 } else { 0u8 }), + // Ownership moves into the bytes; the struct reader releases it, call args leak it. + NativeType::String if matches!(value.get_type(), Ok(ValueType::String)) => { + let hstring = ManuallyDrop::new(hstring_from_js_value(env, value)); + let raw: usize = unsafe { std::mem::transmute_copy(&*hstring) }; + buf.extend_from_slice(&raw.to_le_bytes()); + } _ => buf.extend(std::iter::repeat(0u8).take(native_type.size())), } } @@ -1455,6 +1510,19 @@ pub fn try_unbox_property_value(env: &Env, raw: *mut c_void) -> Option boxed_struct(env, "Windows.Foundation.Point", &unsafe { pv.GetPoint() }.ok()?), + PropertyType::Size => boxed_struct(env, "Windows.Foundation.Size", &unsafe { pv.GetSize() }.ok()?), + PropertyType::Rect => boxed_struct(env, "Windows.Foundation.Rect", &unsafe { pv.GetRect() }.ok()?), + PropertyType::TimeSpan => boxed_struct(env, "Windows.Foundation.TimeSpan", &unsafe { pv.GetTimeSpan() }.ok()?), + PropertyType::DateTime => boxed_struct(env, "Windows.Foundation.DateTime", &unsafe { pv.GetDateTime() }.ok()?), _ => None, } } + +/// A boxed struct value (`IReference` etc.) as the same plain object a struct return gives. +fn boxed_struct(env: &Env, type_name: &str, value: &T) -> Option { + let decl = metadata::meta_data_reader::MetadataReader::find_by_name(type_name)?; + crate::napi_engine::ns_proxy::create_struct_object_from_raw(env, &decl, value as *const T as *const u8) + .ok() + .map(|o| as_unknown(env, o)) +} diff --git a/runtime/src/ns_proxy.rs b/runtime/src/ns_proxy.rs index 49890f1..19aec2c 100644 --- a/runtime/src/ns_proxy.rs +++ b/runtime/src/ns_proxy.rs @@ -521,10 +521,25 @@ fn try_unbox_property_value<'a>( let g = pv.GetGuid().ok()?; v8::String::new(scope, &format!("{g:?}")).map(Into::into) } + PropertyType::Point => boxed_struct(scope, "Windows.Foundation.Point", &pv.GetPoint().ok()?), + PropertyType::Size => boxed_struct(scope, "Windows.Foundation.Size", &pv.GetSize().ok()?), + PropertyType::Rect => boxed_struct(scope, "Windows.Foundation.Rect", &pv.GetRect().ok()?), + PropertyType::TimeSpan => boxed_struct(scope, "Windows.Foundation.TimeSpan", &pv.GetTimeSpan().ok()?), + PropertyType::DateTime => boxed_struct(scope, "Windows.Foundation.DateTime", &pv.GetDateTime().ok()?), _ => None, } } +/// A boxed struct value (`IReference` etc.) as the same object a struct return gives. +fn boxed_struct<'a, T>( + scope: &mut v8::PinScope<'a, '_>, + type_name: &str, + value: &T, +) -> Option> { + let decl = MetadataReader::find_by_name(type_name)?; + Some(crate::create_struct_object_from_raw(decl, value as *const T as *mut c_void, scope).into()) +} + /// Sets a method/property return value, resolving `Object`/IInspectable returns to a full typed /// proxy via GetRuntimeClassName so property/event interceptors work (e.g. the WebView2 returned by /// `XamlReader.Load`). Any other signature defers to `set_ret_val`. A non-resolvable Object falls diff --git a/runtime/src/value.rs b/runtime/src/value.rs index a554e3a..2971507 100644 --- a/runtime/src/value.rs +++ b/runtime/src/value.rs @@ -1248,6 +1248,17 @@ pub fn ffi_parse_pointer_arg( } } + if let Some(inspectable) = arg + .is_object() + .then(|| arg.to_object(scope)) + .flatten() + .and_then(|obj| box_struct_shape(scope, obj)) + { + let ptr = inspectable.as_raw() as *mut c_void; + std::mem::forget(inspectable); + return Ok(NativeValue { pointer: ptr }); + } + let pointer = if let Ok(value) = v8::Local::::try_from(arg) { value.value() } else if arg.is_null_or_undefined() { @@ -1398,6 +1409,62 @@ pub fn ffi_parse_struct_arg( Ok(NativeValue { pointer }) } +/// Boxes a plain object shaped like one of the struct types `IPropertyValue` supports +/// (`{X,Y}`, `{Width,Height}`, `{X,Y,Width,Height}`, `{Duration}`, `{UniversalTime}`). +#[cfg(feature = "classic")] +fn box_struct_shape( + scope: &mut v8::PinScope<'_, '_>, + obj: v8::Local, +) -> Option { + use windows::Foundation::{DateTime, Point, Rect, Size, TimeSpan}; + let names = obj.get_own_property_names(scope, Default::default())?; + let mut keys: Vec = (0..names.length()) + .filter_map(|i| names.get_index(scope, i)) + .map(|k| k.to_rust_string_lossy(scope)) + .collect(); + keys.sort(); + let mut get = |name: &str| -> v8::Local { + v8::String::new(scope, name) + .and_then(|k| obj.get(scope, k.into())) + .unwrap_or_else(|| v8::undefined(scope).into()) + }; + let keys: Vec<&str> = keys.iter().map(String::as_str).collect(); + let boxed = match keys.as_slice() { + ["X", "Y"] => { + let (x, y) = (get("X"), get("Y")); + PropertyValue::CreatePoint(Point { X: f32_of(scope, x), Y: f32_of(scope, y) }) + } + ["Height", "Width"] => { + let (w, h) = (get("Width"), get("Height")); + PropertyValue::CreateSize(Size { Width: f32_of(scope, w), Height: f32_of(scope, h) }) + } + ["Height", "Width", "X", "Y"] => { + let (x, y, w, h) = (get("X"), get("Y"), get("Width"), get("Height")); + PropertyValue::CreateRect(Rect { + X: f32_of(scope, x), + Y: f32_of(scope, y), + Width: f32_of(scope, w), + Height: f32_of(scope, h), + }) + } + ["Duration"] => { + let d = get("Duration"); + PropertyValue::CreateTimeSpan(TimeSpan { Duration: v8_to_i64(scope, d) }) + } + ["UniversalTime"] => { + let t = get("UniversalTime"); + PropertyValue::CreateDateTime(DateTime { UniversalTime: v8_to_i64(scope, t) }) + } + _ => return None, + }; + boxed.ok()?.cast().ok() +} + +#[cfg(feature = "classic")] +fn f32_of(scope: &mut v8::PinScope<'_, '_>, v: v8::Local) -> f32 { + v.number_value(scope).filter(|n| !n.is_nan()).unwrap_or(0.0) as f32 +} + /// Write a single struct field value (from a V8 value) into a byte buffer in little-endian order. /// Used when converting a plain JS object like `{A:255, R:0, G:0, B:0}` to WinRT struct bytes. #[cfg(feature = "classic")] @@ -1407,7 +1474,11 @@ pub(crate) fn append_struct_field_bytes( value: v8::Local, native_type: &NativeType, ) { - let num = value.number_value(scope).unwrap_or(0.0); + let num = if value.is_null_or_undefined() { + 0.0 + } else { + value.number_value(scope).unwrap_or(0.0) + }; match native_type { NativeType::F64 => buf.extend_from_slice(&num.to_le_bytes()), NativeType::F32 => buf.extend_from_slice(&(num as f32).to_le_bytes()), diff --git a/windows-napi/test/struct-test.js b/windows-napi/test/struct-test.js index 60eeb55..7d8f74d 100644 --- a/windows-napi/test/struct-test.js +++ b/windows-napi/test/struct-test.js @@ -33,5 +33,30 @@ check('Colors.Red B', red.B, 0); check('struct spreads', JSON.stringify({ ...color }), '{"A":255,"R":128,"G":64,"B":32}'); check('struct JSON', JSON.stringify(color), s => s.includes('"A":255')); +// Struct constructors from namespaces; missing fields are zero. +const F = Windows.Foundation; +check('struct ctor named', JSON.stringify(new F.Rect({ X: 1, Y: 2, Width: 3, Height: 4 })), '{"X":1,"Y":2,"Width":3,"Height":4}'); +check('struct ctor positional', JSON.stringify(new F.Rect(5, 6, 7, 8)), '{"X":5,"Y":6,"Width":7,"Height":8}'); +check('struct ctor empty', JSON.stringify(new F.Rect()), '{"X":0,"Y":0,"Width":0,"Height":0}'); +check('struct ctor partial', JSON.stringify(new F.Rect({ X: 1 })), '{"X":1,"Y":0,"Width":0,"Height":0}'); +check('struct ctor nested', JSON.stringify(new Windows.UI.Xaml.Duration({ TimeSpan: { Duration: 5 }, Type: 1 })), '{"TimeSpan":{"Duration":5},"Type":1}'); + +// Point passed by value round-trips. +const inkPoint = new Windows.UI.Input.Inking.InkPoint(new F.Point(3, 4), 0.5); +check('by-value Point', JSON.stringify(inkPoint.Position), '{"X":3,"Y":4}'); + +// Boxed structs (IReference etc.) unbox to plain objects and box back by shape. +const PV = F.PropertyValue; +check('PropertyValue.CreatePoint', JSON.stringify(PV.CreatePoint({ X: 1.5, Y: 2.5 })), '{"X":1.5,"Y":2.5}'); +const ps = new F.Collections.PropertySet(); +ps.Insert('size', PV.CreateSize({ Width: 9, Height: 8 })); +ps.Insert('point', { X: 1, Y: 2 }); +ps.Insert('rect', new F.Rect(1, 2, 3, 4)); +ps.Insert('span', { Duration: 10000000 }); +check('boxed Size', JSON.stringify(ps.Lookup('size')), '{"Width":9,"Height":8}'); +check('boxed Point', JSON.stringify(ps.Lookup('point')), '{"X":1,"Y":2}'); +check('boxed Rect', JSON.stringify(ps.Lookup('rect')), '{"X":1,"Y":2,"Width":3,"Height":4}'); +check('boxed TimeSpan', JSON.stringify(ps.Lookup('span')), '{"Duration":10000000}'); + console.log(`\n${pass} passed, ${fail} failed`); process.exit(fail ? 1 : 0);