diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.props b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.props index 2890daaf70828..dae0b088f1607 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.props +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.props @@ -1,5 +1,5 @@ - + wasm browser true diff --git a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.targets.in b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.targets.in index 0f9edfb14f10d..60d993f9c1a53 100644 --- a/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.targets.in +++ b/src/mono/nuget/Microsoft.NET.Runtime.WebAssembly.Sdk/Sdk/Sdk.targets.in @@ -9,7 +9,7 @@ $([MSBuild]::NormalizeDirectory($(MSBuildThisFileDirectory), '..', 'WasmAppHost')) - true + true diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj new file mode 100644 index 0000000000000..32b0ded6b35fd --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/Microsoft.NET.Sdk.WebAssembly.Pack.pkgproj @@ -0,0 +1,14 @@ + + + + + SDK for building and publishing WebAssembly applications. + + + + + + + + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props new file mode 100644 index 0000000000000..dc074e761f24e --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props @@ -0,0 +1,43 @@ + + + + + false + + exe + + false + + false + + + false + + + true + partial + false + + + / + Root + $(StaticWebAssetsAdditionalBuildPropertiesToRemove);RuntimeIdentifier;SelfContained + ComputeFilesToPublish;GetCurrentProjectPublishStaticWebAssetItems + $(StaticWebAssetsAdditionalPublishProperties);BuildProjectReferences=false;ResolveAssemblyReferencesFindRelatedSatellites=true + $(StaticWebAssetsAdditionalPublishPropertiesToRemove);NoBuild;RuntimeIdentifier + + + + + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets new file mode 100644 index 0000000000000..3fe69f51c2e0e --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets @@ -0,0 +1,493 @@ + + + + + true + + + true + + + + + + + $(MSBuildThisFileDirectory)..\ + <_WebAssemblySdkTasksTFM Condition=" '$(MSBuildRuntimeType)' == 'Core'">net8.0 + <_WebAssemblySdkTasksTFM Condition=" '$(MSBuildRuntimeType)' != 'Core'">net472 + <_WebAssemblySdkTasksAssembly>$(WebAssemblySdkDirectoryRoot)tools\$(_WebAssemblySdkTasksTFM)\Microsoft.NET.Sdk.WebAssembly.Pack.Tasks.dll + + + + + + + + true + true + + + false + false + true + false + false + false + <_AggressiveAttributeTrimming Condition="'$(_AggressiveAttributeTrimming)' == ''">true + false + true + false + + + false + false + false + false + true + + + + false + + false + _GatherWasmFilesToPublish;$(WasmNestedPublishAppDependsOn) + <_WasmNestedPublishAppPreTarget>ComputeFilesToPublish + + + + + + + + + + + + $(ResolveStaticWebAssetsInputsDependsOn); + _AddWasmStaticWebAssets; + + + + _GenerateBuildWasmBootJson; + $(StaticWebAssetsPrepareForRunDependsOn) + + + + $(ResolvePublishStaticWebAssetsDependsOn); + ProcessPublishFilesForWasm; + ComputeWasmExtensions; + _AddPublishWasmBootJsonToStaticWebAssets; + + + + $(GenerateStaticWebAssetsPublishManifestDependsOn); + GeneratePublishWasmBootJson; + + + + $(AddWasmStaticWebAssetsDependsOn); + ResolveWasmOutputs; + + + $(GenerateBuildWasmBootJsonDependsOn); + ResolveStaticWebAssetsInputs; + + + $(GeneratePublishWasmBootJsonDependsOn); + + + + + + + + + + + + + + + + + + + + <_BlazorWebAssemblyLoadAllGlobalizationData Condition="'$(InvariantGlobalization)' != 'true'">$(BlazorWebAssemblyLoadAllGlobalizationData) + <_BlazorWebAssemblyLoadAllGlobalizationData Condition="'$(_BlazorWebAssemblyLoadAllGlobalizationData)' == ''">false + <_BlazorIcuDataFileName Condition="'$(InvariantGlobalization)' != 'true' AND '$(BlazorWebAssemblyLoadAllGlobalizationData)' != 'true'">$(BlazorIcuDataFileName) + <_LoadCustomIcuData>false + <_LoadCustomIcuData Condition="'$(_BlazorIcuDataFileName)' != ''">true + + + + + + <_BlazorEnableTimeZoneSupport>$(BlazorEnableTimeZoneSupport) + <_BlazorEnableTimeZoneSupport Condition="'$(_BlazorEnableTimeZoneSupport)' == ''">true + <_WasmInvariantGlobalization>$(InvariantGlobalization) + <_WasmInvariantGlobalization Condition="'$(_WasmInvariantGlobalization)' == ''">true + <_WasmCopyOutputSymbolsToOutputDirectory>$(CopyOutputSymbolsToOutputDirectory) + <_WasmCopyOutputSymbolsToOutputDirectory Condition="'$(_WasmCopyOutputSymbolsToOutputDirectory)'==''">true + <_BlazorWebAssemblyStartupMemoryCache>$(BlazorWebAssemblyStartupMemoryCache) + <_BlazorWebAssemblyJiterpreter>$(BlazorWebAssemblyJiterpreter) + <_BlazorWebAssemblyRuntimeOptions>$(BlazorWebAssemblyRuntimeOptions) + + + $(OutputPath)$(PublishDirName)\ + + + + + + + + <_WasmConfigFileCandidates Include="@(StaticWebAsset)" Condition="'%(SourceType)' == 'Discovered'" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_WasmBuildBootJsonPath>$(IntermediateOutputPath)blazor.boot.json + + + + <_BuildWasmBootJson + Include="$(_WasmBuildBootJsonPath)" + RelativePath="_framework/blazor.boot.json" /> + + + + + + + + + + + + + + + + + <_WasmBuildBootJsonPath>$(IntermediateOutputPath)blazor.boot.json + + + + <_WasmJsModuleCandidatesForBuild + Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSLibraryModule' and '%(AssetKind)' != 'Publish'" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <_WasmPublishPrefilteredAssets + Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.AssetTraitName)' == 'WasmResource' or '%(StaticWebAsset.AssetTraitName)' == 'Culture' or '%(AssetRole)' == 'Alternative'" /> + + + + <_DotNetJsItem Include="@(ResolvedFileToPublish)" Condition="'%(ResolvedFileToPublish.DestinationSubPath)' == 'dotnet.js' AND '%(ResolvedFileToPublish.AssetType)' == 'native'" /> + + + + <_DotNetJsVersion>%(_DotNetJsItem.NuGetPackageVersion) + + + + + + + + + + + + + + + + + + + <_BlazorExtensionsCandidate Include="@(BlazorPublishExtension->'%(FullPath)')"> + $(PackageId) + Computed + $(PublishDir)wwwroot + $(StaticWebAssetBasePath) + %(BlazorPublishExtension.RelativePath) + Publish + All + Primary + WasmResource + extension:%(BlazorPublishExtension.ExtensionName) + Never + PreserveNewest + %(BlazorPublishExtension.Identity) + + + + + + + + + + + + + + + + + + <_PublishWasmBootJson + Include="$(IntermediateOutputPath)blazor.publish.boot.json" + RelativePath="_framework/blazor.boot.json" /> + + + + + + + + + + + <_WasmPublishAsset + Include="@(StaticWebAsset)" + Condition="'%(AssetKind)' != 'Build' and '%(StaticWebAsset.AssetTraitValue)' != 'manifest' and ('%(StaticWebAsset.AssetTraitName)' == 'WasmResource' or '%(StaticWebAsset.AssetTraitName)' == 'Culture') and '%(StaticWebAsset.AssetTraitValue)' != 'boot'" /> + + <_WasmPublishConfigFile + Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.AssetTraitName)' == 'WasmResource' and '%(StaticWebAsset.AssetTraitValue)' == 'settings'"/> + + <_WasmJsModuleCandidatesForPublish + Include="@(StaticWebAsset)" + Condition="'%(StaticWebAsset.AssetTraitName)' == 'JSModule' and '%(StaticWebAsset.AssetTraitValue)' == 'JSLibraryModule' and '%(AssetKind)' != 'Build'" /> + + + <_WasmPublishAsset Remove="@(_BlazorExtensionsCandidatesForPublish)" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.props b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.props new file mode 100644 index 0000000000000..a41609b5c15a6 --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.props @@ -0,0 +1,20 @@ + + + + browser-wasm + + + <_WebAssemblyPropsFile>$(MSBuildThisFileDirectory)\Microsoft.NET.Sdk.WebAssembly.Browser.props + <_WebAssemblyTargetsFile>$(MSBuildThisFileDirectory)\Microsoft.NET.Sdk.WebAssembly.Browser.targets + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.targets new file mode 100644 index 0000000000000..df15f880ba1b3 --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Pack.targets @@ -0,0 +1,12 @@ + + diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Wasm.web.config b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Wasm.web.config new file mode 100644 index 0000000000000..586d3565ed1eb --- /dev/null +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Wasm.web.config @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in b/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in index c9a2c7494450f..b42b351e97ac2 100644 --- a/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in +++ b/src/mono/nuget/Microsoft.NET.Workload.Mono.Toolchain.Current.Manifest/WorkloadManifest.targets.in @@ -20,6 +20,8 @@ <_BrowserWorkloadNotSupportedForTFM Condition="$([MSBuild]::VersionLessThan($(TargetFrameworkVersion), '6.0'))">true <_BrowserWorkloadDisabled>$(_BrowserWorkloadNotSupportedForTFM) + <_UsingBlazorOrWasmSdk Condition="'$(UsingMicrosoftNETSdkBlazorWebAssembly)' == 'true' or '$(UsingMicrosoftNETSdkWebAssembly)' == 'true'">true + @@ -39,7 +41,7 @@ <_WasmNativeWorkloadNeeded Condition="'$(RunAOTCompilation)' == 'true' or '$(WasmEnableSIMD)' == 'true' or '$(WasmBuildNative)' == 'true' or - '$(WasmGenerateAppBundle)' == 'true' or '$(UsingMicrosoftNETSdkBlazorWebAssembly)' != 'true'" >true + '$(WasmGenerateAppBundle)' == 'true' or '$(_UsingBlazorOrWasmSdk)' != 'true'" >true false true @@ -59,7 +61,7 @@ true - + false true diff --git a/src/mono/nuget/mono-packages.proj b/src/mono/nuget/mono-packages.proj index 6a2ddff782b75..438ec97ace3e1 100644 --- a/src/mono/nuget/mono-packages.proj +++ b/src/mono/nuget/mono-packages.proj @@ -8,6 +8,7 @@ + diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs new file mode 100644 index 0000000000000..2854594ae1054 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/AssetsComputingHelper.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Microsoft.NET.Sdk.WebAssembly; + +public class AssetsComputingHelper +{ + public static bool ShouldFilterCandidate( + ITaskItem candidate, + bool timezoneSupport, + bool invariantGlobalization, + bool copySymbols, + string customIcuCandidateFilename, + out string reason) + { + var extension = candidate.GetMetadata("Extension"); + var fileName = candidate.GetMetadata("FileName"); + var assetType = candidate.GetMetadata("AssetType"); + var fromMonoPackage = string.Equals( + candidate.GetMetadata("NuGetPackageId"), + "Microsoft.NETCore.App.Runtime.Mono.browser-wasm", + StringComparison.Ordinal); + + reason = extension switch + { + ".a" when fromMonoPackage => "extension is .a is not supported.", + ".c" when fromMonoPackage => "extension is .c is not supported.", + ".h" when fromMonoPackage => "extension is .h is not supported.", + // It is safe to filter out all XML files since we are not interested in any XML file from the list + // of ResolvedFilesToPublish to become a static web asset. Things like this include XML doc files and + // so on. + ".xml" => "it is a documentation file", + ".rsp" when fromMonoPackage => "extension is .rsp is not supported.", + ".props" when fromMonoPackage => "extension is .props is not supported.", + ".blat" when !timezoneSupport => "timezone support is not enabled.", + ".dat" when invariantGlobalization && fileName.StartsWith("icudt") => "invariant globalization is enabled", + ".dat" when !string.IsNullOrEmpty(customIcuCandidateFilename) && fileName != customIcuCandidateFilename => "custom icu file will be used instead of icu from the runtime pack", + ".json" when fromMonoPackage && (fileName == "emcc-props" || fileName == "package") => $"{fileName}{extension} is not used by Blazor", + ".ts" when fromMonoPackage && fileName == "dotnet.d" => "dotnet type definition is not used by Blazor", + ".ts" when fromMonoPackage && fileName == "dotnet-legacy.d" => "dotnet type definition is not used by Blazor", + ".js" when assetType == "native" && fileName != "dotnet" => $"{fileName}{extension} is not used by Blazor", + ".pdb" when !copySymbols => "copying symbols is disabled", + ".symbols" when fromMonoPackage => "extension .symbols is not required.", + _ => null + }; + + return reason != null; + } + + public static string GetCandidateRelativePath(ITaskItem candidate) + { + var destinationSubPath = candidate.GetMetadata("DestinationSubPath"); + if (!string.IsNullOrEmpty(destinationSubPath)) + return $"_framework/{destinationSubPath}"; + + var relativePath = candidate.GetMetadata("FileName") + candidate.GetMetadata("Extension"); + return $"_framework/{relativePath}"; + } + + public static ITaskItem GetCustomIcuAsset(ITaskItem candidate) + { + var customIcuCandidate = new TaskItem(candidate); + var relativePath = GetCandidateRelativePath(customIcuCandidate); + customIcuCandidate.SetMetadata("RelativePath", relativePath); + customIcuCandidate.SetMetadata("AssetTraitName", "BlazorWebAssemblyResource"); + customIcuCandidate.SetMetadata("AssetTraitValue", "native"); + customIcuCandidate.SetMetadata("AssetType", "native"); + return customIcuCandidate; + } + + public static bool TryGetAssetFilename(ITaskItem candidate, out string filename) + { + bool candidateIsValid = candidate != null && !string.IsNullOrEmpty(candidate.ItemSpec); + filename = candidateIsValid ? + $"{candidate.GetMetadata("FileName")}" : + ""; + return candidateIsValid; + } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs new file mode 100644 index 0000000000000..282d5cf6d0a58 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs @@ -0,0 +1,157 @@ +// 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.Runtime.Serialization; +using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary; + +namespace Microsoft.NET.Sdk.WebAssembly; + +/// +/// Defines the structure of a Blazor boot JSON file +/// +public class BootJsonData +{ + /// + /// Gets the name of the assembly with the application entry point + /// + public string entryAssembly { get; set; } + + /// + /// Gets the set of resources needed to boot the application. This includes the transitive + /// closure of .NET assemblies (including the entrypoint assembly), the dotnet.wasm file, + /// and any PDBs to be loaded. + /// + /// Within , dictionary keys are resource names, + /// and values are SHA-256 hashes formatted in prefixed base-64 style (e.g., 'sha256-abcdefg...') + /// as used for subresource integrity checking. + /// + public ResourcesData resources { get; set; } = new ResourcesData(); + + /// + /// Gets a value that determines whether to enable caching of the + /// inside a CacheStorage instance within the browser. + /// + public bool cacheBootResources { get; set; } + + /// + /// Gets a value that determines if this is a debug build. + /// + public bool debugBuild { get; set; } + + /// + /// Gets a value that determines if the linker is enabled. + /// + public bool linkerEnabled { get; set; } + + /// + /// Config files for the application + /// + public List config { get; set; } + + /// + /// Gets or sets the that determines how icu files are loaded. + /// + public ICUDataMode icuDataMode { get; set; } + + /// + /// Gets or sets a value that determines if the caching startup memory is enabled. + /// + public bool? startupMemoryCache { get; set; } + + /// + /// Gets a value for mono runtime options. + /// + public string[] runtimeOptions { get; set; } + + /// + /// Gets or sets configuration extensions. + /// + public Dictionary> extensions { get; set; } +} + +public class ResourcesData +{ + /// + /// .NET Wasm runtime resources (dotnet.wasm, dotnet.js) etc. + /// + public ResourceHashesByNameDictionary runtime { get; set; } = new ResourceHashesByNameDictionary(); + + /// + /// "assembly" (.dll) resources + /// + public ResourceHashesByNameDictionary assembly { get; set; } = new ResourceHashesByNameDictionary(); + + /// + /// "debug" (.pdb) resources + /// + [DataMember(EmitDefaultValue = false)] + public ResourceHashesByNameDictionary pdb { get; set; } + + /// + /// localization (.satellite resx) resources + /// + [DataMember(EmitDefaultValue = false)] + public Dictionary satelliteResources { get; set; } + + /// + /// Assembly (.dll) resources that are loaded lazily during runtime + /// + [DataMember(EmitDefaultValue = false)] + public ResourceHashesByNameDictionary lazyAssembly { get; set; } + + /// + /// JavaScript module initializers that Blazor will be in charge of loading. + /// + [DataMember(EmitDefaultValue = false)] + public ResourceHashesByNameDictionary libraryInitializers { get; set; } + + /// + /// Extensions created by users customizing the initialization process. The format of the file(s) + /// is up to the user. + /// + [DataMember(EmitDefaultValue = false)] + public Dictionary extensions { get; set; } + + /// + /// Additional assets that the runtime consumes as part of the boot process. + /// + [DataMember(EmitDefaultValue = false)] + public Dictionary runtimeAssets { get; set; } + +} + +public enum ICUDataMode : int +{ + // Note that the numeric values are serialized and used in JS code, so don't change them without also updating the JS code + + /// + /// Load optimized icu data file based on the user's locale + /// + Sharded = 0, + + /// + /// Use the combined icudt.dat file + /// + All = 1, + + /// + /// Do not load any icu data files. + /// + Invariant = 2, + + /// + /// Load custom icu file provided by the developer. + /// + Custom = 3, +} + +[DataContract] +public class AdditionalAsset +{ + [DataMember(Name = "hash")] + public string Hash { get; set; } + + [DataMember(Name = "behavior")] + public string Behavior { get; set; } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmBuildAssets.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmBuildAssets.cs new file mode 100644 index 0000000000000..68a563322f613 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmBuildAssets.cs @@ -0,0 +1,266 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.NET.Sdk.WebAssembly; + +namespace Microsoft.NET.Sdk.WebAssembly; + +// This task does the build work of processing the project inputs and producing a set of pseudo-static web assets. +public class ComputeWasmBuildAssets : Task +{ + [Required] + public ITaskItem[] Candidates { get; set; } + + public ITaskItem CustomIcuCandidate { get; set; } + + [Required] + public ITaskItem[] ProjectAssembly { get; set; } + + [Required] + public ITaskItem[] ProjectDebugSymbols { get; set; } + + [Required] + public ITaskItem[] SatelliteAssemblies { get; set; } + + [Required] + public ITaskItem[] ProjectSatelliteAssemblies { get; set; } + + [Required] + public string OutputPath { get; set; } + + [Required] + public bool TimeZoneSupport { get; set; } + + [Required] + public bool InvariantGlobalization { get; set; } + + [Required] + public bool CopySymbols { get; set; } + + public bool FingerprintDotNetJs { get; set; } + + [Output] + public ITaskItem[] AssetCandidates { get; set; } + + [Output] + public ITaskItem[] FilesToRemove { get; set; } + + public override bool Execute() + { + var filesToRemove = new List(); + var assetCandidates = new List(); + + try + { + if (ProjectAssembly.Length != 1) + { + Log.LogError("Invalid number of project assemblies '{0}'", string.Join("," + Environment.NewLine, ProjectAssembly.Select(a => a.ItemSpec))); + return true; + } + + if (ProjectDebugSymbols.Length > 1) + { + Log.LogError("Invalid number of symbol assemblies '{0}'", string.Join("," + Environment.NewLine, ProjectDebugSymbols.Select(a => a.ItemSpec))); + return true; + } + + if (AssetsComputingHelper.TryGetAssetFilename(CustomIcuCandidate, out string customIcuCandidateFilename)) + { + var customIcuCandidate = AssetsComputingHelper.GetCustomIcuAsset(CustomIcuCandidate); + assetCandidates.Add(customIcuCandidate); + } + + for (int i = 0; i < Candidates.Length; i++) + { + var candidate = Candidates[i]; + if (AssetsComputingHelper.ShouldFilterCandidate(candidate, TimeZoneSupport, InvariantGlobalization, CopySymbols, customIcuCandidateFilename, out var reason)) + { + Log.LogMessage(MessageImportance.Low, "Skipping asset '{0}' because '{1}'", candidate.ItemSpec, reason); + filesToRemove.Add(candidate); + continue; + } + + var satelliteAssembly = SatelliteAssemblies.FirstOrDefault(s => s.ItemSpec == candidate.ItemSpec); + if (satelliteAssembly != null) + { + var inferredCulture = satelliteAssembly.GetMetadata("DestinationSubDirectory").Trim('\\', '/'); + Log.LogMessage(MessageImportance.Low, "Found satellite assembly '{0}' asset for candidate '{1}' with inferred culture '{2}'", satelliteAssembly.ItemSpec, candidate.ItemSpec, inferredCulture); + + var assetCandidate = new TaskItem(satelliteAssembly); + assetCandidate.SetMetadata("AssetKind", "Build"); + assetCandidate.SetMetadata("AssetRole", "Related"); + assetCandidate.SetMetadata("AssetTraitName", "Culture"); + assetCandidate.SetMetadata("AssetTraitValue", inferredCulture); + assetCandidate.SetMetadata("RelativePath", $"_framework/{inferredCulture}/{satelliteAssembly.GetMetadata("FileName")}{satelliteAssembly.GetMetadata("Extension")}"); + assetCandidate.SetMetadata("RelatedAsset", Path.GetFullPath(Path.Combine(OutputPath, "wwwroot", "_framework", Path.GetFileName(assetCandidate.GetMetadata("ResolvedFrom"))))); + + assetCandidates.Add(assetCandidate); + continue; + } + + if (candidate.GetMetadata("FileName") == "dotnet" && candidate.GetMetadata("Extension") == ".js") + { + string newDotnetJSFileName = null; + string newDotNetJSFullPath = null; + if (FingerprintDotNetJs) + { + var itemHash = FileHasher.GetFileHash(candidate.ItemSpec); + newDotnetJSFileName = $"dotnet.{candidate.GetMetadata("NuGetPackageVersion")}.{itemHash}.js"; + + var originalFileFullPath = Path.GetFullPath(candidate.ItemSpec); + var originalFileDirectory = Path.GetDirectoryName(originalFileFullPath); + + newDotNetJSFullPath = Path.Combine(originalFileDirectory, newDotnetJSFileName); + } + else + { + newDotNetJSFullPath = candidate.ItemSpec; + newDotnetJSFileName = Path.GetFileName(newDotNetJSFullPath); + } + + var newDotNetJs = new TaskItem(newDotNetJSFullPath, candidate.CloneCustomMetadata()); + newDotNetJs.SetMetadata("OriginalItemSpec", candidate.ItemSpec); + + var newRelativePath = $"_framework/{newDotnetJSFileName}"; + newDotNetJs.SetMetadata("RelativePath", newRelativePath); + + newDotNetJs.SetMetadata("AssetTraitName", "WasmResource"); + newDotNetJs.SetMetadata("AssetTraitValue", "native"); + + assetCandidates.Add(newDotNetJs); + continue; + } + else + { + string relativePath = AssetsComputingHelper.GetCandidateRelativePath(candidate); + candidate.SetMetadata("RelativePath", relativePath); + } + + // Workaround for https://github.com/dotnet/aspnetcore/issues/37574. + // For items added as "Reference" in project references, the OriginalItemSpec is incorrect. + // Ignore it, and use the FullPath instead. + if (candidate.GetMetadata("ReferenceSourceTarget") == "ProjectReference") + { + candidate.SetMetadata("OriginalItemSpec", candidate.ItemSpec); + } + + var culture = candidate.GetMetadata("Culture"); + if (!string.IsNullOrEmpty(culture)) + { + candidate.SetMetadata("AssetKind", "Build"); + candidate.SetMetadata("AssetRole", "Related"); + candidate.SetMetadata("AssetTraitName", "Culture"); + candidate.SetMetadata("AssetTraitValue", culture); + var fileName = candidate.GetMetadata("FileName"); + var suffixIndex = fileName.Length - ".resources".Length; + var relatedAssetPath = Path.GetFullPath(Path.Combine( + OutputPath, + "wwwroot", + "_framework", + fileName.Substring(0, suffixIndex) + ProjectAssembly[0].GetMetadata("Extension"))); + + candidate.SetMetadata("RelatedAsset", relatedAssetPath); + + Log.LogMessage(MessageImportance.Low, "Found satellite assembly '{0}' asset for inferred candidate '{1}' with culture '{2}'", candidate.ItemSpec, relatedAssetPath, culture); + } + + assetCandidates.Add(candidate); + } + + var intermediateAssembly = new TaskItem(ProjectAssembly[0]); + intermediateAssembly.SetMetadata("RelativePath", $"_framework/{intermediateAssembly.GetMetadata("FileName")}{intermediateAssembly.GetMetadata("Extension")}"); + assetCandidates.Add(intermediateAssembly); + + if (ProjectDebugSymbols.Length > 0) + { + var debugSymbols = new TaskItem(ProjectDebugSymbols[0]); + debugSymbols.SetMetadata("RelativePath", $"_framework/{debugSymbols.GetMetadata("FileName")}{debugSymbols.GetMetadata("Extension")}"); + assetCandidates.Add(debugSymbols); + } + + for (int i = 0; i < ProjectSatelliteAssemblies.Length; i++) + { + var projectSatelliteAssembly = ProjectSatelliteAssemblies[i]; + var candidateCulture = projectSatelliteAssembly.GetMetadata("Culture"); + Log.LogMessage( + "Found satellite assembly '{0}' asset for project '{1}' with culture '{2}'", + projectSatelliteAssembly.ItemSpec, + intermediateAssembly.ItemSpec, + candidateCulture); + + var assetCandidate = new TaskItem(Path.GetFullPath(projectSatelliteAssembly.ItemSpec), projectSatelliteAssembly.CloneCustomMetadata()); + var projectAssemblyAssetPath = Path.GetFullPath(Path.Combine( + OutputPath, + "wwwroot", + "_framework", + ProjectAssembly[0].GetMetadata("FileName") + ProjectAssembly[0].GetMetadata("Extension"))); + + var normalizedPath = assetCandidate.GetMetadata("TargetPath").Replace('\\', '/'); + + assetCandidate.SetMetadata("AssetKind", "Build"); + assetCandidate.SetMetadata("AssetRole", "Related"); + assetCandidate.SetMetadata("AssetTraitName", "Culture"); + assetCandidate.SetMetadata("AssetTraitValue", candidateCulture); + assetCandidate.SetMetadata("RelativePath", Path.Combine("_framework", normalizedPath)); + assetCandidate.SetMetadata("RelatedAsset", projectAssemblyAssetPath); + + assetCandidates.Add(assetCandidate); + } + + for (var i = 0; i < assetCandidates.Count; i++) + { + var candidate = assetCandidates[i]; + ApplyUniqueMetadataProperties(candidate); + } + } + catch (Exception ex) + { + Log.LogError(ex.ToString()); + return false; + } + + FilesToRemove = filesToRemove.ToArray(); + AssetCandidates = assetCandidates.ToArray(); + + return !Log.HasLoggedErrors; + } + + private static void ApplyUniqueMetadataProperties(ITaskItem candidate) + { + var extension = candidate.GetMetadata("Extension"); + var filename = candidate.GetMetadata("FileName"); + switch (extension) + { + case ".dll": + if (string.IsNullOrEmpty(candidate.GetMetadata("AssetTraitName"))) + { + candidate.SetMetadata("AssetTraitName", "WasmResource"); + candidate.SetMetadata("AssetTraitValue", "runtime"); + } + if (string.Equals(candidate.GetMetadata("ResolvedFrom"), "{HintPathFromItem}", StringComparison.Ordinal)) + { + candidate.RemoveMetadata("OriginalItemSpec"); + } + break; + case ".wasm": + case ".blat": + case ".dat" when filename.StartsWith("icudt"): + candidate.SetMetadata("AssetTraitName", "WasmResource"); + candidate.SetMetadata("AssetTraitValue", "native"); + break; + case ".pdb": + candidate.SetMetadata("AssetTraitName", "WasmResource"); + candidate.SetMetadata("AssetTraitValue", "symbol"); + candidate.RemoveMetadata("OriginalItemSpec"); + break; + default: + break; + } + } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs new file mode 100644 index 0000000000000..d622db24a31a5 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ComputeWasmPublishAssets.cs @@ -0,0 +1,628 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.NET.Sdk.WebAssembly; + +namespace Microsoft.NET.Sdk.WebAssembly; + +// This target computes the list of publish static web assets based on the changes that happen during publish and the list of build static +// web assets. +// In this target we need to do 2 things: +// * Harmonize the list of dlls produced at build time with the list of resolved files to publish. +// * We iterate over the list of existing static web assets and do as follows: +// * If we find the assembly in the resolved files to publish and points to the original assembly (linker disabled or assembly not linked) +// we create a new "Publish" static web asset for the assembly. +// * If we find the assembly in the resolved files to publish and points to a new location, we assume this assembly has been updated (as part of linking) +// and we create a new "Publish" static web asset for the asembly pointing to the new location. +// * If we don't find the assembly on the resolved files to publish it has been linked out from the app, so we don't add any new static web asset and we +// also avoid adding any existing related static web asset (satellite assemblies and compressed versions). +// * We update static web assets for satellite assemblies and compressed assets accordingly. +// * Look at the list of "native" assets and determine whether we need to create new publish assets for the current build assets or if we need to +// update the native assets because the app was ahead of time compiled. +public class ComputeWasmPublishAssets : Task +{ + [Required] + public ITaskItem[] ResolvedFilesToPublish { get; set; } + + public ITaskItem CustomIcuCandidate { get; set; } + + [Required] + public ITaskItem[] WasmAotAssets { get; set; } + + [Required] + public ITaskItem[] ExistingAssets { get; set; } + + [Required] + public bool TimeZoneSupport { get; set; } + + [Required] + public bool InvariantGlobalization { get; set; } + + [Required] + public bool CopySymbols { get; set; } + + [Required] + public string PublishPath { get; set; } + + [Required] + public string DotNetJsVersion { get; set; } + + public bool FingerprintDotNetJs { get; set; } + + [Output] + public ITaskItem[] NewCandidates { get; set; } + + [Output] + public ITaskItem[] FilesToRemove { get; set; } + + public override bool Execute() + { + var filesToRemove = new List(); + var newAssets = new List(); + + try + { + // We'll do a first pass over the resolved files to publish to figure out what files need to be removed + // as well as categorize resolved files into different groups. + var resolvedFilesToPublishToRemove = new Dictionary(StringComparer.Ordinal); + + // These assemblies are keyed of the assembly name "computed" based on the relative path, which must be + // unique. + var resolvedAssembliesToPublish = new Dictionary(StringComparer.Ordinal); + var resolvedSymbolsToPublish = new Dictionary(StringComparer.Ordinal); + var satelliteAssemblyToPublish = new Dictionary<(string, string), ITaskItem>(EqualityComparer<(string, string)>.Default); + var resolvedNativeAssetToPublish = new Dictionary(StringComparer.Ordinal); + GroupResolvedFilesToPublish( + resolvedFilesToPublishToRemove, + resolvedAssembliesToPublish, + satelliteAssemblyToPublish, + resolvedSymbolsToPublish, + resolvedNativeAssetToPublish); + + // Group candidate static web assets + var assemblyAssets = new Dictionary(); + var symbolAssets = new Dictionary(); + var nativeAssets = new Dictionary(); + var satelliteAssemblyAssets = new Dictionary(); + var compressedRepresentations = new Dictionary(); + GroupExistingStaticWebAssets( + assemblyAssets, + nativeAssets, + satelliteAssemblyAssets, + symbolAssets, + compressedRepresentations); + + var newStaticWebAssets = ComputeUpdatedAssemblies( + satelliteAssemblyToPublish, + filesToRemove, + resolvedAssembliesToPublish, + assemblyAssets, + satelliteAssemblyAssets, + compressedRepresentations); + + newAssets.AddRange(newStaticWebAssets); + + var nativeStaticWebAssets = ProcessNativeAssets( + nativeAssets, + resolvedFilesToPublishToRemove, + resolvedNativeAssetToPublish, + compressedRepresentations, + filesToRemove); + + newAssets.AddRange(nativeStaticWebAssets); + + var symbolStaticWebAssets = ProcessSymbolAssets( + symbolAssets, + compressedRepresentations, + resolvedFilesToPublishToRemove, + resolvedSymbolsToPublish, + filesToRemove); + + newAssets.AddRange(symbolStaticWebAssets); + + foreach (var kvp in resolvedFilesToPublishToRemove) + { + var resolvedPublishFileToRemove = kvp.Value; + filesToRemove.Add(resolvedPublishFileToRemove); + } + } + catch (Exception ex) + { + Log.LogError(ex.ToString()); + return false; + } + + FilesToRemove = filesToRemove.ToArray(); + NewCandidates = newAssets.ToArray(); + + return !Log.HasLoggedErrors; + } + + private List ProcessNativeAssets( + Dictionary nativeAssets, + IDictionary resolvedPublishFilesToRemove, + Dictionary resolvedNativeAssetToPublish, + Dictionary compressedRepresentations, + List filesToRemove) + { + var nativeStaticWebAssets = new List(); + + // Keep track of the updated assets to determine what compressed assets we can reuse + var updateMap = new Dictionary(); + + foreach (var kvp in nativeAssets) + { + var key = kvp.Key; + var asset = kvp.Value; + var isDotNetJs = IsDotNetJs(key); + var isDotNetWasm = IsDotNetWasm(key); + if (!isDotNetJs && !isDotNetWasm) + { + if (resolvedNativeAssetToPublish.TryGetValue(Path.GetFileName(asset.GetMetadata("OriginalItemSpec")), out var existing)) + { + if (!resolvedPublishFilesToRemove.TryGetValue(existing.ItemSpec, out var removed)) + { + // This is a native asset like timezones.blat or similar that was not filtered and that needs to be updated + // to a publish asset. + var newAsset = new TaskItem(asset); + ApplyPublishProperties(newAsset); + nativeStaticWebAssets.Add(newAsset); + filesToRemove.Add(existing); + updateMap.Add(asset.ItemSpec, newAsset); + Log.LogMessage(MessageImportance.Low, "Promoting asset '{0}' to Publish asset.", asset.ItemSpec); + } + else + { + Log.LogMessage(MessageImportance.Low, "Removing asset '{0}'.", existing.ItemSpec); + // This was a file that was filtered, so just remove it, we don't need to add any publish static web asset + filesToRemove.Add(removed); + + // Remove the file from the list to avoid double processing later when we process other files we filtered. + resolvedPublishFilesToRemove.Remove(existing.ItemSpec); + } + } + + continue; + } + + if (isDotNetJs) + { + var aotDotNetJs = WasmAotAssets.SingleOrDefault(a => $"{a.GetMetadata("FileName")}{a.GetMetadata("Extension")}" == "dotnet.js"); + ITaskItem newDotNetJs = null; + if (aotDotNetJs != null && FingerprintDotNetJs) + { + newDotNetJs = new TaskItem(Path.GetFullPath(aotDotNetJs.ItemSpec), asset.CloneCustomMetadata()); + newDotNetJs.SetMetadata("OriginalItemSpec", aotDotNetJs.ItemSpec); + newDotNetJs.SetMetadata("RelativePath", $"_framework/{$"dotnet.{DotNetJsVersion}.{FileHasher.GetFileHash(aotDotNetJs.ItemSpec)}.js"}"); + + updateMap.Add(asset.ItemSpec, newDotNetJs); + Log.LogMessage(MessageImportance.Low, "Replacing asset '{0}' with AoT version '{1}'", asset.ItemSpec, newDotNetJs.ItemSpec); + } + else + { + newDotNetJs = new TaskItem(asset); + Log.LogMessage(MessageImportance.Low, "Promoting asset '{0}' to Publish asset.", asset.ItemSpec); + } + + ApplyPublishProperties(newDotNetJs); + nativeStaticWebAssets.Add(newDotNetJs); + if (resolvedNativeAssetToPublish.TryGetValue("dotnet.js", out var resolved)) + { + filesToRemove.Add(resolved); + } + continue; + } + + if (isDotNetWasm) + { + var aotDotNetWasm = WasmAotAssets.SingleOrDefault(a => $"{a.GetMetadata("FileName")}{a.GetMetadata("Extension")}" == "dotnet.wasm"); + ITaskItem newDotNetWasm = null; + if (aotDotNetWasm != null) + { + newDotNetWasm = new TaskItem(Path.GetFullPath(aotDotNetWasm.ItemSpec), asset.CloneCustomMetadata()); + newDotNetWasm.SetMetadata("OriginalItemSpec", aotDotNetWasm.ItemSpec); + updateMap.Add(asset.ItemSpec, newDotNetWasm); + Log.LogMessage(MessageImportance.Low, "Replacing asset '{0}' with AoT version '{1}'", asset.ItemSpec, newDotNetWasm.ItemSpec); + } + else + { + newDotNetWasm = new TaskItem(asset); + Log.LogMessage(MessageImportance.Low, "Promoting asset '{0}' to Publish asset.", asset.ItemSpec); + } + + ApplyPublishProperties(newDotNetWasm); + nativeStaticWebAssets.Add(newDotNetWasm); + if (resolvedNativeAssetToPublish.TryGetValue("dotnet.wasm", out var resolved)) + { + filesToRemove.Add(resolved); + } + continue; + } + } + + var compressedUpdatedFiles = ProcessCompressedAssets(compressedRepresentations, nativeAssets, updateMap); + foreach (var f in compressedUpdatedFiles) + { + nativeStaticWebAssets.Add(f); + } + + return nativeStaticWebAssets; + + static bool IsDotNetJs(string key) + { + var fileName = Path.GetFileName(key); + return fileName.StartsWith("dotnet.", StringComparison.Ordinal) && fileName.EndsWith(".js", StringComparison.Ordinal) && !fileName.Contains("worker"); + } + + static bool IsDotNetWasm(string key) => string.Equals("dotnet.wasm", Path.GetFileName(key), StringComparison.Ordinal); + } + + private List ProcessSymbolAssets( + Dictionary symbolAssets, + Dictionary compressedRepresentations, + Dictionary resolvedPublishFilesToRemove, + Dictionary resolvedSymbolAssetToPublish, + List filesToRemove) + { + var symbolStaticWebAssets = new List(); + var updateMap = new Dictionary(); + + foreach (var kvp in symbolAssets) + { + var asset = kvp.Value; + if (resolvedSymbolAssetToPublish.TryGetValue(Path.GetFileName(asset.GetMetadata("OriginalItemSpec")), out var existing)) + { + if (!resolvedPublishFilesToRemove.TryGetValue(existing.ItemSpec, out var removed)) + { + // This is a symbol asset like classlibrary.pdb or similar that was not filtered and that needs to be updated + // to a publish asset. + var newAsset = new TaskItem(asset); + ApplyPublishProperties(newAsset); + symbolStaticWebAssets.Add(newAsset); + updateMap.Add(newAsset.ItemSpec, newAsset); + filesToRemove.Add(existing); + Log.LogMessage(MessageImportance.Low, "Promoting asset '{0}' to Publish asset.", asset.ItemSpec); + } + else + { + // This was a file that was filtered, so just remove it, we don't need to add any publish static web asset + filesToRemove.Add(removed); + + // Remove the file from the list to avoid double processing later when we process other files we filtered. + resolvedPublishFilesToRemove.Remove(existing.ItemSpec); + } + } + } + + var compressedFiles = ProcessCompressedAssets(compressedRepresentations, symbolAssets, updateMap); + + foreach (var file in compressedFiles) + { + symbolStaticWebAssets.Add(file); + } + + return symbolStaticWebAssets; + } + + private List ComputeUpdatedAssemblies( + IDictionary<(string, string assemblyName), ITaskItem> satelliteAssemblies, + List filesToRemove, + Dictionary resolvedAssembliesToPublish, + Dictionary assemblyAssets, + Dictionary satelliteAssemblyAssets, + Dictionary compressedRepresentations) + { + // All assemblies, satellite assemblies and gzip files are initially defined as build assets. + // We need to update them to publish assets when they haven't changed or when they have been linked. + // For satellite assemblies and compressed files, we won't include them in the list of assets to update + // when the original assembly they depend on has been linked out. + var assetsToUpdate = new Dictionary(); + var linkedAssets = new Dictionary(); + + foreach (var kvp in assemblyAssets) + { + var asset = kvp.Value; + var fileName = Path.GetFileName(asset.GetMetadata("RelativePath")); + if (resolvedAssembliesToPublish.TryGetValue(fileName, out var existing)) + { + // We found the assembly, so it'll have to be updated. + assetsToUpdate.Add(asset.ItemSpec, asset); + filesToRemove.Add(existing); + if (!string.Equals(asset.ItemSpec, existing.GetMetadata("FullPath"), StringComparison.Ordinal)) + { + linkedAssets.Add(asset.ItemSpec, existing); + } + } + } + + foreach (var kvp in satelliteAssemblyAssets) + { + var satelliteAssembly = kvp.Value; + var relatedAsset = satelliteAssembly.GetMetadata("RelatedAsset"); + if (assetsToUpdate.ContainsKey(relatedAsset)) + { + assetsToUpdate.Add(satelliteAssembly.ItemSpec, satelliteAssembly); + var culture = satelliteAssembly.GetMetadata("AssetTraitValue"); + var fileName = Path.GetFileName(satelliteAssembly.GetMetadata("RelativePath")); + if (satelliteAssemblies.TryGetValue((culture, fileName), out var existing)) + { + filesToRemove.Add(existing); + } + else + { + var message = $"Can't find the original satellite assembly in the list of resolved files to " + + $"publish for asset '{satelliteAssembly.ItemSpec}'."; + throw new InvalidOperationException(message); + } + } + } + + var compressedFiles = ProcessCompressedAssets(compressedRepresentations, assetsToUpdate, linkedAssets); + + foreach (var file in compressedFiles) + { + assetsToUpdate.Add(file.ItemSpec, file); + } + + var updatedAssetsMap = new Dictionary(StringComparer.Ordinal); + foreach (var asset in assetsToUpdate.Select(a => a.Value).OrderBy(a => a.GetMetadata("AssetRole"), Comparer.Create(OrderByAssetRole))) + { + var assetTraitName = asset.GetMetadata("AssetTraitName"); + switch (assetTraitName) + { + case "WasmResource": + ITaskItem newAsemblyAsset = null; + if (linkedAssets.TryGetValue(asset.ItemSpec, out var linked)) + { + newAsemblyAsset = new TaskItem(linked.GetMetadata("FullPath"), asset.CloneCustomMetadata()); + newAsemblyAsset.SetMetadata("OriginalItemSpec", linked.ItemSpec); + Log.LogMessage(MessageImportance.Low, "Replacing asset '{0}' with linked version '{1}'", + asset.ItemSpec, + newAsemblyAsset.ItemSpec); + } + else + { + Log.LogMessage(MessageImportance.Low, "Linked asset not found for asset '{0}'", asset.ItemSpec); + newAsemblyAsset = new TaskItem(asset); + } + ApplyPublishProperties(newAsemblyAsset); + + updatedAssetsMap.Add(asset.ItemSpec, newAsemblyAsset); + break; + default: + // Satellite assembliess and compressed assets + var dependentAsset = new TaskItem(asset); + ApplyPublishProperties(dependentAsset); + UpdateRelatedAssetProperty(asset, dependentAsset, updatedAssetsMap); + Log.LogMessage(MessageImportance.Low, "Promoting asset '{0}' to Publish asset.", asset.ItemSpec); + + updatedAssetsMap.Add(asset.ItemSpec, dependentAsset); + break; + } + } + + return updatedAssetsMap.Values.ToList(); + } + + private List ProcessCompressedAssets( + Dictionary compressedRepresentations, + Dictionary assetsToUpdate, + Dictionary updatedAssets) + { + var processed = new List(); + var runtimeAssetsToUpdate = new List(); + foreach (var kvp in compressedRepresentations) + { + var compressedAsset = kvp.Value; + var relatedAsset = compressedAsset.GetMetadata("RelatedAsset"); + if (assetsToUpdate.ContainsKey(relatedAsset)) + { + if (!updatedAssets.ContainsKey(relatedAsset)) + { + Log.LogMessage(MessageImportance.Low, "Related assembly for '{0}' was not updated and the compressed asset can be reused.", relatedAsset); + var newCompressedAsset = new TaskItem(compressedAsset); + ApplyPublishProperties(newCompressedAsset); + runtimeAssetsToUpdate.Add(newCompressedAsset); + } + else + { + Log.LogMessage(MessageImportance.Low, "Related assembly for '{0}' was updated and the compressed asset will be discarded.", relatedAsset); + } + + processed.Add(kvp.Key); + } + } + + // Remove all the elements we've found to avoid having to iterate over them when we process other assets. + foreach (var element in processed) + { + compressedRepresentations.Remove(element); + } + + return runtimeAssetsToUpdate; + } + + private static void UpdateRelatedAssetProperty(ITaskItem asset, TaskItem newAsset, Dictionary updatedAssetsMap) + { + if (!updatedAssetsMap.TryGetValue(asset.GetMetadata("RelatedAsset"), out var updatedRelatedAsset)) + { + throw new InvalidOperationException("Related asset not found."); + } + + newAsset.SetMetadata("RelatedAsset", updatedRelatedAsset.ItemSpec); + } + + private int OrderByAssetRole(string left, string right) + { + var leftScore = GetScore(left); + var rightScore = GetScore(right); + + return leftScore - rightScore; + + static int GetScore(string candidate) => candidate switch + { + "Primary" => 0, + "Related" => 1, + "Alternative" => 2, + _ => throw new InvalidOperationException("Invalid asset role"), + }; + } + + private void ApplyPublishProperties(ITaskItem newAsemblyAsset) + { + newAsemblyAsset.SetMetadata("AssetKind", "Publish"); + newAsemblyAsset.SetMetadata("ContentRoot", Path.Combine(PublishPath, "wwwroot")); + newAsemblyAsset.SetMetadata("CopyToOutputDirectory", "Never"); + newAsemblyAsset.SetMetadata("CopyToPublishDirectory", "PreserveNewest"); + } + + private void GroupExistingStaticWebAssets( + Dictionary assemblyAssets, + Dictionary nativeAssets, + Dictionary satelliteAssemblyAssets, + Dictionary symbolAssets, + Dictionary compressedRepresentations) + { + foreach (var asset in ExistingAssets) + { + var traitName = asset.GetMetadata("AssetTraitName"); + if (IsWebAssemblyResource(traitName)) + { + var traitValue = asset.GetMetadata("AssetTraitValue"); + if (IsRuntimeAsset(traitValue)) + { + assemblyAssets.Add(asset.ItemSpec, asset); + } + else if (IsNativeAsset(traitValue)) + { + nativeAssets.Add(asset.ItemSpec, asset); + } + else if (IsSymbolAsset(traitValue)) + { + symbolAssets.Add(asset.ItemSpec, asset); + } + } + else if (IsCulture(traitName)) + { + satelliteAssemblyAssets.Add(asset.ItemSpec, asset); + } + else if (IsAlternative(asset)) + { + compressedRepresentations.Add(asset.ItemSpec, asset); + } + } + } + + private void GroupResolvedFilesToPublish( + Dictionary resolvedFilesToPublishToRemove, + Dictionary resolvedAssemblyToPublish, + Dictionary<(string, string), ITaskItem> satelliteAssemblyToPublish, + Dictionary resolvedSymbolsToPublish, + Dictionary resolvedNativeAssetToPublish) + { + var resolvedFilesToPublish = ResolvedFilesToPublish.ToList(); + if (AssetsComputingHelper.TryGetAssetFilename(CustomIcuCandidate, out string customIcuCandidateFilename)) + { + var customIcuCandidate = AssetsComputingHelper.GetCustomIcuAsset(CustomIcuCandidate); + resolvedFilesToPublish.Add(customIcuCandidate); + } + + foreach (var candidate in resolvedFilesToPublish) + { + if (AssetsComputingHelper.ShouldFilterCandidate(candidate, TimeZoneSupport, InvariantGlobalization, CopySymbols, customIcuCandidateFilename, out var reason)) + { + Log.LogMessage(MessageImportance.Low, "Skipping asset '{0}' because '{1}'", candidate.ItemSpec, reason); + if (!resolvedFilesToPublishToRemove.ContainsKey(candidate.ItemSpec)) + { + resolvedFilesToPublishToRemove.Add(candidate.ItemSpec, candidate); + } + else + { + Log.LogMessage(MessageImportance.Low, "Duplicate candidate '{0}' found in ResolvedFilesToPublish", candidate.ItemSpec); + } + continue; + } + + var extension = candidate.GetMetadata("Extension"); + if (string.Equals(extension, ".dll", StringComparison.Ordinal)) + { + var culture = candidate.GetMetadata("Culture"); + var inferredCulture = candidate.GetMetadata("DestinationSubDirectory").Replace("\\", "/").Trim('/'); + if (!string.IsNullOrEmpty(culture) || !string.IsNullOrEmpty(inferredCulture)) + { + var finalCulture = !string.IsNullOrEmpty(culture) ? culture : inferredCulture; + var assemblyName = Path.GetFileName(candidate.GetMetadata("RelativePath").Replace("\\", "/")); + if (!satelliteAssemblyToPublish.ContainsKey((finalCulture, assemblyName))) + { + satelliteAssemblyToPublish.Add((finalCulture, assemblyName), candidate); + } + else + { + Log.LogMessage(MessageImportance.Low, "Duplicate candidate '{0}' found in ResolvedFilesToPublish", candidate.ItemSpec); + } + continue; + } + + var candidateName = Path.GetFileName(candidate.GetMetadata("RelativePath")); + if (!resolvedAssemblyToPublish.ContainsKey(candidateName)) + { + resolvedAssemblyToPublish.Add(candidateName, candidate); + } + else + { + Log.LogMessage(MessageImportance.Low, "Duplicate candidate '{0}' found in ResolvedFilesToPublish", candidate.ItemSpec); + } + + continue; + } + + if (string.Equals(extension, ".pdb", StringComparison.Ordinal)) + { + var candidateName = Path.GetFileName(candidate.GetMetadata("RelativePath")); + if (!resolvedSymbolsToPublish.ContainsKey(candidateName)) + { + resolvedSymbolsToPublish.Add(candidateName, candidate); + } + else + { + Log.LogMessage(MessageImportance.Low, "Duplicate candidate '{0}' found in ResolvedFilesToPublish", candidate.ItemSpec); + } + + continue; + } + + // Capture all the native unfiltered assets since we need to process them to determine what static web assets need to get + // upgraded + if (string.Equals(candidate.GetMetadata("AssetType"), "native", StringComparison.Ordinal)) + { + var candidateName = $"{candidate.GetMetadata("FileName")}{extension}"; + if (!resolvedNativeAssetToPublish.ContainsKey(candidateName)) + { + resolvedNativeAssetToPublish.Add(candidateName, candidate); + } + else + { + Log.LogMessage(MessageImportance.Low, "Duplicate candidate '{0}' found in ResolvedFilesToPublish", candidate.ItemSpec); + } + continue; + } + } + } + + private static bool IsNativeAsset(string traitValue) => string.Equals(traitValue, "native", StringComparison.Ordinal); + + private static bool IsRuntimeAsset(string traitValue) => string.Equals(traitValue, "runtime", StringComparison.Ordinal); + private static bool IsSymbolAsset(string traitValue) => string.Equals(traitValue, "symbol", StringComparison.Ordinal); + + private static bool IsAlternative(ITaskItem asset) => string.Equals(asset.GetMetadata("AssetRole"), "Alternative", StringComparison.Ordinal); + + private static bool IsCulture(string traitName) => string.Equals(traitName, "Culture", StringComparison.Ordinal); + + private static bool IsWebAssemblyResource(string traitName) => string.Equals(traitName, "WasmResource", StringComparison.Ordinal); +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/FileHasher.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/FileHasher.cs new file mode 100644 index 0000000000000..c264a1b42a461 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/FileHasher.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Numerics; +using System.Security.Cryptography; +using System.Text; + +namespace Microsoft.NET.Sdk.WebAssembly; + +public static class FileHasher +{ + public static string GetFileHash(string filePath) + { + using var hash = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(filePath); + var hashBytes = hash.ComputeHash(bytes); + return ToBase36(hashBytes); + } + + private static string ToBase36(byte[] hash) + { + const string chars = "0123456789abcdefghijklmnopqrstuvwxyz"; + + var result = new char[10]; + var dividend = BigInteger.Abs(new BigInteger(hash.AsSpan().Slice(0, 9).ToArray())); + for (var i = 0; i < 10; i++) + { + dividend = BigInteger.DivRem(dividend, 36, out var remainder); + result[i] = chars[(int)remainder]; + } + + return new string(result); + } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs new file mode 100644 index 0000000000000..d8a5a3d2ae9e6 --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs @@ -0,0 +1,341 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Runtime.Serialization; +using System.Runtime.Serialization.Json; +using System.Text; +using System.Xml; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary; + +namespace Microsoft.NET.Sdk.WebAssembly; + +public class GenerateWasmBootJson : Task +{ + private static readonly string[] jiterpreterOptions = new[] { "jiterpreter-traces-enabled", "jiterpreter-interp-entry-enabled", "jiterpreter-jit-call-enabled" }; + + [Required] + public string AssemblyPath { get; set; } + + [Required] + public ITaskItem[] Resources { get; set; } + + [Required] + public bool DebugBuild { get; set; } + + [Required] + public bool LinkerEnabled { get; set; } + + [Required] + public bool CacheBootResources { get; set; } + + public bool LoadAllICUData { get; set; } + + public bool LoadCustomIcuData { get; set; } + + public string InvariantGlobalization { get; set; } + + public ITaskItem[] ConfigurationFiles { get; set; } + + public ITaskItem[] Extensions { get; set; } + + public string StartupMemoryCache { get; set; } + + public string Jiterpreter { get; set; } + + public string RuntimeOptions { get; set; } + + [Required] + public string OutputPath { get; set; } + + public ITaskItem[] LazyLoadedAssemblies { get; set; } + + public override bool Execute() + { + using var fileStream = File.Create(OutputPath); + var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name; + + try + { + WriteBootJson(fileStream, entryAssemblyName); + } + catch (Exception ex) + { + Log.LogError(ex.ToString()); + } + + return !Log.HasLoggedErrors; + } + + // Internal for tests + public void WriteBootJson(Stream output, string entryAssemblyName) + { + var icuDataMode = ICUDataMode.Sharded; + + if (string.Equals(InvariantGlobalization, "true", StringComparison.OrdinalIgnoreCase)) + { + icuDataMode = ICUDataMode.Invariant; + } + else if (LoadAllICUData) + { + icuDataMode = ICUDataMode.All; + } + else if (LoadCustomIcuData) + { + icuDataMode = ICUDataMode.Custom; + } + + var result = new BootJsonData + { + entryAssembly = entryAssemblyName, + cacheBootResources = CacheBootResources, + debugBuild = DebugBuild, + linkerEnabled = LinkerEnabled, + resources = new ResourcesData(), + config = new List(), + icuDataMode = icuDataMode, + startupMemoryCache = ParseOptionalBool(StartupMemoryCache), + }; + + if (!string.IsNullOrEmpty(RuntimeOptions)) + { + string[] runtimeOptions = RuntimeOptions.Split(' '); + result.runtimeOptions = runtimeOptions; + } + + bool? jiterpreter = ParseOptionalBool(Jiterpreter); + if (jiterpreter != null) + { + var runtimeOptions = result.runtimeOptions?.ToHashSet() ?? new HashSet(3); + foreach (var jiterpreterOption in jiterpreterOptions) + { + if (jiterpreter == true) + { + if (!runtimeOptions.Contains($"--no-{jiterpreterOption}")) + runtimeOptions.Add($"--{jiterpreterOption}"); + } + else + { + if (!runtimeOptions.Contains($"--{jiterpreterOption}")) + runtimeOptions.Add($"--no-{jiterpreterOption}"); + } + } + + result.runtimeOptions = runtimeOptions.ToArray(); + } + + // Build a two-level dictionary of the form: + // - assembly: + // - UriPath (e.g., "System.Text.Json.dll") + // - ContentHash (e.g., "4548fa2e9cf52986") + // - runtime: + // - UriPath (e.g., "dotnet.js") + // - ContentHash (e.g., "3448f339acf512448") + if (Resources != null) + { + var remainingLazyLoadAssemblies = new List(LazyLoadedAssemblies ?? Array.Empty()); + var resourceData = result.resources; + foreach (var resource in Resources) + { + ResourceHashesByNameDictionary resourceList = null; + + string behavior = null; + var fileName = resource.GetMetadata("FileName"); + var fileExtension = resource.GetMetadata("Extension"); + var assetTraitName = resource.GetMetadata("AssetTraitName"); + var assetTraitValue = resource.GetMetadata("AssetTraitValue"); + var resourceName = Path.GetFileName(resource.GetMetadata("RelativePath")); + + if (TryGetLazyLoadedAssembly(resourceName, out var lazyLoad)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a lazy loaded assembly.", resource.ItemSpec); + remainingLazyLoadAssemblies.Remove(lazyLoad); + resourceData.lazyAssembly ??= new ResourceHashesByNameDictionary(); + resourceList = resourceData.lazyAssembly; + } + else if (string.Equals("Culture", assetTraitName, StringComparison.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as satellite assembly with culture '{1}'.", resource.ItemSpec, assetTraitValue); + resourceData.satelliteResources ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + resourceName = assetTraitValue + "/" + resourceName; + + if (!resourceData.satelliteResources.TryGetValue(assetTraitValue, out resourceList)) + { + resourceList = new ResourceHashesByNameDictionary(); + resourceData.satelliteResources.Add(assetTraitValue, resourceList); + } + } + else if (string.Equals("symbol", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + { + if (TryGetLazyLoadedAssembly($"{fileName}.dll", out _)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a lazy loaded symbols file.", resource.ItemSpec); + resourceData.lazyAssembly ??= new ResourceHashesByNameDictionary(); + resourceList = resourceData.lazyAssembly; + } + else + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as symbols file.", resource.ItemSpec); + resourceData.pdb ??= new ResourceHashesByNameDictionary(); + resourceList = resourceData.pdb; + } + } + else if (string.Equals("runtime", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as an app assembly.", resource.ItemSpec); + resourceList = resourceData.assembly; + } + else if (string.Equals(assetTraitName, "WasmResource", StringComparison.OrdinalIgnoreCase) && + string.Equals(assetTraitValue, "native", StringComparison.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a native application resource.", resource.ItemSpec); + if (string.Equals(fileName, "dotnet", StringComparison.OrdinalIgnoreCase) && + string.Equals(fileExtension, ".wasm", StringComparison.OrdinalIgnoreCase)) + { + behavior = "dotnetwasm"; + } + + resourceList = resourceData.runtime; + } + else if (string.Equals("JSModule", assetTraitName, StringComparison.OrdinalIgnoreCase) && + string.Equals(assetTraitValue, "JSLibraryModule", StringComparison.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a library initializer resource.", resource.ItemSpec); + resourceData.libraryInitializers ??= new(); + resourceList = resourceData.libraryInitializers; + var targetPath = resource.GetMetadata("TargetPath"); + Debug.Assert(!string.IsNullOrEmpty(targetPath), "Target path for '{0}' must exist.", resource.ItemSpec); + AddResourceToList(resource, resourceList, targetPath); + continue; + } + else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) && + assetTraitValue.StartsWith("extension:", StringComparison.OrdinalIgnoreCase)) + { + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as an extension resource '{1}'.", resource.ItemSpec, assetTraitValue); + var extensionName = assetTraitValue.Substring("extension:".Length); + resourceData.extensions ??= new(); + if (!resourceData.extensions.TryGetValue(extensionName, out resourceList)) + { + resourceList = new(); + resourceData.extensions[extensionName] = resourceList; + } + var targetPath = resource.GetMetadata("TargetPath"); + Debug.Assert(!string.IsNullOrEmpty(targetPath), "Target path for '{0}' must exist.", resource.ItemSpec); + AddResourceToList(resource, resourceList, targetPath); + continue; + } + else + { + Log.LogMessage(MessageImportance.Low, "Skipping resource '{0}' since it doesn't belong to a defined category.", resource.ItemSpec); + // This should include items such as XML doc files, which do not need to be recorded in the manifest. + continue; + } + + if (resourceList != null) + { + AddResourceToList(resource, resourceList, resourceName); + } + + if (!string.IsNullOrEmpty(behavior)) + { + resourceData.runtimeAssets ??= new Dictionary(); + AddToAdditionalResources(resource, resourceData.runtimeAssets, resourceName, behavior); + } + } + + if (remainingLazyLoadAssemblies.Count > 0) + { + const string message = "Unable to find '{0}' to be lazy loaded later. Confirm that project or " + + "package references are included and the reference is used in the project."; + + Log.LogError( + subcategory: null, + errorCode: "BLAZORSDK1001", + helpKeyword: null, + file: null, + lineNumber: 0, + columnNumber: 0, + endLineNumber: 0, + endColumnNumber: 0, + message: message, + string.Join(";", LazyLoadedAssemblies.Select(a => a.ItemSpec))); + + return; + } + } + + if (ConfigurationFiles != null) + { + foreach (var configFile in ConfigurationFiles) + { + result.config.Add(Path.GetFileName(configFile.ItemSpec)); + } + } + + if (Extensions != null && Extensions.Length > 0) + { + var configSerializer = new DataContractJsonSerializer(typeof(Dictionary), new DataContractJsonSerializerSettings + { + UseSimpleDictionaryFormat = true + }); + + result.extensions = new Dictionary> (); + foreach (var configExtension in Extensions) + { + var key = configExtension.GetMetadata("key"); + var config = (Dictionary)configSerializer.ReadObject(File.OpenRead(configExtension.ItemSpec)); + result.extensions[key] = config; + } + } + + var serializer = new DataContractJsonSerializer(typeof(BootJsonData), new DataContractJsonSerializerSettings + { + UseSimpleDictionaryFormat = true + }); + + using var writer = JsonReaderWriterFactory.CreateJsonWriter(output, Encoding.UTF8, ownsStream: false, indent: true); + serializer.WriteObject(writer, result); + + void AddResourceToList(ITaskItem resource, ResourceHashesByNameDictionary resourceList, string resourceKey) + { + if (!resourceList.ContainsKey(resourceKey)) + { + Log.LogMessage(MessageImportance.Low, "Added resource '{0}' to the manifest.", resource.ItemSpec); + resourceList.Add(resourceKey, $"sha256-{resource.GetMetadata("FileHash")}"); + } + } + } + + private static bool? ParseOptionalBool(string value) + { + if (string.IsNullOrEmpty(value) || !bool.TryParse(value, out var boolValue)) + return null; + + return boolValue; + } + + private void AddToAdditionalResources(ITaskItem resource, Dictionary additionalResources, string resourceName, string behavior) + { + if (!additionalResources.ContainsKey(resourceName)) + { + Log.LogMessage(MessageImportance.Low, "Added resource '{0}' to the list of additional assets in the manifest.", resource.ItemSpec); + additionalResources.Add(resourceName, new AdditionalAsset + { + Hash = $"sha256-{resource.GetMetadata("FileHash")}", + Behavior = behavior + }); + } + } + + private bool TryGetLazyLoadedAssembly(string fileName, out ITaskItem lazyLoadedAssembly) + { + return (lazyLoadedAssembly = LazyLoadedAssemblies?.SingleOrDefault(a => a.ItemSpec == fileName)) != null; + } +} diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks.csproj b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks.csproj new file mode 100644 index 0000000000000..e56ee68e46a5e --- /dev/null +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks.csproj @@ -0,0 +1,32 @@ + + + + $(TargetFrameworkForNETCoreTasks);$(TargetFrameworkForNETFrameworkTasks) + $(NoWarn),CA1050,CA1850,CA1845,CA1859,NU5128 + Microsoft.NET.Sdk.WebAssembly + true + true + + + + + All + true + + + + + + + + + + + <_PublishFramework Remove="@(_PublishFramework)" /> + <_PublishFramework Include="$(TargetFrameworks)" /> + + + + + +