diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a4483b10..dab4bae3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -308,9 +308,8 @@ jobs: & $msbuild "${{ runner.temp }}\integration-tests\buildtask-sdk\buildtask-sdk.slnx" /r shell: pwsh - name: 🧪 dotnet publish buildtask-sdk-style - run: dotnet publish -r win-x64 + run: dotnet publish -c Release -r win-x64 working-directory: ${{ runner.temp }}/integration-tests/buildtask-sdk - shell: pwsh validate: name: ✅ Validate diff --git a/Microsoft.Windows.CsWin32.sln b/Microsoft.Windows.CsWin32.sln index 9fd4764f..926a92ee 100644 --- a/Microsoft.Windows.CsWin32.sln +++ b/Microsoft.Windows.CsWin32.sln @@ -60,6 +60,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenerationSandbox.BuildTask {DBA19B76-E786-297A-C44F-6121CA5FFCDC} = {DBA19B76-E786-297A-C44F-6121CA5FFCDC} EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenerationSandbox.AutoWinRTDisabled.Tests", "test\GenerationSandbox.AutoWinRTDisabled.Tests\GenerationSandbox.AutoWinRTDisabled.Tests.csproj", "{A3AACB6C-0398-4325-B1C7-D9CE72A909DA}" + ProjectSection(ProjectDependencies) = postProject + {D3363193-DE88-5AFE-BA2D-CAF66280F243} = {D3363193-DE88-5AFE-BA2D-CAF66280F243} + {DBA19B76-E786-297A-C44F-6121CA5FFCDC} = {DBA19B76-E786-297A-C44F-6121CA5FFCDC} + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenerationSandbox.BuiltInCom.Tests", "test\GenerationSandbox.BuiltInCom.Tests\GenerationSandbox.BuiltInCom.Tests.csproj", "{1D316174-9568-4128-AE4C-B1A01EBA65FE}" + ProjectSection(ProjectDependencies) = postProject + {D3363193-DE88-5AFE-BA2D-CAF66280F243} = {D3363193-DE88-5AFE-BA2D-CAF66280F243} + {DBA19B76-E786-297A-C44F-6121CA5FFCDC} = {DBA19B76-E786-297A-C44F-6121CA5FFCDC} + EndProjectSection +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -162,6 +174,22 @@ Global {A0253377-BC40-63BD-F41A-AC7EBA7A3C54}.Release|Any CPU.Build.0 = Release|Any CPU {A0253377-BC40-63BD-F41A-AC7EBA7A3C54}.Release|NonWindows.ActiveCfg = Release|Any CPU {A0253377-BC40-63BD-F41A-AC7EBA7A3C54}.Release|NonWindows.Build.0 = Release|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Debug|NonWindows.ActiveCfg = Debug|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Debug|NonWindows.Build.0 = Debug|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Release|Any CPU.Build.0 = Release|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Release|NonWindows.ActiveCfg = Release|Any CPU + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA}.Release|NonWindows.Build.0 = Release|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Debug|NonWindows.ActiveCfg = Debug|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Debug|NonWindows.Build.0 = Debug|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Release|Any CPU.Build.0 = Release|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Release|NonWindows.ActiveCfg = Release|Any CPU + {1D316174-9568-4128-AE4C-B1A01EBA65FE}.Release|NonWindows.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -178,6 +206,8 @@ Global {168AF67E-678E-FDC9-7C20-7407D58A37FE} = {36CCE840-6FE5-4DB9-A8D5-8CF3CB6D342A} {DC26E9DC-DFB1-19BD-6BAA-38B23E99C3C6} = {36CCE840-6FE5-4DB9-A8D5-8CF3CB6D342A} {A0253377-BC40-63BD-F41A-AC7EBA7A3C54} = {36CCE840-6FE5-4DB9-A8D5-8CF3CB6D342A} + {A3AACB6C-0398-4325-B1C7-D9CE72A909DA} = {36CCE840-6FE5-4DB9-A8D5-8CF3CB6D342A} + {1D316174-9568-4128-AE4C-B1A01EBA65FE} = {36CCE840-6FE5-4DB9-A8D5-8CF3CB6D342A} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {E3944F6A-384B-4B0F-B93F-3BD513DC57BD} diff --git a/azure-pipelines/integration-test.yml b/azure-pipelines/integration-test.yml index b75700f3..965e247c 100644 --- a/azure-pipelines/integration-test.yml +++ b/azure-pipelines/integration-test.yml @@ -58,7 +58,7 @@ jobs: solution: $(Pipeline.Workspace)\integration-tests\buildtask-sdk\buildtask-sdk.slnx msbuildArguments: /r condition: eq(variables.currentSdk, 'true') - - pwsh: dotnet publish -r win-x64 + - pwsh: dotnet publish -c Release -r win-x64 displayName: 🧪 dotnet publish workingDirectory: $(Pipeline.Workspace)\integration-tests\buildtask-sdk condition: eq(variables.currentSdk, 'true') diff --git a/docs/design/adaptive-com-winrt-object-marshalling.md b/docs/design/adaptive-com-winrt-object-marshalling.md new file mode 100644 index 00000000..fe622b21 --- /dev/null +++ b/docs/design/adaptive-com-winrt-object-marshalling.md @@ -0,0 +1,184 @@ +# Automatic COM and Windows Runtime object out-parameter marshalling + +## Status + +Accepted. + +CsWin32 will automatically detect Windows Runtime objects returned through recognized COM `IID`/`void**` out-parameter pairs. The automatic behavior is enabled by default and can be disabled globally in `NativeMethods.json`. + +The caller-selected policy was considered but not selected. Unique COM wrapper ownership remains separate work. + +## Motivation + +CsWin32 projects COM object outputs as COM wrappers. That is correct for ordinary COM, but it prevents an object returned through an `IID`/`void**` pair from being used as a C#/WinRT projection: + +```csharp +shellItem.BindToHandler( + null, + bhidStorageItem, + out IStorageItem storageItem); +``` + +The native object returned by `BindToHandler` implements `IInspectable`, but COM-only marshalling creates a `ComObject`. That wrapper cannot safely provide the C#/WinRT `IStorageItem` behavior. The problem also occurs when the immediate output type is `object` or a COM interface and the caller casts to a WinRT interface later. + +Callers should not have to know which wrapper family to request. The returned native identity already provides the authoritative answer: + +- An identity that implements `IInspectable` should be projected through C#/WinRT. +- An identity that returns `E_NOINTERFACE` for `IInspectable` should use normal COM projection. + +The extra `QueryInterface(IInspectable)` is accepted in exchange for automatic behavior and substantially simpler generated APIs. + +## Decision + +For each eligible COM object output: + +1. Request the native interface identified by the friendly method's `T`. +2. Query the returned identity for `IInspectable`. +3. On success, project the value with `WinRT.MarshalInspectable.FromAbi`. +4. On `E_NOINTERFACE`, use the normal COM projection. +5. Propagate every other QI failure. + +This rule applies to source-generated flat P/Invokes, generated COM calls, and built-in COM friendly overloads. + +The generated friendly signature remains: + +```csharp +public static void BindToHandler( + this IShellItem @this, + IBindCtx? pbc, + in Guid bhid, + out T ppv) + where T : class; +``` + +No caller-visible marshalling enum, raw companion method, same-IID companion interface, or analyzer is required. + +## Configuration + +Automatic projection is enabled by default: + +```json +{ + "comInterop": { + "autoWinRTMarshalling": true + } +} +``` + +Set the option to `false` to preserve COM-only projection and avoid the additional `QI(IInspectable)`. + +The option has no effect when marshaling is disabled, C#/WinRT is absent, or the target framework lacks required custom-marshalling support. + +## Eligible methods + +The initial implementation recognizes a final metadata pair: + +```text +Guid* riid, [ComOutPtr] void** ppv +``` + +Non-final pairs and the one SDK method with two pairs remain future work. Disabling generic COM out-pointer friendly overloads does not disable source-generated ABI marshalling. + +## IID selection + +- `object` uses `IID_IUnknown`. +- C#/WinRT types use `WinRT.GuidGenerator.CreateIID(typeof(T))`. +- Generated COM types use `typeof(T).GUID`. + +The generic `T` is annotated with `DynamicallyAccessedMembers(PublicFields)` for trimming and Native AOT. + +IID selection chooses the native interface. The returned identity chooses the managed wrapper family. + +## Adaptive output marshaller + +Source-generated interop uses one object marshaller for `ManagedToUnmanagedOut` and `UnmanagedToManagedOut`. + +Native-to-managed conversion queries `IInspectable`, uses C#/WinRT on success, falls back to `ComInterfaceMarshaller` only for `E_NOINTERFACE`, and releases the original and temporary QI references independently. + +Managed-to-native output first preserves existing RCWs, then selects the CCW marshaller from the +runtime type: + +- An RCW discoverable through `ComWrappers.TryGetComInstance` returns its current native `IUnknown` + identity. +- A classic built-in COM RCW returns its current identity through `Marshal.GetIUnknownForObject`. +- A type marked with `[GeneratedComClass]` uses `ComInterfaceMarshaller`, preserving its + generated COM interface table. +- Every other type uses `WinRT.MarshalInspectable.FromManaged`, enabling the full WinRT CCW + interface set for ordinary managed objects. + +C#/WinRT diagnoses types that combine `[GeneratedComClass]` with projected WinRT interfaces, keeping +the two CCW paths mutually exclusive. + +Eligible `[LibraryImport]` declarations apply `[MarshalUsing]` directly to `out object`; no duplicate raw P/Invoke is generated. + +## Generated COM interfaces + +CsWin32 applies the adaptive marshaller to the object output without changing the IID parameter: + +```csharp +void BindToHandler( + IBindCtx? pbc, + Guid* bhid, + Guid* riid, + [MarshalUsing(typeof(ComOrWinRTObjectMarshaller))] + out object ppv); +``` + +For managed implementations, the output marshaller returns either the generated COM identity or the +C#/WinRT identity according to the runtime type. A generated managed consumer then applies the +adaptive input projection, and the friendly overload casts the projected object to `T`. That cast +performs the required interface QI, so no sibling-parameter state is needed. + +Managed implementations may return WinRT objects, inspectable COM objects, non-inspectable COM +objects, or `null`. Producing the exact interface pointer named by `riid` for arbitrary native callers +of managed implementations is a separate generated COM marshalling concern and is not added by this +proposal. + +## Built-in COM interop + +Classic `[ComImport]` and `DllImport` do not honor source-generated custom marshallers. Their friendly overloads post-process the built-in wrapper: + +1. Obtain its identity with `Marshal.GetIUnknownForObject`. +2. Query `IInspectable`. +3. Project through C#/WinRT on success. +4. Return the original built-in wrapper on `E_NOINTERFACE`. +5. Release temporary references. + +This creates a transient built-in RCW. CsWin32 does not final-release it because it may be identity-cached and shared. + +Runtime coverage invokes a WinRT member after adaptation; a cast alone is not sufficient to prove correct vtable dispatch. + +## Inspectable objects used through COM interfaces + +C#/WinRT wrappers on .NET 8 and later can dynamically expose source-generated COM interfaces. An inspectable shell stream can therefore be projected as `WinRT.IInspectable`, cast to CsWin32's `IStream`, and invoked. + +Consumers that disable C#/WinRT dynamic interface casting cannot rely on this behavior. + +## Native AOT + +The design uses generated COM metadata and custom marshallers. Native AOT callers rely on interface contracts rather than concrete runtime-class wrapper types. + +The integration suite publishes a Native AOT package-consumption app. + +## Behavior and cost + +Inspectable values that previously appeared as COM wrappers now appear as C#/WinRT wrappers. Non-inspectable values remain COM wrappers. + +Each eligible output adds one `QI(IInspectable)`. Only `E_NOINTERFACE` selects COM fallback; other failures propagate. + +CsWin32 projections are primarily internal, so preserving previous generated source or managed ABI signatures is not a requirement. The native ABI remains unchanged. + +## Non-goals + +- Unique COM wrapper ownership. +- Input marshalling changes. +- Every fixed-type COM output. +- Non-final or multiple IID/output pairs in the initial implementation. +- Concrete WinRT runtime-class wrapper identity. +- Exact-`riid` output pointers from managed implementations consumed directly by arbitrary native callers. + +## Validation + +Coverage includes generator-shape tests, source-generated and built-in runtime tests, enabled and +disabled behavior, WinRT and COM outputs, CsWinRT and COM RCW identity, WinRT CCWs, generated COM +CCWs, managed round trips, null output, and Native AOT package publication. diff --git a/integration-tests/buildtask-sdk/app/buildtask-sdk.csproj b/integration-tests/buildtask-sdk/app/buildtask-sdk.csproj index 67fc76eb..42bc6042 100644 --- a/integration-tests/buildtask-sdk/app/buildtask-sdk.csproj +++ b/integration-tests/buildtask-sdk/app/buildtask-sdk.csproj @@ -3,7 +3,7 @@ Exe net9.0-windows - 9 + 12 true true true diff --git a/src/Microsoft.Windows.CsWin32/Generator.Com.cs b/src/Microsoft.Windows.CsWin32/Generator.Com.cs index 799c13af..b85ebe44 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Com.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Com.cs @@ -1017,6 +1017,10 @@ static ExpressionSyntax ThisPointer(PointerTypeSyntax? typedPointer = null) methodDeclaration = methodDeclaration.AddModifiers(TokenWithSpace(SyntaxKind.UnsafeKeyword)); } + methodDeclaration = methodDefinition.Generator.ApplyAutoWinRTMarshalling( + methodDefinition.Method, + signature, + methodDeclaration); propertyOrMethod = methodDeclaration; } diff --git a/src/Microsoft.Windows.CsWin32/Generator.ComOutPtr.cs b/src/Microsoft.Windows.CsWin32/Generator.ComOutPtr.cs new file mode 100644 index 00000000..2d62a6e4 --- /dev/null +++ b/src/Microsoft.Windows.CsWin32/Generator.ComOutPtr.cs @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +namespace Microsoft.Windows.CsWin32; + +/// +/// Contains generation support for automatic COM and Windows Runtime object output projection. +/// +public partial class Generator +{ + /// The generated adaptive custom marshaller and built-in COM projection helper. + private const string ComOrWinRTObjectMarshallerClassName = "ComOrWinRTObjectMarshaller"; + + /// Preserves the fields C#/WinRT reflects over when it generates an interface identifier. + private static readonly AttributeSyntax DynamicallyAccessedPublicFieldsAttributeSyntax = Attribute(IdentifierName("DynamicallyAccessedMembers")) + .AddArgumentListArguments(AttributeArgument(MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + IdentifierName("DynamicallyAccessedMemberTypes"), + IdentifierName("PublicFields")))); + + /// + /// Gets a value indicating whether recognized COM object outputs should be projected adaptively. + /// + private bool UseAutoWinRTMarshalling => + this.options.AllowMarshaling && + this.options.ComInterop.AutoWinRTMarshalling && + this.canUseCsWinRT && + (!this.useSourceGenerators || this.canUseCustomMarshaller); + + /// Gets the fully qualified name of the generated adaptive marshaller. + private NameSyntax ComOrWinRTObjectMarshallerTypeSyntax => + ParseName($"global::{this.MainGenerator.Namespace}.{ComOrWinRTObjectMarshallerClassName}"); + + /// + /// Locates the canonical IID_PPV_ARGS pair on a method: a Guid* parameter immediately followed by + /// a void** parameter carrying [ComOutPtr], positioned as the final two parameters. + /// + /// The method to scan. + /// The decoded signature of . + /// Receives the zero-based parameter index of the Guid*. + /// Receives the zero-based parameter index of the void**. + /// when the pattern was found. + private bool TryFindComOutPtrPair(MethodDefinition methodDefinition, MethodSignature signature, out int riidIndex, out int ppvIndex) + { + riidIndex = -1; + ppvIndex = -1; + + List metadataParams = new(); + foreach (ParameterHandle ph in methodDefinition.GetParameters()) + { + Parameter p = this.Reader.GetParameter(ph); + if (p.SequenceNumber > 0 && p.SequenceNumber - 1 < signature.ParameterTypes.Length) + { + metadataParams.Add(p); + } + } + + if (metadataParams.Count < 2) + { + return false; + } + + Parameter riidParam = metadataParams[metadataParams.Count - 2]; + Parameter ppvParam = metadataParams[metadataParams.Count - 1]; + int riid = riidParam.SequenceNumber - 1; + int ppv = ppvParam.SequenceNumber - 1; + + if (ppv != riid + 1 + || signature.ParameterTypes[riid] is not PointerTypeHandleInfo { ElementType: HandleTypeHandleInfo guidInfo } + || !guidInfo.IsType("Guid") + || this.FindInteropDecorativeAttribute(ppvParam.GetCustomAttributes(), "ComOutPtrAttribute") is null + || signature.ParameterTypes[ppv] is not PointerTypeHandleInfo { ElementType: PointerTypeHandleInfo { ElementType: PrimitiveTypeHandleInfo { PrimitiveTypeCode: PrimitiveTypeCode.Void } } }) + { + return false; + } + + riidIndex = riid; + ppvIndex = ppv; + return true; + } + + /// + /// Locates the IID_PPV_ARGS pair that a friendly overload should replace with a generic type parameter. + /// + /// The method to scan. + /// The decoded signature of . + /// The generated declaration the friendly overload will call. + /// Receives the zero-based parameter index of the Guid*. + /// Receives the zero-based parameter index of the COM output pointer. + /// Receives a value indicating whether the generated output parameter is already an out object. + /// when a generic friendly overload should be generated. + private bool TryFindComOutPtrFriendlyPair( + MethodDefinition methodDefinition, + MethodSignature signature, + MethodDeclarationSyntax externMethodDeclaration, + out int riidIndex, + out int ppvIndex, + out bool ppvIsObjectOut) + { + ppvIsObjectOut = false; + if (!this.options.FriendlyOverloads.ComOutPtrGenericOverloads + || !this.TryFindComOutPtrPair(methodDefinition, signature, out riidIndex, out ppvIndex) + || riidIndex >= externMethodDeclaration.ParameterList.Parameters.Count + || ppvIndex >= externMethodDeclaration.ParameterList.Parameters.Count) + { + riidIndex = -1; + ppvIndex = -1; + return false; + } + + ParameterSyntax ppvExtern = externMethodDeclaration.ParameterList.Parameters[ppvIndex]; + if (ppvExtern.Type is IdentifierNameSyntax { Identifier.ValueText: nameof(IntPtr) }) + { + riidIndex = -1; + ppvIndex = -1; + return false; + } + + ppvIsObjectOut = ppvExtern.Modifiers.Any(SyntaxKind.OutKeyword) + && ppvExtern.Type is PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.ObjectKeyword }; + return true; + } + + /// + /// Applies the adaptive custom marshaller to a source-generated interop declaration when its final parameters + /// follow the canonical IID/output-pointer pattern. + /// + /// The metadata method represented by . + /// The decoded metadata signature. + /// The generated P/Invoke or COM method declaration. + /// The declaration with adaptive marshalling applied when appropriate. + private MethodDeclarationSyntax ApplyAutoWinRTMarshalling( + MethodDefinition methodDefinition, + MethodSignature signature, + MethodDeclarationSyntax methodDeclaration) + { + if (!this.UseAutoWinRTMarshalling + || !this.useSourceGenerators + || !this.TryFindComOutPtrPair(methodDefinition, signature, out _, out int ppvIndex) + || ppvIndex >= methodDeclaration.ParameterList.Parameters.Count) + { + return methodDeclaration; + } + + ParameterSyntax ppv = methodDeclaration.ParameterList.Parameters[ppvIndex]; + if (!ppv.Modifiers.Any(SyntaxKind.OutKeyword) + || ppv.Type is not PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.ObjectKeyword }) + { + return methodDeclaration; + } + + SeparatedSyntaxList parameters = methodDeclaration.ParameterList.Parameters; + SyntaxList attributeLists = default; + foreach (AttributeListSyntax attributeList in ppv.AttributeLists) + { + SeparatedSyntaxList attributes = attributeList.Attributes; + for (int i = attributes.Count - 1; i >= 0; i--) + { + if (attributes[i].Name.ToString() is "MarshalAs" or "MarshalAsAttribute") + { + attributes = attributes.RemoveAt(i); + } + } + + if (attributes.Count > 0) + { + attributeLists = attributeLists.Add(attributeList.WithAttributes(attributes)); + } + } + + AttributeSyntax marshalUsing = Attribute(ParseName("global::System.Runtime.InteropServices.Marshalling.MarshalUsing")) + .AddArgumentListArguments(AttributeArgument(TypeOfExpression(this.ComOrWinRTObjectMarshallerTypeSyntax))); + ppv = ppv + .WithAttributeLists(attributeLists) + .AddAttributeLists(AttributeList(marshalUsing)); + parameters = parameters.Replace(parameters[ppvIndex], ppv); + + this.volatileCode.GenerationTransaction(this.RequestComOrWinRTObjectMarshaller); + return methodDeclaration.WithParameterList(methodDeclaration.ParameterList.WithParameters(parameters)); + } + + /// Emits the adaptive marshaller and projection helper exactly once. + private void RequestComOrWinRTObjectMarshaller() + { + if (!this.IsWin32Sdk) + { + this.MainGenerator.volatileCode.GenerationTransaction(() => this.MainGenerator.RequestComOrWinRTObjectMarshaller()); + return; + } + + this.volatileCode.GenerateSpecialType(ComOrWinRTObjectMarshallerClassName, delegate + { + if (!TryFetchTemplate(ComOrWinRTObjectMarshallerClassName, this, out MemberDeclarationSyntax? declaration)) + { + throw new GenerationFailedException($"Failed to retrieve template: {ComOrWinRTObjectMarshallerClassName}"); + } + + this.volatileCode.AddSpecialType( + ComOrWinRTObjectMarshallerClassName, + declaration + .WithoutLeadingTrivia() + .AddAttributeLists(AttributeList(GeneratedCodeAttribute)) + .WithLeadingTrivia(declaration.GetLeadingTrivia())); + }); + } +} diff --git a/src/Microsoft.Windows.CsWin32/Generator.Extern.cs b/src/Microsoft.Windows.CsWin32/Generator.Extern.cs index 583b6e99..ff3004f9 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Extern.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Extern.cs @@ -397,6 +397,11 @@ static SyntaxToken RefInOutKeyword(ParameterSyntax p) => exposedMethod = exposedMethod.AddModifiers(TokenWithSpace(SyntaxKind.PartialKeyword)); } + exposedMethod = this.ApplyAutoWinRTMarshalling( + methodDefinition, + signature, + exposedMethod); + if (this.GetSupportedOSPlatformAttribute(methodDefinition.GetCustomAttributes()) is AttributeSyntax supportedOSPlatformAttribute) { exposedMethod = exposedMethod.AddAttributeLists(AttributeList(supportedOSPlatformAttribute)); diff --git a/src/Microsoft.Windows.CsWin32/Generator.Features.cs b/src/Microsoft.Windows.CsWin32/Generator.Features.cs index d4ec332e..d6a42647 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.Features.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.Features.cs @@ -23,6 +23,8 @@ public partial class Generator private readonly bool canUseComVariant; private readonly bool canUseMemberFunctionCallingConvention; private readonly bool canUseMarshalInitHandle; + private readonly bool canUseCsWinRT; + private readonly bool canUseCustomMarshaller; private readonly INamedTypeSymbol? runtimeFeatureClass; private readonly bool generateSupportedOSPlatformAttributes; private readonly bool generateSupportedOSPlatformAttributesOnInterfaces; // only supported on net6.0 (https://github.com/dotnet/runtime/pull/48838) diff --git a/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs b/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs index a2441225..39050864 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.FriendlyOverloads.cs @@ -155,46 +155,18 @@ private IEnumerable DeclareFriendlyOverload( int iidPpvPpvOrigIndex = -1; bool iidPpvMarshalingMode = false; bool iidPpvUseNativeOutMarshaling = false; + bool iidPpvAutoWinRTMode = false; - if (this.options.FriendlyOverloads.ComOutPtrGenericOverloads) + if (this.TryFindComOutPtrFriendlyPair(methodDefinition, originalSignature, externMethodDeclaration, out int riidOrig, out int ppvOrig, out bool ppvExternIsObjectOut)) { - var metadataParamsForScan = new List<(Parameter Param, int OrigIndex)>(); - foreach (ParameterHandle ph in methodDefinition.GetParameters()) + iidPpvRiidOrigIndex = riidOrig; + iidPpvPpvOrigIndex = ppvOrig; + iidPpvMarshalingMode = this.options.AllowMarshaling; + iidPpvUseNativeOutMarshaling = iidPpvMarshalingMode && !ppvExternIsObjectOut; + iidPpvAutoWinRTMode = iidPpvMarshalingMode && this.UseAutoWinRTMarshalling; + if (iidPpvAutoWinRTMode) { - Parameter p = this.Reader.GetParameter(ph); - if (p.SequenceNumber > 0 && p.SequenceNumber - 1 < originalSignature.ParameterTypes.Length) - { - metadataParamsForScan.Add((p, p.SequenceNumber - 1)); - } - } - - // Only match when the Guid* + void** [ComOutPtr] pair are the final two parameters (the canonical IID_PPV_ARGS position). - if (metadataParamsForScan.Count >= 2) - { - int i = metadataParamsForScan.Count - 2; - int riidOrig = metadataParamsForScan[i].OrigIndex; - int ppvOrig = metadataParamsForScan[i + 1].OrigIndex; - - if (ppvOrig == riidOrig + 1 - && originalSignature.ParameterTypes[riidOrig] is PointerTypeHandleInfo { ElementType: HandleTypeHandleInfo guidInfo } - && guidInfo.IsType("Guid") - && this.FindInteropDecorativeAttribute(metadataParamsForScan[i + 1].Param.GetCustomAttributes(), "ComOutPtrAttribute") is not null - && originalSignature.ParameterTypes[ppvOrig] is PointerTypeHandleInfo { ElementType: PointerTypeHandleInfo { ElementType: PrimitiveTypeHandleInfo { PrimitiveTypeCode: PrimitiveTypeCode.Void } } } - && riidOrig < parameters.Count && ppvOrig < parameters.Count) - { - ParameterSyntax ppvExtern = externMethodDeclaration.ParameterList.Parameters[ppvOrig]; - - // Skip if ppv is typed as IntPtr (UseIntPtrForComOutPointers mode). - if (ppvExtern.Type is not IdentifierNameSyntax { Identifier.ValueText: nameof(IntPtr) }) - { - bool ppvExternIsObjectOut = ppvExtern.Modifiers.Any(SyntaxKind.OutKeyword) - && ppvExtern.Type is PredefinedTypeSyntax { Keyword.RawKind: (int)SyntaxKind.ObjectKeyword }; - iidPpvRiidOrigIndex = riidOrig; - iidPpvPpvOrigIndex = ppvOrig; - iidPpvMarshalingMode = this.options.AllowMarshaling; - iidPpvUseNativeOutMarshaling = iidPpvMarshalingMode && !ppvExternIsObjectOut; - } - } + this.volatileCode.GenerationTransaction(this.RequestComOrWinRTObjectMarshaller); } } @@ -225,22 +197,28 @@ private IEnumerable DeclareFriendlyOverload( { signatureChanged = true; ParameterSyntax riidExternParam = externMethodDeclaration.ParameterList.Parameters[origParamIndex]; - ExpressionSyntax typeofTGuid = MemberAccessExpression( - SyntaxKind.SimpleMemberAccessExpression, - TypeOfExpression(IdentifierName("T")), - IdentifierName("GUID")); + ExpressionSyntax iidExpression = iidPpvAutoWinRTMode + ? InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + this.ComOrWinRTObjectMarshallerTypeSyntax, + GenericName("GetIID", [IdentifierName("T")]))) + : MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + TypeOfExpression(IdentifierName("T")), + IdentifierName("GUID")); if (riidExternParam.Type is PointerTypeSyntax) { leadingStatements.Add(LocalDeclarationStatement( VariableDeclaration( ParseTypeName("global::System.Guid"), - [VariableDeclarator(Identifier("__riid"), EqualsValueClause(typeofTGuid))]))); + [VariableDeclarator(Identifier("__riid"), EqualsValueClause(iidExpression))]))); arguments[paramIndex] = Argument(PrefixUnaryExpression(SyntaxKind.AddressOfExpression, IdentifierName("__riid"))); } else { - arguments[paramIndex] = Argument(typeofTGuid); + arguments[paramIndex] = Argument(iidExpression); } parametersToRemove.Add(paramIndex); @@ -296,16 +274,29 @@ private IEnumerable DeclareFriendlyOverload( } else { - arguments[paramIndex] = Argument(DeclarationExpression( - PredefinedType(TokenWithSpace(SyntaxKind.ObjectKeyword)), - SingleVariableDesignation(Identifier("__ppv")))) - .WithRefKindKeyword(TokenWithSpace(SyntaxKind.OutKeyword)); + IdentifierNameSyntax nativeObject = IdentifierName("__ppv"); + leadingStatements.Add(LocalDeclarationStatement( + VariableDeclaration( + PredefinedType(TokenWithSpace(SyntaxKind.ObjectKeyword)), + [VariableDeclarator(nativeObject.Identifier, EqualsValueClause(DefaultExpression(PredefinedType(Token(SyntaxKind.ObjectKeyword)))))]))); + arguments[paramIndex] = Argument(nativeObject).WithRefKindKeyword(TokenWithSpace(SyntaxKind.OutKeyword)); + + ExpressionSyntax managedValue = nativeObject; + if (iidPpvAutoWinRTMode && !this.useSourceGenerators) + { + managedValue = InvocationExpression( + MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + this.ComOrWinRTObjectMarshallerTypeSyntax, + IdentifierName("ConvertToManaged")), + [Argument(managedValue)]); + } trailingStatements.Add(ExpressionStatement( AssignmentExpression( SyntaxKind.SimpleAssignmentExpression, ppvName, - CastExpression(tName, IdentifierName("__ppv"))))); + CastExpression(tName, managedValue)))); } } else @@ -1523,8 +1514,15 @@ bool TryHandleCountParam(TypeSyntax elementType, bool nullableSource) TypeParameterConstraintClauseSyntax constraintClause = iidPpvMarshalingMode ? TypeParameterConstraintClause(IdentifierName("T"), [ClassOrStructConstraint(SyntaxKind.ClassConstraint)]) : TypeParameterConstraintClause(IdentifierName("T"), [TypeConstraint(IdentifierName("unmanaged"))]); + TypeParameterSyntax typeParameter = TypeParameter(Identifier("T")); + if (iidPpvAutoWinRTMode) + { + // C#/WinRT IID generation reflects over the type's fields. + typeParameter = typeParameter.AddAttributeLists(AttributeList(DynamicallyAccessedPublicFieldsAttributeSyntax)); + } + friendlyDeclaration = friendlyDeclaration - .AddTypeParameterListParameters(TypeParameter(Identifier("T"))) + .AddTypeParameterListParameters(typeParameter) .AddConstraintClauses(constraintClause); } diff --git a/src/Microsoft.Windows.CsWin32/Generator.WhitespaceRewriter.cs b/src/Microsoft.Windows.CsWin32/Generator.WhitespaceRewriter.cs index e84c02f9..ce7d5eeb 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.WhitespaceRewriter.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.WhitespaceRewriter.cs @@ -127,7 +127,7 @@ internal WhitespaceRewriter() public override SyntaxNode? VisitAttributeList(AttributeListSyntax node) { - if (node.Parent is ParameterSyntax) + if (node.Parent is ParameterSyntax or TypeParameterSyntax) { return node.WithCloseBracketToken(TokenWithSpace(SyntaxKind.CloseBracketToken)); } diff --git a/src/Microsoft.Windows.CsWin32/Generator.cs b/src/Microsoft.Windows.CsWin32/Generator.cs index 450a1043..48a172f4 100644 --- a/src/Microsoft.Windows.CsWin32/Generator.cs +++ b/src/Microsoft.Windows.CsWin32/Generator.cs @@ -135,6 +135,13 @@ public Generator(string metadataLibraryPath, Docs? docs, IEnumerable add this.canUseComVariant = this.compilation?.GetTypeByMetadataName("System.Runtime.InteropServices.Marshalling.ComVariant") is not null; this.canUseMemberFunctionCallingConvention = this.compilation?.GetTypeByMetadataName("System.Runtime.CompilerServices.CallConvMemberFunction") is not null; this.canUseMarshalInitHandle = this.compilation?.GetTypeByMetadataName(typeof(Marshal).FullName)?.GetMembers("InitHandle").Length > 0; + this.canUseCsWinRT = + this.compilation?.GetTypeByMetadataName("WinRT.Projections")?.GetMembers("IsTypeWindowsRuntimeType").IsEmpty is false && + this.compilation?.GetTypeByMetadataName("WinRT.GuidGenerator")?.GetMembers("CreateIID").IsEmpty is false && + this.compilation?.GetTypeByMetadataName("WinRT.MarshalInspectable`1") is not null; + this.canUseCustomMarshaller = + this.compilation?.GetTypeByMetadataName("System.Runtime.InteropServices.Marshalling.CustomMarshallerAttribute") is not null && + this.compilation?.GetTypeByMetadataName("System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller`1") is not null; if (this.FindTypeSymbolIfAlreadyAvailable("System.Runtime.Versioning.SupportedOSPlatformAttribute") is { } attribute) { this.generateSupportedOSPlatformAttributes = true; @@ -164,6 +171,8 @@ public Generator(string metadataLibraryPath, Docs? docs, IEnumerable add AddSymbolIf(compilation?.GetTypeByMetadataName("System.Drawing.Point") is not null, "canUseSystemDrawing"); AddSymbolIf(this.IsFeatureAvailable(Feature.InterfaceStaticMembers), "canUseInterfaceStaticMembers"); AddSymbolIf(this.canUseUnscopedRef, "canUseUnscopedRef"); + AddSymbolIf(this.canUseCsWinRT, "canUseCsWinRT"); + AddSymbolIf(this.useSourceGenerators, "usesComSourceGenerators"); if (extraSymbols.Count > 0) { diff --git a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs index 928f775c..fc968867 100644 --- a/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs +++ b/src/Microsoft.Windows.CsWin32/GeneratorOptions.cs @@ -133,6 +133,17 @@ public record ComInteropOptions /// /// The default value is . public bool? UseComSourceGenerators { get; set; } + + /// + /// Gets or sets a value indicating whether recognized COM object outputs should automatically use a + /// C#/WinRT projection when the returned identity implements IInspectable. + /// + /// + /// This option is enabled by default. Disable it to preserve COM-only output projection and avoid the + /// additional QueryInterface(IInspectable). + /// + /// The default value is . + public bool AutoWinRTMarshalling { get; set; } = true; } /// @@ -155,7 +166,7 @@ public record FriendlyOverloadOptions /// /// Gets or sets a value indicating whether to generate generic <T> overloads for methods /// with the IID_PPV_ARGS pattern (a Guid* parameter immediately preceding a void** [ComOutPtr] parameter), - /// where the GUID is derived from typeof(T).GUID and the output pointer is typed as T. + /// where the GUID is derived from T and the output pointer is typed as T. /// /// The default value is . public bool ComOutPtrGenericOverloads { get; set; } = true; diff --git a/src/Microsoft.Windows.CsWin32/settings.schema.json b/src/Microsoft.Windows.CsWin32/settings.schema.json index aedd0e38..95430b10 100644 --- a/src/Microsoft.Windows.CsWin32/settings.schema.json +++ b/src/Microsoft.Windows.CsWin32/settings.schema.json @@ -34,6 +34,11 @@ "description": "Use COM source generators to support COM marshalling code. Only has an effect with MSBuild property true.", "type": "boolean", "default": false + }, + "autoWinRTMarshalling": { + "description": "Automatically project recognized COM object outputs through C#/WinRT when the returned identity implements IInspectable. Disable this to preserve COM-only output projection and avoid the additional QueryInterface.", + "type": "boolean", + "default": true } } }, @@ -57,7 +62,7 @@ "default": false }, "comOutPtrGenericOverloads": { - "description": "A value indicating whether to generate generic overloads for methods with the IID_PPV_ARGS pattern (a Guid* parameter immediately preceding a void** [ComOutPtr] parameter), where the GUID is derived from typeof(T).GUID and the output pointer is typed as T.", + "description": "A value indicating whether to generate generic overloads for methods with the IID_PPV_ARGS pattern (a Guid* parameter immediately preceding a void** [ComOutPtr] parameter), where the GUID is derived from T and the output pointer is typed as T.", "type": "boolean", "default": true } diff --git a/src/Microsoft.Windows.CsWin32/templates/ComOrWinRTObjectMarshaller.cs b/src/Microsoft.Windows.CsWin32/templates/ComOrWinRTObjectMarshaller.cs new file mode 100644 index 00000000..af33e50f --- /dev/null +++ b/src/Microsoft.Windows.CsWin32/templates/ComOrWinRTObjectMarshaller.cs @@ -0,0 +1,136 @@ +#if usesComSourceGenerators +[global::System.Runtime.InteropServices.Marshalling.CustomMarshaller( + typeof(object), + global::System.Runtime.InteropServices.Marshalling.MarshalMode.ManagedToUnmanagedOut, + typeof(ComOrWinRTObjectMarshaller))] +[global::System.Runtime.InteropServices.Marshalling.CustomMarshaller( + typeof(object), + global::System.Runtime.InteropServices.Marshalling.MarshalMode.UnmanagedToManagedOut, + typeof(ComOrWinRTObjectMarshaller))] +#endif +internal static unsafe class ComOrWinRTObjectMarshaller +{ + private const int E_NOINTERFACE = unchecked((int)0x80004002); + + private static readonly global::System.Guid IID_IUnknown = new global::System.Guid(0x00000000, 0x0000, 0x0000, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x46); + private static readonly global::System.Guid IID_IInspectable = new global::System.Guid(0xAF86E2E0, 0xB12D, 0x4C6A, 0x9C, 0x5A, 0xD7, 0xAA, 0x65, 0x10, 0x1E, 0x90); + + /// Gets the interface identifier to request for a friendly generic COM output. + internal static global::System.Guid GetIID<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>() + where T : class + { + if (typeof(T) == typeof(object)) + { + return IID_IUnknown; + } + + return global::WinRT.Projections.IsTypeWindowsRuntimeType(typeof(T)) + ? global::WinRT.GuidGenerator.CreateIID(typeof(T)) + : typeof(T).GUID; + } + +#if usesComSourceGenerators + /// Projects a native COM identity as a Windows Runtime object when it implements IInspectable. + public static object ConvertToManaged(nint value) + { + if (value == 0) + { + return null; + } + + global::System.Guid iid = IID_IInspectable; + int hr = global::System.Runtime.InteropServices.Marshal.QueryInterface(value, in iid, out nint inspectable); + if (hr >= 0) + { + try + { + return global::WinRT.MarshalInspectable.FromAbi(inspectable); + } + finally + { + global::System.Runtime.InteropServices.Marshal.Release(inspectable); + } + } + + if (hr != E_NOINTERFACE) + { + global::System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(hr); + } + + return global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller.ConvertToManaged((void*)value); + } + + /// Preserves existing RCWs, uses COM marshalling for generated COM classes, and uses C#/WinRT otherwise. + public static nint ConvertToUnmanaged(object value) + { + if (value is null) + { + return 0; + } + + if (global::System.Runtime.InteropServices.ComWrappers.TryGetComInstance(value, out nint comInstance)) + { + return comInstance; + } + + if (global::System.Runtime.InteropServices.Marshal.IsComObject(value)) + { + return global::System.Runtime.InteropServices.Marshal.GetIUnknownForObject(value); + } + + if (value.GetType().IsDefined( + typeof(global::System.Runtime.InteropServices.Marshalling.GeneratedComClassAttribute), + inherit: false)) + { + return (nint)global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller + .ConvertToUnmanaged(value); + } + + return global::WinRT.MarshalInspectable.FromManaged(value); + } + + /// Releases the ABI identity pointer produced for or received from source-generated interop. + public static void Free(nint value) => + global::System.Runtime.InteropServices.Marshalling.ComInterfaceMarshaller.Free((void*)value); +#else + /// + /// Reprojects a built-in COM wrapper through C#/WinRT when the native identity implements IInspectable. + /// + internal static object ConvertToManaged(object value) + { + if (value is null) + { + return null; + } + + nint identity = global::System.Runtime.InteropServices.Marshal.GetIUnknownForObject(value); + try + { + global::System.Guid iid = IID_IInspectable; + int hr = global::System.Runtime.InteropServices.Marshal.QueryInterface(identity, in iid, out nint inspectable); + if (hr >= 0) + { + try + { + return global::WinRT.MarshalInspectable.FromAbi(inspectable); + } + finally + { + global::System.Runtime.InteropServices.Marshal.Release(inspectable); + } + } + + if (hr != E_NOINTERFACE) + { + global::System.Runtime.InteropServices.Marshal.ThrowExceptionForHR(hr); + } + + return value; + } + finally + { + global::System.Runtime.InteropServices.Marshal.Release(identity); + } + } +#endif +} diff --git a/test/GenerationSandbox.AutoWinRTDisabled.Tests/AutoWinRTMarshallingDisabledTests.cs b/test/GenerationSandbox.AutoWinRTDisabled.Tests/AutoWinRTMarshallingDisabledTests.cs new file mode 100644 index 00000000..1c4ee383 --- /dev/null +++ b/test/GenerationSandbox.AutoWinRTDisabled.Tests/AutoWinRTMarshallingDisabledTests.cs @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; +using Windows.Win32; +using Windows.Win32.UI.Shell; + +namespace GenerationSandbox.AutoWinRTDisabled.Tests; + +/// +/// Runtime coverage for opting out of automatic Windows Runtime projection. +/// +[Trait("WindowsOnly", "true")] +public class AutoWinRTMarshallingDisabledTests +{ + private static readonly Guid BHID_StorageItem = new(0x404e2109, 0x77d2, 0x4699, 0xa5, 0xa0, 0x4f, 0xdf, 0x10, 0xdb, 0x98, 0x37); + + /// + /// Verifies that disabling automatic Windows Runtime projection preserves the legacy failure. + /// + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void BindToHandler_AutoWinRTMarshallingDisabled_ThrowsInvalidCastException() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + + string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "win.ini"); + PInvoke.SHCreateItemFromParsingName(path, null, out IShellItem shellItem).ThrowOnFailure(); + + Assert.Throws(() => + shellItem.BindToHandler(null, BHID_StorageItem, out _)); + } +} diff --git a/test/GenerationSandbox.AutoWinRTDisabled.Tests/GenerationSandbox.AutoWinRTDisabled.Tests.csproj b/test/GenerationSandbox.AutoWinRTDisabled.Tests/GenerationSandbox.AutoWinRTDisabled.Tests.csproj new file mode 100644 index 00000000..b9cac3f2 --- /dev/null +++ b/test/GenerationSandbox.AutoWinRTDisabled.Tests/GenerationSandbox.AutoWinRTDisabled.Tests.csproj @@ -0,0 +1,12 @@ + + + net9.0-windows10.0.22621.0;net10.0-windows10.0.22621.0 + + + + + + true + true + + diff --git a/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.json b/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.json new file mode 100644 index 00000000..ca7dd20f --- /dev/null +++ b/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.json @@ -0,0 +1,9 @@ +{ + "$schema": "..\\..\\src\\Microsoft.Windows.CsWin32\\settings.schema.json", + "allowMarshaling": true, + "public": false, + "comInterop": { + "useComSourceGenerators": true, + "autoWinRTMarshalling": false + } +} diff --git a/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.txt b/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.txt new file mode 100644 index 00000000..eb491fdb --- /dev/null +++ b/test/GenerationSandbox.AutoWinRTDisabled.Tests/NativeMethods.txt @@ -0,0 +1,2 @@ +IShellItem +SHCreateItemFromParsingName diff --git a/test/GenerationSandbox.BuildTask.Tests/ComOutPtrMarshallingTests.cs b/test/GenerationSandbox.BuildTask.Tests/ComOutPtrMarshallingTests.cs new file mode 100644 index 00000000..fdbc7c4a --- /dev/null +++ b/test/GenerationSandbox.BuildTask.Tests/ComOutPtrMarshallingTests.cs @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.Marshalling; +using Windows.Storage; +using Windows.Win32; +using Windows.Win32.Foundation; +using Windows.Win32.System.Com; +using Windows.Win32.System.SystemServices; +using Windows.Win32.UI.Shell; +using WinRT; + +namespace GenerationSandbox.BuildTask.Tests; + +/// +/// Runtime coverage for automatic COM and Windows Runtime object marshalling. +/// +[Trait("WindowsOnly", "true")] +public partial class ComOutPtrMarshallingTests +{ + private const int E_NOINTERFACE = unchecked((int)0x80004002); + + private static readonly Guid BHID_Stream = new(0x1cebb3ab, 0x7c10, 0x499a, 0xa4, 0x17, 0x92, 0xca, 0x16, 0xc4, 0xcb, 0x83); + private static readonly Guid BHID_StorageItem = new(0x404e2109, 0x77d2, 0x4699, 0xa5, 0xa0, 0x4f, 0xdf, 0x10, 0xdb, 0x98, 0x37); + + private static string WinIniPath => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "win.ini"); + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void AutomaticProjection_PreservesGeneratedComInterface() + { + IShellItem shellItem = CreateShellItem(); + + shellItem.BindToHandler(null, BHID_Stream, out IStream stream); + + Assert.NotNull(stream); + byte[] buffer = new byte[16]; + stream.Read(buffer, out uint bytesRead); + Assert.True(bytesRead > 0); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void AutomaticProjection_ProjectsWindowsRuntimeInterface() + { + IShellItem shellItem = CreateShellItem(); + + shellItem.BindToHandler(null, BHID_StorageItem, out IStorageItem storageItem); + + Assert.Equal("win.ini", storageItem.Name, ignoreCase: true); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void AutomaticProjection_ProjectsObjectAsConcreteWindowsRuntimeObject() + { + IShellItem shellItem = CreateShellItem(); + + shellItem.BindToHandler(null, BHID_StorageItem, out object storageItem); + + StorageFile storageFile = Assert.IsType(storageItem); + Assert.Equal("win.ini", storageFile.Name, ignoreCase: true); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public async Task WindowsRuntimeObject_CanBePassedAsComInput() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + StorageFile storageFile = await StorageFile.GetFileFromPathAsync(WinIniPath); + IWinRTObject provider = (IWinRTObject)(object)storageFile; + + unsafe + { + _ = PInvoke.CoAllowSetForegroundWindow(provider, null); + } + + Assert.Equal("win.ini", storageFile.Name, ignoreCase: true); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void AutomaticProjection_FallsBackToComObject() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + + PInvoke.CoCreateInstance( + typeof(ShellLink).GUID, + null, + CLSCTX.CLSCTX_INPROC_SERVER, + out object instance).ThrowOnFailure(); + + IShellLinkW link = (IShellLinkW)instance; + link.SetDescription(nameof(ComOutPtrMarshallingTests.AutomaticProjection_FallsBackToComObject)); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public async Task ManagedImplementer_CanReturnWindowsRuntimeObject() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + StorageFile storageFile = await StorageFile.GetFileFromPathAsync(WinIniPath); + + ComOutPtrMarshallingTests.VerifyManagedImplementer( + storageFile, + BHID_StorageItem, + storageItem => Assert.Equal("win.ini", storageItem.Name, ignoreCase: true)); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void ManagedImplementer_CanReturnInspectableComObject() + { + IShellItem shellItem = CreateShellItem(); + shellItem.BindToHandler(null, BHID_Stream, out IStream stream); + + ComOutPtrMarshallingTests.VerifyManagedImplementer( + stream, + BHID_Stream, + returnedStream => + { + byte[] buffer = new byte[8]; + returnedStream.Read(buffer, out uint bytesRead); + Assert.True(bytesRead > 0); + }); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void ManagedImplementer_CanReturnNonInspectableComObject() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + PInvoke.CoCreateInstance( + typeof(ShellLink).GUID, + null, + CLSCTX.CLSCTX_INPROC_SERVER, + out object shellLink).ThrowOnFailure(); + + AssertRcwIdentityIsPreserved(shellLink); + + ComOutPtrMarshallingTests.VerifyManagedImplementer( + shellLink, + BHID_Stream, + link => link.SetDescription(nameof(ComOutPtrMarshallingTests.ManagedImplementer_CanReturnNonInspectableComObject))); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void ManagedImplementer_CanReturnNull() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + ManagedShellItem managed = new(null!); + StrategyBasedComWrappers comWrappers = new(); + nint ccw = comWrappers.GetOrCreateComInterfaceForObject(managed, CreateComInterfaceFlags.None); + object rcw = comWrappers.GetOrCreateObjectForComInstance(ccw, CreateObjectFlags.UniqueInstance); + Marshal.Release(ccw); + try + { + IShellItem proxy = (IShellItem)rcw; + proxy.BindToHandler(null, BHID_StorageItem, out object result); + Assert.Null(result); + + Assert.Equal(1, managed.BindToHandlerCallCount); + } + finally + { + ((ComObject)rcw).FinalRelease(); + } + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public async Task CsWinRTRcw_RoundTripsWithOriginalIdentity() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + StorageFile storageFile = await StorageFile.GetFileFromPathAsync(WinIniPath); + IWinRTObject winrtObject = (IWinRTObject)(object)storageFile; + + nint marshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(storageFile); + try + { + AssertSameComIdentity(winrtObject.NativeObject.ThisPtr, marshalled); + + object roundTripped = ComOrWinRTObjectMarshaller.ConvertToManaged(marshalled); + IStorageItem storageItem = Assert.IsAssignableFrom(roundTripped); + Assert.Equal("win.ini", storageItem.Name, ignoreCase: true); + } + finally + { + ComOrWinRTObjectMarshaller.Free(marshalled); + } + } + + [Fact] + public void ManagedWinRTObject_RoundTripsThroughWinRTCcw() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + DisposableObject managed = new(); + + nint marshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(managed); + try + { + AssertSupportsInterface(marshalled, typeof(WinRT.IInspectable).GUID); + AssertSupportsInterface(marshalled, GuidGenerator.CreateIID(typeof(IDisposable))); + + object roundTripped = ComOrWinRTObjectMarshaller.ConvertToManaged(marshalled); + Assert.Same(managed, roundTripped); + IDisposable disposable = Assert.IsAssignableFrom(roundTripped); + disposable.Dispose(); + Assert.Equal(1, managed.DisposeCallCount); + + nint remarshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(managed); + try + { + AssertSameComIdentity(marshalled, remarshalled); + } + finally + { + ComOrWinRTObjectMarshaller.Free(remarshalled); + } + } + finally + { + ComOrWinRTObjectMarshaller.Free(marshalled); + } + } + + [Fact] + public void GeneratedComClass_RoundTripsThroughGeneratedComCcw() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + ManagedShellItem managed = new(null!); + + nint marshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(managed); + try + { + AssertSupportsInterface(marshalled, typeof(IShellItem).GUID); + AssertDoesNotSupportInterface(marshalled, typeof(WinRT.IInspectable).GUID); + + object roundTripped = ComOrWinRTObjectMarshaller.ConvertToManaged(marshalled); + Assert.Same(managed, roundTripped); + IShellItem shellItem = Assert.IsAssignableFrom(roundTripped); + shellItem.Compare(shellItem, 0, out int order); + Assert.Equal(42, order); + Assert.Equal(1, managed.CompareCallCount); + } + finally + { + ComOrWinRTObjectMarshaller.Free(marshalled); + } + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void ClassicComRcw_RoundTripsWithOriginalIdentity() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + Type shellLinkType = Type.GetTypeFromCLSID(typeof(ShellLink).GUID, throwOnError: true)!; + object shellLink = Activator.CreateInstance(shellLinkType)!; + try + { + Assert.True(Marshal.IsComObject(shellLink)); + nint expected = Marshal.GetIUnknownForObject(shellLink); + try + { + nint marshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(shellLink); + try + { + AssertSameComIdentity(expected, marshalled); + AssertSupportsInterface(marshalled, typeof(IShellLinkW).GUID); + } + finally + { + ComOrWinRTObjectMarshaller.Free(marshalled); + } + } + finally + { + Marshal.Release(expected); + } + } + finally + { + Marshal.FinalReleaseComObject(shellLink); + } + } + + private static IShellItem CreateShellItem() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + Assert.True(File.Exists(WinIniPath), $"Expected '{WinIniPath}' to exist on Windows."); + PInvoke.SHCreateItemFromParsingName(WinIniPath, null, out IShellItem shellItem).ThrowOnFailure(); + return shellItem; + } + + private static void AssertSameComIdentity(nint expected, nint actual) + { + Guid iid = typeof(IUnknown).GUID; + nint expectedIdentity = QueryInterface(expected, iid); + try + { + nint actualIdentity = QueryInterface(actual, iid); + try + { + Assert.Equal(expectedIdentity, actualIdentity); + } + finally + { + Marshal.Release(actualIdentity); + } + } + finally + { + Marshal.Release(expectedIdentity); + } + } + + private static void AssertRcwIdentityIsPreserved(object value) + { + Assert.True(ComWrappers.TryGetComInstance(value, out nint native)); + try + { + nint marshalled = ComOrWinRTObjectMarshaller.ConvertToUnmanaged(value); + try + { + AssertSameComIdentity(native, marshalled); + } + finally + { + ComOrWinRTObjectMarshaller.Free(marshalled); + } + } + finally + { + Marshal.Release(native); + } + } + + private static void AssertSupportsInterface(nint value, Guid iid) + { + nint queried = QueryInterface(value, iid); + Marshal.Release(queried); + } + + private static void AssertDoesNotSupportInterface(nint value, Guid iid) + { + int hr = Marshal.QueryInterface(value, in iid, out nint queried); + if (queried != 0) + { + Marshal.Release(queried); + } + + Assert.Equal(E_NOINTERFACE, hr); + } + + private static nint QueryInterface(nint value, Guid iid) + { + Marshal.ThrowExceptionForHR(Marshal.QueryInterface(value, in iid, out nint queried)); + return queried; + } + + private static void VerifyManagedImplementer<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicFields)] T>(object returnedValue, Guid bindHandler, Action exercise) + where T : class + { + ManagedShellItem managed = new(returnedValue); + StrategyBasedComWrappers comWrappers = new(); + nint ccw = comWrappers.GetOrCreateComInterfaceForObject(managed, CreateComInterfaceFlags.None); + object rcw = comWrappers.GetOrCreateObjectForComInstance(ccw, CreateObjectFlags.UniqueInstance); + Marshal.Release(ccw); + try + { + IShellItem proxy = (IShellItem)rcw; + proxy.BindToHandler(null, bindHandler, out T result); + exercise(result); + + Assert.Equal(1, managed.BindToHandlerCallCount); + } + finally + { + ((ComObject)rcw).FinalRelease(); + } + } + + [GeneratedComClass] + private partial class ManagedShellItem(object returnedValue) : IShellItem + { + internal int BindToHandlerCallCount { get; private set; } + + internal int CompareCallCount { get; private set; } + + public unsafe void BindToHandler(IBindCtx pbc, Guid* bhid, Guid* riid, out object ppv) + { + this.BindToHandlerCallCount++; + ppv = returnedValue; + } + + public void GetParent(out IShellItem ppsi) => throw new NotImplementedException(); + + public unsafe void GetDisplayName(SIGDN sigdnName, PWSTR* ppszName) => throw new NotImplementedException(); + + public unsafe void GetAttributes(SFGAO_FLAGS sfgaoMask, SFGAO_FLAGS* psfgaoAttribs) => throw new NotImplementedException(); + + public void Compare(IShellItem psi, uint hint, out int piOrder) + { + this.CompareCallCount++; + piOrder = 42; + } + } + + private sealed class DisposableObject : IDisposable + { + internal int DisposeCallCount { get; private set; } + + public void Dispose() => this.DisposeCallCount++; + } +} diff --git a/test/GenerationSandbox.BuildTask.Tests/NativeMethods.txt b/test/GenerationSandbox.BuildTask.Tests/NativeMethods.txt index c7e378dc..ada31d72 100644 --- a/test/GenerationSandbox.BuildTask.Tests/NativeMethods.txt +++ b/test/GenerationSandbox.BuildTask.Tests/NativeMethods.txt @@ -1,5 +1,6 @@ # Testing ChoosePixelFormat +CoAllowSetForegroundWindow CoCreateInstance CreateDispatcherQueueController CreateFile diff --git a/test/GenerationSandbox.BuiltInCom.Tests/AutoWinRTMarshallingBuiltInComTests.cs b/test/GenerationSandbox.BuiltInCom.Tests/AutoWinRTMarshallingBuiltInComTests.cs new file mode 100644 index 00000000..007c71f1 --- /dev/null +++ b/test/GenerationSandbox.BuiltInCom.Tests/AutoWinRTMarshallingBuiltInComTests.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Runtime.InteropServices; +using Windows.Storage; +using Windows.Win32; +using Windows.Win32.UI.Shell; + +namespace GenerationSandbox.BuiltInCom.Tests; + +/// +/// Runtime coverage for automatic Windows Runtime projection with built-in COM interop. +/// +[Trait("WindowsOnly", "true")] +public class AutoWinRTMarshallingBuiltInComTests +{ + private static readonly Guid BHID_StorageItem = new(0x404e2109, 0x77d2, 0x4699, 0xa5, 0xa0, 0x4f, 0xdf, 0x10, 0xdb, 0x98, 0x37); + private static readonly Guid IID_IShellItem = new(0x43826d1e, 0xe718, 0x42ee, 0xbc, 0x55, 0xa1, 0xe2, 0x61, 0xc3, 0x7b, 0xfe); + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void BindToHandler_ProjectsWindowsRuntimeInterface() + { + IShellItem shellItem = AutoWinRTMarshallingBuiltInComTests.CreateShellItem(); + + shellItem.BindToHandler(null, BHID_StorageItem, out IStorageItem storageItem); + + Assert.Equal("win.ini", storageItem.Name, ignoreCase: true); + } + + [Fact] + [Trait("TestCategory", "RequiresHardware")] + public void BindToHandler_ProjectsObjectAsWindowsRuntimeObject() + { + IShellItem shellItem = AutoWinRTMarshallingBuiltInComTests.CreateShellItem(); + + shellItem.BindToHandler(null, BHID_StorageItem, out object storageItem); + + IStorageItem projected = Assert.IsAssignableFrom(storageItem); + Assert.Equal("win.ini", projected.Name, ignoreCase: true); + } + + private static IShellItem CreateShellItem() + { + Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "Test calls Windows-specific APIs"); + string path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), "win.ini"); + Marshal.ThrowExceptionForHR(SHCreateItemFromParsingName(path, 0, in IID_IShellItem, out IShellItem shellItem)); + return shellItem; + } + + [DllImport("shell32.dll", CharSet = CharSet.Unicode)] + private static extern int SHCreateItemFromParsingName( + string path, + nint bindContext, + in Guid riid, + [MarshalAs(UnmanagedType.Interface)] out IShellItem shellItem); +} diff --git a/test/GenerationSandbox.BuiltInCom.Tests/GenerationSandbox.BuiltInCom.Tests.csproj b/test/GenerationSandbox.BuiltInCom.Tests/GenerationSandbox.BuiltInCom.Tests.csproj new file mode 100644 index 00000000..d9f1f815 --- /dev/null +++ b/test/GenerationSandbox.BuiltInCom.Tests/GenerationSandbox.BuiltInCom.Tests.csproj @@ -0,0 +1,11 @@ + + + net9.0-windows10.0.22621.0;net10.0-windows10.0.22621.0 + + + + + + true + + diff --git a/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.json b/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.json new file mode 100644 index 00000000..83119d0f --- /dev/null +++ b/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.json @@ -0,0 +1,9 @@ +{ + "$schema": "..\\..\\src\\Microsoft.Windows.CsWin32\\settings.schema.json", + "allowMarshaling": true, + "public": false, + "comInterop": { + "useComSourceGenerators": false, + "autoWinRTMarshalling": true + } +} diff --git a/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.txt b/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.txt new file mode 100644 index 00000000..7237d0d6 --- /dev/null +++ b/test/GenerationSandbox.BuiltInCom.Tests/NativeMethods.txt @@ -0,0 +1 @@ +IShellItem diff --git a/test/Microsoft.Windows.CsWin32.Tests/COMTests.cs b/test/Microsoft.Windows.CsWin32.Tests/COMTests.cs index dd8a5355..88e0b67b 100644 --- a/test/Microsoft.Windows.CsWin32.Tests/COMTests.cs +++ b/test/Microsoft.Windows.CsWin32.Tests/COMTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. public class COMTests : GeneratorTestBase @@ -562,6 +562,175 @@ public void ComOutPtrGenericOverload_IMoniker_BindToObject(bool allowMarshaling) m => m.TypeParameterList?.Parameters.Count == 1); } + [Fact] + public void AutoWinRTMarshalling_GeneratesOneInternalHelper() + { + this.GenerateMarshaledComApi("IShellItem"); + + var helper = Assert.IsType(Assert.Single(this.FindGeneratedType("ComOrWinRTObjectMarshaller"))); + Assert.Contains(helper.Modifiers, m => m.IsKind(SyntaxKind.InternalKeyword)); + Assert.Contains(helper.AttributeLists, al => IsAttributePresent(al, "global::System.CodeDom.Compiler.GeneratedCode")); + + Assert.Empty(this.FindGeneratedType("ComOutPtrMarshalling")); + Assert.Empty(this.FindGeneratedType("ComOutPtrHelpers")); + Assert.Empty(this.FindGeneratedType("IShellItem__ComOutPtrRaw")); + } + + [Fact] + public void AutoWinRTMarshalling_FriendlyOverloadSelectsIidFromType() + { + this.GenerateMarshaledComApi("IShellItem"); + + MethodDeclarationSyntax overload = this.FindComOutPtrOverload("BindToHandler", "IShellItem"); + Assert.Equal(4, overload.ParameterList.Parameters.Count); + Assert.True(IsClassConstrainedGeneric(overload)); + Assert.Contains(overload.TypeParameterList!.Parameters.Single().AttributeLists, al => IsAttributePresent(al, "DynamicallyAccessedMembers")); + Assert.Equal("T", overload.ParameterList.Parameters.Last().Type!.ToString()); + + string body = overload.Body!.ToFullString(); + Assert.Contains("ComOrWinRTObjectMarshaller.GetIID()", body, StringComparison.Ordinal); + Assert.Contains("@this.BindToHandler(", body, StringComparison.Ordinal); + Assert.Contains("object __ppv", body, StringComparison.Ordinal); + Assert.Contains("out __ppv", body, StringComparison.Ordinal); + Assert.Contains("ppv = (T)__ppv", body, StringComparison.Ordinal); + } + + [Fact] + public void AutoWinRTMarshalling_FlatPInvokeUsesCustomMarshaller() + { + this.GenerateMarshaledComApi("SHCreateItemFromParsingName"); + + MethodDeclarationSyntax declaration = Assert.Single( + this.FindGeneratedMethod("SHCreateItemFromParsingName"), + m => m.TypeParameterList is null && FindAttribute(m.AttributeLists, "LibraryImport").Any()); + Assert.Equal("global::System.Guid*", declaration.ParameterList.Parameters[^2].Type!.ToString()); + + ParameterSyntax ppv = declaration.ParameterList.Parameters.Last(); + Assert.True(ppv.Modifiers.Any(SyntaxKind.OutKeyword)); + Assert.Equal("object", ppv.Type!.ToString()); + Assert.Contains( + FindAttribute(ppv.AttributeLists, "global::System.Runtime.InteropServices.Marshalling.MarshalUsing"), + a => a.ToString().Contains("ComOrWinRTObjectMarshaller", StringComparison.Ordinal)); + Assert.Empty(FindAttribute(ppv.AttributeLists, "MarshalAs")); + + Assert.Empty(this.FindGeneratedMethod("SHCreateItemFromParsingName__ComOutPtrRaw")); + MethodDeclarationSyntax overload = this.FindComOutPtrOverload("SHCreateItemFromParsingName", "PInvoke"); + Assert.Contains("PInvoke.SHCreateItemFromParsingName(", overload.Body!.ToFullString(), StringComparison.Ordinal); + } + + [Fact] + public void AutoWinRTMarshalling_GeneratedComUsesAdaptiveOutputMarshaller() + { + this.GenerateMarshaledComApi("IShellItem"); + + var iface = Assert.IsType(Assert.Single(this.FindGeneratedType("IShellItem"))); + MethodDeclarationSyntax method = Assert.Single(iface.Members.OfType(), m => m.Identifier.ValueText == "BindToHandler"); + + ParameterSyntax riid = method.ParameterList.Parameters[^2]; + Assert.Equal("global::System.Guid*", riid.Type!.ToString()); + Assert.Empty(FindAttribute(riid.AttributeLists, "global::System.Runtime.InteropServices.Marshalling.MarshalUsing")); + + ParameterSyntax ppv = method.ParameterList.Parameters.Last(); + Assert.Equal("object", ppv.Type!.ToString()); + Assert.True(ppv.Modifiers.Any(SyntaxKind.OutKeyword)); + Assert.Contains( + FindAttribute(ppv.AttributeLists, "global::System.Runtime.InteropServices.Marshalling.MarshalUsing"), + a => a.ToString().Contains("ComOrWinRTObjectMarshaller", StringComparison.Ordinal)); + Assert.Empty(FindAttribute(ppv.AttributeLists, "MarshalAs")); + } + + [Fact] + public void AutoWinRTMarshalling_SourceGeneratedHelperProjectsAndFallsBack() + { + this.GenerateMarshaledComApi("IShellItem"); + + string helper = Assert.Single(this.FindGeneratedType("ComOrWinRTObjectMarshaller")).ToFullString(); + Assert.Contains("typeof(T) == typeof(object)", helper, StringComparison.Ordinal); + Assert.Contains("global::WinRT.Projections.IsTypeWindowsRuntimeType(typeof(T))", helper, StringComparison.Ordinal); + Assert.Contains("global::WinRT.GuidGenerator.CreateIID(typeof(T))", helper, StringComparison.Ordinal); + Assert.Contains("Marshal.QueryInterface(value, in iid, out nint inspectable)", helper, StringComparison.Ordinal); + Assert.Contains("global::WinRT.MarshalInspectable.FromAbi(inspectable)", helper, StringComparison.Ordinal); + Assert.Contains("if (hr != E_NOINTERFACE)", helper, StringComparison.Ordinal); + Assert.Contains("ComInterfaceMarshaller.ConvertToManaged", helper, StringComparison.Ordinal); + Assert.Contains("ComWrappers.TryGetComInstance", helper, StringComparison.Ordinal); + Assert.Contains("Marshal.IsComObject", helper, StringComparison.Ordinal); + Assert.Contains("Marshal.GetIUnknownForObject", helper, StringComparison.Ordinal); + Assert.Contains("GeneratedComClassAttribute", helper, StringComparison.Ordinal); + Assert.Contains("global::WinRT.MarshalInspectable.FromManaged(value)", helper, StringComparison.Ordinal); + Assert.Contains(".ConvertToUnmanaged(value)", helper, StringComparison.Ordinal); + Assert.DoesNotContain("IidMarshaller", helper, StringComparison.Ordinal); + Assert.DoesNotContain("requestedIids", helper, StringComparison.Ordinal); + } + + [Fact] + public void AutoWinRTMarshalling_BuiltInComPostProcessesFriendlyOutput() + { + this.GenerateMarshaledComApi("IShellItem", useComSourceGenerators: false); + + MethodDeclarationSyntax overload = this.FindComOutPtrOverload("BindToHandler", "IShellItem"); + string body = overload.Body!.ToFullString(); + Assert.Contains("ComOrWinRTObjectMarshaller.GetIID()", body, StringComparison.Ordinal); + Assert.Contains("ComOrWinRTObjectMarshaller.ConvertToManaged(__ppv)", body, StringComparison.Ordinal); + + string helper = Assert.Single(this.FindGeneratedType("ComOrWinRTObjectMarshaller")).ToFullString(); + Assert.Contains("Marshal.GetIUnknownForObject(value)", helper, StringComparison.Ordinal); + Assert.Contains("return value", helper, StringComparison.Ordinal); + Assert.DoesNotContain("CustomMarshaller", helper, StringComparison.Ordinal); + Assert.DoesNotContain("ComInterfaceMarshaller", helper, StringComparison.Ordinal); + } + + [Fact] + public void AutoWinRTMarshalling_NativeMethodsOptOutPreservesLegacyProjection() + { + this.GenerateMarshaledComApi( + "IShellItem", + options => options with + { + ComInterop = options.ComInterop with { AutoWinRTMarshalling = false }, + }); + + Assert.Empty(this.FindGeneratedType("ComOrWinRTObjectMarshaller")); + var iface = Assert.IsType(Assert.Single(this.FindGeneratedType("IShellItem"))); + MethodDeclarationSyntax method = Assert.Single(iface.Members.OfType(), m => m.Identifier.ValueText == "BindToHandler"); + Assert.Equal("global::System.Guid*", method.ParameterList.Parameters[^2].Type!.ToString()); + Assert.Contains(FindAttribute(method.ParameterList.Parameters.Last().AttributeLists, "MarshalAs"), a => a.ToString().Contains("UnmanagedType.Interface", StringComparison.Ordinal)); + + MethodDeclarationSyntax overload = this.FindComOutPtrOverload("BindToHandler", "IShellItem"); + Assert.Contains("typeof(T).GUID", overload.Body!.ToFullString(), StringComparison.Ordinal); + Assert.DoesNotContain("DynamicallyAccessedMembers", overload.TypeParameterList!.ToFullString(), StringComparison.Ordinal); + } + + [Fact] + public void AutoWinRTMarshalling_WithoutCsWinRTOmitsAdaptiveProjection() + { + this.GenerateMarshaledComApi("IShellItem", withCsWinRT: false); + + Assert.Empty(this.FindGeneratedType("ComOrWinRTObjectMarshaller")); + Assert.DoesNotContain( + this.compilation.SyntaxTrees.SelectMany(tree => tree.GetRoot().DescendantNodes().OfType()), + attribute => attribute.ToString().Contains("ComOrWinRTObjectMarshaller", StringComparison.Ordinal)); + } + + [Fact] + public void AutoWinRTMarshalling_FriendlyOverloadOptOutStillMarshalsAbi() + { + this.GenerateMarshaledComApi( + "IShellItem", + options => options with + { + FriendlyOverloads = new GeneratorOptions.FriendlyOverloadOptions { ComOutPtrGenericOverloads = false }, + }); + + Assert.Single(this.FindGeneratedType("ComOrWinRTObjectMarshaller")); + Assert.DoesNotContain(this.FindGeneratedMethod("BindToHandler"), m => m.TypeParameterList?.Parameters.Count == 1); + + var iface = Assert.IsType(Assert.Single(this.FindGeneratedType("IShellItem"))); + MethodDeclarationSyntax method = Assert.Single(iface.Members.OfType(), m => m.Identifier.ValueText == "BindToHandler"); + Assert.Contains( + FindAttribute(method.ParameterList.Parameters.Last().AttributeLists, "global::System.Runtime.InteropServices.Marshalling.MarshalUsing"), + a => a.ToString().Contains("ComOrWinRTObjectMarshaller", StringComparison.Ordinal)); + } + [Theory, PairwiseData] public void NonCOMInterfaceReferences(bool allowMarshaling) { @@ -1168,4 +1337,58 @@ private static bool IsClassConstrainedGeneric(MethodDeclarationSyntax method) => .OfType() .SingleOrDefault() ?.ToFullString(); + + /// + /// Generates an API with marshaling and C#/WinRT references enabled. + /// + /// The API to generate. + /// An optional transformation of the generator options. + /// to drop the C#/WinRT references from the compilation. + /// to generate source-generated COM declarations. + private void GenerateMarshaledComApi( + string apiName, + Func? optionsModifier = null, + bool withCsWinRT = true, + bool useComSourceGenerators = true) + { + this.compilation = this.starterCompilations["net10.0"]; + if (!withCsWinRT) + { + this.compilation = this.compilation.RemoveReferences( + [.. this.compilation.References.Where(r => r is PortableExecutableReference { FilePath: string path } + && Path.GetFileName(path) is "WinRT.Runtime.dll" or "Microsoft.Windows.SDK.NET.dll")]); + } + + this.parseOptions = this.parseOptions.WithLanguageVersion(GetLanguageVersionForTfm("net10.0") ?? LanguageVersion.Latest); + GeneratorOptions options = DefaultTestGeneratorOptions with + { + AllowMarshaling = true, + ComInterop = new GeneratorOptions.ComInteropOptions { UseComSourceGenerators = useComSourceGenerators }, + }; + this.generator = this.CreateGenerator(optionsModifier?.Invoke(options) ?? options); + Assert.True(this.generator.TryGenerate(apiName, CancellationToken.None)); + this.CollectGeneratedCode(this.generator); + + // CS8795: [LibraryImport] and [GeneratedComInterface] partials are implemented by build-task mode, which this harness does not run. + this.AssertNoDiagnostics( + this.compilation, + logAllGeneratedCode: false, + acceptable: d => useComSourceGenerators && d.Id is "CS8795" or "CS1574"); + } + + /// + /// Finds a generic COM output pointer friendly overload. + /// + /// The friendly overload name. + /// A substring of the receiver type (for extension methods) or the host class name. + /// The generic friendly overload. + private MethodDeclarationSyntax FindComOutPtrOverload(string methodName, string declaringTypeHint) + { + List overloads = [.. this.FindGeneratedMethod(methodName) + .Where(m => m.TypeParameterList?.Parameters.Count == 1 + && (m.ParameterList.Parameters.FirstOrDefault()?.Type?.ToString().Contains(declaringTypeHint, StringComparison.Ordinal) is true + || (m.Parent as ClassDeclarationSyntax)?.Identifier.ValueText == declaringTypeHint))]; + + return Assert.Single(overloads); + } }