diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7763de515..c7b30d5f7 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -711,7 +711,7 @@ All five .NET build tools (`cswinrtprojectionrefgen`, `cswinrtprojectiongen`, `c | Projection Generator (host) | `CSWINRTPROJECTIONGENxxxx` | `0001`–`0011`, `9999` | | Projection Writer (library) | `CSWINRTPROJECTIONGEN5xxx` | `5003`–`5021`, `9999` (shares the `CSWINRTPROJECTIONGEN` prefix with the host; the writer reserves the 5000+ range so the two never collide) | | Impl Generator | `CSWINRTIMPLGENxxxx` | `0001`–`0014`, `9999` | -| Interop Generator | `CSWINRTINTEROPGENxxxx` | `0001`–`0097`, `9999` | +| Interop Generator | `CSWINRTINTEROPGENxxxx` | `0001`–`0100`, `9999` | | WinMD Generator | `CSWINRTWINMDGENxxxx` | `0001`–`0010`, `9999` | | Runtime (obsolete markers) | `CSWINRT3xxx` | `CSWINRT3001` | diff --git a/.github/skills/interop-generator/SKILL.md b/.github/skills/interop-generator/SKILL.md index 96e5d1611..fb2d8ce5f 100644 --- a/.github/skills/interop-generator/SKILL.md +++ b/.github/skills/interop-generator/SKILL.md @@ -61,8 +61,9 @@ WinRT.Interop.Generator/ ├── Errors/ # Error/warning infrastructure │ ├── UnhandledInteropException.cs # Wraps unexpected errors with phase context │ ├── WellKnownInteropException.cs # Structured errors with CSWINRTINTEROPGEN### codes -│ ├── WellKnownInteropExceptions.cs # Factory for all error/warning codes (80+ codes) -│ └── WellKnownInteropWarning.cs # Non-fatal warnings (can be promoted to errors) +│ ├── WellKnownInteropExceptions.cs # Factory for all error/warning/message codes (100+ codes) +│ ├── WellKnownInteropWarning.cs # Non-fatal warnings (can be promoted to errors) +│ └── WellKnownInteropMessage.cs # Informational messages (never promoted to errors) ├── Extensions/ # Extension methods (23 files) │ ├── WindowsRuntimeExtensions.cs # Core: IsProjectedWindowsRuntimeType, IsBlittable, etc. │ ├── TypeSignatureExtensions.cs # GetAbiType, EnumerateAllInterfaces, etc. @@ -230,6 +231,8 @@ The generator is invoked via a response file: `cswinrtinteropgen.exe @path/to/re | `--winrt-component-assembly-path` | `string?` | `WinRT.Component.dll` path (for authored components) | | `--generated-assembly-directory` | `string` | Output folder for `WinRT.Interop.dll` | | `--use-windows-ui-xaml-projections` | `bool` | Use UWP XAML (`Windows.UI.Xaml`) instead of WinUI | +| `--marshalling-mode` | `CsWinRTMarshallingMode` | Which assemblies to analyze for discovery: `All`, `Minimal` (default; skip BCL), or `Strict` (only WinRT-referencing) | +| `--marshalling-enabled-assembly-names` | `string[]` | Assembly names (`.dll`/directory ignored) always analyzed regardless of mode (from the `CsWinRTMarshallingEnabledAssembly` item) | | `--validate-winrt-runtime-assembly-version` | `bool` | Check version compatibility | | `--validate-winrt-runtime-dll-version-2-references` | `bool` | Reject CsWinRT 2.x references | | `--enable-incremental-generation` | `bool` | Enable incremental generation (caching) | @@ -282,6 +285,15 @@ The generator processes two categories of assemblies: - `WinRT.Projection.dll` — 3rd-party component projections (optional) - `WinRT.Component.dll` — Authored component projections (optional) +**Marshalling mode** (`--marshalling-mode`, `ShouldProcessModule` in `InteropGenerator.Discover.cs`): controls which modules are analyzed for discovery. Modules referencing the Windows Runtime assembly (i.e. those built targeting a Windows TFM) are **always** analyzed; the mode only affects modules that don't reference any CsWinRT assembly: +- `All` — analyze every module, even plain `.NET` assemblies (including the BCL). +- `Minimal` (default) — analyze every module except those from the BCL (detected via well-known .NET/Microsoft public key tokens, see `BaseClassLibraryIdentity` in `WinRT.Generator.Core` and the `ModuleDefinition.IsBaseClassLibraryModule` extension), to reduce binary size. This still lets projects that don't target a Windows TFM (e.g. a class library with just MVVM viewmodels) contribute the marshalling code their types need. +- `Strict` — only analyze modules referencing the Windows Runtime assembly (the historical behavior). Corresponds to `!ReferencesWindowsRuntimeAssembly && !IsWindowsRuntimeModule → skip`. + +**Legacy-runtime skip** (`ModuleDefinition.TargetsLegacyRuntime` in `InteropGenerator.Discover.cs`): regardless of mode, modules targeting a legacy/portable runtime (i.e. a `netstandard` or `mscorlib` corlib scope) are **never** analyzed. The entire generator infrastructure identifies well-known types (including custom-mapped types such as `IEnumerable`) by comparing against type references scoped to the modern .NET corlib (e.g. `System.Runtime`), which the emit phase also uses. Legacy-runtime assemblies declare those same types against a different corlib (`netstandard`/`mscorlib`), which `AsmResolver`'s `SignatureComparer` treats as a distinct scope — so the generator can't match them, and generating marshalling code for them would fail with `CSWINRTINTEROPGEN0055`/`0020`. Such assemblies could in principle still use custom-mapped types that need marshalling, but for simplicity they're skipped entirely. + +**Opt-in assemblies** (`--marshalling-enabled-assembly-names`, from the `CsWinRTMarshallingEnabledAssembly` MSBuild item): assemblies listed here are **always** analyzed (matched by simple name, ignoring `.dll`/directory), regardless of the mode — checked first in `ShouldProcessModule`. This lets users fine-tune size (e.g. `strict` mode + a few opted-in assemblies). It does **not** override the legacy-runtime skip (legacy assemblies still can't be marshalled). `ValidateMarshallingEnabledAssemblies` emits three diagnostics: `CSWINRTINTEROPGEN0098` (warning: entry doesn't match any referenced assembly), `CSWINRTINTEROPGEN0099` (message: entry already targets Windows, redundant), and `CSWINRTINTEROPGEN0100` (message: mode is `all`, all entries redundant). + **Type exclusions** (`Helpers/TypeExclusions.cs`): - `System.Threading.Tasks.Task` — Cannot be marshalled across Windows Runtime boundary - `System.Collections.Concurrent.ConditionalWeakTable<,>` — Memory semantics conflict diff --git a/docs/usage.md b/docs/usage.md index 12dd217e9..8cb75f930 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -136,10 +136,47 @@ Below are the most commonly used MSBuild properties. For a full list, refer to t | `CsWinRTGenerateProjection` | `true` | Generate C# projection sources from `.winmd` metadata. | | `CsWinRTGenerateReferenceProjection` | `false` | Generate reference-only projections (for NuGet distribution). | | `CsWinRTComponent` | `false` | Enable Windows Runtime component authoring mode. | +| `CsWinRTMarshallingMode` | `minimal` | Controls which assemblies the interop generator analyzes for marshalling code (`all`, `minimal`, or `strict`). See below. | | `CsWinRTIncludes` | *(empty)* | Semicolon-separated namespaces to include in the projection. | | `CsWinRTExcludes` | `Windows;Microsoft` | Semicolon-separated namespaces to exclude from the projection. | | `CsWinRTMessageImportance` | `normal` | Build message verbosity (`normal` or `high`). | +### Marshalling mode + +By default (`minimal`), the interop generator analyzes **every** assembly in the app to discover user-defined +(CCW) types and generic instantiations that need marshalling code — even assemblies that don't target a +Windows TFM and don't reference any CsWinRT assembly — with the exception of the .NET base class library +(BCL). This means a plain `.NET` class library (e.g. one holding MVVM viewmodels) no longer needs to target +a `-windows` TFM just so its types can be marshalled to native code, while the BCL is skipped to keep the +generated interop assembly small. + +`CsWinRTMarshallingMode` controls this behavior: + +| Value | Behavior | +|-------|----------| +| `all` | Analyze all assemblies, including those from the .NET base class library (BCL). | +| `minimal` (default) | Same as `all`, but skip assemblies from the .NET base class library (BCL) to reduce binary size. | +| `strict` | Only analyze assemblies referencing the Windows Runtime assembly (i.e. those targeting a Windows TFM). | + +Assemblies that reference the Windows Runtime assembly are always analyzed, regardless of the mode. Assemblies targeting a legacy or portable runtime (i.e. .NET Standard or .NET Framework) are never analyzed, since the interop generator can only marshal types declared against a modern .NET runtime. + +### Opting in specific assemblies + +The `CsWinRTMarshallingEnabledAssembly` item lets you force the interop generator to analyze specific assemblies, regardless of the marshalling mode. This is useful for fine-tuning binary size: for example, you can keep the `strict` mode (the smallest option) while opting in just the few assemblies you know need marshalling support: + +```xml + + strict + + + + + + +``` + +Each item is an assembly name (the `.dll` extension is optional). The interop generator reports a warning if an entry doesn't match any referenced assembly, and an informational message if an entry is redundant (e.g. it already targets Windows, or the mode is `all` and thus already analyzes everything). + ## Author and consume a C#/WinRT component This is coming soon for CsWinRT 3.0. diff --git a/nuget/Microsoft.Windows.CsWinRT.CsWinRTGen.targets b/nuget/Microsoft.Windows.CsWinRT.CsWinRTGen.targets index bf683b9df..ceb0c4634 100644 --- a/nuget/Microsoft.Windows.CsWinRT.CsWinRTGen.targets +++ b/nuget/Microsoft.Windows.CsWinRT.CsWinRTGen.targets @@ -45,6 +45,19 @@ Copyright (C) Microsoft Corporation. All rights reserved. High true + + minimal + @@ -133,6 +146,8 @@ Copyright (C) Microsoft Corporation. All rights reserved. <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTGeneratorInteropAssemblyDirectory)" /> <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTGeneratorDebugReproDirectory)" /> <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTUseWindowsUIXamlProjections)" /> + <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTMarshallingMode)" /> + <_RunCsWinRTGeneratorInputsCacheToHash Include="@(CsWinRTMarshallingEnabledAssembly)" /> <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTGeneratorValidateWinRTRuntimeAssemblyVersion)" /> <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTGeneratorValidateWinRTRuntimeDllVersion2References)" /> <_RunCsWinRTGeneratorInputsCacheToHash Include="$(CsWinRTGeneratorEnableIncrementalGeneration)" /> @@ -185,6 +200,8 @@ Copyright (C) Microsoft Corporation. All rights reserved. CsWinRTToolsDirectory="$(CsWinRTInteropGenEffectiveToolsDirectory)" CsWinRTToolsArchitecture="$(CsWinRTToolsArchitecture)" UseWindowsUIXamlProjections="$(CsWinRTUseWindowsUIXamlProjections)" + MarshallingMode="$(CsWinRTMarshallingMode)" + MarshallingEnabledAssemblies="@(CsWinRTMarshallingEnabledAssembly)" ValidateWinRTRuntimeAssemblyVersion="$(CsWinRTGeneratorValidateWinRTRuntimeAssemblyVersion)" ValidateWinRTRuntimeDllVersion2References="$(CsWinRTGeneratorValidateWinRTRuntimeDllVersion2References)" EnableIncrementalGeneration="$(CsWinRTGeneratorEnableIncrementalGeneration)" diff --git a/nuget/readme.md b/nuget/readme.md index e74e375d6..5159bf545 100644 --- a/nuget/readme.md +++ b/nuget/readme.md @@ -33,6 +33,8 @@ C#/WinRT behavior can be customized with these project properties: | CsWinRTGenerateReferenceProjection | true \| *false | Generate reference-only projections (for NuGet distribution) | | CsWinRTGenerateInteropAssembly | auto | Generate interop assemblies at build time (defaults to `true` for Exe/WinExe, or Library with `PublishAot=true`) | | CsWinRTComponent | true \| *false | Enable Windows Runtime component authoring mode | +| CsWinRTMarshallingMode | all \| *minimal \| strict | Controls which assemblies the interop generator analyzes for marshalling code. `all` analyzes every assembly (including the .NET base class library), `minimal` analyzes every assembly except the .NET base class library (BCL), and `strict` only analyzes assemblies referencing the Windows Runtime assembly | +| CsWinRTMarshallingEnabledAssembly | *(item)* | An item listing specific assemblies (by name, `.dll` optional) to always analyze for marshalling code, regardless of `CsWinRTMarshallingMode`. Useful for fine-tuning binary size (e.g. using `strict` and opting in a few assemblies) | | CsWinRTUseWindowsUIXamlProjections | true \| *false | Use UWP XAML (`Windows.UI.Xaml`) instead of WinUI (`Microsoft.UI.Xaml`) | | CsWinRTMergeReferencedActivationFactories | true \| *false | Makes `DllGetActivationFactory` forward activation calls to all referenced WinRT components, allowing them to be merged into a single executable | | CsWinRTIncludes | "" | Semicolon-separated namespaces to include in projection output | diff --git a/src/WinRT.Generator.Core/Errors/WellKnownGeneratorMessage.cs b/src/WinRT.Generator.Core/Errors/WellKnownGeneratorMessage.cs new file mode 100644 index 000000000..51b4d3317 --- /dev/null +++ b/src/WinRT.Generator.Core/Errors/WellKnownGeneratorMessage.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace WindowsRuntime.Generator.Errors; + +/// +/// A well-known informational message shared by all generators. +/// +/// +/// Unlike a warning, a message is purely informational: it is never promoted to an error, and it is surfaced +/// by MSBuild as a build message rather than a warning (the emitted line intentionally omits the +/// warning/error category keyword that MSBuild's output parser looks for). It is distinct from +/// , which holds the message templates for the shared logical errors. +/// +internal sealed class WellKnownGeneratorMessage +{ + /// + /// The id of the message. + /// + private readonly string _id; + + /// + /// The message text. + /// + private readonly string _message; + + /// + /// Creates a new instance with the specified parameters. + /// + /// The id of the message. + /// The message text. + public WellKnownGeneratorMessage(string id, string message) + { + _id = id; + _message = message; + } + + /// + /// Logs the message through the provided logger. + /// + /// The logger to write the message to (typically ConsoleApp.Log from ConsoleAppFramework). + public void Log(Action log) + { + log($"{_id}: {_message}"); + } +} diff --git a/src/WinRT.Generator.Core/Parsing/ResponseFileBuilder.cs b/src/WinRT.Generator.Core/Parsing/ResponseFileBuilder.cs index fa90f6971..a7324201e 100644 --- a/src/WinRT.Generator.Core/Parsing/ResponseFileBuilder.cs +++ b/src/WinRT.Generator.Core/Parsing/ResponseFileBuilder.cs @@ -89,6 +89,13 @@ private static string FormatValue(object value) return string.Join(',', array); } + // Enum values are serialized by their member name (e.g. 'All'), matching the parser's + // case-insensitive, name-based enum handling so the round-trip stays deterministic. + if (value is Enum enumValue) + { + return enumValue.ToString(); + } + // All other primitive values use invariant culture, matching the parser's // invariant 'Convert.ChangeType' so the round-trip is deterministic. return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; diff --git a/src/WinRT.Generator.Core/Parsing/ResponseFileParser.cs b/src/WinRT.Generator.Core/Parsing/ResponseFileParser.cs index 25a2bc7a6..39dbacd8b 100644 --- a/src/WinRT.Generator.Core/Parsing/ResponseFileParser.cs +++ b/src/WinRT.Generator.Core/Parsing/ResponseFileParser.cs @@ -261,6 +261,26 @@ private static bool TryConvert(string rawValue, Type targetType, out object? con return true; } + // Enum-typed arguments are parsed by name (case-insensitively), so that MSBuild values such + // as 'all' round-trip to the matching enum member (e.g. 'All'). Numeric values are rejected, + // as they carry no meaning in a response file and would silently accept out-of-range inputs. + if (targetType.IsEnum) + { + if (rawValue.Length > 0 && + !char.IsAsciiDigit(rawValue[0]) && + rawValue[0] != '-' && + Enum.TryParse(targetType, rawValue, ignoreCase: true, out object? parsedEnum)) + { + converted = parsedEnum; + + return true; + } + + converted = null; + + return false; + } + // For primitives ('bool', 'int', etc.) we rely on the standard 'Convert.ChangeType', which // is AOT-safe for the built-in primitives and uses invariant culture for deterministic parsing. try diff --git a/src/WinRT.Generator.Core/References/BaseClassLibraryIdentity.cs b/src/WinRT.Generator.Core/References/BaseClassLibraryIdentity.cs new file mode 100644 index 000000000..cc4c07192 --- /dev/null +++ b/src/WinRT.Generator.Core/References/BaseClassLibraryIdentity.cs @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; + +namespace WindowsRuntime.Generator.References; + +/// +/// Helpers to identify assemblies that are part of the .NET base class library (BCL), i.e. the default set +/// of libraries shipped by the .NET SDK (the runtime shared frameworks and their reference assemblies). +/// +/// +/// +/// Detection is based on the assembly's public key token. The .NET runtime and framework assemblies are all +/// strong-name signed with a small, fixed set of Microsoft-owned keys. User code cannot be signed with these +/// keys, so a match reliably identifies a framework assembly (and, conversely, user or third-party assemblies +/// never match). +/// +/// +/// The set below was validated against the assemblies shipped in the Microsoft.NETCore.App, +/// Microsoft.WindowsDesktop.App, and Microsoft.AspNetCore.App shared frameworks. +/// +/// +internal static class BaseClassLibraryIdentity +{ + /// The ECMA public key token (b77a5c561934e089): mscorlib, System, System.Core, System.Xml, etc. + private static ReadOnlySpan EcmaPublicKeyToken => [0xB7, 0x7A, 0x5C, 0x56, 0x19, 0x34, 0xE0, 0x89]; + + /// The Microsoft public key token (b03f5f7f11d50a3a): most System.* assemblies, Microsoft.CSharp, Microsoft.VisualBasic, Microsoft.Win32.*, etc. + private static ReadOnlySpan MicrosoftPublicKeyToken => [0xB0, 0x3F, 0x5F, 0x7F, 0x11, 0xD5, 0x0A, 0x3A]; + + /// The .NET public key token (cc7b13ffcd2ddd51): netstandard, System.Buffers, System.Memory, System.Numerics.Vectors, etc. + private static ReadOnlySpan DotNetPublicKeyToken => [0xCC, 0x7B, 0x13, 0xFF, 0xCD, 0x2D, 0xDD, 0x51]; + + /// The runtime public key token (7cec85d7bea7798e): System.Private.CoreLib. + private static ReadOnlySpan CoreLibPublicKeyToken => [0x7C, 0xEC, 0x85, 0xD7, 0xBE, 0xA7, 0x79, 0x8E]; + + /// The Windows public key token (31bf3856ad364e35): WindowsBase, WCF/WPF assemblies, System.ServiceModel.Web, etc. + private static ReadOnlySpan WindowsPublicKeyToken => [0x31, 0xBF, 0x38, 0x56, 0xAD, 0x36, 0x4E, 0x35]; + + /// The ASP.NET Core public key token (adb9793829ddae60): the Microsoft.AspNetCore.* shared framework assemblies. + private static ReadOnlySpan AspNetCorePublicKeyToken => [0xAD, 0xB9, 0x79, 0x38, 0x29, 0xDD, 0xAE, 0x60]; + + /// + /// Checks whether a given public key token belongs to a .NET base class library / framework assembly. + /// + /// The 8-byte public key token to check. + /// Whether identifies a .NET base class library / framework assembly. + public static bool IsBaseClassLibraryPublicKeyToken(ReadOnlySpan publicKeyToken) + { + // A public key token is always exactly 8 bytes. Anything else (including an empty span for + // unsigned assemblies) is definitely not a framework assembly. The 'SequenceEqual' checks + // below would already return 'false' in that case, but the explicit length check documents + // the invariant and short-circuits the comparisons for non-framework assemblies. + return + publicKeyToken.Length == 8 && + (publicKeyToken.SequenceEqual(EcmaPublicKeyToken) || + publicKeyToken.SequenceEqual(MicrosoftPublicKeyToken) || + publicKeyToken.SequenceEqual(DotNetPublicKeyToken) || + publicKeyToken.SequenceEqual(CoreLibPublicKeyToken) || + publicKeyToken.SequenceEqual(WindowsPublicKeyToken) || + publicKeyToken.SequenceEqual(AspNetCorePublicKeyToken)); + } +} diff --git a/src/WinRT.Generator.Tasks/RunCsWinRTInteropGenerator.cs b/src/WinRT.Generator.Tasks/RunCsWinRTInteropGenerator.cs index df3cda15c..75e0c6534 100644 --- a/src/WinRT.Generator.Tasks/RunCsWinRTInteropGenerator.cs +++ b/src/WinRT.Generator.Tasks/RunCsWinRTInteropGenerator.cs @@ -17,6 +17,16 @@ namespace Microsoft.NET.Build.Tasks; /// public sealed class RunCsWinRTInteropGenerator : ToolTask { + /// + /// The default marshalling mode, used when none is specified. + /// + private const string DefaultMarshallingMode = "minimal"; + + /// + /// The set of valid marshalling modes (compared case-insensitively). + /// + private static readonly string[] ValidMarshallingModes = ["all", "minimal", "strict"]; + /// /// Gets or sets the paths to assembly files that are reference assemblies, representing /// the entire surface area for compilation. These assemblies, specificaly the ones @@ -96,6 +106,19 @@ public sealed class RunCsWinRTInteropGenerator : ToolTask /// If not set, it will default to (i.e. using Microsoft.UI.Xaml projections). public bool UseWindowsUIXamlProjections { get; set; } = false; + /// + /// Gets or sets the marshalling mode, controlling which assemblies are analyzed to discover + /// user-defined/CCW/generic types (one of "all", "minimal", or "strict"). + /// + /// If not set, it will default to "minimal" (i.e. analyzing every assembly except those from the BCL). + public string MarshallingMode { get; set; } = DefaultMarshallingMode; + + /// + /// Gets or sets the names of assemblies explicitly opted in for analysis, regardless of the marshalling mode. + /// + /// Each item is an assembly name (the .dll extension and any directory are ignored). + public ITaskItem[]? MarshallingEnabledAssemblies { get; set; } + /// /// Gets whether to validate the assembly version of WinRT.Runtime.dll, to ensure it matches the generator. /// @@ -219,9 +242,35 @@ protected override bool ValidateParameters() return false; } + // The marshalling mode must be one of the well-known values (compared case-insensitively). + if (!IsValidMarshallingMode(MarshallingMode)) + { + Log.LogWarning("Invalid 'MarshallingMode' value '{0}'. It must be one of 'all', 'minimal', or 'strict'.", MarshallingMode); + + return false; + } + return true; } + /// + /// Checks whether a given marshalling mode value is valid (i.e. one of the well-known values). + /// + /// The marshalling mode value to check. + /// Whether is a valid marshalling mode. + private static bool IsValidMarshallingMode(string marshallingMode) + { + foreach (string validMode in ValidMarshallingModes) + { + if (validMode.Equals(marshallingMode, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + /// [SuppressMessage("Style", "IDE0072", Justification = "We always use 'x86' as a fallback for all other CPU architectures.")] protected override string GenerateFullPathToTool() @@ -271,6 +320,8 @@ protected override string GenerateResponseFileCommands() AppendResponseFileCommand(args, "--generated-assembly-directory", InteropAssemblyDirectory!); AppendResponseFileOptionalCommand(args, "--debug-repro-directory", DebugReproDirectory); AppendResponseFileCommand(args, "--use-windows-ui-xaml-projections", UseWindowsUIXamlProjections.ToString()); + AppendResponseFileCommand(args, "--marshalling-mode", MarshallingMode); + AppendResponseFileOptionalCommand(args, "--marshalling-enabled-assembly-names", MarshallingEnabledAssemblies); AppendResponseFileCommand(args, "--validate-winrt-runtime-assembly-version", ValidateWinRTRuntimeAssemblyVersion.ToString()); AppendResponseFileCommand(args, "--validate-winrt-runtime-dll-version-2-references", ValidateWinRTRuntimeDllVersion2References.ToString()); AppendResponseFileCommand(args, "--enable-incremental-generation", EnableIncrementalGeneration.ToString()); @@ -311,4 +362,19 @@ private static void AppendResponseFileOptionalCommand(StringBuilder args, string AppendResponseFileCommand(args, commandName, commandValue); } } + + /// + /// Appends an optional command line argument, with a comma-separated list of item specs as its value, to the response file arguments. + /// + /// The command line arguments being built. + /// The command name to append. + /// The optional items whose item specs to append as a comma-separated list. + /// This method will not append the command if is or empty. + private static void AppendResponseFileOptionalCommand(StringBuilder args, string commandName, ITaskItem[]? commandItems) + { + if (commandItems is { Length: > 0 }) + { + AppendResponseFileCommand(args, commandName, string.Join(",", commandItems.Select(static item => item.ItemSpec))); + } + } } \ No newline at end of file diff --git a/src/WinRT.Interop.Generator/Errors/WellKnownInteropExceptions.cs b/src/WinRT.Interop.Generator/Errors/WellKnownInteropExceptions.cs index 6df46e720..311eed8aa 100644 --- a/src/WinRT.Interop.Generator/Errors/WellKnownInteropExceptions.cs +++ b/src/WinRT.Interop.Generator/Errors/WellKnownInteropExceptions.cs @@ -848,6 +848,36 @@ public static WellKnownInteropException DiscoverActivationFactoryTypesError(stri return Exception(97, $"Failed to discover activation factory types for module '{name}'.", exception); } + /// + /// An assembly specified via 'CsWinRTMarshallingEnabledAssembly' could not be found among the referenced assemblies. + /// + public static WellKnownInteropWarning MarshallingEnabledAssemblyNotFoundWarning(string name) + { + return Warning(98, + $"The assembly '{name}' specified via 'CsWinRTMarshallingEnabledAssembly' could not be found among the referenced assemblies. " + + $"Ensure the value matches the name of a referenced assembly (the '.dll' extension is optional), or remove the entry."); + } + + /// + /// An assembly specified via 'CsWinRTMarshallingEnabledAssembly' already targets Windows, so the entry is redundant. + /// + public static WellKnownGeneratorMessage MarshallingEnabledAssemblyTargetsWindowsMessage(string name) + { + return Message(99, + $"The assembly '{name}' specified via 'CsWinRTMarshallingEnabledAssembly' already targets Windows and is therefore always analyzed. " + + $"This entry is redundant and can be removed."); + } + + /// + /// Assemblies were specified via 'CsWinRTMarshallingEnabledAssembly', but the marshalling mode is 'all'. + /// + public static WellKnownGeneratorMessage MarshallingEnabledAssembliesRedundantInAllModeMessage() + { + return Message(100, + "One or more assemblies were specified via 'CsWinRTMarshallingEnabledAssembly', but the 'CsWinRTMarshallingMode' is set to 'all', " + + "which already analyzes every assembly. These entries are redundant and will not affect compilation."); + } + /// /// Creates a new exception with the specified id and message. /// @@ -870,4 +900,15 @@ private static WellKnownInteropWarning Warning(int id, string message) { return new($"{ErrorPrefix}{id:0000}", message); } + + /// + /// Creates a new message with the specified id and message text. + /// + /// The message id. + /// The message text. + /// The resulting message. + private static WellKnownGeneratorMessage Message(int id, string message) + { + return new($"{ErrorPrefix}{id:0000}", message); + } } diff --git a/src/WinRT.Interop.Generator/Extensions/WindowsRuntimeExtensions.cs b/src/WinRT.Interop.Generator/Extensions/WindowsRuntimeExtensions.cs index ce70b8a1a..285805de2 100644 --- a/src/WinRT.Interop.Generator/Extensions/WindowsRuntimeExtensions.cs +++ b/src/WinRT.Interop.Generator/Extensions/WindowsRuntimeExtensions.cs @@ -8,6 +8,7 @@ using AsmResolver.DotNet; using AsmResolver.DotNet.Signatures; using AsmResolver.PE.DotNet.Metadata.Tables; +using WindowsRuntime.Generator.References; using WindowsRuntime.InteropGenerator.References; #pragma warning disable IDE0046 @@ -1116,6 +1117,40 @@ arrayType.BaseType is not SzArrayTypeSignature && /// Whether the module is the Windows Runtime assembly. public bool IsWindowsRuntimeModule => module.Name == WellKnownMetadataNames.WinRTRuntimeModuleName; + /// + /// Checks whether a belongs to the .NET base class library (BCL), i.e. the + /// default set of libraries shipped by the .NET SDK (the runtime shared frameworks). Detection is based on + /// the assembly's public key token (see ). + /// + /// Whether the module is a .NET base class library / framework assembly. + public bool IsBaseClassLibraryModule => BaseClassLibraryIdentity.IsBaseClassLibraryPublicKeyToken(module.Assembly?.GetPublicKeyToken()); + + /// + /// Checks whether a targets a legacy or portable runtime (i.e. .NET Standard + /// or .NET Framework), as opposed to a modern .NET runtime, based on its corlib scope. + /// + /// Whether the module targets a legacy or portable runtime. + /// + /// The entire interop generator infrastructure identifies well-known types (including custom-mapped types + /// such as IEnumerable<T>) by comparing against type references scoped to the modern .NET corlib + /// (e.g. System.Runtime), which is also the corlib the emit phase uses. Modules targeting a legacy or + /// portable runtime declare those same types against a different corlib (netstandard or mscorlib), + /// which AsmResolver's SignatureComparer treats as a distinct scope. As a result, the generator + /// cannot match (and therefore cannot marshal) their types, and attempting to do so would fail during emit. + /// Such modules could in principle still use custom-mapped types that need marshalling, but for simplicity + /// they are skipped entirely. + /// + public bool TargetsLegacyRuntime + { + get + { + Utf8String? corLibName = module.CorLibTypeFactory.CorLibScope?.Name; + + return corLibName == WellKnownMetadataNames.NetStandardAssemblyName || + corLibName == WellKnownMetadataNames.MSCorLibAssemblyName; + } + } + /// /// Checks whether a references the Windows Runtime assembly. /// @@ -1162,6 +1197,16 @@ file static class WellKnownMetadataNames /// public static readonly Utf8String WinRTRuntimeModuleName = "WinRT.Runtime.dll"u8; + /// + /// The assembly name of the .NET Standard corlib (used by portable assemblies). + /// + public static readonly Utf8String NetStandardAssemblyName = "netstandard"u8; + + /// + /// The assembly name of the .NET Framework corlib (used by legacy .NET Framework assemblies). + /// + public static readonly Utf8String MSCorLibAssemblyName = "mscorlib"u8; + /// /// The "WindowsRuntime" text. /// diff --git a/src/WinRT.Interop.Generator/Generation/CsWinRTMarshallingMode.cs b/src/WinRT.Interop.Generator/Generation/CsWinRTMarshallingMode.cs new file mode 100644 index 000000000..2ff9d6834 --- /dev/null +++ b/src/WinRT.Interop.Generator/Generation/CsWinRTMarshallingMode.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace WindowsRuntime.InteropGenerator.Generation; + +/// +/// Controls which assemblies the interop generator analyzes when discovering user-defined (CCW) types and +/// generic instantiations that require marshalling code. +/// +/// +/// Assemblies that reference the Windows Runtime assembly (i.e. those targeting a Windows TFM) are always +/// analyzed, regardless of the selected mode. The mode only affects assemblies that do not reference any +/// CsWinRT assembly, which lets projects that don't target a Windows TFM (e.g. a class library with just +/// MVVM viewmodels) still contribute the marshalling code their types need. +/// +internal enum CsWinRTMarshallingMode +{ + /// + /// Analyzes all assemblies, including those that don't reference any CsWinRT assembly. + /// + All, + + /// + /// Analyzes all assemblies except those from the .NET base class library (BCL), to reduce binary size. This is the default. + /// + Minimal, + + /// + /// Only analyzes assemblies that reference the Windows Runtime assembly (i.e. those targeting a Windows TFM). + /// + Strict +} diff --git a/src/WinRT.Interop.Generator/Generation/InteropGenerator.Discover.cs b/src/WinRT.Interop.Generator/Generation/InteropGenerator.Discover.cs index 42760ba3c..b51b6a290 100644 --- a/src/WinRT.Interop.Generator/Generation/InteropGenerator.Discover.cs +++ b/src/WinRT.Interop.Generator/Generation/InteropGenerator.Discover.cs @@ -10,6 +10,7 @@ using AsmResolver.DotNet; using AsmResolver.DotNet.Signatures; using AsmResolver.PE; +using ConsoleAppFramework; using WindowsRuntime.Generator.Errors; using WindowsRuntime.Generator.Extensions; using WindowsRuntime.Generator.References; @@ -52,9 +53,23 @@ private static InteropGeneratorDiscoveryState Discover(InteropGeneratorArgs args // module load will go through this object, rather than using the assembly resolver directly. RuntimeContext runtimeContext = new(targetRuntime, pathAssemblyResolver); + // Build the set of assembly names explicitly opted in for analysis via 'CsWinRTMarshallingEnabledAssembly'. + // Each entry is normalized to its bare assembly name (dropping any directory and the '.dll' extension), so + // that it can be matched against the simple name of the loaded assemblies during discovery. + HashSet marshallingEnabledAssemblyNames = new(StringComparer.OrdinalIgnoreCase); + + foreach (string assemblyName in args.MarshallingEnabledAssemblyNames) + { + _ = marshallingEnabledAssemblyNames.Add(Path.GetFileNameWithoutExtension(assemblyName)); + } + // Initialize the state, which contains all the discovered info we'll use for generation. // No additional parameters will be passed to later steps: all the info is in this object. - InteropGeneratorDiscoveryState discoveryState = new() { RuntimeContext = runtimeContext }; + InteropGeneratorDiscoveryState discoveryState = new() + { + RuntimeContext = runtimeContext, + MarshallingEnabledAssemblyNames = marshallingEnabledAssemblyNames + }; // First, load the special 'WinRT.Sdk.Projection.dll', 'WinRT.Sdk.Xaml.Projection.dll', 'WinRT.Projection.dll' // and 'WinRT.Component.dll' modules (the last three are optional). These are necessary for surfacing some @@ -99,6 +114,10 @@ private static InteropGeneratorDiscoveryState Discover(InteropGeneratorArgs args // Validate referenced assemblies for CsWinRT 2.x ValidateWinRTRuntimeDllVersion2References(args, discoveryState); + // Validate the assemblies explicitly opted in via 'CsWinRTMarshallingEnabledAssembly', reporting + // any that don't exist or that are redundant (this only emits diagnostics, it doesn't affect state) + ValidateMarshallingEnabledAssemblies(args, discoveryState); + return discoveryState; } @@ -241,10 +260,10 @@ private static void LoadAndProcessModule( args.Token.ThrowIfCancellationRequested(); - // We're only interested in harvesting .dll-s which reference the Windows SDK projections. - // This is true for all .dll-s that were built targeting 'netX.0-windows10.0.XXXX.0'. - // So this check effectively lets us filter all .dll-s that were in projects with this TFM. - if (!module.ReferencesWindowsRuntimeAssembly && !module.IsWindowsRuntimeModule) + // Determine whether this module should be analyzed, based on the runtime it targets, the configured + // marshalling mode, and the explicitly opted-in assemblies. Modules that reference the Windows Runtime + // assembly (i.e. those targeting a Windows TFM) are always analyzed; the mode only affects the others. + if (!ShouldProcessModule(args, discoveryState, module)) { return; } @@ -279,6 +298,69 @@ private static void LoadAndProcessModule( DiscoverSzArrayTypes(args, discoveryState, module); } + /// + /// Determines whether a given module should be analyzed for discovery, based on the marshalling mode. + /// + /// The arguments for this invocation. + /// The discovery state for this invocation. + /// The module to check. + /// Whether should be analyzed for discovery. + /// + /// Modules targeting a legacy or portable runtime are never analyzed. Otherwise, modules that reference the + /// Windows Runtime assembly were built targeting a Windows TFM (i.e. netX.0-windows10.0.XXXX.0), and + /// the Windows Runtime assembly itself, are always analyzed, regardless of the marshalling mode. Assemblies + /// explicitly opted in via CsWinRTMarshallingEnabledAssembly are also always analyzed. Only modules + /// that don't reference any CsWinRT assembly and aren't opted in are subject to the mode-specific filtering. + /// + private static bool ShouldProcessModule(InteropGeneratorArgs args, InteropGeneratorDiscoveryState discoveryState, ModuleDefinition module) + { + // Never analyze modules targeting a legacy or portable runtime (i.e. .NET Standard or .NET Framework), even + // when opted in. The entire interop generator infrastructure identifies well-known types (including custom- + // mapped types such as 'IEnumerable') by comparing against type references scoped to the modern .NET + // corlib (e.g. 'System.Runtime'), which is also the corlib the emit phase uses. A legacy-runtime module + // declares those same types against a different corlib ('netstandard' or 'mscorlib'), which AsmResolver's + // 'SignatureComparer' treats as a distinct scope, so the generator can't match them and marshalling code for + // them would fail to generate. Such modules could in principle still use custom-mapped types that need + // marshalling, but for simplicity we skip them entirely. This was not an issue before the marshalling mode + // was introduced, as only assemblies referencing the Windows Runtime were analyzed, and those always target + // a modern .NET runtime. + if (module.TargetsLegacyRuntime) + { + return false; + } + + // Assemblies explicitly opted in via 'CsWinRTMarshallingEnabledAssembly' are always analyzed, + // regardless of the marshalling mode. This lets users fine-tune which assemblies are analyzed + // (e.g. using the 'strict' mode while opting in a few specific assemblies they know are needed). + if (module.Assembly?.Name is { } assemblyName && + discoveryState.MarshallingEnabledAssemblyNames.Contains(assemblyName.Value)) + { + return true; + } + + return args.MarshallingMode switch + { + // 'All' analyzes every module, even those not referencing any CsWinRT assembly. + CsWinRTMarshallingMode.All => true, + + // 'Minimal' analyzes every module except those from the BCL. Non-BCL modules are checked + // first, as that is the common case for user code and avoids the more expensive Windows + // Runtime reference lookup (a BCL module can only match via the checks that follow if a + // future framework assembly were to reference the Windows Runtime assembly). + CsWinRTMarshallingMode.Minimal => + !module.IsBaseClassLibraryModule || + module.IsWindowsRuntimeModule || + module.ReferencesWindowsRuntimeAssembly, + + // 'Strict' only analyzes modules referencing the Windows Runtime assembly. This matches the + // historical behavior, effectively filtering to '-windows' TFM projects. + CsWinRTMarshallingMode.Strict => module.IsWindowsRuntimeModule || module.ReferencesWindowsRuntimeAssembly, + + // The marshalling mode is always one of the values above (validated during argument parsing). + _ => throw new ArgumentOutOfRangeException(nameof(args), args.MarshallingMode, "Unexpected marshalling mode.") + }; + } + /// /// Discovers all type hierarchy types in a given assembly. /// @@ -542,6 +624,60 @@ private static void ValidateWinRTRuntimeDllVersion2References(InteropGeneratorAr throw WellKnownInteropExceptions.WinRTRuntimeDllVersion2References(invalidModuleNames); } + /// + /// Validates the assemblies explicitly opted in via CsWinRTMarshallingEnabledAssembly, emitting + /// diagnostics for entries that can't be found or that are redundant. + /// + /// The arguments for this invocation. + /// The discovery state for this invocation. + private static void ValidateMarshallingEnabledAssemblies(InteropGeneratorArgs args, InteropGeneratorDiscoveryState discoveryState) + { + // Fast-path if no assemblies were explicitly opted in + if (args.MarshallingEnabledAssemblyNames.Length == 0) + { + return; + } + + // In 'all' mode, every assembly is already analyzed, so all opt-in entries are redundant. We emit a + // single message for the whole set (rather than one per entry), and skip the per-entry checks below. + if (args.MarshallingMode == CsWinRTMarshallingMode.All) + { + WellKnownInteropExceptions.MarshallingEnabledAssembliesRedundantInAllModeMessage().Log(ConsoleApp.Log); + + return; + } + + // Build a lookup from assembly simple name to the loaded module, so we can match the opt-in entries. + // All loaded modules are tracked (even those skipped from analysis), so this correctly recognizes an + // opt-in entry that resolves to a real assembly, regardless of whether it ended up being analyzed. + Dictionary modulesByAssemblyName = new(StringComparer.OrdinalIgnoreCase); + + foreach (ModuleDefinition module in discoveryState.Modules.Values) + { + if (module.Assembly?.Name is { } assemblyName) + { + modulesByAssemblyName[assemblyName.Value] = module; + } + } + + // Iterate the original (non-normalized) entries, so diagnostics echo exactly what the user specified + foreach (string entry in args.MarshallingEnabledAssemblyNames) + { + string assemblyName = Path.GetFileNameWithoutExtension(entry); + + // The entry doesn't match any referenced assembly (likely a typo or a stale reference) + if (!modulesByAssemblyName.TryGetValue(assemblyName, out ModuleDefinition? module)) + { + WellKnownInteropExceptions.MarshallingEnabledAssemblyNotFoundWarning(entry).LogOrThrow(args.TreatWarningsAsErrors); + } + else if (module.IsWindowsRuntimeModule || module.ReferencesWindowsRuntimeAssembly) + { + // The entry resolves to an assembly that already targets Windows, so it's always analyzed anyway + WellKnownInteropExceptions.MarshallingEnabledAssemblyTargetsWindowsMessage(entry).Log(ConsoleApp.Log); + } + } + } + /// /// Creates an instance that can be used for the discovery phase. /// diff --git a/src/WinRT.Interop.Generator/Generation/InteropGeneratorArgs.cs b/src/WinRT.Interop.Generator/Generation/InteropGeneratorArgs.cs index baf3434fe..c14da9003 100644 --- a/src/WinRT.Interop.Generator/Generation/InteropGeneratorArgs.cs +++ b/src/WinRT.Interop.Generator/Generation/InteropGeneratorArgs.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ComponentModel; using System.Threading; using WindowsRuntime.Generator; using WindowsRuntime.Generator.Attributes; @@ -48,6 +49,20 @@ internal sealed class InteropGeneratorArgs : IGeneratorArgs [CommandLineArgumentName("--use-windows-ui-xaml-projections")] public required bool UseWindowsUIXamlProjections { get; init; } + /// Gets the marshalling mode, controlling which assemblies are analyzed to discover user-defined/CCW/generic types. + /// Defaults to when not specified in the response file. + [CommandLineArgumentName("--marshalling-mode")] + [DefaultValue(CsWinRTMarshallingMode.Minimal)] + public CsWinRTMarshallingMode MarshallingMode { get; init; } + + /// Gets the names of assemblies explicitly opted in for analysis, regardless of the marshalling mode. + /// + /// Each entry is an assembly name (the .dll extension and any directory are ignored). This lets users + /// analyze specific assemblies without switching to a broader marshalling mode. Defaults to an empty array. + /// + [CommandLineArgumentName("--marshalling-enabled-assembly-names")] + public string[] MarshallingEnabledAssemblyNames { get; init; } = []; + /// Gets whether to validate the assembly version of WinRT.Runtime.dll, to ensure it matches the generator. [CommandLineArgumentName("--validate-winrt-runtime-assembly-version")] public required bool ValidateWinRTRuntimeAssemblyVersion { get; init; } diff --git a/src/WinRT.Interop.Generator/Generation/InteropGeneratorDiscoveryState.cs b/src/WinRT.Interop.Generator/Generation/InteropGeneratorDiscoveryState.cs index 979bba7ef..9b4a16d41 100644 --- a/src/WinRT.Interop.Generator/Generation/InteropGeneratorDiscoveryState.cs +++ b/src/WinRT.Interop.Generator/Generation/InteropGeneratorDiscoveryState.cs @@ -117,6 +117,13 @@ internal sealed class InteropGeneratorDiscoveryState /// public required RuntimeContext RuntimeContext { get; init; } + /// + /// Gets the set of assembly names (without extension) explicitly opted in for analysis via + /// CsWinRTMarshallingEnabledAssembly, regardless of the marshalling mode. The set uses a + /// case-insensitive comparer, and is empty if no assemblies were opted in. + /// + public required IReadOnlySet MarshallingEnabledAssemblyNames { get; init; } + /// /// Gets the loaded modules. ///