diff --git a/.gitignore b/.gitignore index 8833476..2ba3adf 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ node_modules/ AppPackages/ dotnet-bridge/publish/ /dotnet-bridge-benchmarks/BenchmarkDotNet.Artifacts +test-app/platforms/ diff --git a/README.md b/README.md index 2e449bb..18cf36e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,14 @@ Run .NET bridge tests: dotnet test dotnet-bridge-tests\DotNetBridgeTests.csproj ``` +Run the runtime test app (Jasmine-style specs inside a WinUI app built from the template, with C# +fixtures and a plugin compiled in — the counterpart of the Android/iOS test-app suites). See +[test-app/README.md](test-app/README.md): + +```powershell +pwsh test-app/run.ps1 +``` + ## DevTools DevTools are part of the runtime story, not an optional afterthought. Use the diff --git a/dotnet-bridge-tests/JsSubclassTests.cs b/dotnet-bridge-tests/JsSubclassTests.cs new file mode 100644 index 0000000..5c8ee40 --- /dev/null +++ b/dotnet-bridge-tests/JsSubclassTests.cs @@ -0,0 +1,501 @@ +using System; +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using NativeScriptBridge; +using Xunit; + +namespace DotNetBridgeTests; + +// Bridge side of extending .NET classes from JS (class X extends SomeType / SomeType.extend()): +// constructor arguments, protected constructors, abstract members, base calls for `super`, +// identity of JS-backed instances, results converted to the member's return type, and JS errors +// surfacing as JsException. The JS half is covered by test-app/app/tests/extend-dotnet.js. +[Collection("Bridge")] +public sealed class JsSubclassTests : IDisposable +{ + private static readonly List<(string Member, object?[] Args)> s_calls = new(); + private static Func? s_handler; + + [UnmanagedCallersOnly(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + private static unsafe void Invoker(int id, byte* argsPtr, int argsLen, byte** respPtr, int* respLen) + { + var (member, args) = DecodeCall(new ReadOnlySpan(argsPtr, argsLen)); + s_calls.Add((member, args)); + object? result; + try { result = s_handler?.Invoke(member, args); } + catch (Exception e) { result = new JsError(e.Message); } + WriteResponse(result, respPtr, respLen); + } + + private sealed record JsError(string Message); + + public unsafe JsSubclassTests() + { + Bridge.ClearCaches(); + s_calls.Clear(); + s_handler = null; + s_onDispatch = null; + Bridge.s_jsInvoker = &Invoker; + } + + public unsafe void Dispose() + { + Bridge.s_jsInvoker = null; + Bridge.ClearCaches(); + } + + [Fact] + public void ConstructorArguments_SelectAndFeedTheBaseConstructor() + { + var one = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 1, "rex"); + var two = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 2, "fido", 12); + var pet1 = Assert.IsAssignableFrom(Instance(one)); + var pet2 = Assert.IsAssignableFrom(Instance(two)); + Assert.Equal("rex", pet1.Name); + Assert.Equal(0, pet1.Age); + Assert.Equal("fido", pet2.Name); + Assert.Equal(12, pet2.Age); + } + + [Fact] + public void ConstructorArguments_NoMatchingConstructor_Throws() + { + Assert.ThrowsAny(() => CreateSubclass(typeof(Pet).FullName!, [], [], 3, "a", 1, "extra")); + } + + [Fact] + public void VirtualCalledByBaseConstructor_ReachesJs() + { + s_handler = (member, _) => member == "Speak" ? "js-speak" : null; + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 4, "rex"); + var pet = Assert.IsAssignableFrom(Instance(handle)); + Assert.Equal("js-speak", pet.SpokeInConstructor); + } + + [Fact] + public void AbstractBase_ProtectedConstructorAndAbstractMember() + { + s_handler = (member, _) => member == "Area" ? 4.0 : null; + var handle = CreateSubclass(typeof(AbstractShape).FullName!, [], ["Area"], 5); + var shape = Assert.IsAssignableFrom(Instance(handle)); + Assert.Equal("area=4", shape.Report()); + } + + [Fact] + public void AbstractMember_NotImplementedInJs_StillCreatesTheType() + { + s_handler = (member, _) => throw new InvalidOperationException(member + " is not implemented"); + var handle = CreateSubclass(typeof(AbstractShape).FullName!, [], [], 6); + var shape = Assert.IsAssignableFrom(Instance(handle)); + var error = Assert.Throws(() => shape.Area()); + Assert.Contains("Area is not implemented", error.Message); + } + + [Fact] + public void BaseCall_RunsTheBaseImplementationOfAnOverriddenMember() + { + s_handler = (member, args) => member == "Greet" ? "js:" + args[0] : null; + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Greet"], 7, "rex"); + + Assert.Equal("js:bob", Invoke(handle, "Greet", "bob").PrimitiveValue()); + Assert.Equal("rex greets bob", Invoke(handle, Bridge.BaseCallPrefix + "Greet", "bob").PrimitiveValue()); + } + + [Fact] + public void BaseCall_PropertyGetter() + { + s_handler = (member, _) => member == "get_Legs" ? 2 : null; + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["get_Legs"], 8, "tweety"); + var pet = Assert.IsAssignableFrom(Instance(handle)); + Assert.Equal(2, pet.Legs); + Assert.Equal(4, Invoke(handle, Bridge.BaseCallPrefix + "get_Legs").PrimitiveValue()); + } + + [Fact] + public void JsBackedInstance_BoxesToItsOriginalHandle() + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 9, "rex"); + var keep = InvokeStatic(typeof(PetStore).FullName!, "Keep", new HandleArg(handle)); + var back = InvokeStatic(typeof(PetStore).FullName!, "Kept"); + Assert.Equal(DispatchKind.Handle, back.Kind()); + Assert.Equal(handle, back.HandleId()); + _ = keep; + } + + [Fact] + public void OverrideResult_IsConvertedToTheReturnType() + { + // JS numbers arrive as double; the override returns int. + s_handler = (member, _) => member == "get_Legs" ? 6.0 : null; + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["get_Legs"], 10, "ant"); + Assert.Equal(6, Assert.IsAssignableFrom(Instance(handle)).Legs); + } + + [Fact] + public void StructArgumentsAndResults_TravelAsJson() + { + s_handler = (member, args) => + { + if (member != "Measure") return null; + var json = (string)args[0]!; + Assert.Contains("\"Width\":10", json); + return new JsJson("{\"width\":20,\"height\":5}"); + }; + var handle = CreateSubclass(typeof(Measurer).FullName!, [], ["Measure"], 11); + var measurer = Assert.IsAssignableFrom(Instance(handle)); + var size = measurer.Measure(new Extent { Width = 10, Height = 3 }); + Assert.Equal(20, size.Width); + Assert.Equal(5, size.Height); + } + + [Fact] + public void JsException_InAMemberWithAResult_SurfacesAsJsException() + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 12, "rex"); + var pet = Assert.IsAssignableFrom(Instance(handle)); + s_handler = (_, _) => throw new InvalidOperationException("boom"); + var error = Assert.Throws(() => pet.Speak()); + Assert.Equal("boom", error.Message); + } + + [Fact] + public void InterfaceAsBase_ImplementsTheInterfaceOnSystemObject() + { + s_handler = (member, args) => member == "Compute" ? (int)args[0]! * (int)args[1]! : member == "get_Name" ? "mul" : null; + var handle = CreateSubclass(typeof(ICalc).FullName!, [], ["Compute", "get_Name"], 13); + var calc = Assert.IsAssignableFrom(Instance(handle)); + Assert.Equal(42, calc.Compute(6, 7)); + Assert.Equal("mul", calc.Name); + } + + [Fact] + public void TypeKind_DescribesTypes() + { + Assert.Equal("interface", Bridge.TypeKind(typeof(ICalc).FullName!)); + Assert.Equal("abstract", Bridge.TypeKind(typeof(AbstractShape).FullName!)); + Assert.Equal("class", Bridge.TypeKind(typeof(Pet).FullName!)); + Assert.Equal("sealed", Bridge.TypeKind(typeof(string).FullName!)); + Assert.Equal("", Bridge.TypeKind("No.Such.Type")); + } + + [Fact] + public void IsInstanceOf_UsesTheRuntimeType() + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 14, "rex"); + Assert.True(Bridge.IsInstanceOf(Instance(handle), typeof(Pet).FullName!)); + Assert.False(Bridge.IsInstanceOf(Instance(handle), typeof(AbstractShape).FullName!)); + Assert.False(Bridge.IsInstanceOf(null, typeof(Pet).FullName!)); + } + + [Fact] + public void TaskResults_AreReturnedAsAwaitableHandles_WithoutBlocking() + { + var res = InvokeStatic(typeof(PetStore).FullName!, "SlowAsync", 5000); + Assert.Equal(DispatchKind.TaskHandle, res.Kind()); + Assert.True(Bridge.s_handles.TryGetValue(res.HandleId(), out var task)); + Assert.False(((Task)task!).IsCompleted); + } + + private static readonly List s_released = new(); + + [UnmanagedCallersOnly(CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + private static void RecordRelease(int callbackId) + { + lock (s_released) s_released.Add(callbackId); + } + + [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.NoInlining)] + private static void CreateAndDrop(int callbackId) + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], callbackId, "temp"); + Release(handle); + } + + [Fact] + public unsafe void CollectedInstance_ReleasesItsJsDispatcher() + { + var previous = Bridge.s_jsCallbackRelease; + Bridge.s_jsCallbackRelease = &RecordRelease; + try + { + CreateAndDrop(31); + for (int i = 0; i < 5; i++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + lock (s_released) if (s_released.Contains(31)) break; + } + lock (s_released) Assert.Contains(31, s_released); + } + finally + { + Bridge.s_jsCallbackRelease = previous; + } + } + + [Fact] + public void ReleasedHandle_InstanceGetsANewHandle_UsedForDispatch() + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 32, "rex"); + InvokeStatic(typeof(PetStore).FullName!, "Keep", new HandleArg(handle)); + Release(handle); + + var back = InvokeStatic(typeof(PetStore).FullName!, "Kept"); + Assert.Equal(DispatchKind.Handle, back.Kind()); + Assert.NotEqual(handle, back.HandleId()); + + int dispatchedHandle = -1; + s_handler = (member, _) => member == "Speak" ? "js" : null; + s_onDispatch = h => dispatchedHandle = h; + Assert.Equal("js", Invoke(back.HandleId(), "Speak").PrimitiveValue()); + Assert.Equal(back.HandleId(), dispatchedHandle); + } + + [Fact] + public void JsSelfMember_AsksTheDispatcherForTheJsObject() + { + var handle = CreateSubclass(typeof(Pet).FullName!, [], ["Speak"], 33, "rex"); + s_calls.Clear(); + Invoke(handle, Bridge.JsSelfMember); + Assert.Contains(s_calls, c => c.Member == Bridge.JsSelfMember); + } + + // ── helpers ───────────────────────────────────────────────────────────── + + private static Action? s_onDispatch; + + private static void Release(int handle) + { + var pkt = BuildPacket(w => + { + w.WriteByte(0x04); + w.WriteI32(handle); + }); + var r = new BinReader(pkt.AsSpan()); + Bridge.DispatchBin(ref r); + } + + private sealed record HandleArg(int Handle); + private sealed record JsJson(string Json); + + private static object? Instance(int handle) + { + Assert.True(Bridge.s_handles.TryGetValue(handle, out var instance)); + return instance; + } + + private static int CreateSubclass(string typeName, string[] interfaceNames, string[] memberNames, int callbackId, params object[] ctorArgs) + { + var pkt = BuildPacket(w => + { + w.WriteByte(0x0A); + w.WriteString16(""); + w.WriteString16(typeName); + w.WriteI32(interfaceNames.Length); + foreach (var n in interfaceNames) w.WriteString16(n); + w.WriteI32(memberNames.Length); + foreach (var n in memberNames) w.WriteString16(n); + w.WriteI32(callbackId); + WriteArgs(w, ctorArgs); + }); + var r = new BinReader(pkt.AsSpan()); + var res = Bridge.DispatchBin(ref r); + Assert.Equal(DispatchKind.Handle, res.Kind()); + return res.HandleId(); + } + + private static DispatchResult Invoke(int handle, string method, params object[] args) + { + var pkt = BuildPacket(w => + { + w.WriteByte(0x01); + w.WriteI32(handle); + w.WriteString16(method); + WriteArgs(w, args); + }); + var r = new BinReader(pkt.AsSpan()); + return Bridge.DispatchBin(ref r); + } + + private static DispatchResult InvokeStatic(string typeName, string method, params object[] args) + { + var pkt = BuildPacket(w => + { + w.WriteByte(0x02); + w.WriteString16(typeName); + w.WriteString16(""); + w.WriteString16(method); + WriteArgs(w, args); + }); + var r = new BinReader(pkt.AsSpan()); + return Bridge.DispatchBin(ref r); + } + + private static void WriteArgs(BinWriter w, object[] args) + { + w.WriteByte((byte)args.Length); + foreach (var a in args) + { + switch (a) + { + case string s: w.WriteByte(0x05); w.WriteString16(s); break; + case int i: w.WriteByte(0x03); w.WriteI32(i); break; + case HandleArg h: w.WriteByte(0x06); w.WriteI32(h.Handle); break; + default: throw new NotSupportedException(a.GetType().Name); + } + } + } + + private static byte[] BuildPacket(Action build) + { + var buf = new ArrayBufferWriter(64); + var w = new BinWriter(buf); + build(w); + return buf.WrittenSpan.ToArray(); + } + + private static (string Member, object?[] Args) DecodeCall(ReadOnlySpan span) + { + var r = new BinReader(span); + var count = r.ReadByte(); + // instance handle + Assert.Equal(0x06, r.ReadByte()); + var selfHandle = r.ReadI32(); + s_onDispatch?.Invoke(selfHandle); + r.ReadString16(); + if (r.ReadByte() == 1) r.ReadI64(); + Assert.Equal(0x05, r.ReadByte()); + var member = r.ReadString32(); + var args = new object?[count - 2]; + for (int i = 0; i < args.Length; i++) + { + var tag = r.ReadByte(); + args[i] = tag switch + { + 0x00 => null, + 0x03 => r.ReadI32(), + 0x04 => r.ReadF64(), + 0x05 => r.ReadString32(), + 0x0D => r.ReadString32(), + _ => throw new InvalidOperationException($"unexpected tag 0x{tag:X2}"), + }; + } + return (member, args); + } + + private static unsafe void WriteResponse(object? result, byte** respPtr, int* respLen) + { + byte[] bytes; + switch (result) + { + case null: + *respPtr = null; + *respLen = 0; + return; + case string s: + bytes = Tagged(0x05, Encoding.UTF8.GetBytes(s)); + break; + case JsJson j: + bytes = Tagged(0x0D, Encoding.UTF8.GetBytes(j.Json)); + break; + case JsError e: + bytes = Tagged(0x0F, Encoding.UTF8.GetBytes(e.Message)); + break; + case int i: + bytes = new byte[5]; + bytes[0] = 0x03; + BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(1), i); + break; + case double d: + bytes = new byte[9]; + bytes[0] = 0x04; + BinaryPrimitives.WriteDoubleLittleEndian(bytes.AsSpan(1), d); + break; + default: + throw new NotSupportedException(result.GetType().Name); + } + var mem = (byte*)Marshal.AllocHGlobal(bytes.Length); + bytes.CopyTo(new Span(mem, bytes.Length)); + *respPtr = mem; + *respLen = bytes.Length; + } + + private static byte[] Tagged(byte tag, byte[] utf8) + { + var bytes = new byte[5 + utf8.Length]; + bytes[0] = tag; + BinaryPrimitives.WriteUInt32LittleEndian(bytes.AsSpan(1), (uint)utf8.Length); + utf8.CopyTo(bytes, 5); + return bytes; + } +} + +public class Pet +{ + public Pet(string name) : this(name, 0) { } + + public Pet(string name, int age) + { + Name = name; + Age = age; + SpokeInConstructor = Speak(); + } + + public string Name { get; } + + public int Age { get; } + + public string SpokeInConstructor { get; } + + public virtual string Speak() => "..."; + + public virtual int Legs => 4; + + public virtual string Greet(string other) => $"{Name} greets {other}"; +} + +public abstract class AbstractShape +{ + protected AbstractShape() { } + + public abstract double Area(); + + public string Report() => $"area={Area()}"; +} + +public interface ICalc +{ + int Compute(int a, int b); + + string Name { get; } +} + +public struct Extent +{ + public double Width; + public double Height; +} + +public class Measurer +{ + public virtual Extent Measure(Extent available) => available; +} + +public static class PetStore +{ + private static object? s_kept; + + public static void Keep(object value) => s_kept = value; + + public static object? Kept() => s_kept; + + public static async Task SlowAsync(int ms) + { + await Task.Delay(ms); + return 1; + } +} diff --git a/dotnet-bridge-tests/ManagedSubclassMemberFilterTests.cs b/dotnet-bridge-tests/ManagedSubclassMemberFilterTests.cs index 7c2d726..5db20aa 100644 --- a/dotnet-bridge-tests/ManagedSubclassMemberFilterTests.cs +++ b/dotnet-bridge-tests/ManagedSubclassMemberFilterTests.cs @@ -191,15 +191,14 @@ private static byte[] BuildPacket(Action build) return buf.WrittenSpan.ToArray(); } - // Decodes a dispatcher call built by Bridge.JsDelegate's WriteCallbackArg: 3 args — - // [HandleRef instance][string32 method][HandleRef argsArray] — resolving the args-array - // handle straight out of Bridge.s_handles rather than re-decoding nested tags by hand. + // Decodes a dispatcher call built by Bridge.JsDelegate's WriteCallbackArg: + // [HandleRef instance][string32 member][arg...], each argument tagged individually. private static (string Method, object?[] Args) DecodeCall(ReadOnlySpan span) { var r = new BinReader(span); var count = r.ReadByte(); - if (count != 3) - throw new InvalidOperationException($"expected 3 dispatcher args, got {count}"); + if (count < 2) + throw new InvalidOperationException($"expected at least 2 dispatcher args, got {count}"); ReadOutgoingHandleTag(ref r); // instance handle — unused by these tests @@ -208,9 +207,23 @@ private static (string Method, object?[] Args) DecodeCall(ReadOnlySpan spa throw new InvalidOperationException($"expected string tag 0x05 for method name, got 0x{methodTag:X2}"); var method = r.ReadString32(); - var argsHandleId = ReadOutgoingHandleTag(ref r); - var argsObj = Bridge.s_handles.TryGetValue(argsHandleId, out var o) ? o as object?[] : null; - return (method, argsObj ?? Array.Empty()); + var args = new object?[count - 2]; + for (int i = 0; i < args.Length; i++) + { + var tag = r.ReadByte(); + args[i] = tag switch + { + 0x00 => null, + 0x01 => false, + 0x02 => true, + 0x03 => r.ReadI32(), + 0x04 => r.ReadF64(), + 0x05 => r.ReadString32(), + 0x0D => r.ReadString32(), + _ => throw new InvalidOperationException($"unexpected argument tag 0x{tag:X2}"), + }; + } + return (method, args); } private static int ReadOutgoingHandleTag(ref BinReader r) diff --git a/dotnet-bridge-tests/ProxyMeasureOverrideTests.cs b/dotnet-bridge-tests/ProxyMeasureOverrideTests.cs index b0c9bf1..41f8f3e 100644 --- a/dotnet-bridge-tests/ProxyMeasureOverrideTests.cs +++ b/dotnet-bridge-tests/ProxyMeasureOverrideTests.cs @@ -77,7 +77,7 @@ public void MeasureOverride_CustomOverride_InvokesJsCallback() Assert.Equal(777, s_capturedId); Assert.True(s_capturedArgs.Length > 0); - // Parse captured binary: [count=3][handle][string][handle(args)] + // Parse captured binary: [count=3][handle][string member][i32 argument] var span = s_capturedArgs.AsSpan(); Assert.Equal(3, span[0]); var off = 1; @@ -93,13 +93,9 @@ public void MeasureOverride_CustomOverride_InvokesJsCallback() var strLen = BinaryPrimitives.ReadUInt32LittleEndian(span.Slice(off, 4)); off += 4; var name = Encoding.UTF8.GetString(span.Slice(off, (int)strLen)); off += (int)strLen; Assert.Equal("MeasureOverride", name); - Assert.Equal(0x06, span[off++]); - var argsHandle = BinaryPrimitives.ReadInt32LittleEndian(span.Slice(off, 4)); off += 4; - - Assert.True(Bridge.s_handles.TryGetValue(argsHandle, out var arrObj)); - var arr = Assert.IsType(arrObj); - Assert.Single(arr); - Assert.Equal(123, Convert.ToInt32(arr[0])); + // The argument travels as its own value, not as a handle to an object[]. + Assert.Equal(0x03, span[off++]); + Assert.Equal(123, BinaryPrimitives.ReadInt32LittleEndian(span.Slice(off, 4))); // Release created instance Bridge.Dispatch(new InvokeRequest(null, null, "__release", handle, null)); diff --git a/dotnet-bridge/BinaryProtocol.cs b/dotnet-bridge/BinaryProtocol.cs index dc7595f..11c26c8 100644 --- a/dotnet-bridge/BinaryProtocol.cs +++ b/dotnet-bridge/BinaryProtocol.cs @@ -17,6 +17,20 @@ internal readonly struct WinRtRef(long ptr) public readonly long Ptr = ptr; } +// A JS function passed where a delegate is expected (tag 0x0C): the runtime's callback id, wrapped +// in a delegate of the parameter's type when the call's arguments are coerced. +internal readonly struct JsFunctionRef(int id) +{ + public readonly int Id = id; +} + +// A plain JS object (tag 0x0D) as JSON, deserialized into the struct/record/class the parameter or +// return type expects (e.g. `{ Width: 120, Height: 40 }` for Windows.Foundation.Size). +internal sealed class JsJsonValue(string json) +{ + public readonly string Json = json; +} + internal ref struct BinReader(ReadOnlySpan buf) { private readonly ReadOnlySpan _buf = buf; @@ -24,6 +38,8 @@ internal ref struct BinReader(ReadOnlySpan buf) public byte ReadByte() => _buf[_pos++]; + public bool HasMore => _pos < _buf.Length; + public int ReadI32() { var v = BinaryPrimitives.ReadInt32LittleEndian(_buf[_pos..]); @@ -96,6 +112,8 @@ public uint ReadU32() 0x05 => (object)ReadString16(), 0x06 => (object)new HandleRef(ReadI32()), 0x0A => (object)new WinRtRef(ReadI64()), + 0x0C => (object)new JsFunctionRef(ReadI32()), + 0x0D => (object)new JsJsonValue(ReadString32()), _ => null, }; } diff --git a/dotnet-bridge/Bridge.BinaryDispatch.cs b/dotnet-bridge/Bridge.BinaryDispatch.cs index 7af021e..a1ca9eb 100644 --- a/dotnet-bridge/Bridge.BinaryDispatch.cs +++ b/dotnet-bridge/Bridge.BinaryDispatch.cs @@ -92,6 +92,14 @@ internal static DispatchResult DispatchBin(ref BinReader r) return DispatchResult.Void; } + // A JS subclass instance whose JS object was collected: its dispatcher makes a new one + // for this handle. + if (method == JsSelfMember && target is not null && s_proxyCallbacks.TryGetValue(target, out var jsHolder)) + { + CallJsCallback(jsHolder.CallbackId, [new HandleRef(handle), JsSelfMember], expectsResult: false); + return DispatchResult.Void; + } + return DispatchCallBin(target, type, method, args, isStatic: false); } @@ -116,7 +124,8 @@ internal static DispatchResult DispatchBin(ref BinReader r) for (int i = 0; i < memberCount; i++) memberNames[i] = r.ReadString16(); var callbackId = r.ReadI32(); - return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames); + var ctorArgs = r.HasMore ? r.ReadArgs() : []; + return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames, ctorArgs); } if (op == 0x0B) // get CLR-only property by raw IInspectable ptr (CLR reflection fallback) @@ -176,7 +185,35 @@ private static DispatchResult DispatchCallBin( } } + // Overloads with the same parameter count (Describe(Animal) / Describe(Shape)): pick the + // first whose parameter types the arguments convert to. + var overloads = GetOverloads(type, method, args.Length, flags); + if (overloads.Length > 1) + { + foreach (var overload in overloads) + { + var ps = overload.GetParameters(); + object?[] built; + try { built = BuildArgsBinExact(args, ps); } + catch { continue; } + if (!ArgsFit(built, ps)) continue; + return Box(overload.Invoke(target, built)); + } + } + var entry = GetCachedMethod(type, method, args.Length, flags); + if (entry.Invoke is null && target is not null && IsJsBacked(target)) + { + // A JS subclass may call the protected members it inherits. + var protectedFlags = BindingFlags.Instance | BindingFlags.NonPublic; + var inherited = type.GetMethods(protectedFlags) + .FirstOrDefault(m => m.Name == method && (m.IsFamily || m.IsFamilyOrAssembly) && m.GetParameters().Length == args.Length); + if (inherited is not null) + { + var built = BuildArgsBinExact(args, inherited.GetParameters()); + return Box(inherited.Invoke(target, built)); + } + } if (entry.Invoke is null) { var candidates = type.GetMethods(flags).Where(m => m.Name == method && !m.IsSpecialName); @@ -186,7 +223,7 @@ private static DispatchResult DispatchCallBin( var built = BuildArgsBin(args, parameters); try { - var res = AwaitIfTask(m.Invoke(target, built)); + var res = (m.Invoke(target, built)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) @@ -194,7 +231,7 @@ private static DispatchResult DispatchCallBin( if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -204,7 +241,7 @@ private static DispatchResult DispatchCallBin( if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -219,25 +256,33 @@ private static DispatchResult DispatchCallBin( try { try { - var res = AwaitIfTask(entry.Invoke(target, builtArgs)); + var res = (entry.Invoke(target, builtArgs)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); return Box(res); } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); return Box(res); } } finally { if (builtArgs.Length > 0) ReturnArgs(builtArgs); } } + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_overloadCache = new(); + + private static MethodInfo[] GetOverloads(Type type, string name, int argCount, BindingFlags flags) + => s_overloadCache.GetOrAdd(new MethodKey(type, name, argCount, flags), static k => + k.Type.GetMethods(k.Flags) + .Where(m => m.Name == k.Name && !m.IsGenericMethodDefinition && m.GetParameters().Length == k.ArgCount) + .ToArray()); + private static object?[] BuildArgsBin(object?[] binArgs, ParameterInfo[] parameters) { if (parameters.Length == 0) return []; @@ -256,6 +301,16 @@ private static DispatchResult DispatchCallBin( return result; } + // Plain JS objects <-> .NET structs/records: public fields count (Windows.Foundation.Size and most + // interop structs are fields), member names match case-insensitively. + internal static readonly System.Text.Json.JsonSerializerOptions s_jsJsonOptions = new() + { + IncludeFields = true, + PropertyNameCaseInsensitive = true, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString + | System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + private static object? CoerceBin(object? value, Type targetType) { if (value is null) return null; @@ -319,8 +374,28 @@ private static DispatchResult DispatchCallBin( throw; } } + if (value is JsJsonValue json) + { + if (targetType == typeof(string)) return json.Json; + var target = targetType == typeof(object) ? typeof(System.Text.Json.JsonElement) : targetType; + return System.Text.Json.JsonSerializer.Deserialize(json.Json, target, s_jsJsonOptions); + } + if (value is JsFunctionRef fn) + { + var delegateType = typeof(Delegate).IsAssignableFrom(targetType) + && targetType != typeof(Delegate) && targetType != typeof(MulticastDelegate) + ? targetType + : typeof(Action); + return MakeJsDelegate(delegateType, fn.Id); + } if (value.GetType() == targetType) return value; - try { return Convert.ChangeType(value, targetType); } + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (underlying.IsEnum) + { + try { return Enum.ToObject(underlying, Convert.ToInt64(value)); } + catch { return value; } + } + try { return Convert.ChangeType(value, underlying); } catch { return value; } } diff --git a/dotnet-bridge/Bridge.Dispatch.cs b/dotnet-bridge/Bridge.Dispatch.cs index f3eef5f..323d95b 100644 --- a/dotnet-bridge/Bridge.Dispatch.cs +++ b/dotnet-bridge/Bridge.Dispatch.cs @@ -115,7 +115,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) var built = BuildArgs(args, parameters); try { - var res = AwaitIfTask(m.Invoke(target, built)); + var res = (m.Invoke(target, built)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) @@ -123,7 +123,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -133,7 +133,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -148,19 +148,19 @@ internal static DispatchResult Dispatch(InvokeRequest req) try { try { - var res = AwaitIfTask(dispEntry.Invoke(target, builtArgs)); + var res = (dispEntry.Invoke(target, builtArgs)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(dispEntry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (dispEntry.Invoke(target, builtArgs))); return Box(res); } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(dispEntry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (dispEntry.Invoke(target, builtArgs))); return Box(res); } } @@ -263,34 +263,6 @@ private static void ReturnArgs(object?[] args) => return el.Deserialize(targetType, s_coerceOpts); } - private static object? AwaitIfTask(object? value) - { - if (value is null) return null; - - if (value is Task task) - { - task.GetAwaiter().GetResult(); - return TaskResultCache.GetResult(task); - } - - if (value is ValueTask vt) - { - vt.AsTask().GetAwaiter().GetResult(); - return null; - } - - var type = value.GetType(); - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - var innerTask = ValueTaskAsTaskCache.AsTask(type, value); - innerTask.GetAwaiter().GetResult(); - if (innerTask.IsFaulted) throw innerTask.Exception!.InnerException ?? innerTask.Exception; - return TaskResultCache.GetResult(innerTask); - } - - return value; - } - private static DispatchResult Box(object? value) { if (value is null) return DispatchResult.Void; @@ -321,10 +293,18 @@ private static DispatchResult Box(object? value) return DispatchResult.Primitive(Convert.ChangeType(value, ut), ut); } + // An instance of a JS subclass goes back to JS as the handle its JS object holds, so the + // runtime hands out that same JS object. If that object was collected (its handle released), + // the instance gets a new handle below, recorded for the JS object the runtime revives. + var jsHolder = JsBackedHolder(value, out var jsHandleLive); + if (jsHolder is not null && jsHandleLive) + return DispatchResult.Handle(jsHolder.HandleId, t.FullName ?? t.Name); + var id = Interlocked.Increment(ref s_nextHandle); s_handles[id] = value; + if (jsHolder is not null) jsHolder.HandleId = id; // Try to obtain a canonical IInspectable/IUnknown pointer for COM/WinRT objects // so the runtime can call native vtable methods directly. @@ -363,6 +343,31 @@ private static bool IsAwaitable(object? value, Type t) null, Type.EmptyTypes, null) is not null; } + /// "interface", "abstract", "static", "sealed", "class", "struct", "enum", "delegate", or "" + /// when the type isn't found. + /// Lets JS decide between `new Interface({...})`, subclassing and plain construction. + public static string TypeKind(string typeName) + { + var t = ResolveType(null, typeName); + if (t is null) return ""; + if (t.IsInterface) return "interface"; + if (t.IsEnum) return "enum"; + if (typeof(Delegate).IsAssignableFrom(t)) return "delegate"; + if (t.IsValueType) return "struct"; + if (t.IsAbstract && t.IsSealed) return "static"; + if (t.IsAbstract) return "abstract"; + if (t.IsSealed) return "sealed"; + return "class"; + } + + /// `value instanceof SomeType` for a .NET object that was not constructed from JS. + public static bool IsInstanceOf(object? value, string typeName) + { + if (value is null) return false; + var t = ResolveType(null, typeName); + return t is not null && t.IsInstanceOfType(value); + } + internal static DispatchResult BuildMembersResult(Type t) { const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance; diff --git a/dotnet-bridge/Bridge.JsDelegate.cs b/dotnet-bridge/Bridge.JsDelegate.cs index b238d6c..c0cf68b 100644 --- a/dotnet-bridge/Bridge.JsDelegate.cs +++ b/dotnet-bridge/Bridge.JsDelegate.cs @@ -12,91 +12,129 @@ namespace NativeScriptBridge; public static partial class Bridge { // opcode 0x09: given a delegate type name (or "" for System.Action) and a - // JS callback id, compile a .NET delegate that serialises its parameters as - // binary and calls back into V8 via the s_jsInvoker function pointer. - + // JS callback id, create a .NET delegate that calls back into V8. private static DispatchResult CreateJsDelegate(string typeName, int callbackId) { var delegateType = string.IsNullOrEmpty(typeName) ? typeof(Action) : ResolveType(null, typeName) ?? throw new TypeLoadException($"Delegate type not found: {typeName}"); + return Box(MakeJsDelegate(delegateType, callbackId)); + } - var invokeMethod = delegateType.GetMethod("Invoke") - ?? throw new MissingMethodException($"No Invoke method on {delegateType}"); + // The target a JS-backed delegate is bound to. When the delegate is collected the finalizer + // tells the runtime, which then drops its reference to the JS function. + internal sealed class JsCallbackTarget(int callbackId) + { + public readonly int CallbackId = callbackId; + + public object? Invoke(object?[] args) => CallJsCallback(CallbackId, args, expectsResult: true); + + public void InvokeVoid(object?[] args) => CallJsCallback(CallbackId, args, expectsResult: false); + + ~JsCallbackTarget() + { + ReleaseJsCallback(CallbackId); + } + } - var parameters = invokeMethod.GetParameters(); - var returnType = invokeMethod.ReturnType; + // Function pointer registered by the runtime (RegisterJsCallbackRelease) to drop a JS function + // whose delegate was collected. Called from the finalizer thread; the runtime queues the id. + internal static unsafe delegate* unmanaged[Cdecl] s_jsCallbackRelease; - // Build a dynamic method that marshals parameters into an object[] and - // calls back into JS via CallJsCallback/CallJsCallbackVoid. Expression - // compilation occasionally generates incorrect code for some delegate - // shapes; emitting IL here is reliable across signatures. - var paramTypes = parameters.Select(p => p.ParameterType).ToArray(); - - var dm = new DynamicMethod($"__ns_js_delegate_{callbackId}", - returnType, paramTypes, typeof(Bridge).Module, skipVisibility: true); + [UnmanagedCallersOnly(EntryPoint = "RegisterJsCallbackRelease", + CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + public static unsafe int RegisterJsCallbackRelease(delegate* unmanaged[Cdecl] release) + { + s_jsCallbackRelease = release; + return 0; + } + + internal static unsafe void ReleaseJsCallback(int callbackId) + { + var release = s_jsCallbackRelease; + if (release == null) return; + try { release(callbackId); } catch { } + } + + // One invoker per delegate type: (JsCallbackTarget target, ) -> . + // It packs the arguments into object[] and calls target.Invoke/InvokeVoid; a delegate instance is + // that invoker closed over its target, so creating a delegate emits no code. + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_jsDelegateInvokers = new(); + internal static Delegate MakeJsDelegate(Type delegateType, int callbackId) + { + var invoker = s_jsDelegateInvokers.GetOrAdd(delegateType, BuildJsDelegateInvoker); + return invoker.CreateDelegate(delegateType, new JsCallbackTarget(callbackId)); + } + + private static DynamicMethod BuildJsDelegateInvoker(Type delegateType) + { + var invokeMethod = delegateType.GetMethod("Invoke") + ?? throw new MissingMethodException($"No Invoke method on {delegateType}"); + var paramTypes = invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray(); + var returnType = invokeMethod.ReturnType; + + // Emitting IL (rather than compiling an expression tree) is reliable across delegate shapes. + var dm = new DynamicMethod($"__ns_js_delegate_{delegateType.Name}", returnType, + [typeof(JsCallbackTarget), .. paramTypes], typeof(Bridge).Module, skipVisibility: true); var il = dm.GetILGenerator(); - // local: object[] argsArray - var argsLocal = il.DeclareLocal(typeof(object[])); + + il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldc_I4, paramTypes.Length); il.Emit(OpCodes.Newarr, typeof(object)); - il.Emit(OpCodes.Stloc, argsLocal); - for (int i = 0; i < paramTypes.Length; i++) { - il.Emit(OpCodes.Ldloc, argsLocal); + var paramType = paramTypes[i].IsByRef ? paramTypes[i].GetElementType()! : paramTypes[i]; + il.Emit(OpCodes.Dup); il.Emit(OpCodes.Ldc_I4, i); - // load argument (Ldarg_0..) - switch (i + 1) - { - case 1: il.Emit(OpCodes.Ldarg_0); break; - case 2: il.Emit(OpCodes.Ldarg_1); break; - case 3: il.Emit(OpCodes.Ldarg_2); break; - case 4: il.Emit(OpCodes.Ldarg_3); break; - default: il.Emit(OpCodes.Ldarg, i + 1); break; - } - if (paramTypes[i].IsValueType) il.Emit(OpCodes.Box, paramTypes[i]); + il.Emit(OpCodes.Ldarg, i + 1); + if (paramTypes[i].IsByRef) il.Emit(OpCodes.Ldobj, paramType); + if (paramType.IsValueType) il.Emit(OpCodes.Box, paramType); il.Emit(OpCodes.Stelem_Ref); } - - if (returnType == typeof(void)) { - var callVoid = typeof(Bridge).GetMethod(nameof(CallJsCallbackVoid), BindingFlags.Static | BindingFlags.NonPublic)!; - il.Emit(OpCodes.Ldc_I4, callbackId); - il.Emit(OpCodes.Ldloc, argsLocal); - il.Emit(OpCodes.Call, callVoid); - il.Emit(OpCodes.Ret); + il.Emit(OpCodes.Call, typeof(JsCallbackTarget).GetMethod(nameof(JsCallbackTarget.InvokeVoid))!); } else { - var callObj = typeof(Bridge).GetMethod(nameof(CallJsCallback), BindingFlags.Static | BindingFlags.NonPublic)!; - il.Emit(OpCodes.Ldc_I4, callbackId); - il.Emit(OpCodes.Ldloc, argsLocal); - il.Emit(OpCodes.Call, callObj); - // ignore returned object and return default(returnType) - if (returnType.IsValueType) - { - var loc = il.DeclareLocal(returnType); - il.Emit(OpCodes.Ldloca_S, loc); - il.Emit(OpCodes.Initobj, returnType); - il.Emit(OpCodes.Ldloc, loc); - } - else - { - il.Emit(OpCodes.Ldnull); - } - il.Emit(OpCodes.Ret); + il.Emit(OpCodes.Call, typeof(JsCallbackTarget).GetMethod(nameof(JsCallbackTarget.Invoke))!); + il.Emit(OpCodes.Ldtoken, returnType); + il.Emit(OpCodes.Call, typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!); + il.Emit(OpCodes.Call, typeof(Bridge).GetMethod(nameof(ConvertJsResult), BindingFlags.Static | BindingFlags.NonPublic)!); + il.Emit(OpCodes.Unbox_Any, returnType); } + il.Emit(OpCodes.Ret); + return dm; + } - var del = dm.CreateDelegate(delegateType); - return Box(del); + // A JS callback's result, converted to the type the managed caller expects (a delegate's return + // type or an overridden member's). + internal static object? ConvertJsResult(object? value, Type returnType) + { + if (value is null) + return returnType.IsValueType && Nullable.GetUnderlyingType(returnType) is null + ? Activator.CreateInstance(returnType) + : null; + var coerced = CoerceBin(value, returnType); + if (coerced is null || returnType.IsInstanceOfType(coerced)) return coerced; + var underlying = Nullable.GetUnderlyingType(returnType) ?? returnType; + try { return Convert.ChangeType(coerced, underlying); } + catch (Exception e) + { + throw new InvalidCastException( + $"JavaScript returned {coerced.GetType().Name}, which can't be converted to {returnType.FullName}", e); + } } - internal static unsafe object? CallJsCallback(int id, object?[] args) + internal static object? CallJsCallback(int id, object?[] args) => CallJsCallback(id, args, expectsResult: true); + + // Calls the JS function registered under `id`. When it throws, a caller that expects a result + // gets a JsException (a delegate with a return value, an overridden member); a void delegate or + // event just returns, the error having been reported as an uncaught JS error. + internal static unsafe object? CallJsCallback(int id, object?[] args, bool expectsResult) { if (s_jsInvoker == null) return null; @@ -116,16 +154,21 @@ private static DispatchResult CreateJsDelegate(string typeName, int callbackId) // Response is only set for non-void delegates; parse and free when present. if (respPtr != null && respLen > 0) { + string? jsError = null; try { var span = new ReadOnlySpan(respPtr, respLen); - result = ParseCallbackResponse(span); + if (span[0] == 0x0F) + jsError = span.Length > 5 ? new BinReader(span[1..]).ReadString32() : "the JavaScript callback could not run"; + else + result = ParseCallbackResponse(span); } catch { result = null; } finally { Marshal.FreeHGlobal((IntPtr)respPtr); } + if (jsError != null && expectsResult) throw new JsException(jsError); } return result; @@ -176,6 +219,15 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) if (arg is float f) { w.WriteByte(0x04); w.WriteF64((double)f); return; } if (arg is double d){ w.WriteByte(0x04); w.WriteF64(d); return; } if (arg is string s){ w.WriteByte(0x05); w.WriteString32(s); return; } + if (arg is Enum e) { w.WriteByte(0x04); w.WriteF64(Convert.ToDouble(e)); return; } + if (arg is decimal m){ w.WriteByte(0x04); w.WriteF64((double)m); return; } + // A struct (Windows.Foundation.Size, a C# record struct, ...) arrives as a plain JS object. + if (arg.GetType().IsValueType && !arg.GetType().IsPrimitive) + { + string? json = null; + try { json = System.Text.Json.JsonSerializer.Serialize(arg, arg.GetType(), s_jsJsonOptions); } catch { } + if (json != null) { w.WriteByte(0x0D); w.WriteString32(json); return; } + } // Object: box in the handle map and send as a handle reference. // The JS side receives {__handle, __type} which can be turned into a @@ -209,6 +261,7 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) case 0x05: return (object)r.ReadString32(); case 0x06: return (object)new HandleRef(r.ReadI32()); case 0x0A: return (object)new WinRtRef(r.ReadI64()); + case 0x0D: return new JsJsonValue(r.ReadString32()); case 0x07: // array: u32 count + N tagged items { var count = (int)r.ReadU32(); @@ -237,8 +290,11 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) internal static unsafe void CallJsCallbackVoid(int id, object?[] args) { - CallJsCallback(id, args); + CallJsCallback(id, args, expectsResult: false); } } + +/// Thrown into managed code when a JavaScript callback that was expected to return a value threw. +public sealed class JsException(string message) : Exception(message); diff --git a/dotnet-bridge/Bridge.Proxy.cs b/dotnet-bridge/Bridge.Proxy.cs index 89e3cef..c16910d 100644 --- a/dotnet-bridge/Bridge.Proxy.cs +++ b/dotnet-bridge/Bridge.Proxy.cs @@ -14,9 +14,9 @@ namespace NativeScriptBridge; public static partial class Bridge { - // Cache for dynamically generated proxy types (dev-only fallback). Keyed by base type PLUS - // the exact set of interfaces/members a given JS `overrides` object asked for, so different - // JS subclasses of the same base type (different override sets) get distinct emitted types. + // The types behind JS subclasses, emitted at runtime. Keyed by base type PLUS the exact set of + // interfaces/members a JS class asked for, so different JS subclasses of the same base type + // (different override sets) get distinct emitted types. private readonly record struct ProxyTypeKey(Type BaseType, string InterfaceKey, string MemberKey); private static readonly ConcurrentDictionary s_dynamicProxyCache = new(); private static int s_dynamicProxyTypeCounter = 0; @@ -51,9 +51,7 @@ public static class ProxyRuntime public static T InvokeMethodTyped(object instance, string methodName, object[] args) { var result = ProxyInvokeMethod(instance, methodName, args); - if (result is null) return default!; - if (result is T t) return t; - return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); + return (T)ConvertJsResult(result, typeof(T))!; } public static object? InvokeMethod(object instance, string methodName, object[] args) @@ -69,9 +67,7 @@ public static void InvokeVoid(object instance, string methodName, object[] args) public static T GetProperty(object instance, string propertyName) { var result = ProxyGetProperty(instance, propertyName); - if (result is null) return default!; - if (result is T t) return t; - return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); + return (T)ConvertJsResult(result, typeof(T))!; } public static void SetProperty(object instance, string propertyName, object? value) @@ -79,98 +75,19 @@ public static void SetProperty(object instance, string propertyName, object? val ProxySetProperty(instance, propertyName, value); } } - private sealed class ProxyCallbackHolder { public int CallbackId; public int HandleId; public ProxyCallbackHolder(int cb) { CallbackId = cb; HandleId = 0; } } - - private static readonly ConditionalWeakTable s_proxyCallbacks = new(); - private static readonly ThreadLocal s_pendingProxyCallbackId = new(() => null); - private static readonly object s_proxyRegisterLock = new(); - private static bool s_proxyDispatcherRegistered = false; - - private static void EnsureProxyDispatcherCallbacksRegistered() + // Ties a JS-backed instance to its JS dispatcher (and the handle its JS object holds). Lives as + // long as the instance (ConditionalWeakTable value); when the instance is collected the runtime + // is told to unpin the dispatcher. + private sealed class ProxyCallbackHolder(int callbackId) { - if (s_proxyDispatcherRegistered) return; - lock (s_proxyRegisterLock) - { - if (s_proxyDispatcherRegistered) return; - - Type? pdType = null; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - pdType = asm.GetType("NativeScriptGeneratedProxies.ProxyDispatcher") - ?? asm.GetType("NSWinRTProxies.ProxyDispatcher") - ?? asm.GetType("NativeScriptGeneratedProxies.ProxyDispatcher"); - if (pdType != null) break; - } - catch { } - } + public readonly int CallbackId = callbackId; + public int HandleId; - if (pdType != null) - { - // Properties generated by SBG - var p1 = pdType.GetProperty("JsInvokeMethod", BindingFlags.Public | BindingFlags.Static); - p1?.SetValue(null, new Func(ProxyInvokeMethod)); - - var p2 = pdType.GetProperty("JsInvokeVoid", BindingFlags.Public | BindingFlags.Static); - p2?.SetValue(null, new Action(ProxyInvokeVoid)); - - var p3 = pdType.GetProperty("JsGetProperty", BindingFlags.Public | BindingFlags.Static); - p3?.SetValue(null, new Func(ProxyGetProperty)); - - var p4 = pdType.GetProperty("JsSetProperty", BindingFlags.Public | BindingFlags.Static); - p4?.SetValue(null, new Action(ProxySetProperty)); - - var p5 = pdType.GetProperty("JsInitializeInstance", BindingFlags.Public | BindingFlags.Static); - p5?.SetValue(null, new Action(ProxyInitializeInstance)); - } - - s_proxyDispatcherRegistered = true; - } - } - - private static bool DynamicProxiesEnabled() - { - // Always allow dynamic proxies in dev when Reflection.Emit is available. - return s_dynamicModule != null; + ~ProxyCallbackHolder() => ReleaseJsCallback(CallbackId); } - private static Type? FindGeneratedProxyType(string typeName) - { - if (string.IsNullOrEmpty(typeName)) return null; - var lastDot = typeName.LastIndexOf('.'); - var shortName = lastDot >= 0 ? typeName[(lastDot + 1)..] : typeName; - foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) - { - try - { - // Only consider assemblies that look like generated proxy assemblies - var asmName = asm.GetName().Name ?? ""; - if (!string.Equals(asmName, "NativeScriptGeneratedProxies", StringComparison.Ordinal) - && !string.Equals(asmName, "NSWinRTProxies", StringComparison.Ordinal)) - { - // Also check for candidate full-type names in any assembly - var candidates = new[] { $"NativeScriptGeneratedProxies.{shortName}", $"NSWinRTProxies.{shortName}" }; - foreach (var cn in candidates) - { - var t = asm.GetType(cn); - if (t is not null) return t; - } - continue; - } - - // If this is a generated proxies assembly, look up the proxy type name. - var genCandidates = new[] { $"NativeScriptGeneratedProxies.{shortName}", $"NSWinRTProxies.{shortName}" }; - foreach (var cn in genCandidates) - { - var t = asm.GetType(cn); - if (t is not null) return t; - } - } - catch { } - } - return null; - } + private static readonly ConditionalWeakTable s_proxyCallbacks = new(); + private static readonly ThreadLocal s_pendingProxyCallbackId = new(() => null); private static Type GetOrCreateDynamicProxyType(Type baseType, string[] interfaceNames, string[] memberNames) { @@ -204,20 +121,17 @@ private static Type CreateDynamicProxyType(Type baseType, string[] interfaceName var tb = s_dynamicModule.DefineType(typeName, TypeAttributes.Public | TypeAttributes.Class, baseType); - // Mirror public base constructors and ensure at least a parameterless - // ctor exists on the derived type. If the base type has no parameterless - // ctor, emit a parameterless ctor that forwards default(T) values to the - // first available base ctor (dev-only convenience). - var baseCtors = baseType.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .Where(c => !c.IsGenericMethod) + // Mirror the public and protected base constructors: a JS subclass can call either through + // super(...), and abstract classes / composable WinRT classes (Panel, Control) only have + // protected ones. + var baseCtors = baseType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(c => !c.IsGenericMethod && (c.IsPublic || c.IsFamily || c.IsFamilyOrAssembly)) .ToArray(); - ConstructorInfo? chosenBaseForDefault = null; foreach (var bc in baseCtors) { var parameters = bc.GetParameters(); if (parameters.Any(p => p.ParameterType.IsByRef)) continue; - chosenBaseForDefault ??= bc; // Define a matching ctor on the derived type that forwards args to base. var paramTypes = parameters.Select(p => p.ParameterType).ToArray(); @@ -241,47 +155,6 @@ private static Type CreateDynamicProxyType(Type baseType, string[] interfaceName ilg.Emit(OpCodes.Ret); } - // If we didn't define a parameterless ctor, emit one that forwards - // default(T) values to the chosen base ctor (if any), otherwise call - // System.Object..ctor. - if (!baseCtors.Any(c => c.GetParameters().Length == 0)) - { - var paramless = tb.DefineConstructor(MethodAttributes.Public, CallingConventions.Standard, Type.EmptyTypes); - var ilg = paramless.GetILGenerator(); - if (chosenBaseForDefault is not null) - { - ilg.Emit(OpCodes.Ldarg_0); - var parameters = chosenBaseForDefault.GetParameters(); - // For each param, emit default value (null for ref types, initobj local for value types) - for (int i = 0; i < parameters.Length; i++) - { - var ptype = parameters[i].ParameterType; - if (ptype.IsByRef) throw new InvalidOperationException("Cannot forward byref parameters in dynamic ctor"); - if (ptype.IsValueType) - { - var temp = ilg.DeclareLocal(ptype); - ilg.Emit(OpCodes.Ldloca_S, temp); - ilg.Emit(OpCodes.Initobj, ptype); - ilg.Emit(OpCodes.Ldloc, temp); - } - else - { - ilg.Emit(OpCodes.Ldnull); - } - } - ilg.Emit(OpCodes.Call, chosenBaseForDefault); - ilg.Emit(OpCodes.Ret); - } - else - { - // No public base ctors (rare); call Object..ctor - var objCtor = typeof(object).GetConstructor(Type.EmptyTypes)!; - ilg.Emit(OpCodes.Ldarg_0); - ilg.Emit(OpCodes.Call, objCtor); - ilg.Emit(OpCodes.Ret); - } - } - // Shared by both loops below, keyed by "Name(Param1FullName,Param2FullName,...)" so a // member that's both an overridden base virtual and a requested interface member (e.g. // the base class already implements that interface) reuses one emitted body instead of @@ -293,8 +166,12 @@ static string SignatureKey(string name, Type[] paramTypes) => // Override only the base virtuals JS actually asked for (memberSet). Anything not in // that set is simply never touched, so the base implementation stays live in the vtable — // no "call base" IL needed. (Skip ref/out and generic methods — unsupported.) + // Abstract members are always implemented (dispatching to JS, which reports a missing + // implementation as an error) — a type with an unimplemented abstract member can't be created. var virtualMethods = baseType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(m => m.IsVirtual && !m.IsFinal && !m.IsGenericMethod && !m.IsConstructor && memberSet.Contains(m.Name)) + .Where(m => m.IsVirtual && !m.IsFinal && !m.IsGenericMethod && !m.IsConstructor + && (m.IsPublic || m.IsFamily || m.IsFamilyOrAssembly) + && (memberSet.Contains(m.Name) || m.IsAbstract)) .ToArray(); foreach (var mi in virtualMethods) @@ -310,6 +187,11 @@ static string SignatureKey(string name, Type[] paramTypes) => MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, mi.CallingConvention, mi.ReturnType, paramTypes, mi.Name); definedSignatures[sigKey] = mb; + + // `super.Name(...)` from JS: a public, non-virtual entry point to the base + // implementation (a plain call, so it can't dispatch back into the override). + if (!mi.IsAbstract) + EmitBaseCallMethod(tb, mi, paramTypes); } tb.DefineMethodOverride(mb, mi); @@ -359,6 +241,21 @@ void CollectInterfaces(Type t) return tb.CreateTypeInfo()!.AsType(); } + internal const string BaseCallPrefix = "__ns_base_"; + internal const string JsSelfMember = "__ns_js_self"; + + private static void EmitBaseCallMethod(TypeBuilder tb, MethodInfo baseMethod, Type[] paramTypes) + { + var mb = tb.DefineMethod(BaseCallPrefix + baseMethod.Name, + MethodAttributes.Public | MethodAttributes.HideBySig, CallingConventions.HasThis, + baseMethod.ReturnType, paramTypes); + var il = mb.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + for (int i = 0; i < paramTypes.Length; i++) il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Call, baseMethod); + il.Emit(OpCodes.Ret); + } + // Shared IL body for both base-virtual overrides and interface-member implementations: box // args into an object[], forward to ProxyRuntime.InvokeVoid/InvokeMethodTyped by name. private static MethodBuilder EmitProxyMethodBody( @@ -406,82 +303,106 @@ private static MethodBuilder EmitProxyMethodBody( private static DispatchResult CreateJsSubclass( string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames) - { - EnsureProxyDispatcherCallbacksRegistered(); + => CreateJsSubclass(assemblyName, typeName, callbackId, interfaceNames, memberNames, []); - s_pendingProxyCallbackId.Value = callbackId; - - // Try to find a statically-generated proxy type first (Mechanism A: sbg-compiled into - // the app itself — already has any requested interfaces baked into real C# source). - Type? t = FindGeneratedProxyType(typeName); - if (t is null) + // Creates an instance of a JS subclass: a dynamic subclass of the base type (or of System.Object + // when the "base" is an interface being implemented) whose overridden virtuals and interface + // members call the JS object registered under `callbackId`. `ctorArgs` select and feed the + // base constructor, as `super(...)` arguments do on Android/iOS. + private static DispatchResult CreateJsSubclass( + string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames, object?[] ctorArgs) + { + // `typeName` is the base type; older runtimes passed an auto-generated proxy name there and + // the base type in `assemblyName`. + var baseType = ResolveType(null, typeName) + ?? (string.IsNullOrEmpty(assemblyName) ? null : ResolveType(null, assemblyName)) + ?? throw new TypeLoadException($"Type not found: {typeName}"); + + var interfaces = interfaceNames.ToList(); + if (baseType.IsInterface) { - // When typeName is an auto-generated proxy name (com.tns.gen.winrt.*), - // the caller puts the real base type name in assemblyName so we can - // look up or dynamically emit a proxy for the correct base class. - string? resolveFrom = typeName; - if (!string.IsNullOrEmpty(assemblyName) - && typeName.StartsWith("com.tns.gen.winrt.", StringComparison.Ordinal)) - { - resolveFrom = assemblyName; - } + interfaces.Insert(0, baseType.FullName!); + baseType = typeof(object); + } + if (baseType.IsSealed) + throw new InvalidOperationException($"{baseType.FullName} is sealed and can't be extended."); - // Resolve the WinRT/base type and optionally emit a dynamic proxy (Mechanism B fallback). - var baseType = ResolveType(null, resolveFrom) - ?? ResolveType(assemblyName, typeName) - ?? throw new TypeLoadException($"Type not found: {resolveFrom} (proxy: {typeName}, assembly: {assemblyName})"); + var proxyType = GetOrCreateDynamicProxyType(baseType, interfaces.ToArray(), memberNames); - if (baseType.IsClass && !baseType.IsSealed) - { - try { t = GetOrCreateDynamicProxyType(baseType, interfaceNames, memberNames); } - catch (Exception) { t = baseType; } - } - else + // Pick the constructor by argument count, preferring one whose parameters the arguments + // convert to. + var ctors = proxyType.GetConstructors().Where(c => c.GetParameters().Length == ctorArgs.Length).ToArray(); + if (ctors.Length == 0) + throw new MissingMethodException($"{baseType.FullName} has no constructor taking {ctorArgs.Length} argument(s)."); + object? instance = null; + Exception? lastError = null; + s_pendingProxyCallbackId.Value = callbackId; + try + { + foreach (var ctor in ctors) { - t = baseType; + object?[] built; + try { built = BuildArgsBinExact(ctorArgs, ctor.GetParameters()); } + catch (Exception e) { lastError = e; continue; } + if (!ArgsFit(built, ctor.GetParameters())) continue; + instance = ctor.Invoke(built); + break; } + if (instance is null) + throw lastError ?? new MissingMethodException( + $"No constructor of {baseType.FullName} accepts the given {ctorArgs.Length} argument(s)."); + RegisterJsBacked(instance, callbackId); + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + throw tie.InnerException; + } + finally + { + s_pendingProxyCallbackId.Value = null; } - - // Create the managed instance. Generated classes should call - // ProxyDispatcher.InitializeInstance(this, typeName) in their constructor, - // which will invoke our ProxyInitializeInstance and capture the pending callback id. - object? instance = Activator.CreateInstance(t); - if (instance is null) - throw new InvalidOperationException($"Failed to instantiate proxy type {t.FullName}."); - - // Ensure there's a mapping for this instance immediately to avoid any - // early virtual calls from constructors reaching the JS side before - // the handle is registered. Call our initializer directly to register - // the pending callback id when dynamic proxies don't call it. - try { ProxyInitializeInstance(instance, typeName); } catch { } // Force COM CCW vtable creation for any implemented WinRT interfaces now, rather than // relying on it happening implicitly the first time the instance crosses the ABI boundary. - // Best-effort/reflection-only — no-ops cleanly when CsWinRT isn't loaded (e.g. xunit host). - if (interfaceNames.Length > 0) - { - TryActivateWinRTInterfaces(instance); - } + if (interfaces.Count > 0) TryActivateWinRTInterfaces(instance); - // Box the instance into a handle to return to the runtime. - var result = Box(instance); + return Box(instance); + } - // Populate holder with handle id now that a handle exists. - if (s_proxyCallbacks.TryGetValue(instance, out var holder)) - { - try { holder.HandleId = result.HandleId(); } catch { holder.HandleId = 0; } - } - else + private static bool ArgsFit(object?[] built, ParameterInfo[] parameters) + { + for (int i = 0; i < parameters.Length; i++) { - // If the generated proxy did not call InitializeInstance, create mapping now. - int hid = 0; - try { hid = result.HandleId(); } catch { hid = 0; } - var newHolder = new ProxyCallbackHolder(callbackId) { HandleId = hid }; - s_proxyCallbacks.Add(instance, newHolder); + var p = parameters[i].ParameterType; + if (built[i] is null) { if (p.IsValueType && Nullable.GetUnderlyingType(p) is null) return false; continue; } + if (!p.IsInstanceOfType(built[i])) return false; } + return true; + } - s_pendingProxyCallbackId.Value = null; - return result; + private static void RegisterJsBacked(object instance, int callbackId) + { + if (!s_proxyCallbacks.TryGetValue(instance, out _)) + s_proxyCallbacks.Add(instance, new ProxyCallbackHolder(callbackId)); + } + + internal static bool IsJsBacked(object instance) => s_proxyCallbacks.TryGetValue(instance, out _); + + // The live handle of a JS-backed instance, or null when it has none (its JS object was collected + // and released the handle); Box then gives it a new one. + private static ProxyCallbackHolder? JsBackedHolder(object instance, out bool handleLive) + { + handleLive = false; + if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) return null; + handleLive = holder.HandleId != 0 + && s_handles.TryGetValue(holder.HandleId, out var current) && ReferenceEquals(current, instance); + return holder; + } + + private static int EnsureJsHandle(object instance, ProxyCallbackHolder holder) + { + JsBackedHolder(instance, out var live); + return live ? holder.HandleId : Box(instance).HandleId(); } // Locates CsWinRT's ComWrappersSupport via reflection (same style as the WinRT.IWinRTObject @@ -528,45 +449,53 @@ private static void TryActivateWinRTInterfaces(object instance) catch { /* best-effort only */ } } - private static void ProxyInitializeInstance(object instance, string typeName) + // The holder for a JS-backed instance. A virtual the base constructor calls runs before + // CreateJsSubclass has registered the instance, so fall back to the pending callback id. + private static ProxyCallbackHolder JsHolderFor(object instance) { - var cb = s_pendingProxyCallbackId.Value; - if (cb.HasValue) + if (s_proxyCallbacks.TryGetValue(instance, out var holder)) return holder; + var pending = s_pendingProxyCallbackId.Value; + if (pending.HasValue) { - var holder = new ProxyCallbackHolder(cb.Value); - s_proxyCallbacks.Add(instance, holder); + RegisterJsBacked(instance, pending.Value); + if (s_proxyCallbacks.TryGetValue(instance, out holder)) return holder; } + throw new InvalidOperationException("No JS callback registered for proxy instance."); + } + + // Payload for the JS dispatcher: (self handle, member name, ...args). The arguments are sent + // individually so each arrives as its JS value (numbers, strings, structs as plain objects, + // objects as handles). + private static object?[] DispatchPayload(object instance, ProxyCallbackHolder holder, string memberName, object?[] args) + { + var payload = new object?[args.Length + 2]; + payload[0] = new HandleRef(EnsureJsHandle(instance, holder)); + payload[1] = memberName; + Array.Copy(args, 0, payload, 2, args.Length); + return payload; } private static object? ProxyInvokeMethod(object instance, string methodName, object[] args) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - var payload = new object?[] { new HandleRef(holder.HandleId), methodName, args }; - return CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + return CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, methodName, args), expectsResult: true); } private static void ProxyInvokeVoid(object instance, string methodName, object[] args) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - - var payload = new object?[] { new HandleRef(holder.HandleId), methodName, args }; - CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, methodName, args), expectsResult: false); } private static object? ProxyGetProperty(object instance, string propertyName) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - // Tag "get:" prefix so the JS dispatcher can distinguish getter from method call. - var payload = new object?[] { new HandleRef(holder.HandleId), "get:" + propertyName }; - return CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + return CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, "get_" + propertyName, []), expectsResult: true); } private static void ProxySetProperty(object instance, string propertyName, object? value) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - // Tag "set:" prefix so the JS dispatcher can distinguish setter from method call. - var payload = new object?[] { new HandleRef(holder.HandleId), "set:" + propertyName, value }; - CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + CallJsCallback(holder.CallbackId, DispatchPayload(instance, holder, "set_" + propertyName, [value]), expectsResult: false); } } - diff --git a/dotnet-bridge/Bridge.TaskHelper.cs b/dotnet-bridge/Bridge.TaskHelper.cs index 1361e89..5671e5b 100644 --- a/dotnet-bridge/Bridge.TaskHelper.cs +++ b/dotnet-bridge/Bridge.TaskHelper.cs @@ -22,9 +22,10 @@ internal static void ScheduleTaskContinuation(int handleId, int resolveId, int r $"Object of type {obj.GetType().FullName} is not awaitable. " + $"Expected Task, ValueTask, IAsyncOperation, or any type implementing GetAwaiter()."); + // The handle stays valid after completion (the JS proxy releases it when collected), so + // the same Task can be awaited again. task.ContinueWith(completed => { - s_handles.TryRemove(handleId, out _); try { if (completed.IsFaulted) diff --git a/dotnet-bridge/Bridge.cs b/dotnet-bridge/Bridge.cs index 557c91a..bd556fd 100644 --- a/dotnet-bridge/Bridge.cs +++ b/dotnet-bridge/Bridge.cs @@ -123,8 +123,12 @@ public static string GetNamespaceAssemblyMapJson() catch { continue; } } + // The app's own assembly (C# sources compiled into the app) contributes + // its internal types too: they are reachable from JS (type resolution + // finds non-public types), so their namespaces must be as well. Type[] types; - try { types = asm.GetExportedTypes(); } + try { types = asm == Assembly.GetEntryAssembly() ? asm.GetTypes() : asm.GetExportedTypes(); } + catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray()!; } catch { types = Array.Empty(); } var assemblyName = asm.GetName().Name; @@ -132,7 +136,7 @@ public static string GetNamespaceAssemblyMapJson() foreach (var t in types) { - if (string.IsNullOrEmpty(t.Namespace)) continue; + if (string.IsNullOrEmpty(t.Namespace) || t.Name.StartsWith('<')) continue; map[t.Namespace] = assemblyName; var root = t.Namespace.Split('.')[0]; if (!map.ContainsKey(root)) map[root] = assemblyName; @@ -597,6 +601,12 @@ public static IntPtr GetNativePtrForHandle(int handleId) // WinRT interfaces (e.g. IUIElement for a FlexboxLayout subclass). private static IntPtr ObtainNativePtr(object obj) { + // Path 0 — a JS subclass of a WinRT class: the composed object's outer (its C#/WinRT CCW). + // XAML holds and hands back the outer, so using it here lets the runtime map those objects + // back to the JS instance. The base class's interfaces resolve through the inner. + if (IsJsBacked(obj) && TryGetOuterInspectable(obj) is { } outer && outer != IntPtr.Zero) + return outer; + // Path 1 — C#/WinRT IWinRTObject: use the projected inner object's pointer. // This gives the real native WinRT pointer, not the managed CCW, so // QueryInterface will succeed for all WinRT interfaces inherited by the type. @@ -646,6 +656,36 @@ private static IntPtr ObtainNativePtr(object obj) return iunknown; } + private static MethodInfo? s_marshalInspectableFromManaged; + + // WinRT.MarshalInspectable.FromManaged(obj), found by reflection (the bridge has no + // compile-time reference to C#/WinRT): an AddRef'd IInspectable of the object's CCW. + private static IntPtr? TryGetOuterInspectable(object obj) + { + if (obj.GetType().GetInterface("WinRT.IWinRTObject") is null) return null; + try + { + if (s_marshalInspectableFromManaged is null) + { + var open = AppDomain.CurrentDomain.GetAssemblies() + .Select(a => a.GetType("WinRT.MarshalInspectable`1")) + .FirstOrDefault(t => t is not null); + s_marshalInspectableFromManaged = open?.MakeGenericType(typeof(object)) + .GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "FromManaged" && m.GetParameters() is { Length: > 0 } ps + && ps[0].ParameterType == typeof(object) && ps.Skip(1).All(p => p.IsOptional)); + } + var method = s_marshalInspectableFromManaged; + if (method is null) return null; + var args = method.GetParameters().Select((p, i) => i == 0 ? obj : p.DefaultValue).ToArray(); + return method.Invoke(null, args) as IntPtr?; + } + catch + { + return null; + } + } + private static void OnAppDomainUnhandledException(object? sender, UnhandledExceptionEventArgs e) { try diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 2915f25..57793c8 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -92,6 +92,10 @@ type FnBridgeFree = unsafe extern "C" fn(ptr: *mut u8); // back into V8. Signature: (callback_id, args_ptr, args_len, &resp_ptr, &resp_len) -> void pub(crate) type FnJsCallback = unsafe extern "C" fn(i32, *const u8, i32, *mut *mut u8, *mut i32); type FnRegisterJsCallback = unsafe extern "C" fn(callback: FnJsCallback) -> i32; +/// Called by the bridge (from the GC finalizer thread) when the managed delegate wrapping a JS +/// function has been collected, so the runtime can drop its reference to the function. +pub(crate) type FnReleaseJsCallback = unsafe extern "C" fn(i32); +type FnRegisterJsCallbackRelease = unsafe extern "C" fn(release: FnReleaseJsCallback) -> i32; struct DotNetHost { _hostfxr: HMODULE, @@ -99,6 +103,8 @@ struct DotNetHost { invoke_binary: FnBridgeInvokeBinary, free: FnBridgeFree, register_js_callback: FnRegisterJsCallback, + /// Absent in bridges published before JS functions could be passed as delegates. + register_js_callback_release: Option, } // SAFETY: only ever accessed from the main JS/UI thread. @@ -272,6 +278,8 @@ fn build_host(bridge_dll: &str, runtime_config: &str) -> Result = + bind("RegisterJsCallbackRelease").ok().map(|p| unsafe { std::mem::transmute(p) }); Ok(DotNetHost { _hostfxr: hostfxr, @@ -279,6 +287,7 @@ fn build_host(bridge_dll: &str, runtime_config: &str) -> Result return, }; unsafe { (host.register_js_callback)(callback) }; + if let Some(register_release) = host.register_js_callback_release { + unsafe { register_release(queue_js_callback_release) }; + } +} + +/// Callback ids whose managed delegate was garbage-collected, queued from the finalizer thread +/// and dropped on the JS thread by [`take_released_js_callbacks`]. +static RELEASED_JS_CALLBACKS: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +unsafe extern "C" fn queue_js_callback_release(callback_id: i32) { + if let Ok(mut ids) = RELEASED_JS_CALLBACKS.lock() { + ids.push(callback_id); + } +} + +/// Callback ids released since the last call. Called on the JS thread, which owns the functions. +pub(crate) fn take_released_js_callbacks() -> Vec { + match RELEASED_JS_CALLBACKS.lock() { + Ok(mut ids) if !ids.is_empty() => std::mem::take(&mut *ids), + _ => Vec::new(), + } } /// Calls the managed bridge with a pre-built binary request packet and returns diff --git a/runtime/src/global_fns.rs b/runtime/src/global_fns.rs index 5efc722..279bb91 100644 --- a/runtime/src/global_fns.rs +++ b/runtime/src/global_fns.rs @@ -937,7 +937,11 @@ pub(crate) fn handle_resolve_module_path( // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also // check the sealed bundle's table — otherwise a relative require() from a bundle-only file // would wrongly treat the referrer itself as the base directory instead of its parent. - let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy()) + // A referrer that doesn't exist but names a file (the `app/bundle.js` fallback the + // top-level `require` uses when the entry script is not a bundle) is a file too. + let base = if parent.is_file() + || crate::source_protect::contains(&parent.to_string_lossy()) + || (!parent.exists() && parent.extension().is_some()) { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { @@ -1076,6 +1080,46 @@ pub(crate) fn handle_worker_post_message( } } +/// Runs on a worker's creating thread when the worker has queued messages: hands them to that +/// `Worker` object (`__nsWorkerDeliver`, installed by the Worker shim). +pub(crate) fn deliver_worker_events(worker_id: u64) { + let isolate_ptr = crate::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(ctx_global) = scope.get_slot::>().cloned() else { + return; + }; + let context = v8::Local::new(scope, &ctx_global); + let scope = &mut v8::ContextScope::new(scope, context); + v8::tc_scope!(tc, scope); + + let global = context.global(tc); + let Some(key) = v8::String::new(tc, "__nsWorkerDeliver") else { + return; + }; + let Some(deliver) = global + .get(tc, key.into()) + .and_then(|v| v8::Local::::try_from(v).ok()) + else { + return; + }; + let id: v8::Local = v8::Number::new(tc, worker_id as f64).into(); + let _ = deliver.call(tc, global.into(), &[id]); + if tc.has_caught() { + if let Some(ex) = tc.exception() { + let msg = ex.to_rust_string_lossy(tc); + crate::store_last_js_error(msg); + } + tc.reset(); + } + if !crate::defer_microtask_drain() { + tc.perform_microtask_checkpoint(); + } +} + pub(crate) fn handle_worker_poll_messages( scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, @@ -2269,6 +2313,15 @@ const HELPER_SOURCE: &str = r#" if (typeof Function.prototype.extend !== 'function') { Object.defineProperty(Function.prototype, 'extend', { value: function (nameOrOverrides, maybeOverrides) { + // A WinRT class (or a JS class deriving from one): an ES2015 subclass, so + // instances get the composed-object construction of `class X extends Y`. + if (globalThis.NSWinRT && typeof globalThis.NSWinRT.extendClass === 'function') { + if (typeof this.__typeName__ === 'string') + return globalThis.NSWinRT.extendClass(this, nameOrOverrides, maybeOverrides); + // Object.extend({ interfaces: [...], ... }): a subclass of System.Object. + if (this === Object && globalThis.System) + return globalThis.NSWinRT.extendClass(globalThis.System.Object, nameOrOverrides, maybeOverrides); + } return makeManagedConstructor(this, nameOrOverrides, maybeOverrides); }, writable: true, @@ -2499,6 +2552,27 @@ const HELPER_SOURCE: &str = r#" }; } + // `return global.__native(this)` ends TypeScript subclass constructors written for + // Android/iOS; here `this` already is the native-backed object. + if (typeof globalThis.__native !== 'function') { + globalThis.__native = function (obj) { return obj; }; + } + + // @NativeClass / @NativeClass(): marks a class that extends a native (WinRT/.NET) class. + // Extending works without it; it exists so code written for iOS/Android, where the build + // transforms decorated classes, runs unchanged. + if (typeof globalThis.NativeClass !== 'function') { + Object.defineProperty(globalThis, 'NativeClass', { + value: function NativeClass(target) { + if (typeof target === 'function') return target; + return function (cls) { return cls; }; + }, + writable: true, + configurable: true, + enumerable: false, + }); + } + // @Interfaces([IFoo, IBar]) — attach WinRT interface list to a class. if (typeof globalThis.Interfaces !== 'function') { Object.defineProperty(globalThis, 'Interfaces', { @@ -2655,7 +2729,7 @@ const HELPER_SOURCE: &str = r#" try { var target = _wrapDotNetHandle(a[0]); var method = a[1]; - var margs = a[2] || []; + var margs = a.slice(2).map(_wrapDotNetHandle); if (target && typeof method === 'string') { var fn = overrides[method]; if (typeof fn === 'function') return fn.apply(target, Array.isArray(margs) ? margs : []); @@ -3232,7 +3306,14 @@ const HELPER_SOURCE: &str = r#" if (prop === 'toString') return function () { return '[DotNetObject ' + typeName + ' #' + handle + ']'; }; - if (prop === 'then') return undefined; + // A Task/ValueTask (awaitable) result is thenable, so `await obj.SomethingAsync()` works; + // any other .NET object must not look like a promise. + if (prop === 'then') { + if (isTask !== true) return undefined; + return function (onFulfilled, onRejected) { + return globalThis.NSWinRT.dotnet.taskToPromise({ __handle: handle, __isTask: true }).then(onFulfilled, onRejected); + }; + } // Re-read info in case it was populated after construction. var i = _typeInfoCache[typeName] || _emptyInfo; // Write-only: has setter but no getter — reading it is an error. @@ -3269,6 +3350,14 @@ const HELPER_SOURCE: &str = r#" if (Array.isArray(value)) return value.map(_wrap); if (typeof value === 'object' && typeof value.__handle === 'number') { var typeName = value.__type || ''; + var jsSelf = _jsSelfByHandle && _deref(_jsSelfByHandle.get(value.__handle)); + if (jsSelf) return jsSelf; + // A JS subclass instance whose JS object was collected: its dispatcher revives one. + if (typeName.indexOf('NSWinRTDynamicProxies.') === 0) { + try { _invoke({ handle: value.__handle, method: '__ns_js_self', args: [] }); } catch (_) {} + jsSelf = _deref(_jsSelfByHandle.get(value.__handle)); + if (jsSelf) return jsSelf; + } var assembly = _resolveAssembly(typeName); return _makeDotNetInstance(value.__handle, assembly, typeName, value.__isTask === true, value.__native_ptr); } @@ -3311,6 +3400,11 @@ const HELPER_SOURCE: &str = r#" var proxy = _makeNamespaceProxy(root); try { Object.defineProperty(globalThis, root, { value: proxy, writable: true, configurable: true, enumerable: true }); } catch (_) {} return proxy; + }, + // A namespace root never blocks app code from using the name for its own value + // (`var App = ...` in a classic script assigns through this accessor). + set: function(value) { + try { Object.defineProperty(globalThis, root, { value: value, writable: true, configurable: true, enumerable: true }); } catch (_) {} } }); } catch (_) { @@ -3323,15 +3417,491 @@ const HELPER_SOURCE: &str = r#" }, }; + // ── .NET types as JS classes ──────────────────────────────────────────────── + // A .NET type proxy (System.Text.StringBuilder, MyApp.Native.Greeter) is a constructor with a + // stable `prototype`, so it can be extended the way Java and Objective-C classes are on Android + // and iOS: + // class Cat extends MyApp.Animal { constructor(n) { super(n); } Speak() { return super.Speak(); } } + // const Dog = MyApp.Animal.extend('Dog', { Speak() { return this.super.Speak(); } }); + // const calc = new MyApp.ICalculator({ Compute(a, b) { return a * b; } }); + // Constructing a subclass creates the JS object first, then a managed subclass of the .NET type + // whose overridden virtuals (and implemented interface members) call back into that object. + // `super.X()` reaches the base implementation through the non-virtual `__ns_base_X` entry point + // the bridge emits for every overridden member. Native code handing the instance back to JS gets + // the same JS object. + var _nsProxyCache = Object.create(null); + var _namespaceExtras = { System: Object.create(null) }; + var _typeKindCache = Object.create(null); + var _typeProtoCache = Object.create(null); + var _subclassInfoCache = new WeakMap(); + var _jsSelfByHandle = new Map(); + + // ── Lifetime of JS subclass instances ─────────────────────────────────────── + // An instance is a JS object plus the managed object it drives. The JS object holds the managed + // one through its handle; the managed object reaches the JS object only through its dispatcher, + // which holds it weakly. So once JS drops the JS object, its handle is released and the managed + // object goes as soon as .NET/native code drops it too (the bridge then unpins the dispatcher). + // When native code calls into an instance whose JS object was collected, a new JS object of the + // same class is made for it: fields its constructor set are gone, as on Android when a JS object + // used only from native code is collected. Subclasses of WinRT UI classes are kept strongly while + // they are in a live XAML tree. + var _canWeakRef = typeof WeakRef === 'function'; + var _selfFinalizers = typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry(function (handle) { + if (!_deref(_jsSelfByHandle.get(handle))) _jsSelfByHandle.delete(handle); + try { _invoke({ handle: handle, method: '__release', args: [] }); } catch (_) {} + }) + : null; + + function _weak(obj) { return _canWeakRef ? new WeakRef(obj) : obj; } + function _deref(ref) { return ref && _canWeakRef && ref instanceof WeakRef ? ref.deref() : ref; } + + // Gives `self` the bridge identity in `raw` and registers it as the JS object of `entry`'s instance. + function _adopt(entry, self, raw) { + Object.defineProperty(self, '__handle', { value: raw.__handle }); + Object.defineProperty(self, '__nsOverrides', { value: entry.overrides }); + if (!entry.winrt) { + Object.defineProperty(self, '__type', { value: raw.__type || entry.typeName }); + if (raw.__native_ptr !== undefined) Object.defineProperty(self, '__native_ptr', { value: raw.__native_ptr }); + } + entry.ref = _weak(self); + _jsSelfByHandle.set(raw.__handle, _weak(self)); + if (_selfFinalizers) _selfFinalizers.register(self, raw.__handle); + if (entry.winrt) { + entry.strong = self; + _attachedEntries.add(entry); + } + _sweepAttached(); + return self; + } + + // The JS object of `entry`'s instance, made anew when the previous one was collected. + function _selfFor(entry, raw) { + var self = _deref(entry.ref); + if (self) return self; + if (entry.constructing) throw new Error(entry.typeName + ' called into its JS subclass before the instance existed'); + if (!raw || typeof raw.__handle !== 'number') throw new Error('The ' + entry.typeName + ' subclass instance is gone'); + if (entry.winrt) { + self = globalThis.__nsWrapWinRTPointer(raw.__native_ptr, entry.typeName); + Object.setPrototypeOf(self, entry.proto); + } else { + self = Object.create(entry.proto); + } + return _adopt(entry, self, raw); + } + + // The dispatcher native code calls the instance's overridden and interface members through. + function _makeDispatcher(entry) { + return function (selfHandle, member) { + var self = _selfFor(entry, selfHandle); + if (member === '__ns_js_self') return undefined; + _sweepAttached(); + var margs = Array.prototype.slice.call(arguments, 2).map(_wrap); + return _dispatchToJs(self, entry.baseProto, entry.typeName, member, margs); + }; + } + + // Subclasses of WinRT classes held strongly (entry.strong) while in a live XAML tree, re-checked at + // most every two seconds when subclass instances are created or called into. + var _attachedEntries = new Set(); + var _lastAttachedSweep = 0; + function _sweepAttached() { + var now = Date.now(); + if (now - _lastAttachedSweep < 2000) return; + _lastAttachedSweep = now; + _attachedEntries.forEach(function (entry) { + var self = _deref(entry.ref); + if (!self) { + _attachedEntries.delete(entry); + return; + } + var attached = false; + try { attached = self.XamlRoot != null; } catch (_) {} + entry.strong = attached ? self : undefined; + }); + } + var _baseCallPrefix = '__ns_base_'; + + function _has(list, name) { + return !!list && list.indexOf(name) >= 0; + } + + function _typeKind(path) { + var kind = _typeKindCache[path]; + if (kind !== undefined) return kind; + try { + kind = _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'TypeKind', args: [path] }) || ''; + } catch (_) { + kind = ''; + } + _typeKindCache[path] = kind; + return kind; + } + + // Full name of a .NET type proxy, or of the .NET base of a JS class deriving from one. + function _dotnetTypeName(type) { + if (typeof type === 'string') return type; + if (!type || (typeof type !== 'function' && typeof type !== 'object')) return ''; + var name = type.__dotnetType__; + return typeof name === 'string' ? name : ''; + } + + // The handle of an instance of a JS subclass (an own property); -1 for anything else, such as + // a class prototype further down the chain, without running any property trap. + function _ownHandle(o) { + if (!o || (typeof o !== 'object' && typeof o !== 'function')) return -1; + var d = Object.getOwnPropertyDescriptor(o, '__handle'); + return d && typeof d.value === 'number' ? d.value : -1; + } + + function _baseCallName(self, member) { + var overrides = self.__nsOverrides; + return overrides && overrides.has(member) ? _baseCallPrefix + member : member; + } + + // A native member of `self` (an instance of a JS subclass), reached through the .NET type's + // prototype: properties are read through their getter, methods become functions. Members the JS + // class overrides resolve to the base implementation — only `super.X` gets this far for them. + function _baseMember(path, self, prop) { + if (prop === 'toString') return function () { return String(_invoke({ handle: self.__handle, method: 'ToString', args: [] })); }; + if (prop === 'valueOf') return function () { return this; }; + var info = _getTypeInfo(_resolveAssembly(path), path); + var overrides = self.__nsOverrides; + if (_has(info.properties, prop) || (overrides && overrides.has('get_' + prop))) { + return _wrap(_invoke({ handle: self.__handle, method: _baseCallName(self, 'get_' + prop), args: [] })); + } + if (_has(info.writeonlyProperties, prop)) + throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + path + '\''); + if (_has(info.methods, prop) || (overrides && overrides.has(prop))) { + var method = _baseCallName(self, prop); + return function () { + var args = Array.prototype.slice.call(arguments).map(_unwrap); + return _wrap(_invoke({ handle: self.__handle, method: method, args: args })); + }; + } + return undefined; + } + + // The `prototype` of a .NET type: the end of every JS subclass's prototype chain. Reads resolve + // native members against the receiving instance; writes go to native properties, and anything + // else becomes a plain JS field of the instance. + function _typePrototype(path) { + var cached = _typeProtoCache[path]; + if (cached) return cached; + var proto = new Proxy(Object.create(null), { + get: function (_, prop, receiver) { + if (typeof prop === 'symbol') return undefined; + if (_ownHandle(receiver) < 0) { + // Read off the prototype itself — TypeScript's ES5 super call + // `_super.prototype.Method.call(this, ...)`: a function that runs the base + // implementation on whichever instance it is called with. + var typeInfo = _getTypeInfo(_resolveAssembly(path), path); + if (!_has(typeInfo.methods, prop)) return undefined; + return function () { + if (_ownHandle(this) < 0) throw new TypeError(path + '.prototype.' + prop + ' called on an object that is not a ' + path); + return _baseMember(path, this, prop).apply(this, arguments); + }; + } + return _baseMember(path, receiver, prop); + }, + set: function (_, prop, value, receiver) { + if (typeof prop !== 'symbol' && _ownHandle(receiver) >= 0) { + var info = _getTypeInfo(_resolveAssembly(path), path); + if (_has(info.readonlyProperties, prop)) + throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + path + '\''); + if (_has(info.properties, prop) || _has(info.writeonlyProperties, prop)) { + _invoke({ handle: receiver.__handle, method: _baseCallName(receiver, 'set_' + prop), args: [_unwrap(value)] }); + return true; + } + } + Object.defineProperty(receiver, prop, { value: value, writable: true, enumerable: true, configurable: true }); + return true; + }, + }); + _typeProtoCache[path] = proto; + return proto; + } + + // Members (literal CLR names: methods, get_X/set_X accessors) and interfaces a JS class adds on + // top of its .NET base, from every class between it and the base. + function _subclassInfo(proto, baseProto) { + var cached = _subclassInfoCache.get(proto); + if (cached) return cached; + var members = []; + var overrides = new Set(); + var interfaces = []; + var seen = Object.create(null); + function addInterfaces(list) { + if (!Array.isArray(list)) return; + for (var i = 0; i < list.length; i++) { + var name = _dotnetTypeName(list[i]) || (list[i] && (list[i].__typeName__ || list[i].name)) || ''; + if (name && interfaces.indexOf(name) < 0) interfaces.push(name); + } + } + for (var p = proto; p && p !== baseProto; p = Object.getPrototypeOf(p)) { + var keys = Object.getOwnPropertyNames(p); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key === 'constructor' || key === 'interfaces' || key === 'super' || key === 'init' || seen[key]) continue; + seen[key] = true; + var d = Object.getOwnPropertyDescriptor(p, key); + if (typeof d.get === 'function') { members.push('get_' + key); overrides.add('get_' + key); } + if (typeof d.set === 'function') { members.push('set_' + key); overrides.add('set_' + key); } + if (typeof d.value === 'function') { members.push(key); overrides.add(key); } + } + if (Object.prototype.hasOwnProperty.call(p, 'interfaces')) addInterfaces(p.interfaces); + var ctor = Object.prototype.hasOwnProperty.call(p, 'constructor') ? p.constructor : null; + if (ctor && Object.prototype.hasOwnProperty.call(ctor, 'interfaces')) addInterfaces(ctor.interfaces); + } + var info = { members: members, overrides: overrides, interfaces: interfaces }; + _subclassInfoCache.set(proto, info); + return info; + } + + // The JS-defined member `key` of `self` (an own field, or a member of one of its JS classes). + function _jsMember(self, key, baseProto) { + for (var o = self; o && o !== baseProto; o = Object.getPrototypeOf(o)) { + var d = Object.getOwnPropertyDescriptor(o, key); + if (d) return d; + } + return null; + } + + // Native code calling an overridden member (or an implemented interface member) of `self`. + function _dispatchToJs(self, baseProto, typeName, member, args) { + var accessor = /^(get|set)[_:](.+)$/.exec(member); + if (accessor) { + var ad = _jsMember(self, accessor[2], baseProto); + if (ad) { + if (accessor[1] === 'get') { + if (typeof ad.get === 'function') return ad.get.call(self); + if ('value' in ad && typeof ad.value !== 'function') return ad.value; + } else if (typeof ad.set === 'function') { + ad.set.call(self, args[0]); + return undefined; + } else if ('value' in ad && ad.writable) { + self[accessor[2]] = args[0]; + return undefined; + } + } + } + var md = _jsMember(self, member, baseProto); + if (md && typeof md.value === 'function') return md.value.apply(self, args); + throw new Error((self.constructor && self.constructor.name || typeName) + ' does not implement ' + member); + } + + function _constructSubclass(path, args, proto) { + if (typeof globalThis.__nsDotNetCreateJsSubclass !== 'function') + throw new Error('Extending .NET types is not available in this runtime'); + var baseProto = _typePrototype(path); + var info = _subclassInfo(proto, baseProto); + var self = Object.create(proto); + // A virtual the base constructor calls reaches `self` before it has its handle. + var entry = { proto: proto, baseProto: baseProto, typeName: path, overrides: info.overrides, winrt: false, ref: self }; + var raw = globalThis.__nsDotNetCreateJsSubclass('', path, info.interfaces, info.members, _makeDispatcher(entry), args.map(_unwrap)); + if (!raw || typeof raw.__handle !== 'number') throw new Error('Could not create an instance of a subclass of ' + path); + return _adopt(entry, self, raw); + } + + // `new SomeInterface({ ... })`: an object implementing the interface with the given members. + function _implementInterface(path, impl) { + var proto = Object.create(_typePrototype('System.Object')); + Object.defineProperties(proto, Object.getOwnPropertyDescriptors(impl)); + Object.defineProperty(proto, 'interfaces', { value: [path] }); + return _constructSubclass('System.Object', [], proto); + } + + // `this.super` inside a class made by extend(): the parent class's members bound to `self`. + function _superOf(self, Cls) { + var parent = Object.getPrototypeOf(Cls.prototype); + return new Proxy(Object.create(null), { + get: function (_, prop) { + var value = Reflect.get(parent, prop, self); + return typeof value === 'function' ? value.bind(self) : value; + }, + }); + } + + // Base.extend([name,] members): an ES2015 subclass of Base with the given members; `init`, when + // present, runs after construction with the constructor arguments. + function _extendClass(Base, nameOrMembers, maybeMembers) { + var hasName = typeof nameOrMembers === 'string'; + var members = (hasName ? maybeMembers : nameOrMembers) || {}; + var Extended = class extends Base { + constructor() { + super(...arguments); + if (typeof members.init === 'function') members.init.apply(this, arguments); + } + }; + var descriptors = Object.getOwnPropertyDescriptors(members); + delete descriptors.init; + if (descriptors.interfaces) { + Object.defineProperty(Extended.prototype, 'interfaces', { value: members.interfaces }); + delete descriptors.interfaces; + } + Object.defineProperties(Extended.prototype, descriptors); + Object.defineProperty(Extended.prototype, 'super', { + get: function () { return _superOf(this, Extended); }, + }); + if (hasName) Object.defineProperty(Extended, 'name', { value: nameOrMembers.split('.').pop() }); + return Extended; + } + + // `_super.call(this, ...)` / `_super.apply(this, args)` in TypeScript's ES5 output (what the + // @NativeClass transform emits): construct the subclass instance `thisArg` was created for. + function _es5SuperCall(path, thisArg, args) { + var proto = thisArg && Object.getPrototypeOf(thisArg); + if (!proto || !Object.prototype.isPrototypeOf.call(_typePrototype(path), proto)) + throw new TypeError('Class constructor ' + path + ' cannot be invoked without \'new\''); + return _constructSubclass(path, args, proto); + } + + function _hasInstance(path, value) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false; + if (Object.prototype.isPrototypeOf.call(_typePrototype(path), value)) return true; + if (typeof value.__handle !== 'number') return false; + try { + return _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'IsInstanceOf', args: [{ __handle: value.__handle }, path] }) === true; + } catch (_) { + return false; + } + } + + // ── WinRT classes as JS base classes ──────────────────────────────────────── + // `class FixedPanel extends Microsoft.UI.Xaml.Controls.Panel { MeasureOverride(size) { ... } }`: + // the WinRT constructor hands `super(...)` here. The instance is a composed object created by + // the .NET bridge from the C#/WinRT projection of the class (the way a C# subclass is), so XAML + // calling an overridable member (MeasureOverride, ArrangeOverride, OnApplyTemplate, ...) lands in + // the JS override. The JS object is the ordinary WinRT wrapper of that object, with the subclass + // prototype spliced in front of the class's native members. + var _linkedWinRTPrototypes = new WeakSet(); + + // The WinRT class a JS class derives from: the nearest constructor with its own __typeName__. + function _winrtBaseOf(ctor) { + for (var c = ctor; c; c = Object.getPrototypeOf(c)) { + if (Object.prototype.hasOwnProperty.call(c, '__typeName__')) return c; + } + return null; + } + + // `super.X(...)` for a member a JS subclass of a WinRT class overrides: the base implementation, + // through the bridge (the native member would dispatch back into the override). + function _winrtBaseCall(handle, prop) { + return function () { + var args = Array.prototype.slice.call(arguments).map(_unwrap); + return _wrap(_invoke({ handle: handle, method: _baseCallPrefix + prop, args: args })); + }; + } + + // Puts a layer under the JS subclass prototypes that resolves the class's native members and + // sends `super.X()` for overridden members to the base implementation. On the classic engine the + // class's `prototype` is separate from its instances' native prototype, so the layer goes under the + // class's `prototype` once; on the napi engines they're the same object, so it goes under the + // subclass prototype that sits directly on it. + function _linkWinRTSubclassPrototype(base, nativeProto, proto) { + if (!base || !nativeProto) return; + var target = base.prototype; + if (target === nativeProto) { + target = proto; + while (target && Object.getPrototypeOf(target) !== nativeProto) target = Object.getPrototypeOf(target); + } + if (!target || _linkedWinRTPrototypes.has(target)) return; + _linkedWinRTPrototypes.add(target); + var layer = new Proxy(Object.create(nativeProto), { + get: function (_, prop, receiver) { + var handle = _ownHandle(receiver); + if (typeof prop !== 'string') return Reflect.get(nativeProto, prop, receiver); + if (handle >= 0) { + if (receiver.__nsOverrides && receiver.__nsOverrides.has(prop)) return _winrtBaseCall(handle, prop); + return Reflect.get(nativeProto, prop, receiver); + } + // Read off a prototype — TypeScript's ES5 `_super.prototype.X.call(this, ...)`: decide + // between the base implementation and the native member when it's called. + var native = Reflect.get(nativeProto, prop, receiver); + if (typeof native !== 'function') return native; + return function () { + var h = _ownHandle(this); + if (h >= 0 && this.__nsOverrides && this.__nsOverrides.has(prop)) + return _winrtBaseCall(h, prop).apply(this, arguments); + return native.apply(this, arguments); + }; + }, + has: function (_, prop) { return prop in nativeProto; }, + }); + Object.setPrototypeOf(target, layer); + } + + // napi engines: the class `prototype` is the native prototype itself, so TypeScript's ES5 + // `_super.prototype.X.call(this, ...)` reaches the native member directly. Wrap each native member a + // subclass overrides (once) so that, called on a JS subclass instance, it runs the base + // implementation instead of dispatching back into the override. + function _guardNativeMembers(nativeProto, overrides) { + overrides.forEach(function (name) { + for (var p = nativeProto; p; p = Object.getPrototypeOf(p)) { + var d = Object.getOwnPropertyDescriptor(p, name); + if (!d) continue; + if (typeof d.value === 'function' && !d.value.__nsGuarded && d.writable) { + var native = d.value; + var guarded = function () { + var h = _ownHandle(this); + if (h >= 0 && this.__nsOverrides && this.__nsOverrides.has(name)) + return _winrtBaseCall(h, name).apply(this, arguments); + return native.apply(this, arguments); + }; + Object.defineProperty(guarded, '__nsGuarded', { value: true }); + Object.defineProperty(p, name, { value: guarded, writable: true, configurable: true, enumerable: d.enumerable }); + } + return; + } + }); + } + + // Called by the WinRT class constructors for `super(...)` from a JS subclass (newTarget) or + // TypeScript's ES5 `_super.call(this, ...)` (es5This). Returns undefined when this isn't a subclass + // construction or the .NET bridge isn't available; the constructor then builds a plain instance. + globalThis.__nsConstructWinRTSubclass = function (typeName, newTarget, args, es5This) { + var proto; + var base; + if (newTarget) { + proto = newTarget.prototype; + base = _winrtBaseOf(Object.getPrototypeOf(newTarget)); + } else { + proto = es5This && Object.getPrototypeOf(es5This); + base = proto && proto.constructor ? _winrtBaseOf(Object.getPrototypeOf(proto.constructor)) : null; + if (!base || base.__typeName__ !== typeName || Object.prototype.hasOwnProperty.call(proto.constructor, '__typeName__')) + return undefined; + } + if (typeof globalThis.__nsDotNetCreateJsSubclass !== 'function' || typeof globalThis.__nsWrapWinRTPointer !== 'function') + return undefined; + var baseProto = base ? base.prototype : null; + var info = _subclassInfo(proto, baseProto); + var entry = { proto: proto, baseProto: baseProto, typeName: typeName, overrides: info.overrides, winrt: true, constructing: true }; + var raw = globalThis.__nsDotNetCreateJsSubclass('', typeName, info.interfaces, info.members, _makeDispatcher(entry), (args || []).map(_unwrap)); + entry.constructing = false; + if (!raw || raw.__native_ptr === undefined || raw.__native_ptr === null) + throw new Error('Could not create a WinRT object for a subclass of ' + typeName); + var self = globalThis.__nsWrapWinRTPointer(raw.__native_ptr, typeName); + var nativeProto = Object.getPrototypeOf(self); + _linkWinRTSubclassPrototype(base, nativeProto, proto); + if (base && base.prototype === nativeProto) _guardNativeMembers(nativeProto, info.overrides); + Object.setPrototypeOf(self, proto); + return _adopt(entry, self, raw); + }; + + globalThis.NSWinRT.extendClass = _extendClass; + // System.Diagnostics.Stopwatch.StartNew() → static method call // System.Environment.MachineName → static property get // new System.Text.StringBuilder(64) → constructor // sw.Stop() → instance method // sw.Elapsed → instance property function _makeNamespaceProxy(path) { + var cached = _nsProxyCache[path]; + if (cached) return cached; function _node() {} - return new Proxy(_node, { + var proxy = new Proxy(_node, { get: function (_, prop) { + if (prop === Symbol.hasInstance) return function (value) { return _hasInstance(path, value); }; if (typeof prop === 'symbol') return undefined; // Prevent string-coercion methods from descending into sub-proxies. // V8's console.log / JSON.stringify call toString/valueOf on unknown objects; @@ -3339,6 +3909,12 @@ const HELPER_SOURCE: &str = r#" // type name, producing a spurious "Type not found" bridge error. if (prop === 'toString' || prop === 'valueOf') return function() { return '[.NET ' + path + ']'; }; + var extras = _namespaceExtras[path]; + if (extras && Object.prototype.hasOwnProperty.call(extras, prop)) return extras[prop]; + if (prop === '__dotnetType__') return path; + if (prop === 'prototype') return _typePrototype(path); + // Not a WinRT class: TypeScript's __extends must take the standard path. + if (prop === '__nsWinRTClass__' || prop === 'then') return undefined; var assembly = _resolveAssembly(path); var info = _getTypeInfo(assembly, path); // Write-only static property — reading it is an error. @@ -3354,6 +3930,9 @@ const HELPER_SOURCE: &str = r#" return _wrap(_invoke({ assembly: assembly, typeName: path, method: prop, args: args })); }; } + if (prop === 'extend') return function (nameOrMembers, maybeMembers) { return _extendClass(this, nameOrMembers, maybeMembers); }; + if (prop === 'call') return function (thisArg) { return _es5SuperCall(path, thisArg, Array.prototype.slice.call(arguments, 1)); }; + if (prop === 'apply') return function (thisArg, args) { return _es5SuperCall(path, thisArg, args ? Array.prototype.slice.call(args) : []); }; // Namespace / sub-type: keep descending. return _makeNamespaceProxy(path + '.' + prop); }, @@ -3381,27 +3960,61 @@ const HELPER_SOURCE: &str = r#" var assembly = _resolveAssembly(typeName); return _wrap(_invoke({ assembly: assembly, typeName: typeName, method: method, args: args.map(_unwrap) })); }, - construct: function (_, args) { + construct: function (_, args, newTarget) { + // `super(...)` from a JS subclass. + if (newTarget && newTarget !== proxy) return _constructSubclass(path, args, newTarget.prototype); + if (args.length === 1 && args[0] && typeof args[0] === 'object' && typeof args[0].__handle !== 'number' + && _typeKind(path) === 'interface') + return _implementInterface(path, args[0]); var assembly = _resolveAssembly(path); return _wrap(_invoke({ assembly: assembly, typeName: path, method: '.ctor', args: args.map(_unwrap) })); }, }); + _nsProxyCache[path] = proxy; + return proxy; } - globalThis.System = _makeNamespaceProxy('System'); + // `System` stays the .NET namespace when app code assigns it: @nativescript/core sets + // `global.System = { import() }` (a SystemJS shim). Assigned members are kept and take + // precedence (`System.import(...)` keeps working); everything else is .NET. + (function () { + var systemProxy = _makeNamespaceProxy('System'); + Object.defineProperty(globalThis, 'System', { + configurable: true, + enumerable: true, + get: function () { return systemProxy; }, + set: function (value) { + if (value && (typeof value === 'object' || typeof value === 'function')) + Object.keys(value).forEach(function (k) { _namespaceExtras.System[k] = value[k]; }); + }, + }); + })(); globalThis.Microsoft = _makeNamespaceProxy('Microsoft'); globalThis.NativeScript = _makeNamespaceProxy('NativeScript'); + // Every other root namespace of the app's assemblies (C# sources compiled into the app, + // plugin and NuGet assemblies) is a lazy global too, so `new MyCompany.Native.Foo()` works + // with no registration step, the way Java packages and Objective-C classes are globals on + // Android and iOS. WinRT owns Windows/Microsoft/NativeScript; the rest are implementation + // namespaces of the toolchain that no app reaches for. + (function () { + var reserved = { Windows: 1, Microsoft: 1, NativeScript: 1, System: 1, NativeScriptBridge: 1, NSWinRTDynamicProxies: 1, + WinRT: 1, ABI: 1, XamlGeneratedNamespace: 1, FxResources: 1, Internal: 1, Interop: 1, MS: 1, Mono: 1 }; + for (var ns in _namespaceAssemblyMap) { + if (ns.indexOf('.') !== -1 || reserved[ns] === 1 || !/^[A-Za-z_$][\w$]*$/.test(ns)) continue; + globalThis.NSWinRT.dotnet.registerNamespace(ns); + } + })(); + // Creates a typed .NET BCL delegate (not a WinRT COM delegate). // Use this when the API expects a managed System.Action, // System.EventHandler, etc. rather than a WinRT delegate interface. // For WinRT delegate types use NSWinRT.asDelegate instead. if (typeof globalThis.__nsDotNetAwaitTask === 'function') { globalThis.NSWinRT.dotnet.taskToPromise = function (obj) { - var h = obj && typeof obj.__handle === 'number' ? obj.__handle - : typeof obj === 'number' ? obj : -1; + var h = obj && typeof obj.__handle === 'number' && obj.__isTask === true ? obj.__handle : -1; if (h < 0) return Promise.resolve(obj); return new Promise(function (resolve, reject) { - globalThis.__nsDotNetAwaitTask(h, resolve, reject); + globalThis.__nsDotNetAwaitTask(h, function (value) { resolve(_wrap(value)); }, reject); }); }; } @@ -3914,6 +4527,7 @@ pub(crate) fn handle_dotnet_invoke_binary( } } + release_collected_js_callbacks(); match crate::dotnet::call_dotnet_binary(&req) { Ok(response) => match bin_read_response(scope, &response) { Ok(v8_val) => retval.set(v8_val), @@ -3936,7 +4550,24 @@ pub(crate) unsafe extern "C" fn invoke_dotnet_js_callback( _resp_len: *mut i32, ) { let isolate_ptr = crate::DELEGATE_ISOLATE_PTR.with(|c| c.get()); - if isolate_ptr.is_null() { + let owner = crate::dotnet_callback_owner(callback_id); + if isolate_ptr.is_null() || owner.is_some_and(|o| o != isolate_ptr) { + // A managed delegate, event, Task continuation or overridden member running on a thread + // other than the one its JS function belongs to (the UI thread or a worker): hand the call + // to that thread and wait for it, so the callback runs and its result reaches the managed + // caller (see `run_on_js_thread_sync`). + let (args, resp, resp_len) = (args_ptr as usize, _resp_ptr as usize, _resp_len as usize); + let call = move || { + invoke_dotnet_js_callback(callback_id, args as *const u8, args_len, resp as *mut *mut u8, resp_len as *mut i32) + }; + let delivered = match owner { + Some(owner) => crate::run_on_js_thread_sync(owner, call), + None if !crate::ui_dispatcher::is_ui_thread() => crate::ui_dispatcher::run_on_ui_thread_sync(call), + None => None, + }; + if delivered.is_none() { + write_callback_response(&[0x0F, 0, 0, 0, 0][..], _resp_ptr, _resp_len); + } return; } @@ -3950,6 +4581,7 @@ pub(crate) unsafe extern "C" fn invoke_dotnet_js_callback( let scope = &mut v8::ContextScope::new(scope, context); v8::tc_scope!(tc, scope); + release_collected_js_callbacks(); let func_global = crate::DOTNET_JS_CALLBACKS.with(|m| m.borrow().get(&callback_id).cloned()); let Some(func_global) = func_global else { return; @@ -3965,61 +4597,36 @@ pub(crate) unsafe extern "C" fn invoke_dotnet_js_callback( let js_args = parse_dotnet_callback_args(tc, args_slice); let result_val = func.call(tc, recv, &js_args); - if tc.has_caught() { - if let Some(ex) = tc.exception() { - let msg = ex.to_rust_string_lossy(tc); - crate::store_last_js_error(msg); - } - tc.reset(); - } // Serialize the returned value (if any) into the binary response format // and return it to managed via resp_ptr/resp_len. Managed will call // Bridge.Free (Marshal.FreeHGlobal) to release this memory, so allocate // with the Windows LocalAlloc/LMEM_FIXED allocator for compatibility. let mut out_buf: Vec = Vec::new(); - match result_val { - Some(rv) => { - // Convert the returned V8 value into a single tagged binary value. - bin_write_v8_value(&mut out_buf, tc, rv); - } - None => { - // No return value: null tag. - out_buf.push(0x00u8); - } - } - - if out_buf.is_empty() { - // No response: leave resp pointers null / zero. - if !_resp_ptr.is_null() { - *_resp_ptr = std::ptr::null_mut(); - } - if !_resp_len.is_null() { - *_resp_len = 0; - } + if tc.has_caught() { + let msg = tc + .exception() + .map(|ex| ex.to_rust_string_lossy(tc)) + .unwrap_or_else(|| "JavaScript exception".to_string()); + crate::store_last_js_error(msg.clone()); + tc.reset(); + // 0x0F: the callback threw. A managed caller that expects a result gets a JsException; + // a void delegate/event ignores it (the error is reported like any uncaught JS error). + out_buf.push(0x0F); + bin_write_str32(&mut out_buf, msg.as_bytes()); } else { - // Allocate memory using LocalAlloc so managed Marshal.FreeHGlobal can free it. - let size = out_buf.len(); - unsafe { - let p = LocalAlloc(LMEM_FIXED, size); - if p.is_null() { - if !_resp_ptr.is_null() { - *_resp_ptr = std::ptr::null_mut(); - } - if !_resp_len.is_null() { - *_resp_len = 0; - } - } else { - let dest = p as *mut u8; - std::ptr::copy_nonoverlapping(out_buf.as_ptr(), dest, size); - if !_resp_ptr.is_null() { - *_resp_ptr = dest; - } - if !_resp_len.is_null() { - *_resp_len = size as i32; - } + match result_val { + Some(rv) => { + // Convert the returned V8 value into a single tagged binary value. + bin_write_v8_value(&mut out_buf, tc, rv); + } + None => { + // No return value: null tag. + out_buf.push(0x00u8); } } } + + write_callback_response(&out_buf, _resp_ptr, _resp_len); // If this callback was registered as a one-shot, remove it now to avoid leaks. crate::DOTNET_ONESHOT_JS_CALLBACKS.with(|s| { let mut set = s.borrow_mut(); @@ -4028,6 +4635,7 @@ pub(crate) unsafe extern "C" fn invoke_dotnet_js_callback( crate::DOTNET_JS_CALLBACKS.with(|m| { m.borrow_mut().remove(&callback_id); }); + crate::forget_dotnet_callback_owner(callback_id); set.remove(&callback_id); } }); @@ -4039,6 +4647,74 @@ pub(crate) unsafe extern "C" fn invoke_dotnet_js_callback( } } +/// Hands a callback's binary response to the managed caller through `resp_ptr`/`resp_len`. The +/// buffer comes from LocalAlloc because managed code frees it with Marshal.FreeHGlobal. +unsafe fn write_callback_response(bytes: &[u8], resp_ptr: *mut *mut u8, resp_len: *mut i32) { + let mut dest: *mut u8 = std::ptr::null_mut(); + if !bytes.is_empty() { + let p = LocalAlloc(LMEM_FIXED, bytes.len()); + if !p.is_null() { + dest = p as *mut u8; + std::ptr::copy_nonoverlapping(bytes.as_ptr(), dest, bytes.len()); + } + } + if !resp_ptr.is_null() { + *resp_ptr = dest; + } + if !resp_len.is_null() { + *resp_len = if dest.is_null() { 0 } else { bytes.len() as i32 }; + } +} + +/// Drops the JS functions whose managed delegates the .NET GC has collected. The release queue is +/// process-wide; a function that belongs to another JS thread (a worker's) is dropped there. +pub(crate) fn release_collected_js_callbacks() { + let released = crate::dotnet::take_released_js_callbacks(); + if released.is_empty() { + return; + } + let current = crate::DELEGATE_ISOLATE_PTR.with(|c| c.get()); + let mut elsewhere: HashMap> = HashMap::new(); + crate::DOTNET_JS_CALLBACKS.with(|m| { + let mut m = m.borrow_mut(); + for id in released { + match crate::dotnet_callback_owner(id) { + Some(owner) if owner != current => elsewhere.entry(owner as usize).or_default().push(id), + _ => { + m.remove(&id); + crate::forget_dotnet_callback_owner(id); + } + } + } + }); + for (owner, ids) in elsewhere { + let owned = ids.clone(); + let queued = crate::post_to_js_thread(owner as *mut v8::Isolate, move || { + crate::DOTNET_JS_CALLBACKS.with(|m| { + let mut m = m.borrow_mut(); + for id in ids { + m.remove(&id); + crate::forget_dotnet_callback_owner(id); + } + }); + }); + if !queued { + // That runtime is gone and took its functions with it. + owned.into_iter().for_each(crate::forget_dotnet_callback_owner); + } + } +} + +/// Registers `func` as a callback the bridge can invoke and returns its id. +pub(crate) fn register_dotnet_js_callback(scope: &mut v8::PinScope<'_, '_>, func: v8::Local) -> i32 { + let cb_id = crate::DOTNET_NEXT_CB_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + crate::DOTNET_JS_CALLBACKS.with(|m| { + m.borrow_mut().insert(cb_id, v8::Global::new(scope, func)); + }); + crate::record_dotnet_callback_owner(cb_id); + cb_id +} + fn parse_dotnet_callback_args<'s>( scope: &mut v8::PinScope<'s, '_>, bytes: &[u8], @@ -4096,6 +4772,7 @@ pub(crate) fn handle_dotnet_create_delegate( crate::DOTNET_JS_CALLBACKS.with(|m| { m.borrow_mut().insert(cb_id, v8::Global::new(scope, func)); }); + crate::record_dotnet_callback_owner(cb_id); // opcode 0x09 | type_name (str16) | callback_id (i32) let mut req: Vec = Vec::with_capacity(32); @@ -4183,6 +4860,7 @@ pub(crate) fn handle_dotnet_create_js_subclass( crate::DOTNET_JS_CALLBACKS.with(|m| { m.borrow_mut().insert(cb_id, v8::Global::new(scope, cb_fn)); }); + crate::record_dotnet_callback_owner(cb_id); let mut req: Vec = Vec::with_capacity(64); req.push(0x0A); @@ -4197,7 +4875,18 @@ pub(crate) fn handle_dotnet_create_js_subclass( bin_write_str16(&mut req, name.as_bytes()); } req.extend_from_slice(&cb_id.to_le_bytes()); + // Constructor arguments (optional 6th argument, an array): forwarded to the matching base + // constructor, as `super(...)` arguments are on Android/iOS. + if let Ok(ctor_args) = v8::Local::::try_from(args.get(5)) { + let count = ctor_args.length().min(255); + req.push(count as u8); + for i in 0..count { + let value = ctor_args.get_index(scope, i).unwrap_or_else(|| v8::undefined(scope).into()); + bin_write_v8_arg(&mut req, scope, value); + } + } + release_collected_js_callbacks(); match crate::dotnet::call_dotnet_binary(&req) { Ok(response) => match bin_read_response(scope, &response) { Ok(v) => retval.set(v), @@ -4256,6 +4945,8 @@ pub(crate) fn handle_dotnet_await_task( map.insert(resolve_id, v8::Global::new(scope, resolve_fn)); map.insert(reject_id, v8::Global::new(scope, reject_fn)); }); + crate::record_dotnet_callback_owner(resolve_id); + crate::record_dotnet_callback_owner(reject_id); // Binary instance call: 0x01 | handle(i32) | "__dotnet_await__"(str16) | 2 | i32 resolveId | i32 rejectId let mut req: Vec = Vec::with_capacity(32); @@ -4274,6 +4965,8 @@ pub(crate) fn handle_dotnet_await_task( map.remove(&resolve_id); map.remove(&reject_id); }); + crate::forget_dotnet_callback_owner(resolve_id); + crate::forget_dotnet_callback_owner(reject_id); throw_js_error(scope, &e); } } @@ -4309,6 +5002,24 @@ fn bin_write_v8_arg( return; } + // A JS function where a .NET method expects a delegate (Func<>, Action<>, EventHandler, + // ...): sent as a callback id the bridge wraps in a delegate of the parameter's type, the way a + // JS function passed for a Java interface or an Objective-C block becomes one on Android/iOS. + // Constructors of JS-extended .NET classes and namespace proxies carry a handle and are not + // callbacks. + if let Ok(func) = v8::Local::::try_from(arg) { + let has_handle = v8::String::new(scope, "__handle") + .and_then(|k| func.get(scope, k.into())) + .map(|v| v.is_number()) + .unwrap_or(false); + if !has_handle { + let cb_id = register_dotnet_js_callback(scope, func); + buf.push(0x0C); + buf.extend_from_slice(&cb_id.to_le_bytes()); + return; + } + } + if arg.is_string() { if let Some(s) = arg.to_string(scope) { let bytes = s.to_rust_string_lossy(scope).into_bytes(); @@ -4360,6 +5071,12 @@ fn bin_write_v8_arg( } } + if let Some(json) = plain_object_json(scope, arg) { + buf.push(0x0D); + bin_write_str32(buf, json.as_bytes()); + return; + } + // Fallback: stringify object if let Some(sv) = arg.to_string(scope) { let bytes = sv.to_rust_string_lossy(scope).into_bytes(); @@ -4374,6 +5091,21 @@ fn bin_write_v8_arg( } +/// A plain JS object (`{ Width: 120, Height: 40 }`) as JSON, for a .NET struct/record parameter or +/// return value (tag 0x0D): the bridge deserializes it into the expected type. Class instances, +/// arrays and functions are not plain objects. +fn plain_object_json(scope: &mut v8::PinScope<'_, '_>, value: v8::Local) -> Option { + if !value.is_object() || value.is_array() || value.is_function() || value.is_proxy() { + return None; + } + let obj = value.to_object(scope)?; + if obj.get_constructor_name().to_rust_string_lossy(scope) != "Object" { + return None; + } + let json = v8::json::stringify(scope, value)?; + Some(json.to_rust_string_lossy(scope)) +} + fn bin_write_v8_value( buf: &mut Vec, scope: &mut v8::PinScope<'_, '_>, @@ -4459,6 +5191,11 @@ fn bin_write_v8_value( } } } + if let Some(json) = plain_object_json(scope, arg) { + buf.push(0x0D); + bin_write_str32(buf, &json.into_bytes()); + return; + } // fallback: stringify if let Some(sv) = arg.to_string(scope) { let bytes = sv.to_rust_string_lossy(scope).into_bytes(); @@ -4522,6 +5259,17 @@ fn bin_read_value<'s>( .unwrap_or_else(|| v8::null(scope).into())) } + 0x0D => { + // struct/record as JSON: u32 len + utf8 -> plain object + let len = u32::from_le_bytes(bytes[*pos..*pos + 4].try_into().map_err(|_| "json len")?) + as usize; + *pos += 4; + let text = std::str::from_utf8(&bytes[*pos..*pos + len]).map_err(|_| "utf8")?; + *pos += len; + let json = v8::String::new(scope, text).ok_or("v8 str")?; + Ok(v8::json::parse(scope, json).unwrap_or_else(|| v8::null(scope).into())) + } + 0x06 | 0x0C => { // handle → JS object {__handle, __type} ; 0x0C adds __isTask:true let id = i32::from_le_bytes(bytes[*pos..*pos + 4].try_into().map_err(|_| "handle id")?); @@ -5062,6 +5810,7 @@ pub(crate) fn init_async_helpers( handle_dotnet_create_js_subclass ); register!("__nsDotNetAwaitTask", handle_dotnet_await_task); + register!("__nsWrapWinRTPointer", crate::handle_wrap_winrt_pointer); register!("__nsRunOnUIThread", handle_run_on_ui_thread); register!("__nsWin32Call", handle_win32_call); register!("__nsWin32CallRaw", handle_win32_call_raw); diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 9ee0eb9..211ff20 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -285,18 +285,31 @@ pub fn pump_dispatcher() { if isolate_ptr.is_null() { return; } + { + let isolate: &mut v8::Isolate = unsafe { &mut *isolate_ptr }; + v8::scope!(scope, isolate); + let ctx = match scope.get_slot::>() { + Some(g) => g.clone(), + None => return, + }; + let ctx = v8::Local::new(scope, &ctx); + let scope = &mut v8::ContextScope::new(scope, ctx); + v8::tc_scope!(tc, scope); + tc.perform_microtask_checkpoint(); + } + // With the explicit microtasks policy V8 leaves both of these to the embedder: WeakRef targets + // stay alive until the kept-objects set is cleared, and FinalizationRegistry callbacks (which + // release the .NET objects behind collected JS proxies) run as platform tasks. let isolate: &mut v8::Isolate = unsafe { &mut *isolate_ptr }; - v8::scope!(scope, isolate); - let ctx = match scope.get_slot::>() { - Some(g) => g.clone(), - None => return, - }; - let ctx = v8::Local::new(scope, &ctx); - let scope = &mut v8::ContextScope::new(scope, ctx); - v8::tc_scope!(tc, scope); - tc.perform_microtask_checkpoint(); + isolate.clear_kept_objects(); + if let Some(platform) = V8_PLATFORM.get() { + while v8::Platform::pump_message_loop(platform, isolate, false) {} + } } +#[cfg(feature = "classic")] +static V8_PLATFORM: OnceLock> = OnceLock::new(); + /// Move the post-callback microtask checkpoint out of the current native frame /// when that frame may be a re-entrancy-sensitive XAML callout. /// @@ -361,6 +374,89 @@ pub fn pump_messages() -> bool { dispatched } +/// Runs `f` on the JS thread whose isolate is `owner` (the UI thread's or a worker's) and waits for +/// its result: inline when that is the current thread, otherwise handed over the way a callback +/// invoked on a background thread is. `None` when that thread can't run it (gone, or unknown). +#[cfg(feature = "classic")] +pub(crate) fn run_on_js_thread_sync(owner: *mut v8::Isolate, f: impl FnOnce() -> R + Send) -> Option { + if !is_live_isolate(owner) { + return None; + } + if DELEGATE_ISOLATE_PTR.with(|c| c.get()) == owner { + return Some(f()); + } + if worker_threads::is_worker_isolate(owner as usize) { + return worker_threads::run_on_worker_sync(owner as usize, f); + } + if ui_dispatcher::is_ui_thread() { + return None; + } + ui_dispatcher::run_on_ui_thread_sync(f) +} + +/// Queues `f` on the JS thread whose isolate is `owner` without waiting; runs it inline when that +/// is the current thread. `false` when there is no such thread to run it on. +#[cfg(feature = "classic")] +pub(crate) fn post_to_js_thread(owner: *mut v8::Isolate, f: impl FnOnce() + Send + 'static) -> bool { + if !is_live_isolate(owner) { + return false; + } + if DELEGATE_ISOLATE_PTR.with(|c| c.get()) == owner { + f(); + return true; + } + if worker_threads::is_worker_isolate(owner as usize) { + return worker_threads::post_to_worker(owner as usize, f); + } + if !ui_dispatcher::is_initialized() { + return false; + } + ui_dispatcher::post_to_ui_thread(f); + true +} + +/// The isolates of the runtimes alive in this process (the UI thread's and each worker's). A +/// callback that outlives its worker can't run anywhere: work for a disposed isolate is refused. +#[cfg(feature = "classic")] +static LIVE_ISOLATES: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashSet::new())); + +#[cfg(feature = "classic")] +fn is_live_isolate(isolate: *mut v8::Isolate) -> bool { + !isolate.is_null() && LIVE_ISOLATES.lock().is_ok_and(|live| live.contains(&(isolate as usize))) +} + +/// The isolate each .NET callback's JS function belongs to (callback ids are process-wide, the +/// functions live in their JS thread's `DOTNET_JS_CALLBACKS`), so a callback the bridge invokes on +/// another thread is run on the right one. +#[cfg(feature = "classic")] +static DOTNET_CALLBACK_OWNERS: std::sync::LazyLock>> = + std::sync::LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +#[cfg(feature = "classic")] +pub(crate) fn record_dotnet_callback_owner(callback_id: i32) { + let owner = DELEGATE_ISOLATE_PTR.with(|c| c.get()) as usize; + if let Ok(mut owners) = DOTNET_CALLBACK_OWNERS.lock() { + owners.insert(callback_id, owner); + } +} + +#[cfg(feature = "classic")] +pub(crate) fn dotnet_callback_owner(callback_id: i32) -> Option<*mut v8::Isolate> { + DOTNET_CALLBACK_OWNERS + .lock() + .ok() + .and_then(|owners| owners.get(&callback_id).copied()) + .map(|o| o as *mut v8::Isolate) +} + +#[cfg(feature = "classic")] +pub(crate) fn forget_dotnet_callback_owner(callback_id: i32) { + if let Ok(mut owners) = DOTNET_CALLBACK_OWNERS.lock() { + owners.remove(&callback_id); + } +} + /// Retrieve (and clear) the last stored JS error. pub fn get_last_js_error() -> Option { LAST_JS_ERROR.with(|e| e.borrow_mut().take()) @@ -1285,6 +1381,33 @@ fn symbol_has_instance_callback( return; }; + // `x instanceof Sub` where `class Sub extends SomeWinRTClass`: Sub inherits this function (a + // WinRT constructor owns its @@hasInstance), but the question is about Sub, so answer it the + // ordinary way (Sub.prototype in x's chain). + let receiver = args.this(); + if let Ok(receiver_fn) = v8::Local::::try_from(Local::::from(receiver)) { + let has_instance = v8::Symbol::get_has_instance(scope); + let native_ctor = receiver_fn.has_own_property(scope, has_instance.into()).unwrap_or(true); + if !native_ctor { + let proto_key = v8::String::new(scope, "prototype").unwrap(); + let target = receiver_fn.get(scope, proto_key.into()); + let mut current = obj.get_prototype(scope); + let mut found = false; + while let (Some(t), Some(c)) = (target, current) { + if c.is_null() { + break; + } + if c.strict_equals(t) { + found = true; + break; + } + current = c.to_object(scope).and_then(|o| o.get_prototype(scope)); + } + retval.set_bool(found); + return; + } + } + let data_ext = unsafe { args.data().cast::() }; let data_ptr = data_ext.value() as *const HasInstanceData; let data = unsafe { &*data_ptr }; @@ -1304,7 +1427,10 @@ fn symbol_has_instance_callback( } let Some(iid) = data.iid else { - retval.set_bool(false); + // A class: true when the instance's class derives from it + // (a StackPanel is a Panel, a FrameworkElement and a UIElement). + let own_class = dec.read().full_name().to_string(); + retval.set_bool(class_derives_from(&own_class, &data.full_name)); return; }; let Some(instance) = dec.instance.clone() else { @@ -1835,7 +1961,9 @@ fn resolve_file_specifier(specifier: &str, referrer_path: Option<&str>) -> Strin // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, so also // check the sealed bundle's table: otherwise a relative import from a bundle-only module // would wrongly treat the referrer itself as the base directory instead of its parent. - let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy()) + let base = if parent.is_file() + || crate::source_protect::contains(&parent.to_string_lossy()) + || (!parent.exists() && parent.extension().is_some()) { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { @@ -5799,6 +5927,128 @@ unsafe fn raw_result_to_local<'s>( } } +/// A static WinRT event exposed on its class constructor (see `create_ns_ctor_object`). +#[cfg(feature = "classic")] +struct StaticEventData { + class_name: String, + name: String, + add: MethodDeclaration, + remove: MethodDeclaration, +} + +/// Whether WinRT class `class_name` is `ancestor` or derives from it, following the metadata +/// base-class chain. +#[cfg(feature = "classic")] +fn class_derives_from(class_name: &str, ancestor: &str) -> bool { + let mut current = class_name.to_string(); + for _ in 0..64 { + if current == ancestor { + return true; + } + let Some(decl) = MetadataReader::find_by_name(¤t) else { + return false; + }; + let lock = decl.read(); + let Some(clazz) = lock.as_any().downcast_ref::() else { + return false; + }; + let base = clazz.base_full_name(); + if base.is_empty() || base == "System.Object" || base == current { + return false; + } + current = base.to_string(); + } + false +} + +/// `__nsWrapWinRTPointer(ptr, typeName)`: the WinRT wrapper for a COM object the runtime didn't +/// create itself — the composed instance behind a JS subclass of a WinRT class, created by the +/// .NET bridge. Takes its own reference; `ptr` is a BigInt IInspectable/IUnknown pointer. +#[cfg(feature = "classic")] +pub(crate) fn handle_wrap_winrt_pointer( + scope: &mut v8::PinScope<'_, '_>, + args: v8::FunctionCallbackArguments, + mut retval: v8::ReturnValue, +) { + let ptr = match v8::Local::::try_from(args.get(0)) { + Ok(b) => b.u64_value().0 as usize, + Err(_) => match v8::Local::::try_from(args.get(0)) { + Ok(n) => n.value() as usize, + Err(_) => 0, + }, + }; + let type_name = args.get(1).to_rust_string_lossy(scope); + if ptr == 0 { + throw_js_error(scope, "__nsWrapWinRTPointer: null pointer"); + return; + } + let Some(declaration) = MetadataReader::find_by_name(&type_name) else { + throw_js_error(scope, &format!("__nsWrapWinRTPointer: unknown WinRT type {type_name}")); + return; + }; + let instance: IUnknown = unsafe { + let borrowed = std::mem::ManuallyDrop::new(IUnknown::from_raw(ptr as *mut c_void)); + (*borrowed).clone() + }; + let short_name = type_name.rsplit('.').next().unwrap_or(&type_name).to_string(); + let wrapper = create_ns_ctor_instance_object(&short_name, None, None, declaration, Some(instance), scope); + retval.set(wrapper); +} + +/// `super(...)` from a JS subclass of a WinRT class (or TypeScript's ES5 `_super.call(this, ...)`) +/// reaching the WinRT constructor. Returns true when the bootstrap constructed the subclass +/// instance (set as the return value); false when this is an ordinary construction. +#[cfg(feature = "classic")] +fn construct_winrt_subclass( + scope: &mut v8::PinScope<'_, '_>, + full_name: &str, + args: &v8::FunctionCallbackArguments, + retval: &mut v8::ReturnValue, +) -> bool { + let new_target = args.new_target(); + let type_key = v8::String::new(scope, "__typeName__").unwrap(); + let (new_target_arg, this_arg): (Local, Local) = if new_target.is_function() { + let Some(nt) = new_target.to_object(scope) else { return false }; + // `new SomeWinRTClass()` itself: the constructor has its own __typeName__. + if nt.has_own_property(scope, type_key.into()).unwrap_or(true) { + return false; + } + (new_target, v8::undefined(scope).into()) + } else { + // Called without `new`: only an ES5 subclass constructor does that, passing its `this`. + let this: Local = args.this().into(); + if !this.is_object() { + return false; + } + (v8::null(scope).into(), this) + }; + + let global = scope.get_current_context().global(scope); + let helper_key = v8::String::new(scope, "__nsConstructWinRTSubclass").unwrap(); + let Some(helper) = global + .get(scope, helper_key.into()) + .and_then(|v| v8::Local::::try_from(v).ok()) + else { + return false; + }; + let ctor_args = v8::Array::new(scope, args.length()); + for i in 0..args.length() { + ctor_args.set_index(scope, i as u32, args.get(i)); + } + let name = v8::String::new(scope, full_name).unwrap(); + let call_args = [name.into(), new_target_arg, ctor_args.into(), this_arg]; + let undefined = v8::undefined(scope).into(); + match helper.call(scope, undefined, &call_args) { + Some(result) if result.is_object() => { + retval.set(result); + true + } + // undefined: not a subclass construction after all; an exception propagates as is. + Some(_) => false, + None => true, + } +} + #[cfg(feature = "classic")] fn create_ns_ctor_object<'a>( name: &str, @@ -5877,6 +6127,11 @@ fn create_ns_ctor_object<'a>( parent = dec.parent.clone(); } drop(lock); + // `class X extends ThisClass`: X's instances are composed objects whose overridable + // members (MeasureOverride, OnApplyTemplate, ...) call back into X. + if !is_sealed && construct_winrt_subclass(scope, &full_name, &args, &mut retval) { + return; + } // The constructor's own declaration is the class: no name lookup per `new`. let class_declaration = Arc::clone(&dec.inner); @@ -6704,6 +6959,52 @@ fn create_ns_ctor_object<'a>( ); } + // Static events (CompositionTarget.Rendering, ...) use the same assignment syntax as + // instance events: `Class.Event = handler` subscribes (replacing the previous handler), + // `Class.Event = null` unsubscribes, and reading returns the current handler. They're + // added and removed through the class's statics interface on its activation factory. + for event in clazz.events().iter().filter(|e| e.is_static()) { + if !added_names.insert(event.name().to_string()) { + continue; + } + let Some(event_name) = v8::String::new(scope, event.name()) else { + continue; + }; + let data = Box::into_raw(Box::new(StaticEventData { + class_name: clazz.full_name().to_string(), + name: event.name().to_string(), + add: event.add_method().clone(), + remove: event.remove_method().clone(), + })); + let data = v8::External::new(scope, data as _); + let getter = v8::FunctionTemplate::builder( + |scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, mut retval: v8::ReturnValue| { + let data = unsafe { &*(args.data().cast::().value() as *const StaticEventData) }; + let factory = class_activation_factory(&data.class_name).ok(); + retval.set(crate::ns_proxy::read_winrt_event(scope, factory.as_ref(), &data.name)); + }, + ) + .data(data.into()) + .build(scope); + let setter = v8::FunctionTemplate::builder( + |scope: &mut v8::PinScope<'_, '_>, args: v8::FunctionCallbackArguments, _retval: v8::ReturnValue| { + let data = unsafe { &*(args.data().cast::().value() as *const StaticEventData) }; + match class_activation_factory(&data.class_name) { + Ok(factory) => { + crate::ns_proxy::wire_winrt_event(scope, &data.name, Some(factory), &data.add, &data.remove, args.get(0)); + } + Err(e) => throw_js_error( + scope, + &format!("Failed to subscribe to {}.{}: {}", data.class_name, data.name, e.message()), + ), + } + }, + ) + .data(data.into()) + .build(scope); + tmpl.set_accessor_property(event_name.into(), Some(getter), Some(setter), v8::PropertyAttribute::DONT_DELETE); + } + attach_has_instance_to_template(scope, tmpl, None, &clazz.full_name().to_string()); } @@ -7791,6 +8092,9 @@ pub(crate) struct JsDelegateData { /// For a delegate whose Invoke the shared vtable can't implement (see [`delegate_invoke`]): /// the typed Invoke and the delegate's own vtable, which points at it. typed: Option<(delegate_invoke::TypedInvoke, Box)>, + /// The isolate `js_func` belongs to (the UI thread's or a worker's): Invoke and the final + /// Release run on that isolate's thread. + owner_isolate: *mut v8::Isolate, } /// Wraps `js_func` as a COM delegate implementing `signature`; returns the IUnknown-compatible @@ -7815,7 +8119,8 @@ pub(crate) fn new_js_delegate(js_func: v8::Global, guid: GUID, sig Some((_, vtable)) => &**vtable, None => &JS_DELEGATE_VTBL, }; - let data = Box::new(JsDelegateData { js_func, signature, typed }); + let owner_isolate = DELEGATE_ISOLATE_PTR.with(|c| c.get()); + let data = Box::new(JsDelegateData { js_func, signature, typed, owner_isolate }); let delegate = Box::new(JsDelegate { vtable, ref_count: AtomicU32::new(1), @@ -7867,7 +8172,23 @@ unsafe extern "system" fn js_delegate_release(this: *mut JsDelegate) -> u32 { if prev == 1 { std::sync::atomic::fence(AtomicOrdering::Acquire); let b = Box::from_raw(this); - drop(Box::from_raw(b.data)); + let data = Box::from_raw(b.data); + // The function handle belongs to its JS thread's isolate: a final release on another thread + // (the component that invoked the delegate there) frees it on that thread. + let owner = data.owner_isolate; + if DELEGATE_ISOLATE_PTR.with(|c| c.get()) == owner || owner.is_null() { + drop(data); + } else { + // Never dropped elsewhere: if that thread is gone (a terminated worker), the handle + // leaks rather than being freed into a disposed isolate. + struct SendData(std::mem::ManuallyDrop>); + unsafe impl Send for SendData {} + let data = SendData(std::mem::ManuallyDrop::new(data)); + post_to_js_thread(owner, move || { + let data = data; + drop(std::mem::ManuallyDrop::into_inner(data.0)); + }); + } // b (JsDelegate) dropped here } prev - 1 @@ -7940,10 +8261,33 @@ fn js_delegate_invoke_inner( &*data_ptr }; let args = args(&data.signature); + js_delegate_invoke_with_args(this, data, args, result_slot) +} +#[cfg(feature = "classic")] +fn js_delegate_invoke_with_args( + this: *mut JsDelegate, + data: &JsDelegateData, + args: Vec, + result_slot: *mut c_void, +) -> HRESULT { let isolate_ptr = DELEGATE_ISOLATE_PTR.with(|c| c.get()); - if isolate_ptr.is_null() { - return HRESULT(0x80004005u32 as i32); + if isolate_ptr != data.owner_isolate { + // Invoked on another thread (a component that calls its handlers without marshaling them + // back to the registering apartment): run the JS on the thread that owns the function — + // the UI thread or a worker — and wait, so the call is delivered and its result written + // before Invoke returns. The arguments are borrowed for the duration of Invoke, which is + // exactly how long they're used. + struct SendPtr(*mut JsDelegate, *mut c_void); + unsafe impl Send for SendPtr {} + let ptrs = SendPtr(this, result_slot); + return run_on_js_thread_sync(data.owner_isolate, move || { + let ptrs = ptrs; + // SAFETY: the caller holds a reference on the delegate for the duration of Invoke. + let data = unsafe { &*(*ptrs.0).data }; + js_delegate_invoke_with_args(ptrs.0, data, args, ptrs.1) + }) + .unwrap_or(HRESULT(0x8001010Eu32 as i32)); // RPC_E_WRONG_THREAD: no JS thread to run on } // Re-entrancy guard (see DELEGATE_DEPTH). Mutating XAML inside a delegate (e.g. setting @@ -8596,6 +8940,7 @@ impl Runtime { // can trigger a full GC sweep (useful for debugging and test harnesses). v8::V8::set_flags_from_string("--expose-gc"); let platform = v8::new_default_platform(0, false).make_shared(); + let _ = V8_PLATFORM.set(platform.clone()); v8::V8::initialize_platform(platform); v8::V8::initialize(); }); @@ -8773,6 +9118,9 @@ impl Runtime { pub fn register_delegate_isolate_ptr(&mut self) { let raw_isolate: *mut v8::Isolate = &mut *self.isolate as *mut v8::Isolate; DELEGATE_ISOLATE_PTR.with(|cell| cell.set(raw_isolate)); + if let Ok(mut live) = LIVE_ISOLATES.lock() { + live.insert(raw_isolate as usize); + } } /// Provides mutable access to the underlying V8 isolate. @@ -9089,6 +9437,9 @@ impl Drop for Runtime { // raw isolate pointers) must be cleared here, while `self.isolate` is // still alive. Anything left behind dangles into freed isolate memory // and crashes the next Runtime created on this thread. + if let Ok(mut live) = LIVE_ISOLATES.lock() { + live.remove(&(&mut *self.isolate as *mut v8::Isolate as usize)); + } crate::wrapper_cache::clear(); EVENT_REGISTRY.with(|m| m.borrow_mut().clear()); ESM_MODULE_REGISTRY.with(|m| m.borrow_mut().clear()); @@ -9097,6 +9448,8 @@ impl Drop for Runtime { ESM_HTTP_URLS.with(|m| m.borrow_mut().clear()); ESM_LOAD_ERRORS.with(|m| m.borrow_mut().clear()); crate::esm_http::clear_thread_vocabulary(); + // The ids keep their owner: a managed delegate that outlives this runtime (a terminated + // worker's) is refused rather than run elsewhere, until .NET releases it. DOTNET_JS_CALLBACKS.with(|m| m.borrow_mut().clear()); DOTNET_ONESHOT_JS_CALLBACKS.with(|m| m.borrow_mut().clear()); crate::timers::clear_thread_tasks(); diff --git a/runtime/src/napi_engine/delegate.rs b/runtime/src/napi_engine/delegate.rs index bb999d1..18efa1a 100644 --- a/runtime/src/napi_engine/delegate.rs +++ b/runtime/src/napi_engine/delegate.rs @@ -54,6 +54,9 @@ pub(crate) struct NapiDelegateData { /// For a delegate whose Invoke the shared vtable can't implement (see [`delegate_invoke`]): /// the typed Invoke and the delegate's own vtable, which points at it. typed: Option<(delegate_invoke::TypedInvoke, Box)>, + /// The JS thread the function belongs to (where it was created). An invocation on another + /// thread is handed to it when it is the UI thread (see `napi_delegate_invoke_args`). + owner_thread: std::thread::ThreadId, } impl Drop for NapiDelegateData { @@ -89,6 +92,12 @@ pub fn make_napi_delegate( make_napi_delegate_typed(env, func, guid, signature, Vec::new()) } +/// [`make_napi_delegate`] for a delegate whose Invoke returns nothing and takes `params`: for +/// embedders and tests outside this crate, which can't name [`DelegateSignature`]. +pub fn make_napi_void_delegate(env: &Env, func: &JsFunction, guid: GUID, params: Vec) -> Option<*mut c_void> { + make_napi_delegate(env, func, guid, DelegateSignature::new(params, DelegateReturn::Void)) +} + /// The sealed-class declarations of a delegate's Invoke parameters, aligned with the /// `param_types` that [`crate::delegate_info_from_type_sig`] reports for `iid_name`. Only /// pointer parameters whose declared type is a sealed runtime class get an entry; every other @@ -150,6 +159,7 @@ pub(crate) fn make_napi_delegate_typed( signature, param_classes, typed, + owner_thread: std::thread::current().id(), }); let delegate = Box::new(NapiDelegate { vtable, @@ -205,7 +215,17 @@ unsafe extern "system" fn napi_delegate_release(this: *mut NapiDelegate) -> u32 if prev == 1 { std::sync::atomic::fence(Ordering::Acquire); let b = Box::from_raw(this); - drop(Box::from_raw(b.data)); + let data = Box::from_raw(b.data); + // Deleting the function reference is a napi call: a final release on a background thread + // (the component that invoked the delegate there) does it on the JS thread. + if std::thread::current().id() != data.owner_thread && crate::ui_dispatcher::is_ui_thread_id(data.owner_thread) { + struct SendData(Box); + unsafe impl Send for SendData {} + let data = SendData(data); + crate::ui_dispatcher::post_to_ui_thread(move || drop(data)); + } else { + drop(data); + } } prev - 1 } @@ -274,6 +294,38 @@ fn napi_delegate_invoke_inner( } &*data_ptr }; + let args = args(&data.signature); + napi_delegate_invoke_args(this, data, args, result_slot) +} + +fn napi_delegate_invoke_args( + this: *mut NapiDelegate, + data: &NapiDelegateData, + args: Vec, + result_slot: *mut c_void, +) -> HRESULT { + const E_FAIL: HRESULT = HRESULT(0x80004005u32 as i32); + const RPC_E_WRONG_THREAD: HRESULT = HRESULT(0x8001010Eu32 as i32); + if std::thread::current().id() != data.owner_thread { + // Invoked on a background thread (a component that calls its handlers without marshaling + // them back to the registering apartment): napi may only be used on the JS thread, so run + // the call there and wait for it — the arguments stay valid and the result is written + // before Invoke returns (see `ui_dispatcher::run_on_ui_thread_sync`). Only the UI thread + // can be handed work; any other JS thread's functions stay undeliverable. + if !crate::ui_dispatcher::is_ui_thread_id(data.owner_thread) { + return RPC_E_WRONG_THREAD; + } + struct SendPtr(*mut NapiDelegate, *mut c_void); + unsafe impl Send for SendPtr {} + let ptrs = SendPtr(this, result_slot); + return crate::ui_dispatcher::run_on_ui_thread_sync(move || { + let ptrs = ptrs; + // SAFETY: the caller holds a reference on the delegate for the duration of Invoke. + let data = unsafe { &*(*ptrs.0).data }; + napi_delegate_invoke_args(ptrs.0, data, args, ptrs.1) + }) + .unwrap_or(RPC_E_WRONG_THREAD); + } let env = data.env; if env.is_null() { return E_FAIL; @@ -294,7 +346,6 @@ fn napi_delegate_invoke_inner( return E_FAIL; } - let args = args(&data.signature); let mut js_args: Vec = Vec::with_capacity(args.len()); for (i, arg) in args.iter().enumerate() { let val = match *arg { diff --git a/runtime/src/napi_engine/dotnet.rs b/runtime/src/napi_engine/dotnet.rs index e7f1184..f06390d 100644 --- a/runtime/src/napi_engine/dotnet.rs +++ b/runtime/src/napi_engine/dotnet.rs @@ -9,16 +9,12 @@ //! mirrored verbatim (native names match exactly, so the JS needed no changes) into //! [`DOTNET_HELPERS_JS`]. Keep the two JS copies in sync. //! -//! Includes `__nsDotNetCreateJsSubclass` / `NSWinRT.proxy.createManagedSubclass` and the JS-side -//! managed-subclass system it belongs to (`makeManagedConstructor`, `__extends_winrt`, -//! `CSharpProxy`, `Interfaces`, proxy `emit`/`compile`/`register`): this lets JS both extend a C# -//! class (`Object.extend`/`Function.prototype.extend`/TS `extends`) and implement a WinRT -//! interface, the same as the classic engine, via `Bridge.Proxy.cs`'s `System.Reflection.Emit` -//! dynamic-subclass path (or a build-time `sbg`-generated static proxy when one exists: both -//! funnel through the same `CreateJsSubclass`, unaffected by which JS engine called it). The -//! `emit`/`compile`/`register` trio is the separate, still-dead ahead-of-time pipeline -//! (`hostCanLoadAssemblies: false`: compiles but its output is never loaded), ported for JS -//! API-surface parity only, not because it works. +//! Includes `__nsDotNetCreateJsSubclass` and `__nsWrapWinRTPointer`, which back JS subclasses of +//! .NET and WinRT classes (`class X extends Y`, `Y.extend(...)`, `new Interface({...})`; see the +//! "JS classes" parts of [`DOTNET_HELPERS_JS`]): `Bridge.Proxy.cs` emits a managed subclass at +//! runtime whose overridden members call back into the JS object. The older `NSWinRT.proxy` +//! surface (`createManagedSubclass`, `emit`/`compile`/`register`) is kept for API parity; its +//! ahead-of-time pipeline compiles but its output is never loaded. //! //! ## Binary wire protocol //! @@ -103,6 +99,69 @@ unsafe fn addref_native_ptr(ptr: u64) { ((*vtable).AddRef)(unknown.as_raw()); } +/// Pins `func` as a callback the bridge can invoke; returns its id. +fn pin_js_callback(env: &Env, func: sys::napi_value) -> Option { + let mut func_ref: sys::napi_ref = std::ptr::null_mut(); + let status = unsafe { sys::napi_create_reference(env.raw(), func, 1, &mut func_ref) }; + if status != sys::Status::napi_ok || func_ref.is_null() { + return None; + } + let cb_id = crate::DOTNET_NEXT_CB_ID.fetch_add(1, Ordering::Relaxed); + NAPI_DOTNET_CALLBACKS.with(|m| { + m.borrow_mut().insert(cb_id, (env.raw(), func_ref)); + }); + Some(cb_id) +} + +/// Unpins the callbacks whose managed delegates the .NET GC has collected. +fn release_collected_js_callbacks() { + let released = crate::dotnet::take_released_js_callbacks(); + if released.is_empty() { + return; + } + NAPI_DOTNET_CALLBACKS.with(|m| { + let mut m = m.borrow_mut(); + for id in released { + if let Some((env, func_ref)) = m.remove(&id) { + unsafe { + let _ = sys::napi_delete_reference(env, func_ref); + } + } + } + }); +} + +/// A plain JS object (`{ Width: 120, Height: 40 }`) as JSON, for a .NET struct/record parameter or +/// result (tag 0x0D). Mirrors `global_fns::plain_object_json`. +fn napi_plain_object_json(env: &Env, arg: &JsUnknown) -> Option { + if napi_array_length(env, arg).is_some() { + return None; + } + let obj: JsObject = unsafe { arg.cast() }; + let ctor = obj.get_named_property::("constructor").ok()?; + if ctor.get_type().ok()? != ValueType::Function { + return None; + } + let ctor: JsObject = unsafe { ctor.cast() }; + let name = ctor.get_named_property::("name").ok()?; + if js_to_rust_string(env, &name) != "Object" { + return None; + } + let json: JsObject = env.get_global().ok()?.get_named_property("JSON").ok()?; + let stringify: JsFunction = json.get_named_property("stringify").ok()?; + let text = stringify.call(Some(&json), &[arg]).ok()?; + if text.get_type().ok()? != ValueType::String { + return None; + } + Some(js_to_rust_string(env, &text)) +} + +fn napi_json_parse(env: &Env, text: &str) -> napi::Result { + let json: JsObject = env.get_global()?.get_named_property("JSON")?; + let parse: JsFunction = json.get_named_property("parse")?; + parse.call(Some(&json), &[env.create_string(text)?]) +} + /// Encode one outgoing method-call argument (`u16` string-length prefix; AddRefs `__native_ptr`). /// Mirrors `global_fns::bin_write_v8_arg`. fn napi_bin_write_arg(env: &Env, buf: &mut Vec, arg: &JsUnknown) { @@ -152,6 +211,20 @@ fn napi_bin_write_arg(env: &Env, buf: &mut Vec, arg: &JsUnknown) { } } } + // A JS function where .NET expects a delegate: a callback id the bridge wraps in a + // delegate of the parameter's type (see `global_fns::bin_write_v8_arg`). + if vt == ValueType::Function { + if let Some(cb_id) = pin_js_callback(env, unsafe { arg.raw() }) { + buf.push(0x0C); + buf.extend_from_slice(&cb_id.to_le_bytes()); + return; + } + } + if let Some(json) = napi_plain_object_json(env, arg) { + buf.push(0x0D); + crate::dotnet::bin_write_str32(buf, json.as_bytes()); + return; + } let s = js_to_rust_string(env, arg); buf.push(0x05); crate::dotnet::bin_write_str16(buf, s.as_bytes()); @@ -219,6 +292,11 @@ fn napi_bin_write_value(env: &Env, buf: &mut Vec, arg: &JsUnknown) { } } } + if let Some(json) = napi_plain_object_json(env, arg) { + buf.push(0x0D); + crate::dotnet::bin_write_str32(buf, json.as_bytes()); + return; + } let s = js_to_rust_string(env, arg); buf.push(0x05); crate::dotnet::bin_write_str32(buf, s.as_bytes()); @@ -264,6 +342,15 @@ fn napi_bin_read_value(env: &Env, bytes: &[u8], pos: &mut usize) -> Result { + let len = u32::from_le_bytes(bytes[*pos..*pos + 4].try_into().map_err(|_| "json len")?) + as usize; + *pos += 4; + let text = std::str::from_utf8(&bytes[*pos..*pos + len]).map_err(|_| "utf8")?; + *pos += len; + napi_json_parse(env, text).map_err(map_err) + } + 0x06 | 0x0C => { let id = i32::from_le_bytes(bytes[*pos..*pos + 4].try_into().map_err(|_| "handle id")?); *pos += 4; @@ -449,6 +536,7 @@ fn native_invoke_bin(ctx: &CallContext) -> napi::Result { } } + release_collected_js_callbacks(); match crate::dotnet::call_dotnet_binary(&req) { Ok(response) => napi_bin_read_response(env, &response).map_err(napi::Error::from_reason), Err(e) => Err(napi::Error::from_reason(e)), @@ -552,13 +640,60 @@ fn native_create_js_subclass(ctx: &CallContext) -> napi::Result { crate::dotnet::bin_write_str16(&mut req, name.as_bytes()); } req.extend_from_slice(&cb_id.to_le_bytes()); + // Constructor arguments (optional 6th argument, an array) for the base constructor. + if ctx.length > 5 { + let ctor_args = ctx.get::(5)?; + if let Some(len) = napi_array_length(env, &ctor_args) { + let len = len.min(255); + req.push(len as u8); + for k in 0..len { + match napi_array_get(env, &ctor_args, k) { + Some(v) => napi_bin_write_arg(env, &mut req, &v), + None => req.push(0x00), + } + } + } + } + release_collected_js_callbacks(); match crate::dotnet::call_dotnet_binary(&req) { Ok(response) => napi_bin_read_response(env, &response).map_err(napi::Error::from_reason), Err(e) => Err(napi::Error::from_reason(e)), } } +/// `__nsWrapWinRTPointer(ptr, typeName)`: the WinRT wrapper for the composed instance behind a JS +/// subclass of a WinRT class (created by the bridge). Takes its own reference. Mirrors the classic +/// engine's `handle_wrap_winrt_pointer`. +fn native_wrap_winrt_pointer(ctx: &CallContext) -> napi::Result { + let env = &ctx.env; + let ptr_val = ctx.get::(0)?; + let ptr = match ptr_val.get_type()? { + ValueType::BigInt => { + let b: JsBigInt = unsafe { ptr_val.cast() }; + b.get_u64()?.0 as usize + } + ValueType::Number => { + let n: JsNumber = unsafe { ptr_val.cast() }; + n.get_double()? as usize + } + _ => 0, + }; + if ptr == 0 { + return Err(napi::Error::from_reason("__nsWrapWinRTPointer: null pointer")); + } + let type_name = js_to_rust_string(env, &ctx.get::(1)?); + let declaration = metadata::meta_data_reader::MetadataReader::find_by_name(&type_name) + .ok_or_else(|| napi::Error::from_reason(format!("__nsWrapWinRTPointer: unknown WinRT type {type_name}")))?; + let instance: IUnknown = unsafe { + let borrowed = ManuallyDrop::new(IUnknown::from_raw(ptr as *mut c_void)); + (*borrowed).clone() + }; + let obj = crate::napi_engine::ns_proxy::create_instance_proxy(env, &type_name, declaration, instance.clone())?; + crate::napi_engine::ns_proxy::mark_js_subclass_instance(&instance); + Ok(as_unknown(env, obj)) +} + fn native_await_task(ctx: &CallContext) -> napi::Result<()> { if ctx.length < 3 { return Err(napi::Error::from_reason( @@ -743,6 +878,22 @@ fn parse_callback_args(env: &Env, bytes: &[u8]) -> Vec { result } +/// Reports to the managed caller that a callback could not run (tag 0x0F + message). +unsafe fn write_error_response(message: &str, resp_ptr: *mut *mut u8, resp_len: *mut i32) { + let mut buf = vec![0x0Fu8]; + crate::dotnet::bin_write_str32(&mut buf, message.as_bytes()); + let p = LocalAlloc(LMEM_FIXED, buf.len()); + if !resp_ptr.is_null() { + *resp_ptr = if p.is_null() { std::ptr::null_mut() } else { p as *mut u8 }; + } + if !p.is_null() { + std::ptr::copy_nonoverlapping(buf.as_ptr(), p as *mut u8, buf.len()); + } + if !resp_len.is_null() { + *resp_len = if p.is_null() { 0 } else { buf.len() as i32 }; + } +} + /// Called by the managed bridge (via the stored function pointer) when a .NET delegate or task /// fires. Runs on the JS thread: same pattern as the napi `JsDelegate` COM bridge /// (`napi_engine::delegate`). Wire format for `args_ptr`: `[count: u8] [tagged values...]`. @@ -759,6 +910,18 @@ pub(crate) unsafe extern "C" fn napi_invoke_dotnet_js_callback( ) { let entry = NAPI_DOTNET_CALLBACKS.with(|m| m.borrow().get(&callback_id).copied()); let Some((env_raw, func_ref)) = entry else { + // A managed delegate, event, Task continuation or overridden member running on a thread + // other than the JS thread: hand the call to the JS (UI) thread and wait for it, so the + // callback runs and its result reaches the managed caller. + if !crate::ui_dispatcher::is_ui_thread() { + let (args, resp, len) = (args_ptr as usize, resp_ptr as usize, resp_len as usize); + let delivered = crate::ui_dispatcher::run_on_ui_thread_sync(move || { + napi_invoke_dotnet_js_callback(callback_id, args as *const u8, args_len, resp as *mut *mut u8, len as *mut i32) + }); + if delivered.is_none() { + write_error_response("the JavaScript callback could not run", resp_ptr, resp_len); + } + } return; }; if env_raw.is_null() { @@ -804,7 +967,12 @@ pub(crate) unsafe extern "C" fn napi_invoke_dotnet_js_callback( && !exc.is_null() { if let Some(msg) = stringify_exception(env_raw, exc) { - crate::store_last_js_error(msg); + crate::store_last_js_error(msg.clone()); + // 0x0F: the callback threw. A managed caller that expects a result gets a + // JsException; a void delegate/event ignores it. + let mut buf = vec![0x0Fu8]; + crate::dotnet::bin_write_str32(&mut buf, msg.as_bytes()); + return buf; } } return vec![0x00u8]; @@ -898,6 +1066,12 @@ pub fn install_dotnet(env: &Env) -> napi::Result<()> { )?; global.set_named_property("__nsDotNetCreateJsSubclass", create_js_subclass_fn)?; + let wrap_winrt_pointer_fn = env.create_function_from_closure( + "__nsWrapWinRTPointer", + |ctx: CallContext| native_wrap_winrt_pointer(&ctx), + )?; + global.set_named_property("__nsWrapWinRTPointer", wrap_winrt_pointer_fn)?; + let await_task_fn = env.create_function_from_closure("__nsDotNetAwaitTask", |ctx: CallContext| { native_await_task(&ctx) @@ -1351,6 +1525,15 @@ const DOTNET_HELPERS_JS: &str = r#" if (typeof Function.prototype.extend !== 'function') { Object.defineProperty(Function.prototype, 'extend', { value: function (nameOrOverrides, maybeOverrides) { + // A WinRT class (or a JS class deriving from one): an ES2015 subclass, so + // instances get the composed-object construction of `class X extends Y`. + if (globalThis.NSWinRT && typeof globalThis.NSWinRT.extendClass === 'function') { + if (typeof this.__typeName__ === 'string') + return globalThis.NSWinRT.extendClass(this, nameOrOverrides, maybeOverrides); + // Object.extend({ interfaces: [...], ... }): a subclass of System.Object. + if (this === Object && globalThis.System) + return globalThis.NSWinRT.extendClass(globalThis.System.Object, nameOrOverrides, maybeOverrides); + } return makeManagedConstructor(this, nameOrOverrides, maybeOverrides); }, writable: true, @@ -1581,6 +1764,27 @@ const DOTNET_HELPERS_JS: &str = r#" }; } + // `return global.__native(this)` ends TypeScript subclass constructors written for + // Android/iOS; here `this` already is the native-backed object. + if (typeof globalThis.__native !== 'function') { + globalThis.__native = function (obj) { return obj; }; + } + + // @NativeClass / @NativeClass(): marks a class that extends a native (WinRT/.NET) class. + // Extending works without it; it exists so code written for iOS/Android, where the build + // transforms decorated classes, runs unchanged. + if (typeof globalThis.NativeClass !== 'function') { + Object.defineProperty(globalThis, 'NativeClass', { + value: function NativeClass(target) { + if (typeof target === 'function') return target; + return function (cls) { return cls; }; + }, + writable: true, + configurable: true, + enumerable: false, + }); + } + // @Interfaces([IFoo, IBar]): attach WinRT interface list to a class. if (typeof globalThis.Interfaces !== 'function') { Object.defineProperty(globalThis, 'Interfaces', { @@ -1736,7 +1940,7 @@ const DOTNET_HELPERS_JS: &str = r#" try { var target = _wrapDotNetHandle(a[0]); var method = a[1]; - var margs = a[2] || []; + var margs = a.slice(2).map(_wrapDotNetHandle); if (target && typeof method === 'string') { var fn = overrides[method]; if (typeof fn === 'function') return fn.apply(target, Array.isArray(margs) ? margs : []); @@ -1867,7 +2071,14 @@ const DOTNET_HELPERS_JS: &str = r#" if (prop === 'toString') return function () { return '[DotNetObject ' + typeName + ' #' + handle + ']'; }; - if (prop === 'then') return undefined; + // A Task/ValueTask (awaitable) result is thenable, so `await obj.SomethingAsync()` works; + // any other .NET object must not look like a promise. + if (prop === 'then') { + if (isTask !== true) return undefined; + return function (onFulfilled, onRejected) { + return globalThis.NSWinRT.dotnet.taskToPromise({ __handle: handle, __isTask: true }).then(onFulfilled, onRejected); + }; + } // Re-read info in case it was populated after construction. var i = _typeInfoCache[typeName] || _emptyInfo; // Write-only: has setter but no getter: reading it is an error. @@ -1904,6 +2115,14 @@ const DOTNET_HELPERS_JS: &str = r#" if (Array.isArray(value)) return value.map(_wrap); if (typeof value === 'object' && typeof value.__handle === 'number') { var typeName = value.__type || ''; + var jsSelf = _jsSelfByHandle && _deref(_jsSelfByHandle.get(value.__handle)); + if (jsSelf) return jsSelf; + // A JS subclass instance whose JS object was collected: its dispatcher revives one. + if (typeName.indexOf('NSWinRTDynamicProxies.') === 0) { + try { _invoke({ handle: value.__handle, method: '__ns_js_self', args: [] }); } catch (_) {} + jsSelf = _deref(_jsSelfByHandle.get(value.__handle)); + if (jsSelf) return jsSelf; + } var assembly = _resolveAssembly(typeName); return _makeDotNetInstance(value.__handle, assembly, typeName, value.__isTask === true, value.__native_ptr); } @@ -1946,6 +2165,11 @@ const DOTNET_HELPERS_JS: &str = r#" var proxy = _makeNamespaceProxy(root); try { Object.defineProperty(globalThis, root, { value: proxy, writable: true, configurable: true, enumerable: true }); } catch (_) {} return proxy; + }, + // A namespace root never blocks app code from using the name for its own value + // (`var App = ...` in a classic script assigns through this accessor). + set: function(value) { + try { Object.defineProperty(globalThis, root, { value: value, writable: true, configurable: true, enumerable: true }); } catch (_) {} } }); } catch (_) { @@ -1958,15 +2182,491 @@ const DOTNET_HELPERS_JS: &str = r#" }, }; + // ── .NET types as JS classes ──────────────────────────────────────────────── + // A .NET type proxy (System.Text.StringBuilder, MyApp.Native.Greeter) is a constructor with a + // stable `prototype`, so it can be extended the way Java and Objective-C classes are on Android + // and iOS: + // class Cat extends MyApp.Animal { constructor(n) { super(n); } Speak() { return super.Speak(); } } + // const Dog = MyApp.Animal.extend('Dog', { Speak() { return this.super.Speak(); } }); + // const calc = new MyApp.ICalculator({ Compute(a, b) { return a * b; } }); + // Constructing a subclass creates the JS object first, then a managed subclass of the .NET type + // whose overridden virtuals (and implemented interface members) call back into that object. + // `super.X()` reaches the base implementation through the non-virtual `__ns_base_X` entry point + // the bridge emits for every overridden member. Native code handing the instance back to JS gets + // the same JS object. + var _nsProxyCache = Object.create(null); + var _namespaceExtras = { System: Object.create(null) }; + var _typeKindCache = Object.create(null); + var _typeProtoCache = Object.create(null); + var _subclassInfoCache = new WeakMap(); + var _jsSelfByHandle = new Map(); + + // ── Lifetime of JS subclass instances ─────────────────────────────────────── + // An instance is a JS object plus the managed object it drives. The JS object holds the managed + // one through its handle; the managed object reaches the JS object only through its dispatcher, + // which holds it weakly. So once JS drops the JS object, its handle is released and the managed + // object goes as soon as .NET/native code drops it too (the bridge then unpins the dispatcher). + // When native code calls into an instance whose JS object was collected, a new JS object of the + // same class is made for it: fields its constructor set are gone, as on Android when a JS object + // used only from native code is collected. Subclasses of WinRT UI classes are kept strongly while + // they are in a live XAML tree. + var _canWeakRef = typeof WeakRef === 'function'; + var _selfFinalizers = typeof FinalizationRegistry === 'function' + ? new FinalizationRegistry(function (handle) { + if (!_deref(_jsSelfByHandle.get(handle))) _jsSelfByHandle.delete(handle); + try { _invoke({ handle: handle, method: '__release', args: [] }); } catch (_) {} + }) + : null; + + function _weak(obj) { return _canWeakRef ? new WeakRef(obj) : obj; } + function _deref(ref) { return ref && _canWeakRef && ref instanceof WeakRef ? ref.deref() : ref; } + + // Gives `self` the bridge identity in `raw` and registers it as the JS object of `entry`'s instance. + function _adopt(entry, self, raw) { + Object.defineProperty(self, '__handle', { value: raw.__handle }); + Object.defineProperty(self, '__nsOverrides', { value: entry.overrides }); + if (!entry.winrt) { + Object.defineProperty(self, '__type', { value: raw.__type || entry.typeName }); + if (raw.__native_ptr !== undefined) Object.defineProperty(self, '__native_ptr', { value: raw.__native_ptr }); + } + entry.ref = _weak(self); + _jsSelfByHandle.set(raw.__handle, _weak(self)); + if (_selfFinalizers) _selfFinalizers.register(self, raw.__handle); + if (entry.winrt) { + entry.strong = self; + _attachedEntries.add(entry); + } + _sweepAttached(); + return self; + } + + // The JS object of `entry`'s instance, made anew when the previous one was collected. + function _selfFor(entry, raw) { + var self = _deref(entry.ref); + if (self) return self; + if (entry.constructing) throw new Error(entry.typeName + ' called into its JS subclass before the instance existed'); + if (!raw || typeof raw.__handle !== 'number') throw new Error('The ' + entry.typeName + ' subclass instance is gone'); + if (entry.winrt) { + self = globalThis.__nsWrapWinRTPointer(raw.__native_ptr, entry.typeName); + Object.setPrototypeOf(self, entry.proto); + } else { + self = Object.create(entry.proto); + } + return _adopt(entry, self, raw); + } + + // The dispatcher native code calls the instance's overridden and interface members through. + function _makeDispatcher(entry) { + return function (selfHandle, member) { + var self = _selfFor(entry, selfHandle); + if (member === '__ns_js_self') return undefined; + _sweepAttached(); + var margs = Array.prototype.slice.call(arguments, 2).map(_wrap); + return _dispatchToJs(self, entry.baseProto, entry.typeName, member, margs); + }; + } + + // Subclasses of WinRT classes held strongly (entry.strong) while in a live XAML tree, re-checked at + // most every two seconds when subclass instances are created or called into. + var _attachedEntries = new Set(); + var _lastAttachedSweep = 0; + function _sweepAttached() { + var now = Date.now(); + if (now - _lastAttachedSweep < 2000) return; + _lastAttachedSweep = now; + _attachedEntries.forEach(function (entry) { + var self = _deref(entry.ref); + if (!self) { + _attachedEntries.delete(entry); + return; + } + var attached = false; + try { attached = self.XamlRoot != null; } catch (_) {} + entry.strong = attached ? self : undefined; + }); + } + var _baseCallPrefix = '__ns_base_'; + + function _has(list, name) { + return !!list && list.indexOf(name) >= 0; + } + + function _typeKind(path) { + var kind = _typeKindCache[path]; + if (kind !== undefined) return kind; + try { + kind = _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'TypeKind', args: [path] }) || ''; + } catch (_) { + kind = ''; + } + _typeKindCache[path] = kind; + return kind; + } + + // Full name of a .NET type proxy, or of the .NET base of a JS class deriving from one. + function _dotnetTypeName(type) { + if (typeof type === 'string') return type; + if (!type || (typeof type !== 'function' && typeof type !== 'object')) return ''; + var name = type.__dotnetType__; + return typeof name === 'string' ? name : ''; + } + + // The handle of an instance of a JS subclass (an own property); -1 for anything else, such as + // a class prototype further down the chain, without running any property trap. + function _ownHandle(o) { + if (!o || (typeof o !== 'object' && typeof o !== 'function')) return -1; + var d = Object.getOwnPropertyDescriptor(o, '__handle'); + return d && typeof d.value === 'number' ? d.value : -1; + } + + function _baseCallName(self, member) { + var overrides = self.__nsOverrides; + return overrides && overrides.has(member) ? _baseCallPrefix + member : member; + } + + // A native member of `self` (an instance of a JS subclass), reached through the .NET type's + // prototype: properties are read through their getter, methods become functions. Members the JS + // class overrides resolve to the base implementation — only `super.X` gets this far for them. + function _baseMember(path, self, prop) { + if (prop === 'toString') return function () { return String(_invoke({ handle: self.__handle, method: 'ToString', args: [] })); }; + if (prop === 'valueOf') return function () { return this; }; + var info = _getTypeInfo(_resolveAssembly(path), path); + var overrides = self.__nsOverrides; + if (_has(info.properties, prop) || (overrides && overrides.has('get_' + prop))) { + return _wrap(_invoke({ handle: self.__handle, method: _baseCallName(self, 'get_' + prop), args: [] })); + } + if (_has(info.writeonlyProperties, prop)) + throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + path + '\''); + if (_has(info.methods, prop) || (overrides && overrides.has(prop))) { + var method = _baseCallName(self, prop); + return function () { + var args = Array.prototype.slice.call(arguments).map(_unwrap); + return _wrap(_invoke({ handle: self.__handle, method: method, args: args })); + }; + } + return undefined; + } + + // The `prototype` of a .NET type: the end of every JS subclass's prototype chain. Reads resolve + // native members against the receiving instance; writes go to native properties, and anything + // else becomes a plain JS field of the instance. + function _typePrototype(path) { + var cached = _typeProtoCache[path]; + if (cached) return cached; + var proto = new Proxy(Object.create(null), { + get: function (_, prop, receiver) { + if (typeof prop === 'symbol') return undefined; + if (_ownHandle(receiver) < 0) { + // Read off the prototype itself — TypeScript's ES5 super call + // `_super.prototype.Method.call(this, ...)`: a function that runs the base + // implementation on whichever instance it is called with. + var typeInfo = _getTypeInfo(_resolveAssembly(path), path); + if (!_has(typeInfo.methods, prop)) return undefined; + return function () { + if (_ownHandle(this) < 0) throw new TypeError(path + '.prototype.' + prop + ' called on an object that is not a ' + path); + return _baseMember(path, this, prop).apply(this, arguments); + }; + } + return _baseMember(path, receiver, prop); + }, + set: function (_, prop, value, receiver) { + if (typeof prop !== 'symbol' && _ownHandle(receiver) >= 0) { + var info = _getTypeInfo(_resolveAssembly(path), path); + if (_has(info.readonlyProperties, prop)) + throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + path + '\''); + if (_has(info.properties, prop) || _has(info.writeonlyProperties, prop)) { + _invoke({ handle: receiver.__handle, method: _baseCallName(receiver, 'set_' + prop), args: [_unwrap(value)] }); + return true; + } + } + Object.defineProperty(receiver, prop, { value: value, writable: true, enumerable: true, configurable: true }); + return true; + }, + }); + _typeProtoCache[path] = proto; + return proto; + } + + // Members (literal CLR names: methods, get_X/set_X accessors) and interfaces a JS class adds on + // top of its .NET base, from every class between it and the base. + function _subclassInfo(proto, baseProto) { + var cached = _subclassInfoCache.get(proto); + if (cached) return cached; + var members = []; + var overrides = new Set(); + var interfaces = []; + var seen = Object.create(null); + function addInterfaces(list) { + if (!Array.isArray(list)) return; + for (var i = 0; i < list.length; i++) { + var name = _dotnetTypeName(list[i]) || (list[i] && (list[i].__typeName__ || list[i].name)) || ''; + if (name && interfaces.indexOf(name) < 0) interfaces.push(name); + } + } + for (var p = proto; p && p !== baseProto; p = Object.getPrototypeOf(p)) { + var keys = Object.getOwnPropertyNames(p); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (key === 'constructor' || key === 'interfaces' || key === 'super' || key === 'init' || seen[key]) continue; + seen[key] = true; + var d = Object.getOwnPropertyDescriptor(p, key); + if (typeof d.get === 'function') { members.push('get_' + key); overrides.add('get_' + key); } + if (typeof d.set === 'function') { members.push('set_' + key); overrides.add('set_' + key); } + if (typeof d.value === 'function') { members.push(key); overrides.add(key); } + } + if (Object.prototype.hasOwnProperty.call(p, 'interfaces')) addInterfaces(p.interfaces); + var ctor = Object.prototype.hasOwnProperty.call(p, 'constructor') ? p.constructor : null; + if (ctor && Object.prototype.hasOwnProperty.call(ctor, 'interfaces')) addInterfaces(ctor.interfaces); + } + var info = { members: members, overrides: overrides, interfaces: interfaces }; + _subclassInfoCache.set(proto, info); + return info; + } + + // The JS-defined member `key` of `self` (an own field, or a member of one of its JS classes). + function _jsMember(self, key, baseProto) { + for (var o = self; o && o !== baseProto; o = Object.getPrototypeOf(o)) { + var d = Object.getOwnPropertyDescriptor(o, key); + if (d) return d; + } + return null; + } + + // Native code calling an overridden member (or an implemented interface member) of `self`. + function _dispatchToJs(self, baseProto, typeName, member, args) { + var accessor = /^(get|set)[_:](.+)$/.exec(member); + if (accessor) { + var ad = _jsMember(self, accessor[2], baseProto); + if (ad) { + if (accessor[1] === 'get') { + if (typeof ad.get === 'function') return ad.get.call(self); + if ('value' in ad && typeof ad.value !== 'function') return ad.value; + } else if (typeof ad.set === 'function') { + ad.set.call(self, args[0]); + return undefined; + } else if ('value' in ad && ad.writable) { + self[accessor[2]] = args[0]; + return undefined; + } + } + } + var md = _jsMember(self, member, baseProto); + if (md && typeof md.value === 'function') return md.value.apply(self, args); + throw new Error((self.constructor && self.constructor.name || typeName) + ' does not implement ' + member); + } + + function _constructSubclass(path, args, proto) { + if (typeof globalThis.__nsDotNetCreateJsSubclass !== 'function') + throw new Error('Extending .NET types is not available in this runtime'); + var baseProto = _typePrototype(path); + var info = _subclassInfo(proto, baseProto); + var self = Object.create(proto); + // A virtual the base constructor calls reaches `self` before it has its handle. + var entry = { proto: proto, baseProto: baseProto, typeName: path, overrides: info.overrides, winrt: false, ref: self }; + var raw = globalThis.__nsDotNetCreateJsSubclass('', path, info.interfaces, info.members, _makeDispatcher(entry), args.map(_unwrap)); + if (!raw || typeof raw.__handle !== 'number') throw new Error('Could not create an instance of a subclass of ' + path); + return _adopt(entry, self, raw); + } + + // `new SomeInterface({ ... })`: an object implementing the interface with the given members. + function _implementInterface(path, impl) { + var proto = Object.create(_typePrototype('System.Object')); + Object.defineProperties(proto, Object.getOwnPropertyDescriptors(impl)); + Object.defineProperty(proto, 'interfaces', { value: [path] }); + return _constructSubclass('System.Object', [], proto); + } + + // `this.super` inside a class made by extend(): the parent class's members bound to `self`. + function _superOf(self, Cls) { + var parent = Object.getPrototypeOf(Cls.prototype); + return new Proxy(Object.create(null), { + get: function (_, prop) { + var value = Reflect.get(parent, prop, self); + return typeof value === 'function' ? value.bind(self) : value; + }, + }); + } + + // Base.extend([name,] members): an ES2015 subclass of Base with the given members; `init`, when + // present, runs after construction with the constructor arguments. + function _extendClass(Base, nameOrMembers, maybeMembers) { + var hasName = typeof nameOrMembers === 'string'; + var members = (hasName ? maybeMembers : nameOrMembers) || {}; + var Extended = class extends Base { + constructor() { + super(...arguments); + if (typeof members.init === 'function') members.init.apply(this, arguments); + } + }; + var descriptors = Object.getOwnPropertyDescriptors(members); + delete descriptors.init; + if (descriptors.interfaces) { + Object.defineProperty(Extended.prototype, 'interfaces', { value: members.interfaces }); + delete descriptors.interfaces; + } + Object.defineProperties(Extended.prototype, descriptors); + Object.defineProperty(Extended.prototype, 'super', { + get: function () { return _superOf(this, Extended); }, + }); + if (hasName) Object.defineProperty(Extended, 'name', { value: nameOrMembers.split('.').pop() }); + return Extended; + } + + // `_super.call(this, ...)` / `_super.apply(this, args)` in TypeScript's ES5 output (what the + // @NativeClass transform emits): construct the subclass instance `thisArg` was created for. + function _es5SuperCall(path, thisArg, args) { + var proto = thisArg && Object.getPrototypeOf(thisArg); + if (!proto || !Object.prototype.isPrototypeOf.call(_typePrototype(path), proto)) + throw new TypeError('Class constructor ' + path + ' cannot be invoked without \'new\''); + return _constructSubclass(path, args, proto); + } + + function _hasInstance(path, value) { + if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false; + if (Object.prototype.isPrototypeOf.call(_typePrototype(path), value)) return true; + if (typeof value.__handle !== 'number') return false; + try { + return _invoke({ assembly: '', typeName: 'NativeScriptBridge.Bridge', method: 'IsInstanceOf', args: [{ __handle: value.__handle }, path] }) === true; + } catch (_) { + return false; + } + } + + // ── WinRT classes as JS base classes ──────────────────────────────────────── + // `class FixedPanel extends Microsoft.UI.Xaml.Controls.Panel { MeasureOverride(size) { ... } }`: + // the WinRT constructor hands `super(...)` here. The instance is a composed object created by + // the .NET bridge from the C#/WinRT projection of the class (the way a C# subclass is), so XAML + // calling an overridable member (MeasureOverride, ArrangeOverride, OnApplyTemplate, ...) lands in + // the JS override. The JS object is the ordinary WinRT wrapper of that object, with the subclass + // prototype spliced in front of the class's native members. + var _linkedWinRTPrototypes = new WeakSet(); + + // The WinRT class a JS class derives from: the nearest constructor with its own __typeName__. + function _winrtBaseOf(ctor) { + for (var c = ctor; c; c = Object.getPrototypeOf(c)) { + if (Object.prototype.hasOwnProperty.call(c, '__typeName__')) return c; + } + return null; + } + + // `super.X(...)` for a member a JS subclass of a WinRT class overrides: the base implementation, + // through the bridge (the native member would dispatch back into the override). + function _winrtBaseCall(handle, prop) { + return function () { + var args = Array.prototype.slice.call(arguments).map(_unwrap); + return _wrap(_invoke({ handle: handle, method: _baseCallPrefix + prop, args: args })); + }; + } + + // Puts a layer under the JS subclass prototypes that resolves the class's native members and + // sends `super.X()` for overridden members to the base implementation. On the classic engine the + // class's `prototype` is separate from its instances' native prototype, so the layer goes under the + // class's `prototype` once; on the napi engines they're the same object, so it goes under the + // subclass prototype that sits directly on it. + function _linkWinRTSubclassPrototype(base, nativeProto, proto) { + if (!base || !nativeProto) return; + var target = base.prototype; + if (target === nativeProto) { + target = proto; + while (target && Object.getPrototypeOf(target) !== nativeProto) target = Object.getPrototypeOf(target); + } + if (!target || _linkedWinRTPrototypes.has(target)) return; + _linkedWinRTPrototypes.add(target); + var layer = new Proxy(Object.create(nativeProto), { + get: function (_, prop, receiver) { + var handle = _ownHandle(receiver); + if (typeof prop !== 'string') return Reflect.get(nativeProto, prop, receiver); + if (handle >= 0) { + if (receiver.__nsOverrides && receiver.__nsOverrides.has(prop)) return _winrtBaseCall(handle, prop); + return Reflect.get(nativeProto, prop, receiver); + } + // Read off a prototype — TypeScript's ES5 `_super.prototype.X.call(this, ...)`: decide + // between the base implementation and the native member when it's called. + var native = Reflect.get(nativeProto, prop, receiver); + if (typeof native !== 'function') return native; + return function () { + var h = _ownHandle(this); + if (h >= 0 && this.__nsOverrides && this.__nsOverrides.has(prop)) + return _winrtBaseCall(h, prop).apply(this, arguments); + return native.apply(this, arguments); + }; + }, + has: function (_, prop) { return prop in nativeProto; }, + }); + Object.setPrototypeOf(target, layer); + } + + // napi engines: the class `prototype` is the native prototype itself, so TypeScript's ES5 + // `_super.prototype.X.call(this, ...)` reaches the native member directly. Wrap each native member a + // subclass overrides (once) so that, called on a JS subclass instance, it runs the base + // implementation instead of dispatching back into the override. + function _guardNativeMembers(nativeProto, overrides) { + overrides.forEach(function (name) { + for (var p = nativeProto; p; p = Object.getPrototypeOf(p)) { + var d = Object.getOwnPropertyDescriptor(p, name); + if (!d) continue; + if (typeof d.value === 'function' && !d.value.__nsGuarded && d.writable) { + var native = d.value; + var guarded = function () { + var h = _ownHandle(this); + if (h >= 0 && this.__nsOverrides && this.__nsOverrides.has(name)) + return _winrtBaseCall(h, name).apply(this, arguments); + return native.apply(this, arguments); + }; + Object.defineProperty(guarded, '__nsGuarded', { value: true }); + Object.defineProperty(p, name, { value: guarded, writable: true, configurable: true, enumerable: d.enumerable }); + } + return; + } + }); + } + + // Called by the WinRT class constructors for `super(...)` from a JS subclass (newTarget) or + // TypeScript's ES5 `_super.call(this, ...)` (es5This). Returns undefined when this isn't a subclass + // construction or the .NET bridge isn't available; the constructor then builds a plain instance. + globalThis.__nsConstructWinRTSubclass = function (typeName, newTarget, args, es5This) { + var proto; + var base; + if (newTarget) { + proto = newTarget.prototype; + base = _winrtBaseOf(Object.getPrototypeOf(newTarget)); + } else { + proto = es5This && Object.getPrototypeOf(es5This); + base = proto && proto.constructor ? _winrtBaseOf(Object.getPrototypeOf(proto.constructor)) : null; + if (!base || base.__typeName__ !== typeName || Object.prototype.hasOwnProperty.call(proto.constructor, '__typeName__')) + return undefined; + } + if (typeof globalThis.__nsDotNetCreateJsSubclass !== 'function' || typeof globalThis.__nsWrapWinRTPointer !== 'function') + return undefined; + var baseProto = base ? base.prototype : null; + var info = _subclassInfo(proto, baseProto); + var entry = { proto: proto, baseProto: baseProto, typeName: typeName, overrides: info.overrides, winrt: true, constructing: true }; + var raw = globalThis.__nsDotNetCreateJsSubclass('', typeName, info.interfaces, info.members, _makeDispatcher(entry), (args || []).map(_unwrap)); + entry.constructing = false; + if (!raw || raw.__native_ptr === undefined || raw.__native_ptr === null) + throw new Error('Could not create a WinRT object for a subclass of ' + typeName); + var self = globalThis.__nsWrapWinRTPointer(raw.__native_ptr, typeName); + var nativeProto = Object.getPrototypeOf(self); + _linkWinRTSubclassPrototype(base, nativeProto, proto); + if (base && base.prototype === nativeProto) _guardNativeMembers(nativeProto, info.overrides); + Object.setPrototypeOf(self, proto); + return _adopt(entry, self, raw); + }; + + globalThis.NSWinRT.extendClass = _extendClass; + // System.Diagnostics.Stopwatch.StartNew() → static method call // System.Environment.MachineName → static property get // new System.Text.StringBuilder(64) → constructor // sw.Stop() → instance method // sw.Elapsed → instance property function _makeNamespaceProxy(path) { + var cached = _nsProxyCache[path]; + if (cached) return cached; function _node() {} - return new Proxy(_node, { + var proxy = new Proxy(_node, { get: function (_, prop) { + if (prop === Symbol.hasInstance) return function (value) { return _hasInstance(path, value); }; if (typeof prop === 'symbol') return undefined; // Prevent string-coercion methods from descending into sub-proxies. // V8's console.log / JSON.stringify call toString/valueOf on unknown objects; @@ -1974,9 +2674,15 @@ const DOTNET_HELPERS_JS: &str = r#" // type name, producing a spurious "Type not found" bridge error. if (prop === 'toString' || prop === 'valueOf') return function() { return '[.NET ' + path + ']'; }; + var extras = _namespaceExtras[path]; + if (extras && Object.prototype.hasOwnProperty.call(extras, prop)) return extras[prop]; + if (prop === '__dotnetType__') return path; + if (prop === 'prototype') return _typePrototype(path); + // Not a WinRT class: TypeScript's __extends must take the standard path. + if (prop === '__nsWinRTClass__' || prop === 'then') return undefined; var assembly = _resolveAssembly(path); var info = _getTypeInfo(assembly, path); - // Write-only static property: reading it is an error. + // Write-only static property — reading it is an error. if (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0) throw new TypeError('Cannot read write-only property \'' + prop + '\' of .NET type \'' + path + '\''); // Readable static property: resolve value immediately. @@ -1989,6 +2695,9 @@ const DOTNET_HELPERS_JS: &str = r#" return _wrap(_invoke({ assembly: assembly, typeName: path, method: prop, args: args })); }; } + if (prop === 'extend') return function (nameOrMembers, maybeMembers) { return _extendClass(this, nameOrMembers, maybeMembers); }; + if (prop === 'call') return function (thisArg) { return _es5SuperCall(path, thisArg, Array.prototype.slice.call(arguments, 1)); }; + if (prop === 'apply') return function (thisArg, args) { return _es5SuperCall(path, thisArg, args ? Array.prototype.slice.call(args) : []); }; // Namespace / sub-type: keep descending. return _makeNamespaceProxy(path + '.' + prop); }, @@ -1996,19 +2705,19 @@ const DOTNET_HELPERS_JS: &str = r#" if (typeof prop === 'symbol') return true; var assembly = _resolveAssembly(path); var info = _getTypeInfo(assembly, path); - // Read-only static property: assignment is an error. + // Read-only static property — assignment is an error. if (info.readonlyStaticProperties && info.readonlyStaticProperties.indexOf(prop) >= 0) throw new TypeError('Cannot assign to read-only property \'' + prop + '\' of .NET type \'' + path + '\''); - // Writable (read-write or write-only): invoke the setter. + // Writable (read-write or write-only) — invoke the setter. if ((info.staticProperties && info.staticProperties.indexOf(prop) >= 0) || (info.writeonlyStaticProperties && info.writeonlyStaticProperties.indexOf(prop) >= 0)) _invoke({ assembly: assembly, typeName: path, method: 'set_' + prop, args: [_unwrap(value)] }); - // Not a native property: don't intercept. + // Not a native property — don't intercept. return true; }, apply: function (_, _this, args) { var lastDot = path.lastIndexOf('.'); - // No dot means this is a top-level name being called directly: + // No dot means this is a top-level name being called directly — // there is no type+method pair to dispatch, so bail out. if (lastDot <= 0) return undefined; var typeName = path.substring(0, lastDot); @@ -2016,27 +2725,61 @@ const DOTNET_HELPERS_JS: &str = r#" var assembly = _resolveAssembly(typeName); return _wrap(_invoke({ assembly: assembly, typeName: typeName, method: method, args: args.map(_unwrap) })); }, - construct: function (_, args) { + construct: function (_, args, newTarget) { + // `super(...)` from a JS subclass. + if (newTarget && newTarget !== proxy) return _constructSubclass(path, args, newTarget.prototype); + if (args.length === 1 && args[0] && typeof args[0] === 'object' && typeof args[0].__handle !== 'number' + && _typeKind(path) === 'interface') + return _implementInterface(path, args[0]); var assembly = _resolveAssembly(path); return _wrap(_invoke({ assembly: assembly, typeName: path, method: '.ctor', args: args.map(_unwrap) })); }, }); + _nsProxyCache[path] = proxy; + return proxy; } - globalThis.System = _makeNamespaceProxy('System'); + // `System` stays the .NET namespace when app code assigns it: @nativescript/core sets + // `global.System = { import() }` (a SystemJS shim). Assigned members are kept and take + // precedence (`System.import(...)` keeps working); everything else is .NET. + (function () { + var systemProxy = _makeNamespaceProxy('System'); + Object.defineProperty(globalThis, 'System', { + configurable: true, + enumerable: true, + get: function () { return systemProxy; }, + set: function (value) { + if (value && (typeof value === 'object' || typeof value === 'function')) + Object.keys(value).forEach(function (k) { _namespaceExtras.System[k] = value[k]; }); + }, + }); + })(); globalThis.Microsoft = _makeNamespaceProxy('Microsoft'); globalThis.NativeScript = _makeNamespaceProxy('NativeScript'); + // Every other root namespace of the app's assemblies (C# sources compiled into the app, + // plugin and NuGet assemblies) is a lazy global too, so `new MyCompany.Native.Foo()` works + // with no registration step, the way Java packages and Objective-C classes are globals on + // Android and iOS. WinRT owns Windows/Microsoft/NativeScript; the rest are implementation + // namespaces of the toolchain that no app reaches for. + (function () { + var reserved = { Windows: 1, Microsoft: 1, NativeScript: 1, System: 1, NativeScriptBridge: 1, NSWinRTDynamicProxies: 1, + WinRT: 1, ABI: 1, XamlGeneratedNamespace: 1, FxResources: 1, Internal: 1, Interop: 1, MS: 1, Mono: 1 }; + for (var ns in _namespaceAssemblyMap) { + if (ns.indexOf('.') !== -1 || reserved[ns] === 1 || !/^[A-Za-z_$][\w$]*$/.test(ns)) continue; + globalThis.NSWinRT.dotnet.registerNamespace(ns); + } + })(); + // Creates a typed .NET BCL delegate (not a WinRT COM delegate). // Use this when the API expects a managed System.Action, // System.EventHandler, etc. rather than a WinRT delegate interface. // For WinRT delegate types use NSWinRT.asDelegate instead. if (typeof globalThis.__nsDotNetAwaitTask === 'function') { globalThis.NSWinRT.dotnet.taskToPromise = function (obj) { - var h = obj && typeof obj.__handle === 'number' ? obj.__handle - : typeof obj === 'number' ? obj : -1; + var h = obj && typeof obj.__handle === 'number' && obj.__isTask === true ? obj.__handle : -1; if (h < 0) return Promise.resolve(obj); return new Promise(function (resolve, reject) { - globalThis.__nsDotNetAwaitTask(h, resolve, reject); + globalThis.__nsDotNetAwaitTask(h, function (value) { resolve(_wrap(value)); }, reject); }); }; } diff --git a/runtime/src/napi_engine/module_natives.rs b/runtime/src/napi_engine/module_natives.rs index 41de399..24abfae 100644 --- a/runtime/src/napi_engine/module_natives.rs +++ b/runtime/src/napi_engine/module_natives.rs @@ -90,8 +90,11 @@ pub fn install_module_natives(env: &Env, app_root: &str) -> napi::Result<()> { // A packed (virtual, no-plaintext-on-disk) referrer never satisfies `.is_file()`, // so also check the sealed bundle's table — otherwise a relative require() from a // bundle-only file would wrongly treat the referrer as the base dir, not its parent. + // A referrer that doesn't exist but names a file (the `app/bundle.js` fallback the + // top-level `require` uses when the entry script is not a bundle) is a file too. let base = if parent.is_file() || crate::source_protect::contains(&parent.to_string_lossy()) + || (!parent.exists() && parent.extension().is_some()) { parent.parent().map(Path::to_path_buf).unwrap_or(parent) } else { diff --git a/runtime/src/napi_engine/ns_hostobject.rs b/runtime/src/napi_engine/ns_hostobject.rs index 48ac880..542ce1f 100644 --- a/runtime/src/napi_engine/ns_hostobject.rs +++ b/runtime/src/napi_engine/ns_hostobject.rs @@ -521,6 +521,23 @@ fn class_prototype(env: &Env, class_name: &str) -> napi::Result { // __typeName__ is the same for every instance → carry it once on the prototype, not per-instance. proto.set_named_property("__typeName__", env.create_string(class_name)?)?; + // The base class's prototype is next in the chain, so `x instanceof BaseClass` holds for + // instances of derived classes (a StackPanel is a Panel and a UIElement). + let base_name = { + let lock = declaration.read(); + lock.as_any() + .downcast_ref::() + .map(|c| c.base_full_name().to_string()) + .unwrap_or_default() + }; + if !base_name.is_empty() && base_name != "System.Object" && base_name != class_name { + if let Ok(base_proto) = class_prototype(env, &base_name) { + let (object, _create, _define) = object_helpers(env)?; + let set_proto: JsFunction = object.get_named_property("setPrototypeOf")?; + set_proto.call(Some(&object), &[as_unknown(env, unsafe { JsObject::from_raw_unchecked(env.raw(), proto.raw()) }), as_unknown(env, base_proto)])?; + } + } + HOST_PROTOS.with(|c| { c.borrow_mut().insert(class_name.to_string(), make_ref(env, &proto)); }); @@ -566,10 +583,27 @@ fn make_ctor_wrapper( impl_fn: &JsFunction, proto: &JsObject, class_name: &str, + sealed: bool, ) -> napi::Result { + // A JS subclass (`class Sub extends ThisClass`, or TypeScript's ES5 `_super.call(this, ...)`) + // of an unsealed class is constructed by the bootstrap's `__nsConstructWinRTSubclass`: a + // composed object whose overridable members call back into Sub. It returns undefined when the + // .NET bridge isn't available; then (and for sealed classes) the instance is a plain one + // re-linked to Sub's prototype. const BODY: &str = r#"'use strict'; function Ctor() { - if (!new.target) { throw new TypeError(clsName + ' is a WinRT class constructor — use `new`'); } + var sub = globalThis.__nsConstructWinRTSubclass; + if (!new.target) { + if (!sealed && this && typeof this === 'object' && typeof sub === 'function') { + var es5 = sub(clsName, null, Array.prototype.slice.call(arguments), this); + if (es5) { return es5; } + } + throw new TypeError(clsName + ' is a WinRT class constructor — use `new`'); + } + if (!sealed && new.target !== Ctor && typeof sub === 'function') { + var composed = sub(clsName, new.target, Array.prototype.slice.call(arguments), undefined); + if (composed) { return composed; } + } var obj = arguments.length === 0 ? native() : native.apply(null, arguments); var p = new.target.prototype; if (p && typeof p === 'object' && p !== sharedProto) { @@ -590,6 +624,7 @@ return Ctor;"#; as_unknown(env, env.create_string("native")?), as_unknown(env, env.create_string("sharedProto")?), as_unknown(env, env.create_string("clsName")?), + as_unknown(env, env.create_string("sealed")?), as_unknown(env, env.create_string(BODY)?), ], )?; @@ -604,6 +639,7 @@ return Ctor;"#; JsUnknown::from_raw_unchecked(env.raw(), proto.raw()) }), as_unknown(env, env.create_string(class_name)?), + as_unknown(env, env.get_boolean(sealed)?), ], )?; Ok(unsafe { wrapper_unknown.cast() }) @@ -674,7 +710,7 @@ pub fn build_host_ctor(env: &Env, class_name: &str, declaration: Decl) -> napi:: // It enforces construct-only calls and, for `class Sub extends WinRTClass`, re-links the // wrapped instance to new.target.prototype when that prototype chains through the shared // class prototype. - let ctor = make_ctor_wrapper(env, &impl_fn, &proto, class_name)?; + let ctor = make_ctor_wrapper(env, &impl_fn, &proto, class_name, ctor_sealed)?; let ctor_raw = unsafe { ctor.raw() }; let mut ctor_obj = unsafe { JsObject::from_raw_unchecked(env.raw(), ctor_raw) }; @@ -790,6 +826,42 @@ pub fn build_host_ctor(env: &Env, class_name: &str, declaration: Decl) -> napi:: define_accessor(env, &ctor_obj, &name, getter, setter)?; } + // Static events (CompositionTarget.Rendering, ...): `Class.Event = handler` subscribes through + // the class's statics interface on its activation factory, replacing the previous handler; + // `= null` unsubscribes; reading returns the current handler. Same syntax as instance events. + let static_events: Vec<_> = { + let lock = declaration.read(); + lock.as_any() + .downcast_ref::() + .map(|class| { + use metadata::declarations::base_class_declaration::BaseClassDeclarationImpl; + class.events().iter().filter(|e| e.is_static()).cloned().collect() + }) + .unwrap_or_default() + }; + for e in &static_events { + let name = e.name().to_string(); + let (cls_get, cls_set) = (class_name.to_string(), class_name.to_string()); + let (add, remove) = (e.add_method().clone(), e.remove_method().clone()); + let gname = name.clone(); + let getter = env.create_function_from_closure(&name, move |ctx: CallContext| { + let factory = crate::class_activation_factory(&cls_get) + .map_err(|err| napi::Error::from_reason(err.to_string()))?; + read_winrt_event_napi(&ctx.env, &factory, &gname) + })?; + let sname = name.clone(); + let setter = env.create_function_from_closure(&name, move |ctx: CallContext| { + let env = &ctx.env; + crate::napi_engine::invoke::ensure_winrt_initialized(); + let factory = crate::class_activation_factory(&cls_set) + .map_err(|err| napi::Error::from_reason(format!("{cls_set}.{sname}: {err}")))?; + let value = ctx.get::(0)?; + wire_winrt_event_napi(env, &sname, &factory, &add, &remove, &value)?; + Ok(as_unknown(env, env.get_undefined()?)) + })?; + define_accessor(env, &ctor_obj, &name, getter, Some(setter))?; + } + HOST_CTORS.with(|c| { c.borrow_mut().insert(class_name.to_string(), make_ref(env, &ctor)); }); diff --git a/runtime/src/napi_engine/ns_proxy.rs b/runtime/src/napi_engine/ns_proxy.rs index a6b2cb0..85e18e9 100644 --- a/runtime/src/napi_engine/ns_proxy.rs +++ b/runtime/src/napi_engine/ns_proxy.rs @@ -204,6 +204,9 @@ struct InstanceCacheEntry { /// after the old object is freed; if a weak ref hands back a stale proxy for that address /// (observed on JSC), returning it would be the wrong type. We validate this on every hit. type_name: String, + /// The wrapper is the JS object of a JS subclass instance: returned for this identity whatever + /// class the caller expects (its runtime class, a managed type, isn't in the metadata). + any_type: bool, /// Serial of the InstanceState that owns this entry. On that state's Drop we remove the entry /// only if the serial still matches — so a newer proxy for a reused COM address is never evicted. serial: u64, @@ -225,7 +228,7 @@ fn cached_instance(env: &Env, identity: usize, class_name: &str) -> Option Option { + let identity = com_identity_key(instance)?; + let type_name = NAPI_INSTANCE_CACHE.with(|c| { + c.borrow().get(&identity).filter(|e| e.any_type).map(|e| e.type_name.clone()) + })?; + cached_instance(env, identity, &type_name) +} + +/// Marks the cached wrapper of `instance` as the JS object of a JS subclass instance (see +/// `InstanceCacheEntry::any_type`). +pub(crate) fn mark_js_subclass_instance(instance: &IUnknown) { + let Some(identity) = com_identity_key(instance) else { return }; + NAPI_INSTANCE_CACHE.with(|c| { + if let Some(entry) = c.borrow_mut().get_mut(&identity) { + entry.any_type = true; + } + }); +} + fn com_identity_key(instance: &IUnknown) -> Option { crate::com_identity(instance).map(|k| k as usize) } @@ -1511,6 +1534,10 @@ pub fn try_wrap_inspectable_pointer(env: &Env, raw: *mut c_void) -> Option().ok()?; let class_name = unsafe { inspectable.GetRuntimeClassName() }.ok()?; let name_str = class_name.to_string(); diff --git a/runtime/src/ns_proxy.rs b/runtime/src/ns_proxy.rs index 306f249..49f7bf7 100644 --- a/runtime/src/ns_proxy.rs +++ b/runtime/src/ns_proxy.rs @@ -466,6 +466,11 @@ pub(crate) fn try_wrap_inspectable_pointer<'a>( return None; } let instance = unsafe { IUnknown::from_raw(value) }; + // A boxed primitive (an `Object` property such as `Tag` holding a string, a PropertySet + // value) reads back as the JS primitive; dropping `instance` releases the box. + if let Some(v) = try_unbox_property_value(&instance, scope) { + return Some(v); + } let resolved = instance .cast::() .ok() @@ -494,6 +499,38 @@ pub(crate) fn try_wrap_inspectable_pointer<'a>( } } +/// The JS primitive for a boxed scalar (`IPropertyValue`: numbers, booleans, strings, GUIDs), or +/// `None` for anything else (arrays, structs and real objects stay wrapped). +fn try_unbox_property_value<'a>( + instance: &IUnknown, + scope: &mut v8::PinScope<'a, '_>, +) -> Option> { + use windows::Foundation::{IPropertyValue, PropertyType}; + let pv: IPropertyValue = instance.cast().ok()?; + let num = |scope: &mut v8::PinScope<'a, '_>, v: f64| Some(v8::Number::new(scope, v).into()); + match pv.Type().ok()? { + PropertyType::UInt8 => num(scope, pv.GetUInt8().ok()? as f64), + PropertyType::Int16 => num(scope, pv.GetInt16().ok()? as f64), + PropertyType::UInt16 => num(scope, pv.GetUInt16().ok()? as f64), + PropertyType::Int32 => num(scope, pv.GetInt32().ok()? as f64), + PropertyType::UInt32 => num(scope, pv.GetUInt32().ok()? as f64), + PropertyType::Int64 => num(scope, pv.GetInt64().ok()? as f64), + PropertyType::UInt64 => num(scope, pv.GetUInt64().ok()? as f64), + PropertyType::Single => num(scope, pv.GetSingle().ok()? as f64), + PropertyType::Double => num(scope, pv.GetDouble().ok()?), + PropertyType::Boolean => Some(v8::Boolean::new(scope, pv.GetBoolean().ok()?).into()), + PropertyType::String => { + let s = pv.GetString().ok()?; + v8::String::new_from_two_byte(scope, &s, v8::NewStringType::Normal).map(Into::into) + } + PropertyType::Guid => { + let g = pv.GetGuid().ok()?; + v8::String::new(scope, &format!("{g:?}")).map(Into::into) + } + _ => None, + } +} + /// 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/timers.rs b/runtime/src/timers.rs index 6937c88..9f5b44d 100644 --- a/runtime/src/timers.rs +++ b/runtime/src/timers.rs @@ -255,6 +255,11 @@ fn invoke_callback_by_id(id: i32) { } } +/// Whether this thread's runtime has timers scheduled. +pub(crate) fn has_pending() -> bool { + TASKS.with(|t| !t.borrow().is_empty()) +} + pub fn pump() { crate::websocket::pump(); // If the scheduler hasn't been initialized yet, nothing to do. diff --git a/runtime/src/ui_dispatcher.rs b/runtime/src/ui_dispatcher.rs index 4b7015b..7949dc2 100644 --- a/runtime/src/ui_dispatcher.rs +++ b/runtime/src/ui_dispatcher.rs @@ -122,6 +122,79 @@ pub fn is_initialized() -> bool { UI_QUEUE.get().is_some() } +/// Runs `f` on the UI (JS) thread and waits for it to finish, returning its result; runs it inline +/// when already on the UI thread. `None` when there is no UI dispatcher or the work item could not +/// be queued (the queue is shutting down). +/// +/// This is how a native callback that fires on a background thread reaches JavaScript: the iOS and +/// Android runtimes take the isolate's lock on the calling thread, which blocks it until the JS +/// thread is free; here the JS thread is the UI thread, so the call is handed to it instead, with +/// the same blocking semantics — the caller's arguments stay valid and the callback's result (or +/// failure) flows back to native code. As with the lock, a UI thread that is itself blocked waiting +/// on the calling thread deadlocks. +pub fn run_on_ui_thread_sync(f: impl FnOnce() -> R + Send) -> Option { + if is_ui_thread() { + return Some(f()); + } + let dq = UI_QUEUE.get()?; + run_job_sync(f, |job| { + let cell = std::sync::Mutex::new(Some(job)); + let handler = DispatcherQueueHandler::new(move || { + if let Some(job) = cell.lock().unwrap_or_else(|e| e.into_inner()).take() { + job(); + } + Ok(()) + }); + dq.TryEnqueue(&handler).unwrap_or(false) + }) +} + +/// Hands `f` to another thread through `enqueue` and waits for its result. `None` when `enqueue` +/// reports failure or the job is dropped without running (its thread is shutting down). +pub(crate) fn run_job_sync( + f: impl FnOnce() -> R + Send, + enqueue: impl FnOnce(Box) -> bool, +) -> Option { + let slot: std::sync::Arc<(std::sync::Mutex>, std::sync::Condvar)> = + std::sync::Arc::new((std::sync::Mutex::new(None), std::sync::Condvar::new())); + let slot2 = slot.clone(); + let job: Box = Box::new(move || { + let r = f(); + let (lock, cvar) = &*slot2; + *lock.lock().unwrap_or_else(|e| e.into_inner()) = Some(r); + cvar.notify_all(); + }); + // SAFETY: the job may borrow from the caller's stack (`f` is not 'static). Once queued, this + // function waits until the job has run or has been dropped unrun; if queueing fails, the job + // is dropped before returning. Either way it never outlives what it borrows. + let job: Box = unsafe { std::mem::transmute(job) }; + if !enqueue(job) { + return None; + } + + let (lock, cvar) = &*slot; + let mut guard = lock.lock().unwrap_or_else(|e| e.into_inner()); + loop { + if let Some(r) = guard.take() { + return Some(r); + } + // The only way out without a result is the job being dropped unrun (shutdown): then + // nothing else holds the job's half of `slot`. + if std::sync::Arc::strong_count(&slot) == 1 { + return None; + } + guard = cvar + .wait_timeout(guard, std::time::Duration::from_millis(100)) + .unwrap_or_else(|e| e.into_inner()) + .0; + } +} + +/// Whether `id` is the recorded UI thread. +pub fn is_ui_thread_id(id: ThreadId) -> bool { + UI_THREAD_ID.get().copied() == Some(id) +} + /// Returns true if the current thread is the recorded UI thread. pub fn is_ui_thread() -> bool { UI_THREAD_ID.get().copied() == Some(std::thread::current().id()) diff --git a/runtime/src/worker_support.rs b/runtime/src/worker_support.rs index 0c144c1..3211345 100644 --- a/runtime/src/worker_support.rs +++ b/runtime/src/worker_support.rs @@ -75,6 +75,8 @@ pub fn install_worker_runtime(scope: &mut ContextScope) { executor(workerGlobal, workerGlobal, workerGlobal.postMessage, MessagePort, MessageChannel, filename); } + var __threadedWorkers = {}; + function Worker(specifierOrSource, options) { if (!(this instanceof Worker)) { throw new TypeError('Class constructor Worker cannot be invoked without new'); @@ -100,6 +102,10 @@ pub fn install_worker_runtime(scope: &mut ContextScope) { resolved.filename, String(globalThis.__nsAppRoot || '') ); + if (workerId >= 0) { + // The runtime calls this whenever the worker has posted messages. + __threadedWorkers[workerId] = function () { dispatchThreadedMessages(); }; + } } function dispatchThreadedMessages(messages) { @@ -138,6 +144,7 @@ pub fn install_worker_runtime(scope: &mut ContextScope) { if (value && typeof value === 'object' && value.__workerExit) { terminated = true; + delete __threadedWorkers[workerId]; delivered += 1; continue; } @@ -178,6 +185,7 @@ pub fn install_worker_runtime(scope: &mut ContextScope) { this.terminate = function () { if (terminated) { return; } terminated = true; + delete __threadedWorkers[workerId]; if (canUseThreadedHost && workerId >= 0) { try { globalThis.__nsWorkerTerminate(workerId); } catch (_) {} @@ -233,6 +241,10 @@ pub fn install_worker_runtime(scope: &mut ContextScope) { } globalThis.Worker = Worker; + globalThis.__nsWorkerDeliver = function (workerId) { + var deliver = __threadedWorkers[workerId]; + if (deliver) { deliver(); } + }; })(); "#; diff --git a/runtime/src/worker_threads.rs b/runtime/src/worker_threads.rs index b654304..0bf2bcf 100644 --- a/runtime/src/worker_threads.rs +++ b/runtime/src/worker_threads.rs @@ -1,18 +1,79 @@ use crate::Runtime; use parking_lot::{Mutex, RwLock}; use std::collections::HashMap; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; use std::sync::{Arc, OnceLock}; use std::thread; use std::time::Duration; +use windows::Win32::Foundation::HANDLE; +use windows::Win32::System::Threading::{CreateEventW, SetEvent, INFINITE}; +use windows::Win32::UI::WindowsAndMessaging::{MsgWaitForMultipleObjectsEx, MWMO_INPUTAVAILABLE, QS_ALLINPUT}; -#[derive(Debug)] enum WorkerCommand { PostMessage(Vec), + /// Work handed to the worker's thread from another thread: a callback created in the worker + /// (see [`run_on_worker_sync`]) or the release of one. + Run(Box), Terminate, } +/// An auto-reset event that wakes a worker's loop; the loop also wakes for Windows messages, which +/// a worker (an STA thread) needs pumped for WinRT to deliver marshaled calls and completions. +#[derive(Clone, Copy)] +struct WakeEvent(isize); + +impl WakeEvent { + fn new() -> Self { + let handle = unsafe { CreateEventW(None, false, false, None) }.unwrap_or_default(); + WakeEvent(handle.0 as isize) + } + + fn handle(self) -> HANDLE { + HANDLE(self.0 as *mut std::ffi::c_void) + } + + fn signal(self) { + let _ = unsafe { SetEvent(self.handle()) }; + } +} + +/// The command channel of each worker's runtime, keyed by its isolate, so a callback created in a +/// worker can be handed to that worker's thread. +static WORKER_EXECUTORS: OnceLock, WakeEvent)>>> = OnceLock::new(); + +fn worker_executors() -> &'static Mutex, WakeEvent)>> { + WORKER_EXECUTORS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// Whether `isolate` is a worker's (as opposed to the UI thread's). +pub(crate) fn is_worker_isolate(isolate: usize) -> bool { + worker_executors().lock().contains_key(&isolate) +} + +/// Runs `f` on the thread of the worker that owns `isolate` and waits for its result, the way +/// [`crate::ui_dispatcher::run_on_ui_thread_sync`] does for the UI thread. `None` when the worker +/// is gone. +pub(crate) fn run_on_worker_sync(isolate: usize, f: impl FnOnce() -> R + Send) -> Option { + let (tx, wake) = worker_executors().lock().get(&isolate).cloned()?; + crate::ui_dispatcher::run_job_sync(f, |job| { + let sent = tx.send(WorkerCommand::Run(job)).is_ok(); + wake.signal(); + sent + }) +} + +/// Queues `f` on the thread of the worker that owns `isolate` without waiting. `false` when the +/// worker is gone. +pub(crate) fn post_to_worker(isolate: usize, f: impl FnOnce() + Send + 'static) -> bool { + let Some((tx, wake)) = worker_executors().lock().get(&isolate).cloned() else { + return false; + }; + let sent = tx.send(WorkerCommand::Run(Box::new(f))).is_ok(); + wake.signal(); + sent +} + #[derive(Debug)] enum WorkerEvent { Message(Vec), @@ -20,9 +81,9 @@ enum WorkerEvent { Exited, } -#[derive(Debug)] struct WorkerHandle { tx: Sender, + wake: WakeEvent, /// Wrapped in Arc so the WORKERS registry lock can be released before /// a blocking recv — otherwise poll_events_blocking would hold the global /// registry lock for the entire timeout duration, starving create/terminate. @@ -119,54 +180,84 @@ pub fn create_worker(app_root: String, source: String, filename: String) -> Resu let (cmd_tx, cmd_rx) = mpsc::channel::(); let (evt_tx, evt_rx) = mpsc::channel::(); let evt_rx = Arc::new(Mutex::new(evt_rx)); + let wake = WakeEvent::new(); + let executor_tx = cmd_tx.clone(); + // Events reach the creating thread's `Worker` without it having to poll: each batch queues a + // delivery there (one at a time). + let creator = crate::DELEGATE_ISOLATE_PTR.with(|c| c.get()) as usize; + let delivery_pending = Arc::new(AtomicBool::new(false)); + let notify_creator = move || { + if delivery_pending.swap(true, Ordering::AcqRel) { + return; + } + let pending = delivery_pending.clone(); + let queued = crate::post_to_js_thread(creator as *mut v8::Isolate, move || { + pending.store(false, Ordering::Release); + crate::global_fns::deliver_worker_events(worker_id); + }); + if !queued { + delivery_pending.store(false, Ordering::Release); + } + }; let join = thread::Builder::new() .name(format!("ns-worker-{worker_id}")) .spawn(move || { let mut runtime = Runtime::new(app_root.as_str()); + // `runtime` stays put on this thread's stack from here on. + runtime.register_delegate_isolate_ptr(); + let isolate = crate::DELEGATE_ISOLATE_PTR.with(|c| c.get()) as usize; + worker_executors().lock().insert(isolate, (executor_tx, wake)); match worker_bootstrap_script(source.as_str(), filename.as_str()) { Ok(script) => runtime.run_script(script.as_str(), filename.as_str()), Err(err) => { + worker_executors().lock().remove(&isolate); let _ = evt_tx.send(WorkerEvent::Error(err)); let _ = evt_tx.send(WorkerEvent::Exited); + notify_creator(); return; } } - for result in runtime.drain_outbox_bytes() { - match result { - Ok(bytes) => { - let _ = evt_tx.send(WorkerEvent::Message(bytes)); - } - Err(err) => { - let _ = evt_tx.send(WorkerEvent::Error(err)); - } + let forward_outbox = |runtime: &mut Runtime| { + let mut sent = false; + for result in runtime.drain_outbox_bytes() { + sent = true; + let _ = evt_tx.send(match result { + Ok(b) => WorkerEvent::Message(b), + Err(e) => WorkerEvent::Error(e), + }); } - } - - loop { - match cmd_rx.recv() { - Ok(WorkerCommand::PostMessage(bytes)) => { - runtime.dispatch_to_worker(&bytes); - for result in runtime.drain_outbox_bytes() { - match result { - Ok(b) => { - let _ = evt_tx.send(WorkerEvent::Message(b)); - } - Err(e) => { - let _ = evt_tx.send(WorkerEvent::Error(e)); - } - } - } - } - Ok(WorkerCommand::Terminate) | Err(_) => { - break; + if sent { + notify_creator(); + } + }; + forward_outbox(&mut runtime); + // Commands, Windows messages (WinRT calls marshaled to this STA thread, async + // completions) and the worker's timers, until terminated. + 'run: loop { + loop { + match cmd_rx.try_recv() { + Ok(WorkerCommand::PostMessage(bytes)) => runtime.dispatch_to_worker(&bytes), + Ok(WorkerCommand::Run(job)) => job(), + Ok(WorkerCommand::Terminate) | Err(TryRecvError::Disconnected) => break 'run, + Err(TryRecvError::Empty) => break, } + forward_outbox(&mut runtime); + } + crate::pump_messages(); + crate::timers::pump(); + forward_outbox(&mut runtime); + let timeout = if crate::timers::has_pending() { 10 } else { INFINITE }; + unsafe { + MsgWaitForMultipleObjectsEx(Some(&[wake.handle()]), timeout, QS_ALLINPUT, MWMO_INPUTAVAILABLE); } } + worker_executors().lock().remove(&isolate); let _ = evt_tx.send(WorkerEvent::Exited); + notify_creator(); }) .map_err(|e| format!("Failed to spawn worker thread: {e}"))?; @@ -174,6 +265,7 @@ pub fn create_worker(app_root: String, source: String, filename: String) -> Resu worker_id, WorkerHandle { tx: cmd_tx, + wake, rx: evt_rx, join, }, @@ -188,10 +280,12 @@ pub fn post_message(worker_id: u64, payload_bytes: Vec) -> Result<(), String return Err(format!("Unknown worker id: {worker_id}")); }; - worker + let sent = worker .tx .send(WorkerCommand::PostMessage(payload_bytes)) - .map_err(|e| format!("Failed to send worker message: {e}")) + .map_err(|e| format!("Failed to send worker message: {e}")); + worker.wake.signal(); + sent } fn collect_events(rx: &Receiver) -> Vec { @@ -266,6 +360,7 @@ pub fn terminate_worker(worker_id: u64) -> Result<(), String> { drop(workers_guard); let _ = worker.tx.send(WorkerCommand::Terminate); + worker.wake.signal(); let _ = worker.join.join(); Ok(()) diff --git a/template/build.ps1 b/template/build.ps1 index 5900012..b56dd47 100644 --- a/template/build.ps1 +++ b/template/build.ps1 @@ -328,40 +328,6 @@ if (Test-Path $nsbundlePackX64Path) { Write-Host " copied nsbundle_pack-x64.exe -> nsbundle_pack.exe" } -# sbg prebuilt binaries -Write-Host "`n=== Build sbg prebuilt binaries ===" -ForegroundColor Cyan - -foreach ($t in $Targets) { - $arch = $t.Arch - $rustTarget = $t.RustTarget - Write-Host "Building sbg for $arch ($rustTarget)..." - Push-Location $RepoRoot - try { - & cargo build -p sbg --release --target $rustTarget - $buildExit = $LASTEXITCODE - } finally { - Pop-Location - } - if ($buildExit -ne 0) { - Write-Host "cargo build for sbg failed for target $rustTarget (exit $buildExit). Skipping copy for $arch." -ForegroundColor Yellow - continue - } - $candidate = Join-Path $RepoRoot "target\$rustTarget\release\sbg.exe" - if (Test-Path $candidate) { - $dest = Join-Path $ToolsDir "sbg-$arch.exe" - Copy-Item -Force $candidate $dest - Write-Host " copied sbg -> $(Resolve-Path $dest -Relative)" - } else { - Write-Host "Expected build output not found: $candidate" -ForegroundColor Yellow - } -} - -$x64Path = Join-Path $ToolsDir "sbg-x64.exe" -if (Test-Path $x64Path) { - Copy-Item -Force $x64Path (Join-Path $ToolsDir "sbg.exe") - Write-Host " copied sbg-x64.exe -> sbg.exe" -} - # typings-generator: shipped so `ns typings windows` can generate WinRT/.NET typings. Write-Host "`n=== Build typings-generator prebuilt binaries ===" -ForegroundColor Cyan diff --git a/template/framework/__PROJECT_NAME__/App.xaml.cs b/template/framework/__PROJECT_NAME__/App.xaml.cs index 6ed7b9e..a246c51 100644 --- a/template/framework/__PROJECT_NAME__/App.xaml.cs +++ b/template/framework/__PROJECT_NAME__/App.xaml.cs @@ -57,7 +57,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs e) try { var panicLogPath = System.IO.Path.Combine( - ApplicationData.Current.LocalFolder.Path, "nativescript-panic.log"); + RuntimeHost.LocalFolderPath, "nativescript-panic.log"); if (System.IO.File.Exists(panicLogPath)) { var content = System.IO.File.ReadAllText(panicLogPath); @@ -121,7 +121,8 @@ private void OnWindowClosed(object sender, WindowEventArgs e) { StopPump(); _runtimeHost.NotifyAppEvent((int)AppEventKind.Exit, null); - ApplicationData.Current.LocalSettings.Values[LastLaunchArgsKey] = string.Empty; + try { ApplicationData.Current.LocalSettings.Values[LastLaunchArgsKey] = string.Empty; } + catch (InvalidOperationException) { } // unpackaged: no ApplicationData _runtimeHost.Dispose(); } diff --git a/template/framework/__PROJECT_NAME__/CrashDiagnostics.cs b/template/framework/__PROJECT_NAME__/CrashDiagnostics.cs index 8917bf5..b843e17 100644 --- a/template/framework/__PROJECT_NAME__/CrashDiagnostics.cs +++ b/template/framework/__PROJECT_NAME__/CrashDiagnostics.cs @@ -101,7 +101,7 @@ public static string BuildErrorReport(Exception ex, string jsError = null, strin // Include panic log from previous or current run try { - var panicLog = Path.Combine(ApplicationData.Current.LocalFolder.Path, "nativescript-panic.log"); + var panicLog = Path.Combine(RuntimeHost.LocalFolderPath, "nativescript-panic.log"); if (File.Exists(panicLog)) { var content = File.ReadAllText(panicLog, Encoding.UTF8).Trim(); @@ -130,7 +130,7 @@ public static string BuildErrorReport(Exception ex, string jsError = null, strin public static string CrashLogPath() { - try { return Path.Combine(ApplicationData.Current.LocalFolder.Path, "nativescript-crash.log"); } + try { return Path.Combine(RuntimeHost.LocalFolderPath, "nativescript-crash.log"); } catch { return null; } } @@ -272,7 +272,7 @@ private static void AppendToLog(string content) { try { - var logPath = Path.Combine(ApplicationData.Current.LocalFolder.Path, "nativescript-crash.log"); + var logPath = Path.Combine(RuntimeHost.LocalFolderPath, "nativescript-crash.log"); File.AppendAllText(logPath, content, Encoding.UTF8); } catch { } diff --git a/template/framework/__PROJECT_NAME__/RuntimeHost.cs b/template/framework/__PROJECT_NAME__/RuntimeHost.cs index 893b8e7..8165c7c 100644 --- a/template/framework/__PROJECT_NAME__/RuntimeHost.cs +++ b/template/framework/__PROJECT_NAME__/RuntimeHost.cs @@ -175,13 +175,38 @@ private void StartDevtoolsSafely() private long _runtime; private bool _initialized; + private static string _localFolderPath; + + /// The app's local data folder: ApplicationData's LocalFolder for a packaged app, or + /// %LOCALAPPDATA%\ for an unpackaged one (WindowsPackageType=None), which has + /// no package identity and therefore no ApplicationData. + internal static string LocalFolderPath + { + get + { + if (_localFolderPath != null) return _localFolderPath; + try + { + _localFolderPath = Windows.Storage.ApplicationData.Current.LocalFolder.Path; + } + catch (InvalidOperationException) + { + var name = Assembly.GetEntryAssembly()?.GetName().Name ?? "NativeScriptApp"; + _localFolderPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), name); + Directory.CreateDirectory(_localFolderPath); + } + return _localFolderPath; + } + } + public void Initialize() { if (_initialized) return; bool attached = false; try { attached = AttachConsole(ATTACH_PARENT_PROCESS); } catch { } runtime_install_ctrlc_handler(0); - runtime_set_local_folder(Windows.Storage.ApplicationData.Current.LocalFolder.Path); + runtime_set_local_folder(LocalFolderPath); _runtime = runtime_init(AppContext.BaseDirectory); #if DEBUG TrySetDebugBuild(); @@ -222,7 +247,7 @@ public void Initialize() #if DEBUG private static string MarkerPath(string name) => - Path.Combine(Windows.Storage.ApplicationData.Current.LocalFolder.Path, name); + Path.Combine(LocalFolderPath, name); private static bool ConsumeMarker(string name) { diff --git a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj index b33c6e5..31b0b51 100644 --- a/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj +++ b/template/framework/__PROJECT_NAME__/__PROJECT_NAME__.csproj @@ -25,19 +25,14 @@ $(MSBuildProjectDirectory)\.. - - $(BaseIntermediateOutputPath)_ns_\gen - $(MSBuildProjectDirectory)\$(NSGenDir) $(NS_WINDOWS_RUNTIME_ROOT) $(MSBuildProjectDirectory)\.. - $(SBG_OUTPUT_DIR)\sbg_metadata.json $(NSWindowsRoot)\tools - $(NSToolsDir)\sbg.exe $(NSToolsDir)\dotnet-tool.exe @@ -96,8 +91,12 @@ PreserveNewest - - + + PreserveNewest @@ -192,47 +191,17 @@ - - - - - + + - - - - - - - - - - - - - - - - - buf) { private readonly ReadOnlySpan _buf = buf; @@ -24,6 +38,8 @@ internal ref struct BinReader(ReadOnlySpan buf) public byte ReadByte() => _buf[_pos++]; + public bool HasMore => _pos < _buf.Length; + public int ReadI32() { var v = BinaryPrimitives.ReadInt32LittleEndian(_buf[_pos..]); @@ -96,6 +112,8 @@ public uint ReadU32() 0x05 => (object)ReadString16(), 0x06 => (object)new HandleRef(ReadI32()), 0x0A => (object)new WinRtRef(ReadI64()), + 0x0C => (object)new JsFunctionRef(ReadI32()), + 0x0D => (object)new JsJsonValue(ReadString32()), _ => null, }; } diff --git a/template/framework/dotnet-bridge/Bridge.BinaryDispatch.cs b/template/framework/dotnet-bridge/Bridge.BinaryDispatch.cs index 7af021e..fb4847d 100644 --- a/template/framework/dotnet-bridge/Bridge.BinaryDispatch.cs +++ b/template/framework/dotnet-bridge/Bridge.BinaryDispatch.cs @@ -116,7 +116,8 @@ internal static DispatchResult DispatchBin(ref BinReader r) for (int i = 0; i < memberCount; i++) memberNames[i] = r.ReadString16(); var callbackId = r.ReadI32(); - return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames); + var ctorArgs = r.HasMore ? r.ReadArgs() : []; + return CreateJsSubclass(NullIfEmpty(assembly), typeName, callbackId, interfaceNames, memberNames, ctorArgs); } if (op == 0x0B) // get CLR-only property by raw IInspectable ptr (CLR reflection fallback) @@ -176,7 +177,35 @@ private static DispatchResult DispatchCallBin( } } + // Overloads with the same parameter count (Describe(Animal) / Describe(Shape)): pick the + // first whose parameter types the arguments convert to. + var overloads = GetOverloads(type, method, args.Length, flags); + if (overloads.Length > 1) + { + foreach (var overload in overloads) + { + var ps = overload.GetParameters(); + object?[] built; + try { built = BuildArgsBinExact(args, ps); } + catch { continue; } + if (!ArgsFit(built, ps)) continue; + return Box(overload.Invoke(target, built)); + } + } + var entry = GetCachedMethod(type, method, args.Length, flags); + if (entry.Invoke is null && target is not null && IsJsBacked(target)) + { + // A JS subclass may call the protected members it inherits. + var protectedFlags = BindingFlags.Instance | BindingFlags.NonPublic; + var inherited = type.GetMethods(protectedFlags) + .FirstOrDefault(m => m.Name == method && (m.IsFamily || m.IsFamilyOrAssembly) && m.GetParameters().Length == args.Length); + if (inherited is not null) + { + var built = BuildArgsBinExact(args, inherited.GetParameters()); + return Box(inherited.Invoke(target, built)); + } + } if (entry.Invoke is null) { var candidates = type.GetMethods(flags).Where(m => m.Name == method && !m.IsSpecialName); @@ -186,7 +215,7 @@ private static DispatchResult DispatchCallBin( var built = BuildArgsBin(args, parameters); try { - var res = AwaitIfTask(m.Invoke(target, built)); + var res = (m.Invoke(target, built)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) @@ -194,7 +223,7 @@ private static DispatchResult DispatchCallBin( if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -204,7 +233,7 @@ private static DispatchResult DispatchCallBin( if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -219,25 +248,33 @@ private static DispatchResult DispatchCallBin( try { try { - var res = AwaitIfTask(entry.Invoke(target, builtArgs)); + var res = (entry.Invoke(target, builtArgs)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); return Box(res); } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(entry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (entry.Invoke(target, builtArgs))); return Box(res); } } finally { if (builtArgs.Length > 0) ReturnArgs(builtArgs); } } + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_overloadCache = new(); + + private static MethodInfo[] GetOverloads(Type type, string name, int argCount, BindingFlags flags) + => s_overloadCache.GetOrAdd(new MethodKey(type, name, argCount, flags), static k => + k.Type.GetMethods(k.Flags) + .Where(m => m.Name == k.Name && !m.IsGenericMethodDefinition && m.GetParameters().Length == k.ArgCount) + .ToArray()); + private static object?[] BuildArgsBin(object?[] binArgs, ParameterInfo[] parameters) { if (parameters.Length == 0) return []; @@ -256,6 +293,16 @@ private static DispatchResult DispatchCallBin( return result; } + // Plain JS objects <-> .NET structs/records: public fields count (Windows.Foundation.Size and most + // interop structs are fields), member names match case-insensitively. + internal static readonly System.Text.Json.JsonSerializerOptions s_jsJsonOptions = new() + { + IncludeFields = true, + PropertyNameCaseInsensitive = true, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString + | System.Text.Json.Serialization.JsonNumberHandling.AllowNamedFloatingPointLiterals, + }; + private static object? CoerceBin(object? value, Type targetType) { if (value is null) return null; @@ -319,8 +366,28 @@ private static DispatchResult DispatchCallBin( throw; } } + if (value is JsJsonValue json) + { + if (targetType == typeof(string)) return json.Json; + var target = targetType == typeof(object) ? typeof(System.Text.Json.JsonElement) : targetType; + return System.Text.Json.JsonSerializer.Deserialize(json.Json, target, s_jsJsonOptions); + } + if (value is JsFunctionRef fn) + { + var delegateType = typeof(Delegate).IsAssignableFrom(targetType) + && targetType != typeof(Delegate) && targetType != typeof(MulticastDelegate) + ? targetType + : typeof(Action); + return MakeJsDelegate(delegateType, fn.Id); + } if (value.GetType() == targetType) return value; - try { return Convert.ChangeType(value, targetType); } + var underlying = Nullable.GetUnderlyingType(targetType) ?? targetType; + if (underlying.IsEnum) + { + try { return Enum.ToObject(underlying, Convert.ToInt64(value)); } + catch { return value; } + } + try { return Convert.ChangeType(value, underlying); } catch { return value; } } diff --git a/template/framework/dotnet-bridge/Bridge.Dispatch.cs b/template/framework/dotnet-bridge/Bridge.Dispatch.cs index f3eef5f..bdca5ad 100644 --- a/template/framework/dotnet-bridge/Bridge.Dispatch.cs +++ b/template/framework/dotnet-bridge/Bridge.Dispatch.cs @@ -115,7 +115,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) var built = BuildArgs(args, parameters); try { - var res = AwaitIfTask(m.Invoke(target, built)); + var res = (m.Invoke(target, built)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) @@ -123,7 +123,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -133,7 +133,7 @@ internal static DispatchResult Dispatch(InvokeRequest req) if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{m.Name} on UI thread"); try { - var res = InvokeOnUIThread(() => AwaitIfTask(m.Invoke(target, built))); + var res = InvokeOnUIThread(() => (m.Invoke(target, built))); return Box(res); } catch { /* retry failed — try next candidate */ } @@ -148,19 +148,19 @@ internal static DispatchResult Dispatch(InvokeRequest req) try { try { - var res = AwaitIfTask(dispEntry.Invoke(target, builtArgs)); + var res = (dispEntry.Invoke(target, builtArgs)); return Box(res); } catch (TargetInvocationException tie) when (IsMarshaledForDifferentThread(tie.InnerException)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected wrong-thread COM error; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(dispEntry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (dispEntry.Invoke(target, builtArgs))); return Box(res); } catch (System.Runtime.InteropServices.COMException ce) when (IsMarshaledForDifferentThread(ce)) { if (Bridge.IsLogToConsole()) Console.Error.WriteLine($"[Bridge] Detected COMException wrong-thread; retrying {type.FullName}.{method} on UI thread"); - var res = InvokeOnUIThread(() => AwaitIfTask(dispEntry.Invoke(target, builtArgs))); + var res = InvokeOnUIThread(() => (dispEntry.Invoke(target, builtArgs))); return Box(res); } } @@ -263,34 +263,6 @@ private static void ReturnArgs(object?[] args) => return el.Deserialize(targetType, s_coerceOpts); } - private static object? AwaitIfTask(object? value) - { - if (value is null) return null; - - if (value is Task task) - { - task.GetAwaiter().GetResult(); - return TaskResultCache.GetResult(task); - } - - if (value is ValueTask vt) - { - vt.AsTask().GetAwaiter().GetResult(); - return null; - } - - var type = value.GetType(); - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(ValueTask<>)) - { - var innerTask = ValueTaskAsTaskCache.AsTask(type, value); - innerTask.GetAwaiter().GetResult(); - if (innerTask.IsFaulted) throw innerTask.Exception!.InnerException ?? innerTask.Exception; - return TaskResultCache.GetResult(innerTask); - } - - return value; - } - private static DispatchResult Box(object? value) { if (value is null) return DispatchResult.Void; @@ -321,6 +293,11 @@ private static DispatchResult Box(object? value) return DispatchResult.Primitive(Convert.ChangeType(value, ut), ut); } + // An instance of a JS subclass goes back to JS as the handle its JS object was created + // with, so the runtime hands out that same JS object (identity, fields, overrides). + if (TryGetJsBackedHandle(value, out var jsHandle)) + return DispatchResult.Handle(jsHandle, t.FullName ?? t.Name); + var id = Interlocked.Increment(ref s_nextHandle); @@ -363,6 +340,30 @@ private static bool IsAwaitable(object? value, Type t) null, Type.EmptyTypes, null) is not null; } + /// "interface", "abstract", "sealed", "class", "struct", "enum", "delegate" or "" (not found). + /// Lets JS decide between `new Interface({...})`, subclassing and plain construction. + public static string TypeKind(string typeName) + { + var t = ResolveType(null, typeName); + if (t is null) return ""; + if (t.IsInterface) return "interface"; + if (t.IsEnum) return "enum"; + if (typeof(Delegate).IsAssignableFrom(t)) return "delegate"; + if (t.IsValueType) return "struct"; + if (t.IsAbstract && t.IsSealed) return "static"; + if (t.IsAbstract) return "abstract"; + if (t.IsSealed) return "sealed"; + return "class"; + } + + /// `value instanceof SomeType` for a .NET object that was not constructed from JS. + public static bool IsInstanceOf(object? value, string typeName) + { + if (value is null) return false; + var t = ResolveType(null, typeName); + return t is not null && t.IsInstanceOfType(value); + } + internal static DispatchResult BuildMembersResult(Type t) { const BindingFlags inst = BindingFlags.Public | BindingFlags.Instance; diff --git a/template/framework/dotnet-bridge/Bridge.JsDelegate.cs b/template/framework/dotnet-bridge/Bridge.JsDelegate.cs index b238d6c..c0cf68b 100644 --- a/template/framework/dotnet-bridge/Bridge.JsDelegate.cs +++ b/template/framework/dotnet-bridge/Bridge.JsDelegate.cs @@ -12,91 +12,129 @@ namespace NativeScriptBridge; public static partial class Bridge { // opcode 0x09: given a delegate type name (or "" for System.Action) and a - // JS callback id, compile a .NET delegate that serialises its parameters as - // binary and calls back into V8 via the s_jsInvoker function pointer. - + // JS callback id, create a .NET delegate that calls back into V8. private static DispatchResult CreateJsDelegate(string typeName, int callbackId) { var delegateType = string.IsNullOrEmpty(typeName) ? typeof(Action) : ResolveType(null, typeName) ?? throw new TypeLoadException($"Delegate type not found: {typeName}"); + return Box(MakeJsDelegate(delegateType, callbackId)); + } - var invokeMethod = delegateType.GetMethod("Invoke") - ?? throw new MissingMethodException($"No Invoke method on {delegateType}"); + // The target a JS-backed delegate is bound to. When the delegate is collected the finalizer + // tells the runtime, which then drops its reference to the JS function. + internal sealed class JsCallbackTarget(int callbackId) + { + public readonly int CallbackId = callbackId; + + public object? Invoke(object?[] args) => CallJsCallback(CallbackId, args, expectsResult: true); + + public void InvokeVoid(object?[] args) => CallJsCallback(CallbackId, args, expectsResult: false); + + ~JsCallbackTarget() + { + ReleaseJsCallback(CallbackId); + } + } - var parameters = invokeMethod.GetParameters(); - var returnType = invokeMethod.ReturnType; + // Function pointer registered by the runtime (RegisterJsCallbackRelease) to drop a JS function + // whose delegate was collected. Called from the finalizer thread; the runtime queues the id. + internal static unsafe delegate* unmanaged[Cdecl] s_jsCallbackRelease; - // Build a dynamic method that marshals parameters into an object[] and - // calls back into JS via CallJsCallback/CallJsCallbackVoid. Expression - // compilation occasionally generates incorrect code for some delegate - // shapes; emitting IL here is reliable across signatures. - var paramTypes = parameters.Select(p => p.ParameterType).ToArray(); - - var dm = new DynamicMethod($"__ns_js_delegate_{callbackId}", - returnType, paramTypes, typeof(Bridge).Module, skipVisibility: true); + [UnmanagedCallersOnly(EntryPoint = "RegisterJsCallbackRelease", + CallConvs = [typeof(System.Runtime.CompilerServices.CallConvCdecl)])] + public static unsafe int RegisterJsCallbackRelease(delegate* unmanaged[Cdecl] release) + { + s_jsCallbackRelease = release; + return 0; + } + + internal static unsafe void ReleaseJsCallback(int callbackId) + { + var release = s_jsCallbackRelease; + if (release == null) return; + try { release(callbackId); } catch { } + } + + // One invoker per delegate type: (JsCallbackTarget target, ) -> . + // It packs the arguments into object[] and calls target.Invoke/InvokeVoid; a delegate instance is + // that invoker closed over its target, so creating a delegate emits no code. + private static readonly System.Collections.Concurrent.ConcurrentDictionary s_jsDelegateInvokers = new(); + internal static Delegate MakeJsDelegate(Type delegateType, int callbackId) + { + var invoker = s_jsDelegateInvokers.GetOrAdd(delegateType, BuildJsDelegateInvoker); + return invoker.CreateDelegate(delegateType, new JsCallbackTarget(callbackId)); + } + + private static DynamicMethod BuildJsDelegateInvoker(Type delegateType) + { + var invokeMethod = delegateType.GetMethod("Invoke") + ?? throw new MissingMethodException($"No Invoke method on {delegateType}"); + var paramTypes = invokeMethod.GetParameters().Select(p => p.ParameterType).ToArray(); + var returnType = invokeMethod.ReturnType; + + // Emitting IL (rather than compiling an expression tree) is reliable across delegate shapes. + var dm = new DynamicMethod($"__ns_js_delegate_{delegateType.Name}", returnType, + [typeof(JsCallbackTarget), .. paramTypes], typeof(Bridge).Module, skipVisibility: true); var il = dm.GetILGenerator(); - // local: object[] argsArray - var argsLocal = il.DeclareLocal(typeof(object[])); + + il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ldc_I4, paramTypes.Length); il.Emit(OpCodes.Newarr, typeof(object)); - il.Emit(OpCodes.Stloc, argsLocal); - for (int i = 0; i < paramTypes.Length; i++) { - il.Emit(OpCodes.Ldloc, argsLocal); + var paramType = paramTypes[i].IsByRef ? paramTypes[i].GetElementType()! : paramTypes[i]; + il.Emit(OpCodes.Dup); il.Emit(OpCodes.Ldc_I4, i); - // load argument (Ldarg_0..) - switch (i + 1) - { - case 1: il.Emit(OpCodes.Ldarg_0); break; - case 2: il.Emit(OpCodes.Ldarg_1); break; - case 3: il.Emit(OpCodes.Ldarg_2); break; - case 4: il.Emit(OpCodes.Ldarg_3); break; - default: il.Emit(OpCodes.Ldarg, i + 1); break; - } - if (paramTypes[i].IsValueType) il.Emit(OpCodes.Box, paramTypes[i]); + il.Emit(OpCodes.Ldarg, i + 1); + if (paramTypes[i].IsByRef) il.Emit(OpCodes.Ldobj, paramType); + if (paramType.IsValueType) il.Emit(OpCodes.Box, paramType); il.Emit(OpCodes.Stelem_Ref); } - - if (returnType == typeof(void)) { - var callVoid = typeof(Bridge).GetMethod(nameof(CallJsCallbackVoid), BindingFlags.Static | BindingFlags.NonPublic)!; - il.Emit(OpCodes.Ldc_I4, callbackId); - il.Emit(OpCodes.Ldloc, argsLocal); - il.Emit(OpCodes.Call, callVoid); - il.Emit(OpCodes.Ret); + il.Emit(OpCodes.Call, typeof(JsCallbackTarget).GetMethod(nameof(JsCallbackTarget.InvokeVoid))!); } else { - var callObj = typeof(Bridge).GetMethod(nameof(CallJsCallback), BindingFlags.Static | BindingFlags.NonPublic)!; - il.Emit(OpCodes.Ldc_I4, callbackId); - il.Emit(OpCodes.Ldloc, argsLocal); - il.Emit(OpCodes.Call, callObj); - // ignore returned object and return default(returnType) - if (returnType.IsValueType) - { - var loc = il.DeclareLocal(returnType); - il.Emit(OpCodes.Ldloca_S, loc); - il.Emit(OpCodes.Initobj, returnType); - il.Emit(OpCodes.Ldloc, loc); - } - else - { - il.Emit(OpCodes.Ldnull); - } - il.Emit(OpCodes.Ret); + il.Emit(OpCodes.Call, typeof(JsCallbackTarget).GetMethod(nameof(JsCallbackTarget.Invoke))!); + il.Emit(OpCodes.Ldtoken, returnType); + il.Emit(OpCodes.Call, typeof(Type).GetMethod(nameof(Type.GetTypeFromHandle))!); + il.Emit(OpCodes.Call, typeof(Bridge).GetMethod(nameof(ConvertJsResult), BindingFlags.Static | BindingFlags.NonPublic)!); + il.Emit(OpCodes.Unbox_Any, returnType); } + il.Emit(OpCodes.Ret); + return dm; + } - var del = dm.CreateDelegate(delegateType); - return Box(del); + // A JS callback's result, converted to the type the managed caller expects (a delegate's return + // type or an overridden member's). + internal static object? ConvertJsResult(object? value, Type returnType) + { + if (value is null) + return returnType.IsValueType && Nullable.GetUnderlyingType(returnType) is null + ? Activator.CreateInstance(returnType) + : null; + var coerced = CoerceBin(value, returnType); + if (coerced is null || returnType.IsInstanceOfType(coerced)) return coerced; + var underlying = Nullable.GetUnderlyingType(returnType) ?? returnType; + try { return Convert.ChangeType(coerced, underlying); } + catch (Exception e) + { + throw new InvalidCastException( + $"JavaScript returned {coerced.GetType().Name}, which can't be converted to {returnType.FullName}", e); + } } - internal static unsafe object? CallJsCallback(int id, object?[] args) + internal static object? CallJsCallback(int id, object?[] args) => CallJsCallback(id, args, expectsResult: true); + + // Calls the JS function registered under `id`. When it throws, a caller that expects a result + // gets a JsException (a delegate with a return value, an overridden member); a void delegate or + // event just returns, the error having been reported as an uncaught JS error. + internal static unsafe object? CallJsCallback(int id, object?[] args, bool expectsResult) { if (s_jsInvoker == null) return null; @@ -116,16 +154,21 @@ private static DispatchResult CreateJsDelegate(string typeName, int callbackId) // Response is only set for non-void delegates; parse and free when present. if (respPtr != null && respLen > 0) { + string? jsError = null; try { var span = new ReadOnlySpan(respPtr, respLen); - result = ParseCallbackResponse(span); + if (span[0] == 0x0F) + jsError = span.Length > 5 ? new BinReader(span[1..]).ReadString32() : "the JavaScript callback could not run"; + else + result = ParseCallbackResponse(span); } catch { result = null; } finally { Marshal.FreeHGlobal((IntPtr)respPtr); } + if (jsError != null && expectsResult) throw new JsException(jsError); } return result; @@ -176,6 +219,15 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) if (arg is float f) { w.WriteByte(0x04); w.WriteF64((double)f); return; } if (arg is double d){ w.WriteByte(0x04); w.WriteF64(d); return; } if (arg is string s){ w.WriteByte(0x05); w.WriteString32(s); return; } + if (arg is Enum e) { w.WriteByte(0x04); w.WriteF64(Convert.ToDouble(e)); return; } + if (arg is decimal m){ w.WriteByte(0x04); w.WriteF64((double)m); return; } + // A struct (Windows.Foundation.Size, a C# record struct, ...) arrives as a plain JS object. + if (arg.GetType().IsValueType && !arg.GetType().IsPrimitive) + { + string? json = null; + try { json = System.Text.Json.JsonSerializer.Serialize(arg, arg.GetType(), s_jsJsonOptions); } catch { } + if (json != null) { w.WriteByte(0x0D); w.WriteString32(json); return; } + } // Object: box in the handle map and send as a handle reference. // The JS side receives {__handle, __type} which can be turned into a @@ -209,6 +261,7 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) case 0x05: return (object)r.ReadString32(); case 0x06: return (object)new HandleRef(r.ReadI32()); case 0x0A: return (object)new WinRtRef(r.ReadI64()); + case 0x0D: return new JsJsonValue(r.ReadString32()); case 0x07: // array: u32 count + N tagged items { var count = (int)r.ReadU32(); @@ -237,8 +290,11 @@ private static void WriteCallbackArg(ref BinWriter w, object? arg) internal static unsafe void CallJsCallbackVoid(int id, object?[] args) { - CallJsCallback(id, args); + CallJsCallback(id, args, expectsResult: false); } } + +/// Thrown into managed code when a JavaScript callback that was expected to return a value threw. +public sealed class JsException(string message) : Exception(message); diff --git a/template/framework/dotnet-bridge/Bridge.Proxy.cs b/template/framework/dotnet-bridge/Bridge.Proxy.cs index 89e3cef..2c7619f 100644 --- a/template/framework/dotnet-bridge/Bridge.Proxy.cs +++ b/template/framework/dotnet-bridge/Bridge.Proxy.cs @@ -51,9 +51,7 @@ public static class ProxyRuntime public static T InvokeMethodTyped(object instance, string methodName, object[] args) { var result = ProxyInvokeMethod(instance, methodName, args); - if (result is null) return default!; - if (result is T t) return t; - return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); + return (T)ConvertJsResult(result, typeof(T))!; } public static object? InvokeMethod(object instance, string methodName, object[] args) @@ -69,9 +67,7 @@ public static void InvokeVoid(object instance, string methodName, object[] args) public static T GetProperty(object instance, string propertyName) { var result = ProxyGetProperty(instance, propertyName); - if (result is null) return default!; - if (result is T t) return t; - return (T)Convert.ChangeType(result, typeof(T), CultureInfo.InvariantCulture); + return (T)ConvertJsResult(result, typeof(T))!; } public static void SetProperty(object instance, string propertyName, object? value) @@ -208,8 +204,10 @@ private static Type CreateDynamicProxyType(Type baseType, string[] interfaceName // ctor exists on the derived type. If the base type has no parameterless // ctor, emit a parameterless ctor that forwards default(T) values to the // first available base ctor (dev-only convenience). - var baseCtors = baseType.GetConstructors(BindingFlags.Public | BindingFlags.Instance) - .Where(c => !c.IsGenericMethod) + // Public and protected base constructors: a JS subclass can call either through super(...), + // and abstract classes / composable WinRT classes (Panel, Control) only have protected ones. + var baseCtors = baseType.GetConstructors(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) + .Where(c => !c.IsGenericMethod && (c.IsPublic || c.IsFamily || c.IsFamilyOrAssembly)) .ToArray(); ConstructorInfo? chosenBaseForDefault = null; @@ -293,8 +291,12 @@ static string SignatureKey(string name, Type[] paramTypes) => // Override only the base virtuals JS actually asked for (memberSet). Anything not in // that set is simply never touched, so the base implementation stays live in the vtable — // no "call base" IL needed. (Skip ref/out and generic methods — unsupported.) + // Abstract members are always implemented (dispatching to JS, which reports a missing + // implementation as an error) — a type with an unimplemented abstract member can't be created. var virtualMethods = baseType.GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance) - .Where(m => m.IsVirtual && !m.IsFinal && !m.IsGenericMethod && !m.IsConstructor && memberSet.Contains(m.Name)) + .Where(m => m.IsVirtual && !m.IsFinal && !m.IsGenericMethod && !m.IsConstructor + && (m.IsPublic || m.IsFamily || m.IsFamilyOrAssembly) + && (memberSet.Contains(m.Name) || m.IsAbstract)) .ToArray(); foreach (var mi in virtualMethods) @@ -310,6 +312,11 @@ static string SignatureKey(string name, Type[] paramTypes) => MethodAttributes.Public | MethodAttributes.Virtual | MethodAttributes.HideBySig, mi.CallingConvention, mi.ReturnType, paramTypes, mi.Name); definedSignatures[sigKey] = mb; + + // `super.Name(...)` from JS: a public, non-virtual entry point to the base + // implementation (a plain call, so it can't dispatch back into the override). + if (!mi.IsAbstract) + EmitBaseCallMethod(tb, mi, paramTypes); } tb.DefineMethodOverride(mb, mi); @@ -359,6 +366,20 @@ void CollectInterfaces(Type t) return tb.CreateTypeInfo()!.AsType(); } + internal const string BaseCallPrefix = "__ns_base_"; + + private static void EmitBaseCallMethod(TypeBuilder tb, MethodInfo baseMethod, Type[] paramTypes) + { + var mb = tb.DefineMethod(BaseCallPrefix + baseMethod.Name, + MethodAttributes.Public | MethodAttributes.HideBySig, CallingConventions.HasThis, + baseMethod.ReturnType, paramTypes); + var il = mb.GetILGenerator(); + il.Emit(OpCodes.Ldarg_0); + for (int i = 0; i < paramTypes.Length; i++) il.Emit(OpCodes.Ldarg, i + 1); + il.Emit(OpCodes.Call, baseMethod); + il.Emit(OpCodes.Ret); + } + // Shared IL body for both base-virtual overrides and interface-member implementations: box // args into an object[], forward to ProxyRuntime.InvokeVoid/InvokeMethodTyped by name. private static MethodBuilder EmitProxyMethodBody( @@ -406,82 +427,103 @@ private static MethodBuilder EmitProxyMethodBody( private static DispatchResult CreateJsSubclass( string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames) + => CreateJsSubclass(assemblyName, typeName, callbackId, interfaceNames, memberNames, []); + + // Creates an instance of a JS subclass: a dynamic subclass of the base type (or of System.Object + // when the "base" is an interface being implemented) whose overridden virtuals and interface + // members call the JS object registered under `callbackId`. `ctorArgs` select and feed the + // base constructor, as `super(...)` arguments do on Android/iOS. + private static DispatchResult CreateJsSubclass( + string? assemblyName, string typeName, int callbackId, string[] interfaceNames, string[] memberNames, object?[] ctorArgs) { EnsureProxyDispatcherCallbacksRegistered(); - s_pendingProxyCallbackId.Value = callbackId; + // `typeName` is the base type; older runtimes passed an auto-generated proxy name there and + // the base type in `assemblyName`. + var baseType = ResolveType(null, typeName) + ?? (string.IsNullOrEmpty(assemblyName) ? null : ResolveType(null, assemblyName)) + ?? throw new TypeLoadException($"Type not found: {typeName}"); - // Try to find a statically-generated proxy type first (Mechanism A: sbg-compiled into - // the app itself — already has any requested interfaces baked into real C# source). - Type? t = FindGeneratedProxyType(typeName); - if (t is null) + var interfaces = interfaceNames.ToList(); + if (baseType.IsInterface) { - // When typeName is an auto-generated proxy name (com.tns.gen.winrt.*), - // the caller puts the real base type name in assemblyName so we can - // look up or dynamically emit a proxy for the correct base class. - string? resolveFrom = typeName; - if (!string.IsNullOrEmpty(assemblyName) - && typeName.StartsWith("com.tns.gen.winrt.", StringComparison.Ordinal)) - { - resolveFrom = assemblyName; - } + interfaces.Insert(0, baseType.FullName!); + baseType = typeof(object); + } + if (baseType.IsSealed) + throw new InvalidOperationException($"{baseType.FullName} is sealed and can't be extended."); - // Resolve the WinRT/base type and optionally emit a dynamic proxy (Mechanism B fallback). - var baseType = ResolveType(null, resolveFrom) - ?? ResolveType(assemblyName, typeName) - ?? throw new TypeLoadException($"Type not found: {resolveFrom} (proxy: {typeName}, assembly: {assemblyName})"); + var proxyType = GetOrCreateDynamicProxyType(baseType, interfaces.ToArray(), memberNames); - if (baseType.IsClass && !baseType.IsSealed) - { - try { t = GetOrCreateDynamicProxyType(baseType, interfaceNames, memberNames); } - catch (Exception) { t = baseType; } - } - else + // Pick the constructor by argument count, preferring one whose parameters the arguments + // convert to. + var ctors = proxyType.GetConstructors().Where(c => c.GetParameters().Length == ctorArgs.Length).ToArray(); + if (ctors.Length == 0) + throw new MissingMethodException($"{baseType.FullName} has no constructor taking {ctorArgs.Length} argument(s)."); + object? instance = null; + Exception? lastError = null; + s_pendingProxyCallbackId.Value = callbackId; + try + { + foreach (var ctor in ctors) { - t = baseType; + object?[] built; + try { built = BuildArgsBinExact(ctorArgs, ctor.GetParameters()); } + catch (Exception e) { lastError = e; continue; } + if (!ArgsFit(built, ctor.GetParameters())) continue; + instance = ctor.Invoke(built); + break; } + if (instance is null) + throw lastError ?? new MissingMethodException( + $"No constructor of {baseType.FullName} accepts the given {ctorArgs.Length} argument(s)."); + RegisterJsBacked(instance, callbackId); + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + throw tie.InnerException; + } + finally + { + s_pendingProxyCallbackId.Value = null; } - - // Create the managed instance. Generated classes should call - // ProxyDispatcher.InitializeInstance(this, typeName) in their constructor, - // which will invoke our ProxyInitializeInstance and capture the pending callback id. - object? instance = Activator.CreateInstance(t); - if (instance is null) - throw new InvalidOperationException($"Failed to instantiate proxy type {t.FullName}."); - - // Ensure there's a mapping for this instance immediately to avoid any - // early virtual calls from constructors reaching the JS side before - // the handle is registered. Call our initializer directly to register - // the pending callback id when dynamic proxies don't call it. - try { ProxyInitializeInstance(instance, typeName); } catch { } // Force COM CCW vtable creation for any implemented WinRT interfaces now, rather than // relying on it happening implicitly the first time the instance crosses the ABI boundary. - // Best-effort/reflection-only — no-ops cleanly when CsWinRT isn't loaded (e.g. xunit host). - if (interfaceNames.Length > 0) - { - TryActivateWinRTInterfaces(instance); - } + if (interfaces.Count > 0) TryActivateWinRTInterfaces(instance); - // Box the instance into a handle to return to the runtime. var result = Box(instance); - - // Populate holder with handle id now that a handle exists. if (s_proxyCallbacks.TryGetValue(instance, out var holder)) + holder.HandleId = result.HandleId(); + return result; + } + + private static bool ArgsFit(object?[] built, ParameterInfo[] parameters) + { + for (int i = 0; i < parameters.Length; i++) { - try { holder.HandleId = result.HandleId(); } catch { holder.HandleId = 0; } - } - else - { - // If the generated proxy did not call InitializeInstance, create mapping now. - int hid = 0; - try { hid = result.HandleId(); } catch { hid = 0; } - var newHolder = new ProxyCallbackHolder(callbackId) { HandleId = hid }; - s_proxyCallbacks.Add(instance, newHolder); + var p = parameters[i].ParameterType; + if (built[i] is null) { if (p.IsValueType && Nullable.GetUnderlyingType(p) is null) return false; continue; } + if (!p.IsInstanceOfType(built[i])) return false; } + return true; + } - s_pendingProxyCallbackId.Value = null; - return result; + private static void RegisterJsBacked(object instance, int callbackId) + { + if (!s_proxyCallbacks.TryGetValue(instance, out _)) + s_proxyCallbacks.Add(instance, new ProxyCallbackHolder(callbackId)); + } + + internal static bool IsJsBacked(object instance) => s_proxyCallbacks.TryGetValue(instance, out _); + + private static bool TryGetJsBackedHandle(object instance, out int handle) + { + handle = 0; + if (!s_proxyCallbacks.TryGetValue(instance, out var holder) || holder.HandleId == 0) return false; + if (!s_handles.TryGetValue(holder.HandleId, out var current) || !ReferenceEquals(current, instance)) return false; + handle = holder.HandleId; + return true; } // Locates CsWinRT's ComWrappersSupport via reflection (same style as the WinRT.IWinRTObject @@ -531,42 +573,56 @@ private static void TryActivateWinRTInterfaces(object instance) private static void ProxyInitializeInstance(object instance, string typeName) { var cb = s_pendingProxyCallbackId.Value; - if (cb.HasValue) + if (cb.HasValue) RegisterJsBacked(instance, cb.Value); + } + + // The holder for a JS-backed instance. A virtual the base constructor calls runs before + // CreateJsSubclass has registered the instance, so fall back to the pending callback id. + private static ProxyCallbackHolder JsHolderFor(object instance) + { + if (s_proxyCallbacks.TryGetValue(instance, out var holder)) return holder; + var pending = s_pendingProxyCallbackId.Value; + if (pending.HasValue) { - var holder = new ProxyCallbackHolder(cb.Value); - s_proxyCallbacks.Add(instance, holder); + RegisterJsBacked(instance, pending.Value); + if (s_proxyCallbacks.TryGetValue(instance, out holder)) return holder; } + throw new InvalidOperationException("No JS callback registered for proxy instance."); + } + + // Payload for the JS dispatcher: (self handle, member name, ...args). The arguments are sent + // individually so each arrives as its JS value (numbers, strings, structs as plain objects, + // objects as handles). + private static object?[] DispatchPayload(ProxyCallbackHolder holder, string memberName, object?[] args) + { + var payload = new object?[args.Length + 2]; + payload[0] = new HandleRef(holder.HandleId); + payload[1] = memberName; + Array.Copy(args, 0, payload, 2, args.Length); + return payload; } private static object? ProxyInvokeMethod(object instance, string methodName, object[] args) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - var payload = new object?[] { new HandleRef(holder.HandleId), methodName, args }; - return CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + return CallJsCallback(holder.CallbackId, DispatchPayload(holder, methodName, args), expectsResult: true); } private static void ProxyInvokeVoid(object instance, string methodName, object[] args) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - - var payload = new object?[] { new HandleRef(holder.HandleId), methodName, args }; - CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + CallJsCallback(holder.CallbackId, DispatchPayload(holder, methodName, args), expectsResult: false); } private static object? ProxyGetProperty(object instance, string propertyName) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - // Tag "get:" prefix so the JS dispatcher can distinguish getter from method call. - var payload = new object?[] { new HandleRef(holder.HandleId), "get:" + propertyName }; - return CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + return CallJsCallback(holder.CallbackId, DispatchPayload(holder, "get_" + propertyName, []), expectsResult: true); } private static void ProxySetProperty(object instance, string propertyName, object? value) { - if (!s_proxyCallbacks.TryGetValue(instance, out var holder)) throw new InvalidOperationException("No JS callback registered for proxy instance."); - // Tag "set:" prefix so the JS dispatcher can distinguish setter from method call. - var payload = new object?[] { new HandleRef(holder.HandleId), "set:" + propertyName, value }; - CallJsCallback(holder.CallbackId, payload); + var holder = JsHolderFor(instance); + CallJsCallback(holder.CallbackId, DispatchPayload(holder, "set_" + propertyName, [value]), expectsResult: false); } } - diff --git a/template/framework/dotnet-bridge/Bridge.TaskHelper.cs b/template/framework/dotnet-bridge/Bridge.TaskHelper.cs index 1361e89..5671e5b 100644 --- a/template/framework/dotnet-bridge/Bridge.TaskHelper.cs +++ b/template/framework/dotnet-bridge/Bridge.TaskHelper.cs @@ -22,9 +22,10 @@ internal static void ScheduleTaskContinuation(int handleId, int resolveId, int r $"Object of type {obj.GetType().FullName} is not awaitable. " + $"Expected Task, ValueTask, IAsyncOperation, or any type implementing GetAwaiter()."); + // The handle stays valid after completion (the JS proxy releases it when collected), so + // the same Task can be awaited again. task.ContinueWith(completed => { - s_handles.TryRemove(handleId, out _); try { if (completed.IsFaulted) diff --git a/template/framework/dotnet-bridge/Bridge.cs b/template/framework/dotnet-bridge/Bridge.cs index 557c91a..2da4720 100644 --- a/template/framework/dotnet-bridge/Bridge.cs +++ b/template/framework/dotnet-bridge/Bridge.cs @@ -123,8 +123,12 @@ public static string GetNamespaceAssemblyMapJson() catch { continue; } } + // The app's own assembly (C# sources compiled into the app) contributes + // its internal types too: they are reachable from JS (type resolution + // finds non-public types), so their namespaces must be as well. Type[] types; - try { types = asm.GetExportedTypes(); } + try { types = asm == Assembly.GetEntryAssembly() ? asm.GetTypes() : asm.GetExportedTypes(); } + catch (ReflectionTypeLoadException e) { types = e.Types.Where(t => t != null).ToArray()!; } catch { types = Array.Empty(); } var assemblyName = asm.GetName().Name; @@ -132,7 +136,7 @@ public static string GetNamespaceAssemblyMapJson() foreach (var t in types) { - if (string.IsNullOrEmpty(t.Namespace)) continue; + if (string.IsNullOrEmpty(t.Namespace) || t.Name.StartsWith('<')) continue; map[t.Namespace] = assemblyName; var root = t.Namespace.Split('.')[0]; if (!map.ContainsKey(root)) map[root] = assemblyName; diff --git a/template/framework/tools/sbg-arm64.exe b/template/framework/tools/sbg-arm64.exe deleted file mode 100644 index 27152b1..0000000 Binary files a/template/framework/tools/sbg-arm64.exe and /dev/null differ diff --git a/template/framework/tools/sbg-x64.exe b/template/framework/tools/sbg-x64.exe deleted file mode 100644 index eb40eb4..0000000 Binary files a/template/framework/tools/sbg-x64.exe and /dev/null differ diff --git a/template/framework/tools/sbg.exe b/template/framework/tools/sbg.exe deleted file mode 100644 index eb40eb4..0000000 Binary files a/template/framework/tools/sbg.exe and /dev/null differ diff --git a/test-app/App_Resources/Windows/app.csproj b/test-app/App_Resources/Windows/app.csproj new file mode 100644 index 0000000..e09a8a0 --- /dev/null +++ b/test-app/App_Resources/Windows/app.csproj @@ -0,0 +1,11 @@ + + + + + + + NativeScript Windows TestRunner + + diff --git a/test-app/App_Resources/Windows/src/TestFixtures/Basics.cs b/test-app/App_Resources/Windows/src/TestFixtures/Basics.cs new file mode 100644 index 0000000..ff55b9a --- /dev/null +++ b/test-app/App_Resources/Windows/src/TestFixtures/Basics.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; + +// C# sources placed under App_Resources/Windows are compiled into the app, the way Java/Kotlin +// under App_Resources/Android/src and Objective-C/Swift under App_Resources/iOS/src are on the +// other platforms. JavaScript reaches them by their namespace, with no registration step. +namespace TestFixtures.Basics +{ + public enum Mood + { + Happy, + Sad, + Sleepy = 10, + } + + public class Greeter + { + public static int Instances; + + public Greeter() : this("World") { } + + public Greeter(string name) + { + Name = name; + Instances++; + } + + public string Name { get; set; } + + public Mood Mood { get; set; } = Mood.Happy; + + public string Greet() => $"Hello, {Name}!"; + + public string Greet(string other) => $"Hello, {other}, from {Name}!"; + + public static int Add(int a, int b) => a + b; + + public static double Scale(double value, double factor) => value * factor; + + public static string Version => "1.0"; + + public int[] Numbers() => new[] { 1, 2, 3 }; + + public List Names() => new List { "a", "b" }; + + public event EventHandler Greeted; + + public void RaiseGreeted(string message) => Greeted?.Invoke(this, message); + } + + public static class Callbacks + { + public static int Apply(Func f, int value) => f(value); + + public static string Join(Func f, string a, string b) => f(a, b); + + public static int Repeat(Action action, int times) + { + for (var i = 0; i < times; i++) action(i); + return times; + } + + public static async Task AddLaterAsync(int a, int b, int delayMs) + { + await Task.Delay(delayMs).ConfigureAwait(false); + return a + b; + } + + public static async Task FailLaterAsync(string message, int delayMs) + { + await Task.Delay(delayMs).ConfigureAwait(false); + throw new InvalidOperationException(message); + } + } + + // Internal types are reachable too (only public members are bound), as with Java + // package-private classes from JS on Android. + internal static class InternalHelper + { + public static string Ping() => "pong"; + } +} + +namespace TestFixtures.Basics.Nested.Deeper +{ + public static class Deep + { + public static string Where() => "deep"; + } +} diff --git a/test-app/App_Resources/Windows/src/TestFixtures/Inheritance.cs b/test-app/App_Resources/Windows/src/TestFixtures/Inheritance.cs new file mode 100644 index 0000000..1a3671d --- /dev/null +++ b/test-app/App_Resources/Windows/src/TestFixtures/Inheritance.cs @@ -0,0 +1,91 @@ +using System; + +// Base types for the JS class-extension specs (tests/extend-dotnet.js): every member that JS +// overrides is invoked from C# here, so a passing spec proves native -> JS virtual dispatch, the +// same guarantee the Android (Java) and iOS (Objective-C) extension tests make. +namespace TestFixtures.Inheritance +{ + public class Animal + { + public Animal() : this("animal") { } + + public Animal(string name) + { + Name = name; + } + + public Animal(string name, int age) + { + Name = name; + Age = age; + } + + public string Name { get; } + + public int Age { get; } + + public virtual string Speak() => "..."; + + public virtual int Legs => 4; + + public virtual string Greet(string other) => $"{Name} greets {other}"; + + protected virtual string Secret() => "base-secret"; + + // Non-virtual members that call the virtual ones from C#. + public string Describe() => $"{Name} says {Speak()} on {Legs} legs"; + + public string RevealSecret() => Secret(); + + public string GreetFromNative(string other) => Greet(other); + } + + public abstract class Shape + { + public abstract double Area(); + + public virtual string Kind => "shape"; + + public string Report() => $"{Kind}:{Area():0.##}"; + } + + public interface ICalculator + { + int Compute(int a, int b); + + string Name { get; } + } + + public interface INamed + { + string GetName(); + } + + public static class Native + { + public static string Describe(Animal animal) => animal.Describe(); + + public static string Speak(Animal animal) => animal.Speak(); + + public static int Legs(Animal animal) => animal.Legs; + + public static string Report(Shape shape) => shape.Report(); + + public static int Run(ICalculator calculator, int a, int b) => calculator.Compute(a, b); + + public static string NameOf(ICalculator calculator) => calculator.Name; + + public static string NameOf(INamed named) => named.GetName(); + + public static bool IsAnimal(object value) => value is Animal; + + public static string TypeName(object value) => value?.GetType().BaseType?.FullName ?? ""; + + // Keeps a reference on the native side, so identity can be checked on the way back. + private static object s_kept; + + public static void Keep(object value) => s_kept = value; + + public static object Kept() => s_kept; + } +} diff --git a/test-app/App_Resources/Windows/src/TestFixtures/Lifetime.cs b/test-app/App_Resources/Windows/src/TestFixtures/Lifetime.cs new file mode 100644 index 0000000..69d935d --- /dev/null +++ b/test-app/App_Resources/Windows/src/TestFixtures/Lifetime.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading; + +// Lifetime of JS subclass instances (tests/lifetime.js): the managed half of an instance JS has +// dropped must become collectable, while one native code still holds keeps working. +namespace TestFixtures.Lifetime +{ + public class Tracked + { + private static int s_finalized; + + public static int Finalized => Volatile.Read(ref s_finalized); + + public virtual string Name() => "tracked"; + + ~Tracked() => Interlocked.Increment(ref s_finalized); + } + + public static class Gc + { + public static void Collect() + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } +} diff --git a/test-app/App_Resources/Windows/src/TestFixtures/Threading.cs b/test-app/App_Resources/Windows/src/TestFixtures/Threading.cs new file mode 100644 index 0000000..c1affba --- /dev/null +++ b/test-app/App_Resources/Windows/src/TestFixtures/Threading.cs @@ -0,0 +1,101 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +// Calls into JavaScript from threads other than the UI thread (tests/threading.js). On iOS and +// Android a callback invoked from a background thread still reaches JavaScript; these fixtures +// check the same on Windows. None of them block the UI thread while the background thread calls +// back, because that deadlocks on every runtime (the UI thread owns the JS isolate). +namespace TestFixtures.Threading +{ + public static class BackgroundInvoker + { + public static int UiThreadId { get; private set; } + + public static void CaptureUiThread() => UiThreadId = Environment.CurrentManagedThreadId; + + public static bool IsUiThread() => Environment.CurrentManagedThreadId == UiThreadId; + + public static int CurrentThreadId() => Environment.CurrentManagedThreadId; + + /// Calls `compute` on a thread-pool thread and reports its result through `done`, also + /// from that thread: (result, ranOnBackgroundThread). + public static void ComputeInBackground(Func compute, int value, Action done) + { + Task.Run(() => + { + var background = Environment.CurrentManagedThreadId != UiThreadId; + var result = compute(value); + done(result, background); + }); + } + + /// Calls `callback` from `count` background threads at once and reports how many calls + /// returned, from the last thread to finish. + public static void FanOut(Func callback, int count, Action done) + { + var remaining = count; + var sum = 0; + for (var i = 0; i < count; i++) + { + var n = i; + new Thread(() => + { + Interlocked.Add(ref sum, callback(n)); + if (Interlocked.Decrement(ref remaining) == 0) done(count, sum); + }) { IsBackground = true }.Start(); + } + } + + /// Calls `callback` from a thread-pool thread after `delayMs`; whether it was delivered is + /// reported through `LastLateCall` ("delivered" or the exception's type name). A Func, so a + /// call that can't run surfaces as an exception (a void delegate just returns). + public static void CallLater(Func callback, int delayMs) + { + LastLateCall = null; + Task.Delay(delayMs).ContinueWith(_ => + { + try + { + callback(); + LastLateCall = "delivered"; + } + catch (Exception e) + { + LastLateCall = e.GetType().Name; + } + }); + } + + public static string LastLateCall { get; private set; } + + public static void ThrowInBackground(Func callback, Action done) + { + Task.Run(() => + { + try + { + callback(); + done("no-exception"); + } + catch (Exception e) + { + done(e.GetType().Name); + } + }); + } + } + + public class Ticker + { + public event EventHandler Tick; + + public void StartInBackground(int ticks) + { + new Thread(() => + { + for (var i = 1; i <= ticks; i++) Tick?.Invoke(this, i); + }) { IsBackground = true }.Start(); + } + } +} diff --git a/test-app/README.md b/test-app/README.md new file mode 100644 index 0000000..5bd184d --- /dev/null +++ b/test-app/README.md @@ -0,0 +1,52 @@ +# Windows runtime test app + +The Windows counterpart of the Android (`test-app`) and iOS (`TestRunner`) runtime test suites: Jasmine-style specs that run inside a real WinUI app built from `template/framework`, against native fixtures compiled into it. + +``` +test-app/ + app/ JavaScript: main.js (entry), runner/ (Jasmine-compatible runner), tests/ + App_Resources/Windows/ + src/TestFixtures/*.cs C# fixtures, compiled into the app like any app's App_Resources sources + app.csproj app-level MSBuild hook (unpackaged build for the runner) + plugins/test-plugin/ a plugin shipping C# sources in platforms/windows/src + run.ps1 build + run +``` + +## Running + +From the repository root, in a shell where `cargo` and the .NET SDK are available: + +```powershell +pwsh test-app/run.ps1 # build nativescript.dll (debug) and the app, run every spec +pwsh test-app/run.ps1 -Filter Threading # only specs whose full name matches the regex +pwsh test-app/run.ps1 -SkipRuntimeBuild # reuse target/debug/nativescript.dll +pwsh test-app/run.ps1 -Clean # regenerate platforms/ from scratch +``` + +`run.ps1` does what `ns platform add windows` and `ns prepare windows` do: it generates `test-app/platforms/windows/TestRunner` from `template/framework`, copies `app/` and `App_Resources/Windows` into it, and stages each plugin's `platforms/windows` folder under `plugins/`. It uses the freshly built runtime DLL and the `dotnet-bridge` sources from this repository. `dotnet-tool` runs from source through `cargo`. Pass `-EngineDll` to test a napi engine build (for example `packages/windows-v8/target/release/windows_v8.dll`) instead of the classic runtime. + +The app is built unpackaged (`WindowsPackageType=None`, self-contained Windows App SDK) so it starts straight from `bin\` and can read environment variables. When the run finishes: + +- `bin/test-results.log` holds one `[PASS]`/`[FAIL]` line per spec plus a `[TEST SUMMARY]` line; +- `bin/test-results.xml` holds JUnit XML; +- the process exit code is the number of failed specs. + +The script prints the log and exits non-zero when any spec failed. + +## What the specs cover + +| Spec | Covers | +| --- | --- | +| `csharp-sources.js` | App and plugin C# types reachable by namespace with no registration step: constructors and overloads, properties, statics, arrays, enums, internal types, nested namespaces. Also JS functions passed as `Func<>`/`Action<>`, C# events, and `Task` results awaited without blocking the UI thread. | +| `extend-dotnet.js` | Extending C# classes with `Class.extend()`, ES2015 `class`, `@NativeClass` and TypeScript ES5 output. Native code reaches JS overrides; constructor arguments are forwarded. `super` and `this.super` reach the base implementation; protected and abstract members and virtual properties can be overridden. JS fields are visible to native calls, `instanceof` works, and identity is kept across native round-trips. Interfaces are implemented with `new Interface({...})` and `@Interfaces`. | +| `extend-winrt.js` | Extending WinUI classes: XAML calls `MeasureOverride`/`ArrangeOverride`/`OnApplyTemplate` on the JS subclass. Covers `super`, `instanceof` of the subclass and its WinRT bases, `Class.extend()` and TypeScript ES5 output. | +| `threading.js` | WinRT and .NET callbacks invoked on background threads are delivered on the JS thread that created them (the UI thread or a worker). Return values and exceptions flow back to the native caller. Worker specs are skipped on engines without workers. | +| `boxing.js` | Values in `Object`-typed members (`Tag`, `PropertySet`) read back as JS primitives. | +| `lifetime.js` | JS subclass instances that nothing references are collected on both sides. An instance only native code holds keeps working, with its JS object revived. WinRT UI subclasses in the visual tree keep their fields. Needs `gc()`, so it is skipped on engines that don't expose it. | +| `layout-mutation.js` | Adding, removing and reparenting XAML elements synchronously from `SizeChanged`, `LayoutUpdated` and `CompositionTarget.Rendering`, and from promise continuations scheduled there. | + +Each spec asserts behaviour that works the same way on iOS and Android. + +## Adding specs + +Add a file under `app/tests/` and list it in `app/main.js`. If it needs a native fixture, add a `.cs` file under `App_Resources/Windows/src/TestFixtures/`; it is compiled into the app and reachable from JS by namespace. The runner supports `describe`/`it`/`xit`/`fit`, `beforeEach`/`afterEach`/`beforeAll`/`afterAll`, async specs (`done` or a returned promise) and the usual `expect` matchers. diff --git a/test-app/app/main.js b/test-app/app/main.js new file mode 100644 index 0000000..7446752 --- /dev/null +++ b/test-app/app/main.js @@ -0,0 +1,81 @@ +// Entry point of the Windows runtime test app. Mirrors the Android/iOS test-app layout: a list +// of spec files run by a Jasmine-compatible runner, with native fixtures compiled into the app +// (App_Resources/Windows/src, the counterpart of Android's src/main/java and iOS's TestFixtures) +// and a local plugin (plugins/test-plugin) that ships C# sources. + +// The runtime exposes timers as __ns__* host functions; @nativescript/core installs the standard +// globals on top of them (as on iOS), and this app runs without core. +for (const name of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) { + if (typeof globalThis[name] !== "function") globalThis[name] = globalThis["__ns__" + name]; +} + +const { api, execute, toJUnitXml } = require("./runner/jasmine-lite"); +Object.assign(globalThis, api); + +const specs = [ + "./tests/csharp-sources.js", + "./tests/extend-dotnet.js", + "./tests/extend-winrt.js", + "./tests/threading.js", + "./tests/boxing.js", + "./tests/layout-mutation.js", + "./tests/lifetime.js", +]; + +function env(name) { + try { + return System.Environment.GetEnvironmentVariable(name) || ""; + } catch (_) { + return ""; + } +} + +// Each line is appended as it happens, so a crash still leaves the log of everything before it. +const logPath = env("NS_TEST_LOG"); +function log(line) { + console.log(line); + if (!logPath) return; + try { + System.IO.File.AppendAllText(logPath, line + "\r\n"); + } catch (_) {} +} + +// The window activates only once it has content, and the layout specs need a live window. +try { + require("./tests/helpers").root(); +} catch (e) { + log("[ERROR] could not create the window content: " + (e && e.message)); +} + +for (const spec of specs) { + try { + require(spec); + } catch (e) { + // A spec file that can't even load is reported as a failing spec, not a silent skip. + describe(spec, () => { + it("loads", () => { + throw e; + }); + }); + } +} + +function finish(run) { + const resultsPath = env("NS_TEST_RESULTS"); + try { + if (resultsPath) System.IO.File.WriteAllText(resultsPath, toJUnitXml(run)); + } catch (e) { + log("[ERROR] could not write test results: " + (e && e.message)); + } + if (resultsPath) System.Environment.Exit(run.failed); +} + +// Start after the host has shown the window and started pumping, so specs that need layout, +// rendering or timers run inside a live app. +setTimeout(() => { + const filterText = env("NS_TEST_FILTER"); + execute({ log, filter: filterText ? new RegExp(filterText, "i") : null }).then(finish, (e) => { + log("[ERROR] runner crashed: " + (e && e.stack ? e.stack : e)); + finish({ results: [], passed: 0, failed: 1, pending: 0, time: 0 }); + }); +}, 0); diff --git a/test-app/app/package.json b/test-app/app/package.json new file mode 100644 index 0000000..a00212a --- /dev/null +++ b/test-app/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "test-app", + "main": "main.js" +} diff --git a/test-app/app/runner/jasmine-lite.js b/test-app/app/runner/jasmine-lite.js new file mode 100644 index 0000000..2de6313 --- /dev/null +++ b/test-app/app/runner/jasmine-lite.js @@ -0,0 +1,373 @@ +// A small Jasmine-compatible test runner for the Windows runtime test app. +// +// The Android and iOS runtimes run their test-app suites on Jasmine; this keeps the same spec +// surface (describe/it/beforeEach/expect/done/...) without depending on a bundler or on the +// Node globals Jasmine's boot code expects, so the specs read the same across the three runtimes. + +const DEFAULT_TIMEOUT_MS = 5000; + +function Suite(description, parent) { + this.description = description; + this.parent = parent; + this.children = []; + this.beforeEach = []; + this.afterEach = []; + this.beforeAll = []; + this.afterAll = []; +} + +Suite.prototype.fullName = function () { + const parts = []; + for (let s = this; s && s.parent; s = s.parent) parts.unshift(s.description); + return parts.join(" "); +}; + +const root = new Suite("", null); +let current = root; +let focused = false; + +function addSpec(description, fn, opts) { + current.children.push({ kind: "spec", description, fn, suite: current, pending: !!opts.pending, focused: !!opts.focused, timeout: opts.timeout }); + if (opts.focused) focused = true; +} + +function addSuite(description, fn, opts) { + const suite = new Suite(description, current); + suite.pending = !!opts.pending || !!(current && current.pending); + suite.focused = !!opts.focused || !!(current && current.focused); + if (opts.focused) focused = true; + current.children.push({ kind: "suite", suite }); + const prev = current; + current = suite; + try { + fn(); + } finally { + current = prev; + } +} + +// ── matchers ───────────────────────────────────────────────────────────────── + +function fmt(v) { + if (typeof v === "string") return JSON.stringify(v); + if (typeof v === "function") return `[Function ${v.name || "anonymous"}]`; + if (typeof v === "bigint") return `${v}n`; + try { + const s = JSON.stringify(v); + if (s !== undefined) return s; + } catch (_) {} + try { + return String(v); + } catch (_) { + return Object.prototype.toString.call(v); + } +} + +function deepEqual(a, b, seen) { + if (Object.is(a, b)) return true; + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false; + seen = seen || []; + for (const [x, y] of seen) if (x === a && y === b) return true; + seen.push([a, b]); + if (Array.isArray(a) !== Array.isArray(b)) return false; + const ka = Object.keys(a); + const kb = Object.keys(b); + if (ka.length !== kb.length) return false; + for (const k of ka) { + if (!Object.prototype.hasOwnProperty.call(b, k)) return false; + if (!deepEqual(a[k], b[k], seen)) return false; + } + return true; +} + +class ExpectationFailed extends Error {} + +function makeExpect(onFailure) { + return function expect(actual) { + function build(negate) { + function check(pass, message) { + if (pass === negate) onFailure(new ExpectationFailed(negate ? message.replace(/^Expected (.*?) to /, "Expected $1 not to ") : message)); + } + const m = { + toBe: (e) => check(Object.is(actual, e), `Expected ${fmt(actual)} to be ${fmt(e)}.`), + toEqual: (e) => check(deepEqual(actual, e), `Expected ${fmt(actual)} to equal ${fmt(e)}.`), + toBeTruthy: () => check(!!actual, `Expected ${fmt(actual)} to be truthy.`), + toBeFalsy: () => check(!actual, `Expected ${fmt(actual)} to be falsy.`), + toBeTrue: () => check(actual === true, `Expected ${fmt(actual)} to be true.`), + toBeFalse: () => check(actual === false, `Expected ${fmt(actual)} to be false.`), + toBeDefined: () => check(actual !== undefined, `Expected ${fmt(actual)} to be defined.`), + toBeUndefined: () => check(actual === undefined, `Expected ${fmt(actual)} to be undefined.`), + toBeNull: () => check(actual === null, `Expected ${fmt(actual)} to be null.`), + toBeNaN: () => check(Number.isNaN(actual), `Expected ${fmt(actual)} to be NaN.`), + toBeGreaterThan: (e) => check(actual > e, `Expected ${fmt(actual)} to be greater than ${fmt(e)}.`), + toBeGreaterThanOrEqual: (e) => check(actual >= e, `Expected ${fmt(actual)} to be greater than or equal to ${fmt(e)}.`), + toBeLessThan: (e) => check(actual < e, `Expected ${fmt(actual)} to be less than ${fmt(e)}.`), + toBeLessThanOrEqual: (e) => check(actual <= e, `Expected ${fmt(actual)} to be less than or equal to ${fmt(e)}.`), + toBeCloseTo: (e, precision = 2) => check(Math.abs(e - actual) < Math.pow(10, -precision) / 2, `Expected ${fmt(actual)} to be close to ${fmt(e)}.`), + toBeInstanceOf: (ctor) => check(actual instanceof ctor, `Expected ${fmt(actual)} to be an instance of ${(ctor && ctor.name) || fmt(ctor)}.`), + toContain: (e) => check(actual != null && (typeof actual === "string" ? actual.indexOf(e) !== -1 : Array.prototype.some.call(actual, (x) => deepEqual(x, e))), `Expected ${fmt(actual)} to contain ${fmt(e)}.`), + toMatch: (re) => check(new RegExp(re).test(String(actual)), `Expected ${fmt(actual)} to match ${String(re)}.`), + toHaveBeenCalled: () => check(!!(actual && actual.calls && actual.calls.count() > 0), `Expected spy to have been called.`), + toHaveBeenCalledTimes: (n) => check(!!(actual && actual.calls && actual.calls.count() === n), `Expected spy to have been called ${n} times but was called ${actual && actual.calls ? actual.calls.count() : 0} times.`), + toHaveBeenCalledWith: (...args) => check(!!(actual && actual.calls && actual.calls.all().some((c) => deepEqual(c.args, args))), `Expected spy to have been called with ${fmt(args)}.`), + toThrow: (e) => { + let threw = false; + let err; + try { + actual(); + } catch (x) { + threw = true; + err = x; + } + check(threw && (arguments.length === 0 || e === undefined || deepEqual(err, e)), `Expected function to throw${e !== undefined ? " " + fmt(e) : ""}${threw ? `, but it threw ${fmt(err && err.message)}` : ""}.`); + }, + toThrowError: (typeOrMessage, maybeMessage) => { + let threw = false; + let err; + try { + actual(); + } catch (x) { + threw = true; + err = x; + } + let ok = threw; + let type = typeof typeOrMessage === "function" ? typeOrMessage : null; + let message = type ? maybeMessage : typeOrMessage; + if (ok && type) ok = err instanceof type; + if (ok && message !== undefined) { + const text = err && err.message !== undefined ? String(err.message) : String(err); + ok = message instanceof RegExp ? message.test(text) : text === message; + } + check(ok, `Expected function to throw an Error${message !== undefined ? " matching " + String(message) : ""}${threw ? `, but it threw ${fmt(err && err.message !== undefined ? err.message : err)}` : ", but it did not throw"}.`); + }, + }; + return m; + } + const matchers = build(false); + matchers.not = build(true); + return matchers; + }; +} + +// ── spies ──────────────────────────────────────────────────────────────────── + +function createSpy(name, impl) { + const calls = []; + let strategy = impl || null; + const spy = function (...args) { + calls.push({ object: this, args }); + return strategy ? strategy.apply(this, args) : undefined; + }; + spy.and = { + returnValue(v) { + strategy = () => v; + return spy; + }, + callFake(fn) { + strategy = fn; + return spy; + }, + throwError(e) { + strategy = () => { + throw typeof e === "string" ? new Error(e) : e; + }; + return spy; + }, + }; + spy.calls = { + count: () => calls.length, + argsFor: (i) => (calls[i] ? calls[i].args : []), + all: () => calls.slice(), + mostRecent: () => calls[calls.length - 1], + reset: () => { + calls.length = 0; + }, + }; + spy.and.identity = name || "unknown"; + return spy; +} + +// ── execution ──────────────────────────────────────────────────────────────── + +function runFn(fn, ctx, timeoutMs, label) { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (err) => { + if (settled) return; + settled = true; + clearTimeout(timer); + err ? reject(err) : resolve(); + }; + const timer = setTimeout(() => finish(new Error(`Timeout - ${label} did not complete within ${timeoutMs}ms`)), timeoutMs); + try { + if (fn.length > 0) { + const done = () => finish(); + done.fail = (e) => finish(e instanceof Error ? e : new Error(e === undefined ? "Failed" : String(e))); + fn.call(ctx, done); + } else { + const r = fn.call(ctx); + if (r && typeof r.then === "function") r.then(() => finish(), (e) => finish(e || new Error("Promise rejected"))); + else finish(); + } + } catch (e) { + finish(e); + } + }); +} + +function chain(suite, key) { + const out = []; + for (let s = suite; s; s = s.parent) out.unshift(...s[key]); + return key === "afterEach" ? out.reverse() : out; +} + +function isFocused(node) { + return node.kind === "spec" ? node.focused || node.suite.focused : node.suite.focused || node.suite.children.some(isFocused); +} + +async function execute(options) { + const results = []; + const log = options.log || ((s) => console.log(s)); + const started = Date.now(); + + const fullNameOf = (spec) => `${spec.suite.fullName()} ${spec.description}`.trim(); + const isSkipped = (spec) => spec.pending || spec.suite.pending || (options.filter && !options.filter.test(fullNameOf(spec))); + const willRun = (node) => (!focused || isFocused(node)) && (node.kind === "spec" ? !isSkipped(node) : node.suite.children.some(willRun)); + + async function visitSuite(suite) { + const runnable = suite.children.filter((c) => !focused || isFocused(c)); + if (runnable.length === 0) return; + // A suite whose specs are all skipped only reports them: its hooks may depend on what made + // it skip (xdescribe on a missing feature). + const hooks = runnable.some(willRun); + for (const fn of hooks ? suite.beforeAll : []) { + try { + await runFn(fn, {}, DEFAULT_TIMEOUT_MS, `beforeAll in "${suite.fullName()}"`); + } catch (e) { + log(`[ERROR] beforeAll failed in "${suite.fullName()}": ${e && e.message}`); + } + } + for (const child of runnable) { + if (child.kind === "suite") await visitSuite(child.suite); + else await visitSpec(child); + } + for (const fn of hooks ? suite.afterAll : []) { + try { + await runFn(fn, {}, DEFAULT_TIMEOUT_MS, `afterAll in "${suite.fullName()}"`); + } catch (e) { + log(`[ERROR] afterAll failed in "${suite.fullName()}": ${e && e.message}`); + } + } + } + + async function visitSpec(spec) { + const fullName = fullNameOf(spec); + if (isSkipped(spec)) { + results.push({ suite: spec.suite.fullName(), name: spec.description, fullName, status: "pending", failures: [], time: 0 }); + return; + } + const failures = []; + const t0 = Date.now(); + currentFailures = failures; + const ctx = {}; + const timeout = spec.timeout || DEFAULT_TIMEOUT_MS; + try { + for (const fn of chain(spec.suite, "beforeEach")) await runFn(fn, ctx, timeout, "beforeEach"); + await runFn(spec.fn, ctx, timeout, `"${fullName}"`); + } catch (e) { + failures.push(e); + } + try { + for (const fn of chain(spec.suite, "afterEach")) await runFn(fn, ctx, timeout, "afterEach"); + } catch (e) { + failures.push(e); + } + currentFailures = null; + const status = failures.length ? "failed" : "passed"; + results.push({ suite: spec.suite.fullName(), name: spec.description, fullName, status, failures: failures.map(describeError), time: (Date.now() - t0) / 1000 }); + if (status === "passed") log(`[PASS] ${fullName}`); + else for (const f of failures) log(`[FAIL] ${fullName} - ${describeError(f).message}`); + } + + await visitSuite(root); + const passed = results.filter((r) => r.status === "passed").length; + const failed = results.filter((r) => r.status === "failed").length; + const pending = results.filter((r) => r.status === "pending").length; + log(`[TEST SUMMARY] passed=${passed}, failed=${failed}, pending=${pending}`); + return { results, passed, failed, pending, time: (Date.now() - started) / 1000 }; +} + +function describeError(e) { + if (e && typeof e === "object" && "message" in e && "stack" in e) return e; + if (e && typeof e === "object") return { message: e.message !== undefined ? String(e.message) : fmt(e), stack: e.stack || "" }; + return { message: String(e), stack: "" }; +} + +let currentFailures = null; +const expect = makeExpect((err) => { + if (currentFailures) currentFailures.push(err); + else throw err; +}); + +function escapeXml(s) { + return String(s).replace(/[<>&"']/g, (c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]); +} + +/** JUnit XML in the shape the Android test-app reporter produces, so CI can consume either. */ +function toJUnitXml(run) { + const bySuite = new Map(); + for (const r of run.results) { + if (!bySuite.has(r.suite)) bySuite.set(r.suite, []); + bySuite.get(r.suite).push(r); + } + const lines = ['', ``]; + for (const [suite, specs] of bySuite) { + const failures = specs.filter((s) => s.status === "failed").length; + const skipped = specs.filter((s) => s.status === "pending").length; + lines.push(` `); + for (const s of specs) { + lines.push(` `); + if (s.status === "pending") lines.push(" "); + for (const f of s.failures) lines.push(` ${escapeXml(f.stack || f.message)}`); + lines.push(" "); + } + lines.push(" "); + } + lines.push(""); + return lines.join("\n"); +} + +const api = { + describe: (d, fn) => addSuite(d, fn, {}), + xdescribe: (d, fn) => addSuite(d, fn, { pending: true }), + fdescribe: (d, fn) => addSuite(d, fn, { focused: true }), + it: (d, fn, timeout) => addSpec(d, fn || (() => {}), { pending: !fn, timeout }), + xit: (d, fn) => addSpec(d, fn || (() => {}), { pending: true }), + fit: (d, fn, timeout) => addSpec(d, fn, { focused: true, timeout }), + beforeEach: (fn) => current.beforeEach.push(fn), + afterEach: (fn) => current.afterEach.push(fn), + beforeAll: (fn) => current.beforeAll.push(fn), + afterAll: (fn) => current.afterAll.push(fn), + expect, + fail: (message) => { + const err = new ExpectationFailed(message === undefined ? "Failed" : String(message)); + if (currentFailures) currentFailures.push(err); + else throw err; + }, + pending: () => {}, + jasmine: { + createSpy, + DEFAULT_TIMEOUT_INTERVAL: DEFAULT_TIMEOUT_MS, + }, + spyOn(obj, method) { + const original = obj[method]; + const spy = createSpy(method, function (...args) { + return original.apply(this, args); + }); + spy.and.callThrough = () => spy.and.callFake(original); + obj[method] = spy; + return spy; + }, +}; + +module.exports = { api, execute, toJUnitXml }; diff --git a/test-app/app/tests/boxing.js b/test-app/app/tests/boxing.js new file mode 100644 index 0000000..8e381ec --- /dev/null +++ b/test-app/app/tests/boxing.js @@ -0,0 +1,36 @@ +// Values stored in Object-typed WinRT members (FrameworkElement.Tag, PropertySet entries) are +// boxed as IPropertyValue; reading them back yields the JS primitive, as on iOS and Android. + +describe("Object values", () => { + const Controls = Microsoft.UI.Xaml.Controls; + + it("round-trips a string through Tag", () => { + const panel = new Controls.StackPanel(); + panel.Tag = "tagged"; + expect(panel.Tag).toBe("tagged"); + }); + + it("round-trips numbers and booleans through Tag", () => { + const panel = new Controls.StackPanel(); + panel.Tag = 42.5; + expect(panel.Tag).toBe(42.5); + panel.Tag = true; + expect(panel.Tag).toBe(true); + }); + + it("returns objects stored in Tag as themselves", () => { + const panel = new Controls.StackPanel(); + const text = new Controls.TextBlock(); + text.Text = "inner"; + panel.Tag = text; + expect(panel.Tag.Text).toBe("inner"); + }); + + it("unboxes PropertySet values", () => { + const set = new Windows.Foundation.Collections.PropertySet(); + set.Insert("name", "value"); + set.Insert("count", 3); + expect(set.Lookup("name")).toBe("value"); + expect(set.Lookup("count")).toBe(3); + }); +}); diff --git a/test-app/app/tests/csharp-sources.js b/test-app/app/tests/csharp-sources.js new file mode 100644 index 0000000..ee00d98 --- /dev/null +++ b/test-app/app/tests/csharp-sources.js @@ -0,0 +1,135 @@ +// C# sources dropped into App_Resources/Windows (or shipped in a plugin's platforms/windows) are +// compiled into the app and usable from JavaScript by namespace, like Java/Kotlin/Objective-C/ +// Swift sources are on Android and iOS. Fixtures: App_Resources/Windows/src/TestFixtures/Basics.cs, +// plugins/test-plugin/platforms/windows/src/PluginGreeter.cs. + +describe("C# sources in App_Resources", () => { + it("exposes the root namespace of app C# types as a global without registration", () => { + expect(typeof TestFixtures).not.toBe("undefined"); + expect(typeof TestFixtures.Basics.Greeter).toBe("function"); + }); + + it("constructs with the default constructor", () => { + const g = new TestFixtures.Basics.Greeter(); + expect(g.Name).toBe("World"); + expect(g.Greet()).toBe("Hello, World!"); + }); + + it("constructs with arguments", () => { + const g = new TestFixtures.Basics.Greeter("JS"); + expect(g.Greet()).toBe("Hello, JS!"); + }); + + it("selects overloads by argument count", () => { + const g = new TestFixtures.Basics.Greeter("JS"); + expect(g.Greet("C#")).toBe("Hello, C#, from JS!"); + }); + + it("reads and writes instance properties", () => { + const g = new TestFixtures.Basics.Greeter(); + g.Name = "Changed"; + expect(g.Name).toBe("Changed"); + expect(g.Greet()).toBe("Hello, Changed!"); + }); + + it("calls static methods", () => { + expect(TestFixtures.Basics.Greeter.Add(2, 3)).toBe(5); + expect(TestFixtures.Basics.Greeter.Scale(1.5, 2)).toBe(3); + }); + + it("reads static properties", () => { + expect(TestFixtures.Basics.Greeter.Version).toBe("1.0"); + }); + + it("marshals arrays", () => { + const g = new TestFixtures.Basics.Greeter(); + expect(g.Numbers()).toEqual([1, 2, 3]); + }); + + it("marshals enums as numbers", () => { + const g = new TestFixtures.Basics.Greeter(); + expect(g.Mood).toBe(0); + g.Mood = 10; + expect(g.Mood).toBe(10); + }); + + it("resolves nested namespaces", () => { + expect(TestFixtures.Basics.Nested.Deeper.Deep.Where()).toBe("deep"); + }); + + it("reaches internal types", () => { + expect(TestFixtures.Basics.InternalHelper.Ping()).toBe("pong"); + }); + + it("passes JavaScript functions as Func<> delegates", () => { + expect(TestFixtures.Basics.Callbacks.Apply((x) => x * 2, 21)).toBe(42); + expect(TestFixtures.Basics.Callbacks.Join((a, b) => a + "+" + b, "x", "y")).toBe("x+y"); + }); + + it("passes JavaScript functions as Action<> delegates", () => { + const seen = []; + const n = TestFixtures.Basics.Callbacks.Repeat((i) => seen.push(i), 3); + expect(n).toBe(3); + expect(seen).toEqual([0, 1, 2]); + }); + + it("subscribes to C# events with add_/remove_", () => { + const g = new TestFixtures.Basics.Greeter(); + const messages = []; + const handler = (sender, message) => messages.push(message); + g.add_Greeted(handler); + g.RaiseGreeted("one"); + expect(messages).toEqual(["one"]); + }); + + it("returns Task as an awaitable", async () => { + expect(await TestFixtures.Basics.Callbacks.AddLaterAsync(20, 22, 10)).toBe(42); + }); + + it("converts a Task to a promise with taskToPromise", async () => { + const task = TestFixtures.Basics.Callbacks.AddLaterAsync(1, 2, 10); + expect(await NSWinRT.dotnet.taskToPromise(task)).toBe(3); + // The same Task can be awaited again. + expect(await task).toBe(3); + }); + + it("does not block the UI thread while a Task runs", async () => { + let timerRan = false; + setTimeout(() => (timerRan = true), 0); + const result = await TestFixtures.Basics.Callbacks.AddLaterAsync(1, 1, 200); + expect(result).toBe(2); + expect(timerRan).toBe(true); + }); + + it("rejects when a Task faults", async () => { + let error = null; + try { + await TestFixtures.Basics.Callbacks.FailLaterAsync("boom", 10); + } catch (e) { + error = e; + } + expect(String(error && (error.message || error))).toContain("boom"); + }); + + it("keeps System the .NET namespace when app code assigns it", () => { + // What @nativescript/core does (a SystemJS shim). + const load = (p) => p; + globalThis.System = { import: load }; + expect(System.import).toBe(load); + expect(System.IO.Path.Combine("a", "b")).toBe("a\\b"); + }); + + it("throws a catchable JS error for a missing member", () => { + const g = new TestFixtures.Basics.Greeter(); + expect(() => g.NoSuchMethod()).toThrow(); + }); +}); + +describe("C# sources in plugins", () => { + it("exposes plugin C# types by namespace", () => { + expect(typeof TestPlugin).not.toBe("undefined"); + const g = new TestPlugin.Native.PluginGreeter(); + expect(g.Greet("app")).toBe("Hello from the plugin, app!"); + expect(TestPlugin.Native.PluginGreeter.PluginName).toBe("test-plugin"); + }); +}); diff --git a/test-app/app/tests/extend-dotnet.js b/test-app/app/tests/extend-dotnet.js new file mode 100644 index 0000000..63469bd --- /dev/null +++ b/test-app/app/tests/extend-dotnet.js @@ -0,0 +1,295 @@ +// Extending C# classes and implementing C# interfaces from JavaScript, with the semantics the +// Android and iOS runtimes give Java/Objective-C classes: native code calling a virtual member +// lands in the JS override, constructors forward their arguments, `super` reaches the base +// implementation, `this` is the JS object (fields survive native round-trips) and instanceof +// works. Fixtures: App_Resources/Windows/src/TestFixtures/Inheritance.cs. + +const { Animal, Shape, ICalculator, INamed, Native } = TestFixtures.Inheritance; + +describe("Extending C# classes", () => { + describe("with Class.extend()", () => { + it("dispatches native virtual calls to the JS override", () => { + const Dog = Animal.extend({ + Speak() { + return "Woof"; + }, + }); + const dog = new Dog(); + expect(dog.Speak()).toBe("Woof"); + expect(Native.Speak(dog)).toBe("Woof"); + expect(dog.Describe()).toBe("animal says Woof on 4 legs"); + }); + + it("supports a type name as the first argument", () => { + const Cow = Animal.extend("TestApp.Cow", { + Speak() { + return "Moo"; + }, + }); + expect(Native.Speak(new Cow())).toBe("Moo"); + }); + + it("forwards constructor arguments to the base constructor", () => { + const Dog = Animal.extend({ + Speak() { + return "Woof"; + }, + }); + const rex = new Dog("rex"); + expect(rex.Name).toBe("rex"); + expect(rex.Describe()).toBe("rex says Woof on 4 legs"); + const old = new Dog("fido", 12); + expect(old.Age).toBe(12); + }); + + it("calls the base implementation through this.super", () => { + const Loud = Animal.extend({ + Greet(other) { + return this.super.Greet(other).toUpperCase(); + }, + }); + expect(new Loud("max").GreetFromNative("bob")).toBe("MAX GREETS BOB"); + }); + + it("keeps members that are not overridden native", () => { + const Dog = Animal.extend({ + Speak() { + return "Woof"; + }, + }); + expect(new Dog("rex").Greet("bob")).toBe("rex greets bob"); + expect(Native.Legs(new Dog())).toBe(4); + }); + + it("overrides protected members", () => { + const Spy = Animal.extend({ + Secret() { + return "js-secret"; + }, + }); + expect(new Spy().RevealSecret()).toBe("js-secret"); + }); + + it("overrides virtual properties with accessors", () => { + const Bird = Animal.extend({ + get Legs() { + return 2; + }, + Speak() { + return "Tweet"; + }, + }); + expect(Native.Legs(new Bird())).toBe(2); + expect(new Bird("tweety").Describe()).toBe("tweety says Tweet on 2 legs"); + }); + + it("is instanceof the extended and the base class", () => { + const Dog = Animal.extend({ + Speak() { + return "Woof"; + }, + }); + const dog = new Dog(); + expect(dog instanceof Dog).toBe(true); + expect(dog instanceof Animal).toBe(true); + expect(Native.IsAnimal(dog)).toBe(true); + }); + + it("can be extended again", () => { + const Dog = Animal.extend({ + Speak() { + return "Woof"; + }, + }); + const Puppy = Dog.extend({ + Speak() { + return "Yip"; + }, + }); + const p = new Puppy("bit"); + expect(Native.Speak(p)).toBe("Yip"); + expect(p instanceof Dog).toBe(true); + expect(p instanceof Animal).toBe(true); + }); + }); + + describe("with ES2015 class syntax", () => { + class Cat extends Animal { + constructor(name) { + super(name, 3); + this.lives = 9; + } + + Speak() { + return "Meow x" + this.lives; + } + + Greet(other) { + return super.Greet(other) + "!"; + } + } + + it("dispatches native virtual calls to the JS override", () => { + const cat = new Cat("tom"); + expect(Native.Speak(cat)).toBe("Meow x9"); + expect(cat.Describe()).toBe("tom says Meow x9 on 4 legs"); + }); + + it("forwards super(...) arguments to the base constructor", () => { + const cat = new Cat("tom"); + expect(cat.Name).toBe("tom"); + expect(cat.Age).toBe(3); + }); + + it("keeps JS fields visible to overrides called from native code", () => { + const cat = new Cat("tom"); + cat.lives = 3; + expect(Native.Speak(cat)).toBe("Meow x3"); + }); + + it("calls the base implementation with super", () => { + expect(new Cat("tom").GreetFromNative("jerry")).toBe("tom greets jerry!"); + }); + + it("is instanceof the subclass and the base class", () => { + const cat = new Cat("tom"); + expect(cat instanceof Cat).toBe(true); + expect(cat instanceof Animal).toBe(true); + expect(Native.IsAnimal(cat)).toBe(true); + }); + + it("returns the same JS object when native code hands the instance back", () => { + const cat = new Cat("tom"); + Native.Keep(cat); + expect(Native.Kept()).toBe(cat); + }); + + it("supports subclasses of subclasses", () => { + class Kitten extends Cat { + Speak() { + return "mew (" + super.Speak() + ")"; + } + } + const k = new Kitten("kit"); + expect(Native.Speak(k)).toBe("mew (Meow x9)"); + expect(k instanceof Cat).toBe(true); + }); + + it("implements abstract members", () => { + class Square extends Shape { + constructor(side) { + super(); + this.side = side; + } + + Area() { + return this.side * this.side; + } + + get Kind() { + return "square"; + } + } + expect(Native.Report(new Square(2))).toBe("square:4"); + }); + + it("accepts `return global.__native(this)` in constructors, as on Android/iOS", () => { + class Shared extends Animal { + constructor() { + super("shared"); + return global.__native(this); + } + + Speak() { + return "shared"; + } + } + const s = new Shared(); + expect(s instanceof Shared).toBe(true); + expect(Native.Speak(s)).toBe("shared"); + }); + + it("accepts the @NativeClass decorator", () => { + expect(typeof NativeClass).toBe("function"); + // What TypeScript emits for `@NativeClass() class Decorated extends Animal {}`. + let Decorated = class Decorated extends Animal { + Speak() { + return "decorated"; + } + }; + Decorated = NativeClass()(Decorated) || Decorated; + expect(Native.Speak(new Decorated())).toBe("decorated"); + }); + }); +}); + +describe("Extending C# classes with TypeScript's ES5 output", () => { + // What the @NativeClass transform (and TypeScript targeting ES5) emits for + // `class Es5Dog extends Animal { constructor(name) { super(name); this.tricks = 2; } Speak() {...} }`. + const Es5Dog = (function (_super) { + __extends(Es5Dog, _super); + function Es5Dog(name) { + var _this = _super.call(this, name) || this; + _this.tricks = 2; + return _this; + } + Es5Dog.prototype.Speak = function () { + return "woof x" + this.tricks; + }; + Es5Dog.prototype.Greet = function (other) { + return _super.prototype.Greet.call(this, other) + "?"; + }; + return Es5Dog; + })(Animal); + + it("dispatches native virtual calls to the JS override", () => { + const dog = new Es5Dog("rex"); + expect(Native.Speak(dog)).toBe("woof x2"); + expect(dog.Describe()).toBe("rex says woof x2 on 4 legs"); + }); + + it("calls the base implementation through _super.prototype", () => { + expect(new Es5Dog("rex").GreetFromNative("bob")).toBe("rex greets bob?"); + }); + + it("is instanceof the subclass and the base class", () => { + const dog = new Es5Dog("rex"); + expect(dog instanceof Es5Dog).toBe(true); + expect(dog instanceof Animal).toBe(true); + }); +}); + +describe("Implementing C# interfaces", () => { + it("implements an interface with new Interface({...})", () => { + const calc = new ICalculator({ + Compute(a, b) { + return a * b; + }, + get Name() { + return "multiply"; + }, + }); + expect(Native.Run(calc, 6, 7)).toBe(42); + expect(Native.NameOf(calc)).toBe("multiply"); + }); + + it("implements interfaces with Object.extend({ interfaces })", () => { + const Named = Object.extend({ + interfaces: [INamed], + GetName() { + return "object-extend"; + }, + }); + expect(Native.NameOf(new Named())).toBe("object-extend"); + }); + + it("implements interfaces on a class with @Interfaces", () => { + class Named extends System.Object { + GetName() { + return "named"; + } + } + Interfaces([INamed])(Named); + expect(Native.NameOf(new Named())).toBe("named"); + }); +}); diff --git a/test-app/app/tests/extend-winrt.js b/test-app/app/tests/extend-winrt.js new file mode 100644 index 0000000..07ebf14 --- /dev/null +++ b/test-app/app/tests/extend-winrt.js @@ -0,0 +1,165 @@ +// Extending WinRT/WinUI classes from JavaScript. XAML's layout engine calls MeasureOverride / +// ArrangeOverride / OnApplyTemplate on our subclass, which must land in the JS overrides — the +// equivalent of overriding onMeasure/onLayout on an Android View or layoutSubviews on a UIView. + +const { Controls, host, detach, waitFor } = require("./helpers"); +const Size = (w, h) => ({ Width: w, Height: h }); + +describe("Extending WinRT classes", () => { + let container; + beforeEach(() => { + container = host(); + }); + afterEach(() => { + detach(container); + }); + + describe("with ES2015 class syntax", () => { + class FixedPanel extends Controls.Panel { + constructor() { + super(); + this.measures = 0; + this.arranges = 0; + } + + MeasureOverride(available) { + this.measures++; + return Size(120, 40); + } + + ArrangeOverride(finalSize) { + this.arranges++; + return finalSize; + } + } + + it("dispatches XAML layout to MeasureOverride/ArrangeOverride", async () => { + const panel = new FixedPanel(); + container.Children.Append(panel); + await waitFor(() => panel.arranges > 0); + expect(panel.measures).toBeGreaterThan(0); + expect(panel.DesiredSize.Width).toBe(120); + expect(panel.DesiredSize.Height).toBe(40); + }); + + it("is instanceof the subclass and its WinRT bases", () => { + const panel = new FixedPanel(); + expect(panel instanceof FixedPanel).toBe(true); + expect(panel instanceof Controls.Panel).toBe(true); + expect(panel instanceof Microsoft.UI.Xaml.UIElement).toBe(true); + }); + + it("returns the same JS object when XAML hands the instance back", () => { + const panel = new FixedPanel(); + panel.marker = "mine"; + container.Children.Append(panel); + const back = container.Children.GetAt(0); + expect(back).toBe(panel); + expect(back.marker).toBe("mine"); + }); + + it("keeps native members of the base class", () => { + const panel = new FixedPanel(); + panel.Width = 123; + expect(panel.Width).toBe(123); + panel.Children.Append(new Controls.TextBlock()); + expect(panel.Children.Size).toBe(1); + }); + + it("calls the base implementation with super", async () => { + class Logged extends Controls.StackPanel { + MeasureOverride(available) { + this.called = true; + return super.MeasureOverride(available); + } + } + const panel = new Logged(); + const text = new Controls.TextBlock(); + text.Text = "hello"; + panel.Children.Append(text); + container.Children.Append(panel); + await waitFor(() => panel.called === true && panel.ActualHeight > 0); + expect(panel.DesiredSize.Height).toBeGreaterThan(0); + }); + + it("dispatches OnApplyTemplate on a templated control", async () => { + class TemplatedButton extends Controls.Button { + OnApplyTemplate() { + super.OnApplyTemplate(); + this.applied = (this.applied || 0) + 1; + } + } + const button = new TemplatedButton(); + button.Content = "tap"; + container.Children.Append(button); + await waitFor(() => button.applied > 0); + expect(button.applied).toBe(1); + }); + }); + + describe("with TypeScript's ES5 output", () => { + // What the @NativeClass transform emits for a class extending a WinRT class. + const Es5Panel = (function (_super) { + __extends(Es5Panel, _super); + function Es5Panel() { + var _this = _super.call(this) || this; + _this.measured = 0; + return _this; + } + Es5Panel.prototype.MeasureOverride = function (available) { + this.measured++; + return Size(48, 24); + }; + return Es5Panel; + })(Controls.Panel); + + it("dispatches XAML layout to the overrides", async () => { + const panel = new Es5Panel(); + expect(panel instanceof Es5Panel).toBe(true); + expect(panel instanceof Controls.Panel).toBe(true); + container.Children.Append(panel); + await waitFor(() => panel.measured > 0 && panel.DesiredSize.Width === 48); + expect(panel.DesiredSize.Height).toBe(24); + }); + + it("calls the base implementation through _super.prototype", async () => { + const Es5Stack = (function (_super) { + __extends(Es5Stack, _super); + function Es5Stack() { + return _super.call(this) || this; + } + Es5Stack.prototype.MeasureOverride = function (available) { + this.called = true; + return _super.prototype.MeasureOverride.call(this, available); + }; + return Es5Stack; + })(Controls.StackPanel); + const panel = new Es5Stack(); + const label = new Controls.TextBlock(); + label.Text = "hello"; + panel.Children.Append(label); + container.Children.Append(panel); + await waitFor(() => panel.called === true && panel.ActualHeight > 0); + expect(panel.DesiredSize.Height).toBeGreaterThan(0); + }); + }); + + describe("with Class.extend()", () => { + it("dispatches XAML layout to the overrides", async () => { + const Sized = Controls.Panel.extend({ + MeasureOverride(available) { + return Size(64, 32); + }, + ArrangeOverride(finalSize) { + this.arranged = true; + return finalSize; + }, + }); + const panel = new Sized(); + container.Children.Append(panel); + await waitFor(() => panel.arranged === true); + expect(panel.DesiredSize.Width).toBe(64); + expect(panel instanceof Controls.Panel).toBe(true); + }); + }); +}); diff --git a/test-app/app/tests/helpers.js b/test-app/app/tests/helpers.js new file mode 100644 index 0000000..4cd5024 --- /dev/null +++ b/test-app/app/tests/helpers.js @@ -0,0 +1,66 @@ +// Shared helpers for specs that need the live XAML tree. + +const Controls = Microsoft.UI.Xaml.Controls; + +let rootPanel = null; +let window = null; + +function mainWindow() { + if (!window) { + window = new Microsoft.UI.Xaml.Window(); + window.Title = "NativeScript Windows TestRunner"; + window.Activate(); + } + return window; +} + +/** The window's root panel (a StackPanel), created on first use. */ +function root() { + if (rootPanel) return rootPanel; + const win = mainWindow(); + rootPanel = new Controls.StackPanel(); + win.Content = rootPanel; + return rootPanel; +} + +/** A fresh, empty Grid attached to the root for one spec; removed by `detach`. */ +function host(width = 300, height = 200) { + const grid = new Controls.Grid(); + grid.Width = width; + grid.Height = height; + root().Children.Append(grid); + return grid; +} + +function detach(element) { + const children = root().Children; + for (let i = 0; i < children.Size; i++) { + if (children.GetAt(i).Equals ? children.GetAt(i).Equals(element) : children.GetAt(i) === element) { + children.RemoveAt(i); + return; + } + } +} + +function waitFor(predicate, timeoutMs = 4000) { + return new Promise((resolve, reject) => { + const started = Date.now(); + (function poll() { + let ok = false; + try { + ok = predicate(); + } catch (e) { + return reject(e); + } + if (ok) return resolve(); + if (Date.now() - started > timeoutMs) return reject(new Error("timed out waiting for condition")); + setTimeout(poll, 10); + })(); + }); +} + +function delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +module.exports = { Controls, mainWindow, root, host, detach, waitFor, delay }; diff --git a/test-app/app/tests/layout-mutation.js b/test-app/app/tests/layout-mutation.js new file mode 100644 index 0000000..95b5d99 --- /dev/null +++ b/test-app/app/tests/layout-mutation.js @@ -0,0 +1,187 @@ +// Changing the XAML tree synchronously from layout and rendering callbacks. In a C# WinUI app +// adding, removing or reparenting elements from SizeChanged, LayoutUpdated or +// CompositionTarget.Rendering is legal, and on Android/iOS changing views from onSizeChanged / +// layoutSubviews works too, so it must not take the app down (stowed exception 0xC000027B) when +// the handler is JavaScript. + +const { Controls, host, detach, waitFor, delay } = require("./helpers"); + +function text(value) { + const t = new Controls.TextBlock(); + t.Text = value; + return t; +} + +describe("Changing the UI during layout", () => { + let container; + beforeEach(() => { + container = host(); + }); + afterEach(() => { + detach(container); + }); + + it("adds children from SizeChanged", async () => { + const panel = new Controls.StackPanel(); + let added = false; + panel.SizeChanged = () => { + if (added) return; + added = true; + panel.Children.Append(text("from SizeChanged")); + panel.Children.Append(text("and another")); + }; + container.Children.Append(panel); + await waitFor(() => added && panel.Children.Size === 2); + await delay(50); + expect(panel.Children.Size).toBe(2); + }); + + it("removes and reparents children from SizeChanged", async () => { + const a = new Controls.StackPanel(); + const b = new Controls.StackPanel(); + const child = text("moving"); + a.Children.Append(child); + let moved = false; + a.SizeChanged = () => { + if (moved) return; + moved = true; + a.Children.Clear(); + b.Children.Append(child); + }; + container.Children.Append(a); + container.Children.Append(b); + await waitFor(() => moved); + await delay(50); + expect(a.Children.Size).toBe(0); + expect(b.Children.Size).toBe(1); + }); + + it("adds children from LayoutUpdated", async () => { + const panel = new Controls.StackPanel(); + let added = false; + panel.LayoutUpdated = () => { + if (added) return; + added = true; + panel.Children.Append(text("from LayoutUpdated")); + }; + container.Children.Append(panel); + await waitFor(() => added && panel.Children.Size === 1); + await delay(50); + panel.LayoutUpdated = null; + expect(panel.Children.Size).toBe(1); + }); + + it("adds and removes children from CompositionTarget.Rendering", async () => { + const panel = new Controls.StackPanel(); + container.Children.Append(panel); + let frames = 0; + const handler = () => { + frames++; + if (frames === 1) panel.Children.Append(text("frame 1")); + if (frames === 2) panel.Children.Append(text("frame 2")); + if (frames === 3) panel.Children.RemoveAt(0); + }; + Microsoft.UI.Xaml.Media.CompositionTarget.Rendering = handler; + try { + await waitFor(() => frames >= 4); + } finally { + Microsoft.UI.Xaml.Media.CompositionTarget.Rendering = null; + } + await delay(50); + expect(panel.Children.Size).toBe(1); + }); + + it("reparents children from LayoutUpdated", async () => { + const a = new Controls.StackPanel(); + const b = new Controls.StackPanel(); + const child = text("moving"); + a.Children.Append(child); + let moved = false; + a.LayoutUpdated = () => { + if (moved) return; + moved = true; + a.Children.RemoveAt(0); + b.Children.Append(child); + }; + container.Children.Append(a); + container.Children.Append(b); + await waitFor(() => moved); + await delay(50); + a.LayoutUpdated = null; + expect(b.Children.Size).toBe(1); + }); + + it("reparents a subtree from CompositionTarget.Rendering", async () => { + const a = new Controls.StackPanel(); + const b = new Controls.StackPanel(); + const subtree = new Controls.StackPanel(); + subtree.Children.Append(text("one")); + subtree.Children.Append(text("two")); + a.Children.Append(subtree); + container.Children.Append(a); + container.Children.Append(b); + let moved = false; + Microsoft.UI.Xaml.Media.CompositionTarget.Rendering = () => { + if (moved) return; + moved = true; + a.Children.Clear(); + b.Children.Append(subtree); + }; + try { + await waitFor(() => moved); + await delay(50); + } finally { + Microsoft.UI.Xaml.Media.CompositionTarget.Rendering = null; + } + expect(a.Children.Size).toBe(0); + expect(b.Children.Size).toBe(1); + }); + + it("removes the element whose SizeChanged is firing", async () => { + const panel = new Controls.StackPanel(); + panel.Children.Append(text("about to go")); + let removed = false; + panel.SizeChanged = () => { + if (removed) return; + removed = true; + container.Children.Clear(); + }; + container.Children.Append(panel); + await waitFor(() => removed); + await delay(50); + expect(container.Children.Size).toBe(0); + }); + + it("mutates the tree from promise continuations scheduled in a layout callback", async () => { + const panel = new Controls.StackPanel(); + let scheduled = false; + let ran = false; + panel.SizeChanged = () => { + if (scheduled) return; + scheduled = true; + Promise.resolve().then(() => { + panel.Children.Append(text("from a microtask")); + ran = true; + }); + }; + container.Children.Append(panel); + await waitFor(() => ran); + await delay(50); + expect(panel.Children.Size).toBe(1); + }); + + it("replaces content from SizeChanged", async () => { + const border = new Controls.Border(); + border.Child = text("before"); + let swapped = false; + border.SizeChanged = () => { + if (swapped) return; + swapped = true; + border.Child = text("after"); + }; + container.Children.Append(border); + await waitFor(() => swapped); + await delay(50); + expect(border.Child.Text).toBe("after"); + }); +}); diff --git a/test-app/app/tests/lifetime.js b/test-app/app/tests/lifetime.js new file mode 100644 index 0000000..d26e035 --- /dev/null +++ b/test-app/app/tests/lifetime.js @@ -0,0 +1,81 @@ +// Lifetime of JS subclass instances. An instance is a JS object plus a managed object; neither +// keeps the other alive on its own, so an instance nothing references is collected, while one +// native code holds keeps working (its JS object is made anew if it was collected, as on Android). +// Subclasses of WinRT UI classes stay alive, fields included, while they are in the visual tree. +// Fixtures: App_Resources/Windows/src/TestFixtures/Lifetime.cs. + +const { Controls, host, detach, delay } = require("./helpers"); +const { Tracked, Gc } = TestFixtures.Lifetime; +const { Animal, Native } = TestFixtures.Inheritance; + +// Collects garbage on both sides until `done()` holds: JS first (FinalizationRegistry callbacks run +// as tasks, so wait between rounds), then .NET. +async function collect(done, rounds = 10) { + for (let i = 0; i < rounds && !(done && done()); i++) { + gc(); + await delay(20); + Gc.Collect(); + await delay(20); + } +} + +const describeIfGc = typeof gc === "function" ? describe : xdescribe; + +describeIfGc("Lifetime of JS subclass instances", () => { + it("releases instances neither JS nor native code references", async () => { + class Dropped extends Tracked { + Name() { + return "dropped"; + } + } + const before = Tracked.Finalized; + (function () { + for (let i = 0; i < 20; i++) new Dropped(); + })(); + await collect(() => Tracked.Finalized - before >= 20); + expect(Tracked.Finalized - before).toBeGreaterThanOrEqual(15); + }, 15000); + + it("keeps working when only native code holds the instance", async () => { + class Parrot extends Animal { + Speak() { + return "polly"; + } + } + Native.Keep(new Parrot("p")); + await collect(null, 4); + const kept = Native.Kept(); + expect(Native.Speak(kept)).toBe("polly"); + expect(kept instanceof Parrot).toBe(true); + expect(kept.Name).toBe("p"); + expect(Native.Kept()).toBe(kept); + }, 15000); + + it("keeps a WinRT UI subclass in the visual tree alive, fields included", async () => { + class Sized extends Controls.Panel { + constructor() { + super(); + this.width = 77; + } + + MeasureOverride() { + return { Width: this.width, Height: 10 }; + } + } + const container = host(); + try { + container.Children.Append(new Sized()); + // Let it get into the tree and past the next attachment check. + await delay(2100); + container.InvalidateMeasure(); + container.UpdateLayout(); + await collect(null, 4); + const child = container.Children.GetAt(0); + expect(child instanceof Sized).toBe(true); + expect(child.width).toBe(77); + expect(child.DesiredSize.Width).toBe(77); + } finally { + detach(container); + } + }, 15000); +}); diff --git a/test-app/app/tests/threading.js b/test-app/app/tests/threading.js new file mode 100644 index 0000000..9cabe69 --- /dev/null +++ b/test-app/app/tests/threading.js @@ -0,0 +1,211 @@ +// Callbacks invoked by native code on a background thread are delivered to JavaScript, as on iOS +// and Android: the calling thread waits while the callback runs on the JS (UI) thread, so return +// values flow back to native code. Fixtures: App_Resources/Windows/src/TestFixtures/Threading.cs. + +function waitFor(predicate, timeoutMs = 4000) { + return new Promise((resolve, reject) => { + const started = Date.now(); + (function poll() { + if (predicate()) return resolve(); + if (Date.now() - started > timeoutMs) return reject(new Error("timed out waiting for condition")); + setTimeout(poll, 10); + })(); + }); +} + +describe("Threading", () => { + beforeAll(() => { + TestFixtures.Threading.BackgroundInvoker.CaptureUiThread(); + }); + + describe("WinRT delegates", () => { + it("delivers a WorkItemHandler invoked on the thread pool", async () => { + let ran = false; + let onUiThread = null; + const op = Windows.System.Threading.ThreadPool.RunAsync(() => { + ran = true; + onUiThread = TestFixtures.Threading.BackgroundInvoker.IsUiThread(); + }); + await NSWinRT.toPromise(op); + expect(ran).toBe(true); + expect(onUiThread).toBe(true); + }); + + it("delivers a ThreadPoolTimer callback", async () => { + let fired = 0; + Windows.System.Threading.ThreadPoolTimer.CreateTimer(() => { + fired++; + }, { Duration: 10 * 10000 }); + await waitFor(() => fired > 0); + expect(fired).toBe(1); + }); + + it("delivers many background invocations in order", async () => { + let count = 0; + const ops = []; + for (let i = 0; i < 20; i++) ops.push(NSWinRT.toPromise(Windows.System.Threading.ThreadPool.RunAsync(() => count++))); + await Promise.all(ops); + expect(count).toBe(20); + }); + }); + + describe(".NET delegates", () => { + it("delivers a Func<> invoked on a background thread and returns its result", async () => { + let result = null; + let background = null; + let calledOnUi = null; + TestFixtures.Threading.BackgroundInvoker.ComputeInBackground( + (v) => { + calledOnUi = TestFixtures.Threading.BackgroundInvoker.IsUiThread(); + return v * 2; + }, + 21, + (r, bg) => { + result = r; + background = bg; + } + ); + await waitFor(() => result !== null); + expect(result).toBe(42); + expect(background).toBe(true); + expect(calledOnUi).toBe(true); + }); + + it("delivers calls from several threads at once", async () => { + let done = null; + TestFixtures.Threading.BackgroundInvoker.FanOut((n) => n + 1, 8, (count, sum) => (done = { count, sum })); + await waitFor(() => done !== null); + expect(done).toEqual({ count: 8, sum: 36 }); + }); + + it("surfaces a JS exception thrown from a background call to the native caller", async () => { + let outcome = null; + TestFixtures.Threading.BackgroundInvoker.ThrowInBackground( + () => { + throw new Error("from js"); + }, + (r) => (outcome = r) + ); + await waitFor(() => outcome !== null); + expect(outcome).not.toBe("no-exception"); + }); + + it("delivers C# events raised on a background thread", async () => { + const ticker = new TestFixtures.Threading.Ticker(); + const ticks = []; + ticker.add_Tick((sender, i) => ticks.push(i)); + ticker.StartInBackground(3); + await waitFor(() => ticks.length === 3); + expect(ticks).toEqual([1, 2, 3]); + }); + + it("resolves Task continuations that complete on the thread pool", async () => { + const value = await TestFixtures.Basics.Callbacks.AddLaterAsync(1, 2, 20); + expect(value).toBe(3); + }); + }); + + // A callback created in a worker belongs to that worker's isolate: invoked on a background + // thread, it runs on the worker's thread (as on iOS and Android), not the UI thread. The + // Node-API engines don't implement workers. + (typeof Worker === "function" ? describe : xdescribe)("Workers", () => { + const workerSource = ` + for (const name of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) { + if (typeof globalThis[name] !== "function") globalThis[name] = globalThis["__ns__" + name]; + } + const BI = TestFixtures.Threading.BackgroundInvoker; + const workerThread = BI.CurrentThreadId(); + const onWorker = () => BI.CurrentThreadId() === workerThread; + const report = (test, run) => + Promise.resolve() + .then(run) + .then((result) => self.postMessage({ test, result }), (e) => self.postMessage({ test, error: String(e && e.message || e) })); + self.onmessage = (e) => { + if (e.data === "winrt") { + report("winrt", async () => { + let ranOnWorker = null; + await NSWinRT.toPromise(Windows.System.Threading.ThreadPool.RunAsync(() => (ranOnWorker = onWorker()))); + return { ranOnWorker }; + }); + } else if (e.data === "dotnet") { + report("dotnet", () => new Promise((resolve) => { + let computedOnWorker = null; + BI.ComputeInBackground( + (v) => ((computedOnWorker = onWorker()), v * 2), + 21, + (r, background) => resolve({ r, background, computedOnWorker, doneOnWorker: onWorker() }) + ); + })); + } else if (e.data === "event") { + report("event", () => new Promise((resolve) => { + const ticker = new TestFixtures.Threading.Ticker(); + const ticks = []; + ticker.add_Tick((sender, i) => { + ticks.push(onWorker() ? i : -i); + if (ticks.length === 3) resolve(ticks); + }); + ticker.StartInBackground(3); + })); + } + }; + `; + let worker; + const replies = {}; + + function ask(test, timeoutMs = 6000) { + worker.postMessage(test); + return waitFor(() => test in replies, timeoutMs).then(() => { + const reply = replies[test]; + if (reply.error) throw new Error(reply.error); + return reply.result; + }); + } + + beforeAll(() => { + worker = new Worker(workerSource, { eval: true }); + worker.onmessage = (e) => (replies[e.data.test] = e.data); + }); + + afterAll(() => worker.terminate()); + + it("delivers a WinRT delegate created in a worker on the worker's thread", async () => { + expect(await ask("winrt")).toEqual({ ranOnWorker: true }); + }); + + it("delivers a .NET delegate created in a worker on the worker's thread", async () => { + expect(await ask("dotnet")).toEqual({ r: 42, background: true, computedOnWorker: true, doneOnWorker: true }); + }); + + it("delivers C# events subscribed in a worker on the worker's thread", async () => { + expect(await ask("event")).toEqual([1, 2, 3]); + }); + + it("delivers messages a worker posts on its own", async () => { + const seen = []; + const ticking = new Worker( + `for (const name of ["setTimeout", "clearTimeout"]) if (typeof globalThis[name] !== "function") globalThis[name] = globalThis["__ns__" + name]; + setTimeout(() => self.postMessage("later"), 50);`, + { eval: true } + ); + ticking.onmessage = (e) => seen.push(e.data); + await waitFor(() => seen.length > 0); + ticking.terminate(); + expect(seen).toEqual(["later"]); + }); + + it("refuses a callback whose worker has terminated without crashing", async () => { + const BI = TestFixtures.Threading.BackgroundInvoker; + let armed = false; + const doomed = new Worker( + `TestFixtures.Threading.BackgroundInvoker.CallLater(() => 1, 300); self.postMessage("armed");`, + { eval: true } + ); + doomed.onmessage = () => (armed = true); + await waitFor(() => armed); + doomed.terminate(); + await waitFor(() => BI.LastLateCall != null); + expect(BI.LastLateCall).toBe("JsException"); + expect(BI.CurrentThreadId()).toBeGreaterThan(0); + }); + }); +}); diff --git a/test-app/plugins/test-plugin/package.json b/test-app/plugins/test-plugin/package.json new file mode 100644 index 0000000..93799eb --- /dev/null +++ b/test-app/plugins/test-plugin/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-plugin", + "version": "1.0.0", + "main": "index.js", + "nativescript": { + "platforms": { + "windows": "0.1.0" + } + } +} diff --git a/test-app/plugins/test-plugin/platforms/windows/src/PluginGreeter.cs b/test-app/plugins/test-plugin/platforms/windows/src/PluginGreeter.cs new file mode 100644 index 0000000..664676d --- /dev/null +++ b/test-app/plugins/test-plugin/platforms/windows/src/PluginGreeter.cs @@ -0,0 +1,11 @@ +// A plugin's C# sources (platforms/windows/**/*.cs) are compiled into the app, the way a plugin's +// platforms/android Java/Kotlin and platforms/ios Objective-C/Swift sources are. +namespace TestPlugin.Native +{ + public class PluginGreeter + { + public string Greet(string name) => $"Hello from the plugin, {name}!"; + + public static string PluginName => "test-plugin"; + } +} diff --git a/test-app/run.ps1 b/test-app/run.ps1 new file mode 100644 index 0000000..3a665f2 --- /dev/null +++ b/test-app/run.ps1 @@ -0,0 +1,210 @@ +#!/usr/bin/env pwsh +<# +.SYNOPSIS + Builds and runs the Windows runtime test app (the counterpart of the Android/iOS test-app suites). + +.DESCRIPTION + Generates a platform project from template/framework the way `ns platform add windows` + + `ns prepare windows` do, then builds and launches it: + - template/framework/__PROJECT_NAME__ -> platforms/windows/TestRunner (placeholders replaced) + - test-app/app -> TestRunner/app (JS specs + runner) + - test-app/App_Resources/Windows -> TestRunner/App_Resources/Windows (C# fixtures in src/) + - test-app/plugins//platforms/windows -> TestRunner/plugins/ (plugin C# sources) + The freshly built nativescript.dll and the dotnet-bridge sources from this repo are used, and the + dotnet-tool runs from source via cargo, so the run reflects the working tree. + + The app writes JUnit XML and a plain log next to the executable, then exits with the number of + failed specs. This script prints the log and exits non-zero when any spec failed. + +.EXAMPLE + pwsh test-app/run.ps1 + pwsh test-app/run.ps1 -Filter "Threading" + pwsh test-app/run.ps1 -SkipRuntimeBuild + pwsh test-app/run.ps1 -EngineDll packages/windows-v8/target/release/windows_v8.dll # a napi engine +#> +param( + [ValidateSet("Debug", "Release")] + [string]$Configuration = "Debug", + [ValidateSet("x64", "arm64")] + [string]$Platform = "x64", + [switch]$SkipRuntimeBuild, + # Another runtime DLL to test instead of the classic one, e.g. an engine package's cdylib + # (packages/windows-, `cargo build --release --features host_dll`); DLLs next to it + # (engine sidecars) are deployed too. + [string]$EngineDll = "", + [switch]$Clean, + [string]$Filter = "", + [int]$TimeoutSeconds = 300 +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +$TestAppDir = $PSScriptRoot +$RepoRoot = Resolve-Path (Join-Path $TestAppDir "..") +$Framework = Join-Path $RepoRoot "template\framework" +$PlatformsDir = Join-Path $TestAppDir "platforms\windows" +$ProjectName = "TestRunner" +$AppId = "org.nativescript.windows.testrunner" +$ProjectDir = Join-Path $PlatformsDir $ProjectName + +function Step([string]$msg) { Write-Host "==> $msg" -ForegroundColor Cyan } + +# ── 1. runtime ─────────────────────────────────────────────────────────────── +$rustTarget = if ($Platform -eq "arm64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } +$cargoProfile = if ($Configuration -eq "Debug") { "dev" } else { "release" } +$profileDir = if ($Configuration -eq "Debug") { "debug" } else { "release" } +$hostTarget = if ($env:PROCESSOR_ARCHITECTURE -eq "ARM64") { "aarch64-pc-windows-msvc" } else { "x86_64-pc-windows-msvc" } +$runtimeDll = if ($rustTarget -eq $hostTarget) { + Join-Path $RepoRoot "target\$profileDir\nativescript.dll" +} else { + Join-Path $RepoRoot "target\$rustTarget\$profileDir\nativescript.dll" +} +if ($EngineDll) { + $runtimeDll = (Resolve-Path $EngineDll).Path + $SkipRuntimeBuild = $true +} +if (-not $SkipRuntimeBuild) { + Step "Building nativescript.dll ($cargoProfile, $rustTarget)" + Push-Location $RepoRoot + try { + if ($rustTarget -eq $hostTarget) { cargo build -p nativescript --profile $cargoProfile } + else { cargo build -p nativescript --profile $cargoProfile --target $rustTarget } + if ($LASTEXITCODE -ne 0) { throw "cargo build failed" } + } finally { Pop-Location } +} +if (-not (Test-Path $runtimeDll)) { throw "Runtime DLL not found at $runtimeDll (run without -SkipRuntimeBuild)" } + +# ── 2. platform project (ns platform add) ──────────────────────────────────── +if ($Clean -and (Test-Path $PlatformsDir)) { + Step "Removing $PlatformsDir" + Remove-Item -Recurse -Force $PlatformsDir +} +New-Item -ItemType Directory -Force -Path $PlatformsDir | Out-Null + +function Copy-Tree([string]$src, [string]$dest, [string[]]$excludeDirs = @("bin", "obj")) { + New-Item -ItemType Directory -Force -Path $dest | Out-Null + Get-ChildItem -LiteralPath $src -Force | ForEach-Object { + if ($_.PSIsContainer) { + if ($excludeDirs -notcontains $_.Name) { Copy-Tree $_.FullName (Join-Path $dest $_.Name) $excludeDirs } + } else { + Copy-Item -LiteralPath $_.FullName -Destination (Join-Path $dest $_.Name) -Force + } + } +} + +Step "Generating platform project from template/framework" +Copy-Tree (Join-Path $Framework "__PROJECT_NAME__") $ProjectDir +$placeholderCsproj = Join-Path $ProjectDir "__PROJECT_NAME__.csproj" +$csproj = Join-Path $ProjectDir "$ProjectName.csproj" +if (Test-Path $placeholderCsproj) { Move-Item -Force $placeholderCsproj $csproj } +$textExt = @(".cs", ".csproj", ".xaml", ".xml", ".json", ".appxmanifest", ".props", ".targets") +Get-ChildItem -LiteralPath $ProjectDir -Recurse -File | + Where-Object { $textExt -contains $_.Extension.ToLowerInvariant() -and $_.FullName -notmatch "\\(bin|obj)\\" } | + ForEach-Object { + $content = [IO.File]::ReadAllText($_.FullName) + if ($content.Contains("__PROJECT_NAME__") -or $content.Contains("__APP_IDENTIFIER__")) { + $content = $content.Replace("__PROJECT_NAME__", $ProjectName).Replace("__APP_IDENTIFIER__", $AppId) + [IO.File]::WriteAllText($_.FullName, $content) + } + } + +# Bridge from source (not the template's synced copy) and the freshly built runtime. +Copy-Tree (Join-Path $RepoRoot "dotnet-bridge") (Join-Path $PlatformsDir "dotnet-bridge") @("bin", "obj", "publish", "publish_build") +$bridgePublish = Join-Path $PlatformsDir "dotnet-bridge\publish" +if (Test-Path $bridgePublish) { Remove-Item -Recurse -Force $bridgePublish } +$libDir = Join-Path $PlatformsDir "libs\$Platform" +# Start from an empty libs dir and no deployed runtime: the csproj copies with PreserveNewest, and +# a switched DLL can be older than the one already in bin\. +if (Test-Path $libDir) { Remove-Item -Recurse -Force $libDir } +New-Item -ItemType Directory -Force -Path $libDir | Out-Null +Get-ChildItem -LiteralPath (Join-Path $ProjectDir "bin") -Filter *.dll -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq "nativescript.dll" -or ($EngineDll -and (Test-Path (Join-Path (Split-Path $runtimeDll) $_.Name))) } | + Remove-Item -Force +Copy-Item -Force $runtimeDll (Join-Path $libDir "nativescript.dll") +if ($EngineDll) { + Get-ChildItem -LiteralPath (Split-Path $runtimeDll) -Filter *.dll | + Where-Object { $_.FullName -ne $runtimeDll } | + ForEach-Object { Copy-Item -Force $_.FullName $libDir } +} +# The ManifestMerger task ships prebuilt; dotnet-tool runs from source (cargo) because the +# template's tools\*.exe are not copied, which makes the csproj fall back to `cargo run`. +Copy-Tree (Join-Path $Framework "tools\ManifestMerger") (Join-Path $PlatformsDir "tools\ManifestMerger") + +# ── 3. prepare (app, App_Resources, plugins) ───────────────────────────────── +Step "Preparing app, App_Resources and plugins" +$appDest = Join-Path $ProjectDir "app" +if (Test-Path $appDest) { Remove-Item -Recurse -Force $appDest } +Copy-Tree (Join-Path $TestAppDir "app") $appDest +$resDest = Join-Path $ProjectDir "App_Resources\Windows" +if (Test-Path $resDest) { Remove-Item -Recurse -Force $resDest } +Copy-Tree (Join-Path $TestAppDir "App_Resources\Windows") $resDest + +# Mirrors WindowsProjectService.preparePluginNativeCode/prepareProject in the CLI: stage each +# plugin's platforms/windows folder under plugins// and import its plugin.props/targets. +$pluginsDest = Join-Path $ProjectDir "plugins" +if (Test-Path $pluginsDest) { Remove-Item -Recurse -Force $pluginsDest } +New-Item -ItemType Directory -Force -Path $pluginsDest | Out-Null +$propsLines = @('', '') +$targetsLines = @('', '') +$pluginsSrc = Join-Path $TestAppDir "plugins" +if (Test-Path $pluginsSrc) { + Get-ChildItem -LiteralPath $pluginsSrc -Directory | ForEach-Object { + $native = Join-Path $_.FullName "platforms\windows" + if (-not (Test-Path $native)) { return } + Copy-Tree $native (Join-Path $pluginsDest $_.Name) + foreach ($f in @("plugin.props", "plugin.targets")) { + $p = Join-Path $_.FullName $f + if (Test-Path $p) { Copy-Item -Force $p (Join-Path $pluginsDest "$($_.Name)\$f") } + } + $propsLines += " " + $targetsLines += " " + } +} +$propsLines += '' +$targetsLines += '' +Set-Content -Encoding utf8 (Join-Path $pluginsDest "Plugins.props") ($propsLines -join "`n") +Set-Content -Encoding utf8 (Join-Path $pluginsDest "Plugins.targets") ($targetsLines -join "`n") + +# ── 4. build ───────────────────────────────────────────────────────────────── +Step "Building $ProjectName ($Configuration|$Platform)" +$outDir = Join-Path $ProjectDir "bin" +$env:NS_WINDOWS_RUNTIME_ROOT = "$RepoRoot" +# Unpackaged + self-contained Windows App SDK so the runner starts straight from bin\ without +# registering an MSIX package (the CLI's `ns run windows` registers one instead). +$buildLog = Join-Path $PlatformsDir "build.log" +dotnet build $csproj -c $Configuration "-p:Platform=$Platform" --output $outDir ` + "-p:WindowsPackageType=None" "-p:WindowsAppSDKSelfContained=true" -nologo -v:minimal *> $buildLog +if ($LASTEXITCODE -ne 0) { + Get-Content $buildLog | Select-String -Pattern "error" | Select-Object -First 40 | ForEach-Object { Write-Host $_ } + throw "dotnet build failed (full log: $buildLog)" +} + +# ── 5. run ─────────────────────────────────────────────────────────────────── +$exe = Join-Path $outDir "$ProjectName.exe" +$resultsXml = Join-Path $outDir "test-results.xml" +$resultsLog = Join-Path $outDir "test-results.log" +Remove-Item -Force -ErrorAction SilentlyContinue $resultsXml, $resultsLog + +Step "Running $exe" +$env:NS_TEST_RESULTS = $resultsXml +$env:NS_TEST_LOG = $resultsLog +$env:NS_TEST_FILTER = $Filter +$proc = Start-Process -FilePath $exe -WorkingDirectory $outDir -PassThru +if (-not $proc.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue + if (Test-Path $resultsLog) { Get-Content $resultsLog } + throw "Test app did not finish within $TimeoutSeconds s" +} + +if (Test-Path $resultsLog) { Get-Content $resultsLog } +if (-not (Test-Path $resultsXml)) { + throw ("Test app exited with 0x{0:X8} without writing results (crash?). See {1}" -f $proc.ExitCode, $outDir) +} +Write-Host "JUnit results: $resultsXml" +$failed = $proc.ExitCode +if ($failed -ne 0) { + Write-Host ("{0} spec(s) failed" -f $failed) -ForegroundColor Red + exit 1 +} +Write-Host "All specs passed" -ForegroundColor Green diff --git a/typings/nswinrt-helpers.d.ts b/typings/nswinrt-helpers.d.ts index 7d11e8a..4be1290 100644 --- a/typings/nswinrt-helpers.d.ts +++ b/typings/nswinrt-helpers.d.ts @@ -7,6 +7,12 @@ declare global { // Usage: @Interfaces([Windows.UI.Xaml.Controls.Primitives.IButtonBase]) function Interfaces(interfaces: any[]): ClassDecorator; + // TypeScript @NativeClass decorator — marks a class extending a native (WinRT/.NET) class, as on + // iOS/Android. Extending works without it on Windows; it keeps shared code compiling unchanged. + // Usage: @NativeClass() class MyPanel extends Microsoft.UI.Xaml.Controls.Panel { ... } + function NativeClass(target: T): T; + function NativeClass(): ClassDecorator; + // TypeScript @CSharpProxy decorator — set an explicit managed type name. // Usage: @CSharpProxy('MyApp.Namespace.MyButton') // When present, creates a real C# subclass via the managed bridge. diff --git a/windows-napi/src/delegate_test.rs b/windows-napi/src/delegate_test.rs index bf9c196..292260c 100644 --- a/windows-napi/src/delegate_test.rs +++ b/windows-napi/src/delegate_test.rs @@ -7,7 +7,7 @@ use napi_derive::napi; use windows_core::GUID; use runtime::napi_engine::delegate::{ - invoke_delegate_raw, make_napi_delegate, release_delegate_raw, + invoke_delegate_raw, make_napi_void_delegate, release_delegate_raw, }; use runtime::napi_engine::NativeType; @@ -37,7 +37,7 @@ pub fn make_delegate(env: Env, func: JsFunction, param_types: Vec) -> na .collect::>>() .ok_or_else(|| napi::Error::from_reason("unknown param type"))?; let guid = GUID::from_u128(0x11223344_5566_7788_99aa_bbccddeeff00); - match make_napi_delegate(&env, &func, guid, types) { + match make_napi_void_delegate(&env, &func, guid, types) { Some(ptr) => Ok(ptr as usize as f64), None => Err(napi::Error::from_reason("make_napi_delegate failed")), }