Skip to content

Add 'RunCsWinRTGenerator' task and target #49417

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 238 additions & 0 deletions src/Tasks/Microsoft.NET.Build.Tasks/RunCsWinRTGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;

namespace Microsoft.NET.Build.Tasks;

/// <summary>
/// The custom MSBuild task that invokes the 'cswinrtgen' tool.
/// </summary>
public sealed class RunCsWinRTGenerator : ToolTask
{
/// <summary>
/// The name of the generated interop assembly.
/// </summary>
private const string InteropAssemblyName = "WinRT.Interop.dll";

/// <summary>
/// Gets or sets the paths to assembly files that are reference assemblies, representing
/// the entire surface area for compilation. These assemblies are the full set of assemblies
/// that will contribute to the interop .dll being generated.
/// </summary>
[Required]
public ITaskItem[]? ReferenceAssemblyPaths { get; set; }

/// <summary>
/// Gets or sets the path to the output assembly that was produced by the build (for the current project).
/// </summary>
/// <remarks>
/// This property is an array, but it should only ever receive a single item.
/// </remarks>
[Required]
public ITaskItem[]? OutputAssemblyPath { get; set; }

/// <summary>
/// Gets or sets the directory where the generated interop assembly will be placed.
/// </summary>
/// <remarks>If not set, the same directory as <see cref="OutputAssemblyPath"/> will be used.</remarks>
public string? InteropAssemblyDirectory { get; set; }

/// <summary>
/// Gets or sets the directory where the debug repro will be produced.
/// </summary>
/// <remarks>If not set, no debug repro will be produced.</remarks>
public string? DebugReproDirectory { get; set; }

/// <summary>
/// Gets or sets the tools directory where the 'cswinrtgen' tool is located.
/// </summary>
[Required]
public string? CsWinRTToolsDirectory { get; set; }

/// <summary>
/// Gets or sets whether to use <c>Windows.UI.Xaml</c> projections.
/// </summary>
/// <remarks>If not set, it will default to <see langword="false"/> (i.e. using <c>Microsoft.UI.Xaml</c> projections).</remarks>
public bool UseWindowsUIXamlProjections { get; set; } = false;

/// <summary>
/// Gets whether to validate the assembly version of <c>WinRT.Runtime.dll</c>, to ensure it matches the generator.
/// </summary>
public bool ValidateWinRTRuntimeAssemblyVersion { get; init; } = true;

/// <summary>
/// Gets whether to treat warnings coming from 'cswinrtgen' as errors (regardless of the global 'TreatWarningsAsErrors' setting).
/// </summary>
public bool TreatWarningsAsErrors { get; init; } = false;

/// <summary>
/// Gets or sets the maximum number of parallel tasks to use for execution.
/// </summary>
/// <remarks>If not set, the default will match the number of available processor cores.</remarks>
public int MaxDegreesOfParallelism { get; set; } = -1;

/// <summary>
/// Gets or sets additional arguments to pass to the tool.
/// </summary>
public ITaskItem[]? AdditionalArguments { get; set; }

/// <summary>
/// Gets the resulting generated interop .dll item.
/// </summary>
[Output]
public ITaskItem? InteropAssemblyPath { get; private set; }

/// <inheritdoc/>
protected override string ToolName => "cswinrtgen.exe";

/// <summary>
/// Gets the effective item spec for the output assembly.
/// </summary>
private string EffectiveOutputAssemblyItemSpec => OutputAssemblyPath![0].ItemSpec;

/// <summary>
/// Gets the effective directory where the generated interop assembly will be placed.
/// </summary>
private string EffectiveGeneratedAssemblyDirectory => InteropAssemblyDirectory ?? Path.GetDirectoryName(EffectiveOutputAssemblyItemSpec)!;

/// <summary>
/// Gets the effective path of the produced interop assembly.
/// </summary>
private string EffectiveGeneratedAssemblyPath => Path.Combine(EffectiveGeneratedAssemblyDirectory, InteropAssemblyName);

/// <inheritdoc/>
public override bool Execute()
{
// If the tool execution fails, we will not have a generated interop .dll path
if (!base.Execute())
{
InteropAssemblyPath = null;

return false;
}

// Return the generated interop assembly path as an output item
InteropAssemblyPath = new TaskItem(EffectiveGeneratedAssemblyPath);

return true;
}

/// <inheritdoc/>
protected override bool ValidateParameters()
{
if (!base.ValidateParameters())
{
return false;
}

if (ReferenceAssemblyPaths is not { Length: > 0 })
{
Log.LogWarning("Invalid 'ReferenceAssemblyPaths' input(s).");

return false;
}

if (OutputAssemblyPath is not { Length: 1 })
{
Log.LogWarning("Invalid 'OutputAssemblyPath' input.");

return false;
}

if (InteropAssemblyDirectory is not null && !Directory.Exists(InteropAssemblyDirectory))
{
Log.LogWarning("Generated assembly directory '{0}' does not exist.", InteropAssemblyDirectory);

return false;
}

if (DebugReproDirectory is not null && !Directory.Exists(DebugReproDirectory))
{
Log.LogWarning("Debug repro directory '{0}' does not exist.", DebugReproDirectory);

return false;
}

if (CsWinRTToolsDirectory is null || !Directory.Exists(CsWinRTToolsDirectory))
{
Log.LogWarning("Tools directory '{0}' does not exist.", CsWinRTToolsDirectory);

return false;
}

if (MaxDegreesOfParallelism is 0 or < -1)
{
Log.LogWarning("Invalid 'MaxDegreesOfParallelism' value. It must be '-1' or greater than '0' (but was '{0}').", MaxDegreesOfParallelism);

return false;
}

return true;
}

/// <inheritdoc/>
[SuppressMessage("Style", "IDE0072", Justification = "We always use 'x86' as a fallback for all other CPU architectures.")]
protected override string GenerateFullPathToTool()
{
// The tool is inside an architecture-specific subfolder, as it's a native binary
string architectureDirectory = RuntimeInformation.ProcessArchitecture switch
{
Architecture.X64 => "win-x64",
Architecture.Arm64 => "win-arm64",
_ => "win-x86"
};

return Path.Combine(CsWinRTToolsDirectory!, architectureDirectory, ToolName);
}

/// <inheritdoc/>
protected override string GenerateResponseFileCommands()
{
StringBuilder args = new();

IEnumerable<string> referenceAssemblyPaths = ReferenceAssemblyPaths!.Select(static path => path.ItemSpec);
string referenceAssemblyPathsArg = string.Join(",", referenceAssemblyPaths);

AppendResponseFileCommand(args, "--reference-assembly-paths", referenceAssemblyPathsArg);
AppendResponseFileCommand(args, "--output-assembly-path", EffectiveOutputAssemblyItemSpec);
AppendResponseFileCommand(args, "--generated-assembly-directory", EffectiveGeneratedAssemblyDirectory);

// The debug repro directory is optional, and might not be set
if (DebugReproDirectory is not null)
{
AppendResponseFileCommand(args, "--debug-repro-directory", DebugReproDirectory);
}

AppendResponseFileCommand(args, "--use-windows-ui-xaml-projections", UseWindowsUIXamlProjections.ToString());
AppendResponseFileCommand(args, "--validate-winrt-runtime-assembly-version", ValidateWinRTRuntimeAssemblyVersion.ToString());
AppendResponseFileCommand(args, "--treat-warnings-as-errors", TreatWarningsAsErrors.ToString());
AppendResponseFileCommand(args, "--max-degrees-of-parallelism", MaxDegreesOfParallelism.ToString());

// Add any additional arguments that are not statically known
foreach (ITaskItem additionalArgument in AdditionalArguments ?? [])
{
_ = args.AppendLine(additionalArgument.ItemSpec);
}

return args.ToString();
}

/// <summary>
/// Appends a command line argument to the response file arguments, with the right format.
/// </summary>
/// <param name="args">The command line arguments being built.</param>
/// <param name="commandName">The command name to append.</param>
/// <param name="commandValue">The command value to append.</param>
private static void AppendResponseFileCommand(StringBuilder args, string commandName, string commandValue)
{
_ = args.Append($"{commandName} ").AppendLine(commandValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -167,4 +167,73 @@ Copyright (c) .NET Foundation. All rights reserved.
and '$(UseUwp)' == 'true' ">
<NetSdkError ResourceName="WindowsSDKXamlInvalidTfm" />
</Target>

<!--
============================================================
_RunCsWinRTGenerator

Runs 'cswinrtgen' to produce the 'WinRT.Interop.dll' assembly.
============================================================
-->
<UsingTask Condition="'$(CsWinRTGeneratorTasksOverriden)' != 'true'" TaskName="RunCsWinRTGenerator" AssemblyFile="$(MicrosoftNETBuildTasksAssembly)" />
<Target
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ideally, this target would only run if something changed. As in, if there's been any code changes or if any transitive dependency changed. I'm assuming the Csc task that's invoked in CoreCompile would already be doing some kind of check like that, to skip work if eg. you just F5 again without changing anything. Is there a way for us to perform the same check (or get that result out of CoreCompile) so we can also skip this target if not needed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This concept is known as incrementality. The short form is that if you know your inputs, and your inputs are files whose timestamps can be tracked, then you can set Inputs=..all that stuff.. on your Target and the MSBuild engine will transparently track them and only call your target if any of them are out of date. Similarly, if you know your Outputs, you can provide those to the engine as well and up-to-date checking will take place.

This means that many complex Targets often have a 'compute the inputs and outputs for Target XXXXX' Target that runs immediately before them, so that you can compute the correct set of inputs/outputs for incrementality purposes.

Copy link
Contributor Author

@Sergio0694 Sergio0694 Jun 20, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! I added the Inputs property. I have a couple more questions though:

  • I don't know the exact output path in advance, as that's computed by the target. I get that via that CsWinRTGeneratorInteropAssemblyPath item that the target produces as output. Is there a way to configure that returned item as the Outputs item for the wrapping task?
  • I also need to re-run the target if any of the other input properties changes. The docs seem to only talk about input/output items. How can one handle incrementality with respect to properties as well? 🤔

Thank you!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the first point, we usually try to have those paths be able to be input into the Task to help with this kind of incrementality.
For the second, this is a gap in the MSBuild Incrementality model - it only supports files today. To get around this, most people have a 'generate property cache file' Target that they have run immediately before their real Target and they add this generated file to their Inputs. You can see an example of this pattern here.

Name="_RunCsWinRTGenerator"
DependsOnTargets="CoreCompile;$(GetTargetPathDependsOn);$(GetTargetPathWithTargetPlatformMonikerDependsOn)"
BeforeTargets="GetTargetPath;GetTargetPathWithTargetPlatformMoniker;GenerateBuildDependencyFile;GeneratePublishDependencyFile"
Inputs="@(ReferencePath);@(IntermediateAssembly)"
Condition="'$(CsWinRTGenerateInteropAssembly)' == 'true'">

<!-- Default property values for 'cswinrtgen' -->
<PropertyGroup>
<CsWinRTToolsDirectory Condition="'$(CsWinRTToolsDirectory)' == ''"></CsWinRTToolsDirectory>
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we need to get the path to the root directory of the Microsoft.Windows.SDK.NET.Ref package that's going to be used. Not sure how to get it from here though.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there's a packageref to this package (implicit or explicit), there may be a PackagePath_MIcrosoft_Windows_SDK_NET_Ref MSbuild property already ambiently available. Check a binlog and see?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried in a blank app targeting net9.0-windows10.0.26100.0, not seeing that variable 🥲
Is this something we could configure in the .NET SDK? Since it's the one adding this 🤔

<CsWinRTGeneratorValidateWinRTRuntimeAssemblyVersion Condition="'$(ValidateWinRTRuntimeAssemblyVersion)' == ''">true</CsWinRTGeneratorValidateWinRTRuntimeAssemblyVersion>
<CsWinRTGeneratorTreatWarningsAsErrors Condition="'$(CsWinRTGeneratorTreatWarningsAsErrors)' == ''">false</CsWinRTGeneratorTreatWarningsAsErrors>
<CsWinRTGeneratorMaxDegreesOfParallelism Condition="'$(CsWinRTGeneratorMaxDegreesOfParallelism)' == ''">-1</CsWinRTGeneratorMaxDegreesOfParallelism>
<CsWinRTGeneratorStandardOutputImportance Condition="'$(CsWinRTGeneratorStandardOutputImportance)' == ''">High</CsWinRTGeneratorStandardOutputImportance>
<CsWinRTGeneratorStandardErrorImportance Condition="'$(CsWinRTGeneratorStandardErrorImportance)' == ''">High</CsWinRTGeneratorStandardErrorImportance>
<CsWinRTGeneratorLogStandardErrorAsError Condition="'$(CsWinRTGeneratorLogStandardErrorAsError)' == ''">true</CsWinRTGeneratorLogStandardErrorAsError>
</PropertyGroup>

<!-- Invoke 'cswinrtgen' -->
<CsWinRTGenerator
ReferenceAssemblyPaths="@(ReferencePath)"
OutputAssemblyPath="@(IntermediateAssembly)"
InteropAssemblyDirectory="$(CsWinRTGeneratorInteropAssemblyDirectory)"
DebugReproDirectory="$(CsWinRTGeneratorDebugReproDirectory)"
CsWinRTToolsDirectory="$(CsWinRTToolsDirectory)"
UseWindowsUIXamlProjections="$(CsWinRTUseWindowsUIXamlProjections)"
ValidateWinRTRuntimeAssemblyVersion="$(CsWinRTGeneratorValidateWinRTRuntimeAssemblyVersion)"
TreatWarningsAsErrors="$(CsWinRTGeneratorTreatWarningsAsErrors)"
MaxDegreesOfParallelism="$(CsWinRTGeneratorMaxDegreesOfParallelism)"
AdditionalArguments="@(CsWinRTGeneratorAdditionalArgument)"
StandardOutputImportance="$(CsWinRTGeneratorStandardOutputImportance)"
StandardErrorImportance="$(CsWinRTGeneratorStandardErrorImportance)"
LogStandardErrorAsError="$(CsWinRTGeneratorLogStandardErrorAsError)">
<Output ItemName="CsWinRTGeneratorInteropAssemblyPath" TaskParameter="InteropAssemblyPath" />
</CsWinRTGenerator>

<!-- Set the metadata for the interop .dll item -->
<ItemGroup>
<CsWinRTGeneratorInteropAssemblyPath Condition="'%(Filename)%(Extension)' == 'WinRT.Interop.dll'">
<AssemblyName>WinRT.Interop</AssemblyName>
<TargetFrameworkIdentifier>.NETCoreApp</TargetFrameworkIdentifier>
<ReferenceOutputAssembly>true</ReferenceOutputAssembly>
<IncludeRuntimeDependency>true</IncludeRuntimeDependency>
<Private>true</Private>
<MSBuildSourceTargetName>InvokeCsWinRTGenerator</MSBuildSourceTargetName>
<Platforms>AnyCPU</Platforms>
<CopyLocal>true</CopyLocal>
</CsWinRTGeneratorInteropAssemblyPath>
</ItemGroup>

<!-- Add the interop .dll to the list of reference paths -->
<ItemGroup>
<ReferencePath Include="@(CsWinRTGeneratorInteropAssemblyPath)" />
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need two things here:

  • @(CsWinRTGeneratorInteropAssemblyPath) needs to be copied to the output directory
  • @(CsWinRTGeneratorInteropAssemblyPath) also needs to be added to .deps.json

I'm not sure how to do both. I could do it by adding the item to both ReferencePath, UserRuntimeAssembly, and IntermediatePath, but that doesn't seem correct. What is the right way to achieve this?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will need to look into answers for both of these, I'll try to do that tomorrow.

</ItemGroup>

<!-- Append to 'FileWrites' so the interop .dll will be removed on clean -->
<ItemGroup>
<FileWrites Include="@(CsWinRTGeneratorInteropAssemblyPath)"/>
</ItemGroup>
</Target>
</Project>
Loading