Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions integration-tests/tests/boxed_structs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
use runtime::Runtime;

fn eval(rt: &mut Runtime, expr: &str) -> String {
rt.eval_script_to_string(expr)
.unwrap_or_else(|| "<exception>".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}"#);
}
40 changes: 39 additions & 1 deletion runtime/src/napi_engine/ns_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -618,11 +618,49 @@ fn resolve_member(env: &Env, full_name: &str) -> napi::Result<JsUnknown> {
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<JsFunction> {
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::<JsUnknown>(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::<StructDeclaration>() 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::<JsUnknown>(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<JsObject> {
Expand Down
70 changes: 69 additions & 1 deletion runtime/src/napi_engine/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<windows::core::IInspectable> {
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<String> = 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::<JsUnknown>(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<NativeValue, AnyError> {
let vt = value_type(arg)?;

Expand Down Expand Up @@ -941,6 +978,15 @@ pub fn napi_parse_pointer(env: &Env, arg: &JsUnknown) -> Result<NativeValue, Any
}
}

if vt == ValueType::Object {
let obj: JsObject = unsafe { arg.cast() };
if let Some(inspectable) = box_struct_shape(env, &obj) {
let ptr = inspectable.as_raw() as *mut c_void;
std::mem::forget(inspectable);
return Ok(NativeValue { pointer: ptr });
}
}

let pointer = if vt == ValueType::External {
ptr_from_external(env, arg).ok_or_else(|| {
type_error("Invalid FFI pointer type, expected null, External, or { handle: External|null }")
Expand Down Expand Up @@ -1143,7 +1189,10 @@ pub fn append_struct_field_bytes(
value: &JsUnknown,
native_type: &NativeType,
) {
let num = coerce_f64(env, value).unwrap_or(0.0);
let num = match value.get_type() {
Ok(ValueType::Undefined | ValueType::Null) => 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()),
Expand All @@ -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())),
}
}
Expand Down Expand Up @@ -1455,6 +1510,19 @@ pub fn try_unbox_property_value(env: &Env, raw: *mut c_void) -> Option<JsUnknown
let g = unsafe { pv.GetGuid() }.ok()?;
env.create_string(&format!("{g:?}")).ok().map(|v| as_unknown(env, v))
}
PropertyType::Point => 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<Point>` etc.) as the same plain object a struct return gives.
fn boxed_struct<T>(env: &Env, type_name: &str, value: &T) -> Option<JsUnknown> {
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))
}
15 changes: 15 additions & 0 deletions runtime/src/ns_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Point>` 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<v8::Local<'a, v8::Value>> {
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
Expand Down
73 changes: 72 additions & 1 deletion runtime/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<v8::External>::try_from(arg) {
value.value()
} else if arg.is_null_or_undefined() {
Expand Down Expand Up @@ -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<v8::Object>,
) -> Option<windows::core::IInspectable> {
use windows::Foundation::{DateTime, Point, Rect, Size, TimeSpan};
let names = obj.get_own_property_names(scope, Default::default())?;
let mut keys: Vec<String> = (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::Value> {
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<v8::Value>) -> 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")]
Expand All @@ -1407,7 +1474,11 @@ pub(crate) fn append_struct_field_bytes(
value: v8::Local<v8::Value>,
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()),
Expand Down
25 changes: 25 additions & 0 deletions windows-napi/test/struct-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<Point> 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);
Loading