Skip to content

Commit c46e8d4

Browse files
mattleibowCopilot
andcommitted
Generate deterministic Blazor asset manifest to fix universal builds
The bundled fingerprint manifest was the SDK's raw static web assets endpoints manifest, which embeds volatile per-file data (Last-Modified timestamps). On a universal MacCatalyst build the x64 and arm64 legs produce byte-different manifests, so the SDK's app-bundle merge fails: error : Unable to merge the file 'Contents/Resources/_maui/blazor-asset-manifest.json', it's different between the input app bundles. This broke every Blazor Hybrid template build on macOS (BlazorTemplateTest and SimpleTemplateTest with the maui-blazor template). Single-RID device tests never hit the universal merge, so it wasn't caught locally. Instead of bundling the raw manifest, generate a minimal manifest from the @(StaticWebAssetEndpoint) items at build time containing only the fingerprinted route and its logical label. That data is entirely content-derived (fingerprints are content hashes, labels are logical names) with no timestamps, absolute paths, or RIDs, and is emitted sorted - so it is byte-identical across architectures and the universal merge succeeds. Verified: the x64 and arm64 manifests now have an identical SHA-256. Also simplifies the runtime: it now parses a tiny purpose-built file (via STJ source-gen) instead of walking the large SDK endpoints manifest. MacCatalyst device tests: 45 passed, 1 pre-existing skip, including AppTypeResolvesFingerprintedAssetsViaAssets. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 2ca68fe1-bef6-46a6-a0fd-17b7654cef34
1 parent fb24f61 commit c46e8d4

2 files changed

Lines changed: 120 additions & 102 deletions

File tree

src/BlazorWebView/src/Maui/StaticWebAssetsManifest.cs

Lines changed: 26 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,20 @@
1010
namespace Microsoft.AspNetCore.Components.WebView.Maui
1111
{
1212
/// <summary>
13-
/// Parses the static web assets endpoints manifest (<c>*.staticwebassets.endpoints.json</c>) that
14-
/// is bundled with a hybrid app and exposes it as:
13+
/// Loads the minimal fingerprinted-asset manifest that MAUI generates at build time (from the
14+
/// static web assets endpoints) and bundles with a hybrid app, and exposes it as:
1515
/// <list type="bullet">
1616
/// <item><description>a <see cref="ResourceAssetCollection"/> so <c>@Assets["logical"]</c> resolves to the
1717
/// fingerprinted URL at render time; and</description></item>
1818
/// <item><description>a route-to-physical map so the web view can serve the physical asset for a
1919
/// fingerprinted request URL.</description></item>
2020
/// </list>
2121
/// Blazor Web Apps build this from endpoint metadata via <c>MapStaticAssets</c>; hybrid apps have no
22-
/// server, so this reconstructs the same information from the bundled manifest. The manifest is
23-
/// bundled outside the web root and read via the app package APIs, so it is never exposed to the
24-
/// web view.
22+
/// server, so MAUI reconstructs just the fingerprint mapping at build time. The manifest is bundled
23+
/// outside the web root and read via the app package APIs, so it is never exposed to the web view.
24+
/// Its content is derived entirely from asset fingerprints and logical names (no timestamps, absolute
25+
/// paths, or runtime identifiers), so it is deterministic and identical across architectures - which
26+
/// is required for universal (multi-RID) app bundles to merge.
2527
/// </summary>
2628
internal sealed class StaticWebAssetsManifest
2729
{
@@ -41,7 +43,7 @@ private StaticWebAssetsManifest(ResourceAssetCollection assets, IReadOnlyDiction
4143
/// <summary>Gets the fingerprint-aware asset collection used to resolve <c>@Assets</c>.</summary>
4244
public ResourceAssetCollection Assets { get; }
4345

44-
/// <summary>Gets the map of request route (possibly fingerprinted) to the physical asset file.</summary>
46+
/// <summary>Gets the map of fingerprinted request route to the physical asset file under the web root.</summary>
4547
public IReadOnlyDictionary<string, string> RouteToPhysicalPath { get; }
4648

4749
/// <summary>
@@ -84,53 +86,28 @@ internal static StaticWebAssetsManifest Parse(Stream stream)
8486
internal static StaticWebAssetsManifest FromData(ManifestData? data)
8587
{
8688
var resources = new List<ResourceAsset>();
87-
var seenLabels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
8889
var routeToPhysical = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
8990

90-
var endpoints = data?.Endpoints;
91-
if (endpoints is null || endpoints.Count == 0)
91+
var assets = data?.Assets;
92+
if (assets is null || assets.Count == 0)
9293
{
9394
return new StaticWebAssetsManifest(ResourceAssetCollection.Empty, routeToPhysical);
9495
}
9596

96-
foreach (var endpoint in endpoints)
97+
foreach (var asset in assets)
9798
{
98-
var route = endpoint.Route;
99-
var assetFile = endpoint.AssetFile;
100-
if (route is null || assetFile is null)
99+
var route = asset.Route;
100+
var label = asset.Label;
101+
if (route is null || label is null)
101102
{
102103
continue;
103104
}
104105

105-
// Endpoints with selectors are alternative representations (for example gzip/brotli
106-
// content negotiation). They are not distinct assets, so skip them - mirroring the
107-
// framework's own ResourceCollectionResolver.
108-
if (endpoint.Selectors is { Count: > 0 })
109-
{
110-
continue;
111-
}
112-
113-
var isCompressed = assetFile.EndsWith(".gz", StringComparison.OrdinalIgnoreCase) ||
114-
assetFile.EndsWith(".br", StringComparison.OrdinalIgnoreCase);
115-
116-
var (label, integrity) = ReadProperties(endpoint.EndpointProperties);
117-
118-
// @Assets resolution: map the human-readable label to the fingerprinted route. Skip
119-
// compressed variants and duplicate labels to avoid collisions.
120-
if (label is not null && !isCompressed && seenLabels.Add(label))
121-
{
122-
var properties = integrity is null
123-
? new[] { new ResourceAssetProperty("label", label) }
124-
: new[] { new ResourceAssetProperty("label", label), new ResourceAssetProperty("integrity", integrity) };
125-
resources.Add(new ResourceAsset(route, properties));
126-
}
106+
// @Assets resolution: the fingerprinted route is exposed under its logical label.
107+
resources.Add(new ResourceAsset(route, new[] { new ResourceAssetProperty("label", label) }));
127108

128-
// Serving: map the (possibly fingerprinted) route to the physical file on disk. Prefer
129-
// the uncompressed asset and keep the first mapping for a given route.
130-
if (!isCompressed)
131-
{
132-
routeToPhysical.TryAdd(NormalizePath(route), NormalizePath(assetFile));
133-
}
109+
// Serving: the physical file under the web root is the logical (non-fingerprinted) label.
110+
routeToPhysical.TryAdd(NormalizePath(route), NormalizePath(label));
134111
}
135112

136113
return new StaticWebAssetsManifest(new ResourceAssetCollection(resources), routeToPhysical);
@@ -157,72 +134,25 @@ public bool TryResolvePhysicalPath(string requestedPath, out string physicalPath
157134
return false;
158135
}
159136

160-
private static (string? Label, string? Integrity) ReadProperties(List<EndpointProperty>? properties)
161-
{
162-
string? label = null;
163-
string? integrity = null;
164-
165-
if (properties is not null)
166-
{
167-
foreach (var property in properties)
168-
{
169-
if (property.Name is null)
170-
{
171-
continue;
172-
}
173-
174-
if (label is null && property.Name.Equals("label", StringComparison.OrdinalIgnoreCase))
175-
{
176-
label = property.Value;
177-
}
178-
else if (integrity is null && property.Name.Equals("integrity", StringComparison.OrdinalIgnoreCase))
179-
{
180-
integrity = property.Value;
181-
}
182-
}
183-
}
184-
185-
return (label, integrity);
186-
}
187-
188137
private static string NormalizePath(string path) =>
189138
(path ?? string.Empty).Replace('\\', '/').TrimStart('/');
190139

191-
/// <summary>The subset of the endpoints manifest that hybrid asset resolution needs.</summary>
140+
/// <summary>The minimal fingerprint manifest MAUI generates at build time.</summary>
192141
internal sealed class ManifestData
193142
{
194-
[JsonPropertyName("Endpoints")]
195-
public List<Endpoint>? Endpoints { get; set; }
143+
[JsonPropertyName("Assets")]
144+
public List<AssetEntry>? Assets { get; set; }
196145
}
197146

198-
internal sealed class Endpoint
147+
internal sealed class AssetEntry
199148
{
149+
/// <summary>The fingerprinted, served route (for example <c>_content/Pkg/app.abc123.css</c>).</summary>
200150
[JsonPropertyName("Route")]
201151
public string? Route { get; set; }
202152

203-
[JsonPropertyName("AssetFile")]
204-
public string? AssetFile { get; set; }
205-
206-
[JsonPropertyName("Selectors")]
207-
public List<EndpointSelector>? Selectors { get; set; }
208-
209-
[JsonPropertyName("EndpointProperties")]
210-
public List<EndpointProperty>? EndpointProperties { get; set; }
211-
}
212-
213-
internal sealed class EndpointSelector
214-
{
215-
[JsonPropertyName("Name")]
216-
public string? Name { get; set; }
217-
}
218-
219-
internal sealed class EndpointProperty
220-
{
221-
[JsonPropertyName("Name")]
222-
public string? Name { get; set; }
223-
224-
[JsonPropertyName("Value")]
225-
public string? Value { get; set; }
153+
/// <summary>The logical, non-fingerprinted path used by <c>@Assets</c> and stored on disk under the web root.</summary>
154+
[JsonPropertyName("Label")]
155+
public string? Label { get; set; }
226156
}
227157
}
228158

src/BlazorWebView/src/Maui/build/Microsoft.AspNetCore.Components.WebView.Maui.targets

Lines changed: 94 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,105 @@
7070
</Target>
7171

7272
<!--
73-
Bundle the static web assets endpoints manifest so that fingerprinted assets (@Assets, @ImportMap)
73+
Generate a minimal, deterministic fingerprint manifest so that fingerprinted assets (@Assets)
7474
can be resolved at runtime. Blazor Web Apps get this from MapStaticAssets endpoint metadata; hybrid
75-
apps have no server, so BlazorWebView loads this bundled manifest instead. It is bundled OUTSIDE
76-
wwwroot so it is never exposed to the web view, and BlazorWebView reads it from the app package via
77-
FileSystem.OpenAppPackageFileAsync.
75+
apps have no server, so MAUI reconstructs just the fingerprint mapping (fingerprinted route -> logical
76+
label) from the static web asset endpoints.
77+
78+
The generated file is bundled OUTSIDE wwwroot so it is never exposed to the web view, and BlazorWebView
79+
reads it from the app package via FileSystem.OpenAppPackageFileAsync. Its content is derived entirely
80+
from content fingerprints and logical names (no timestamps, absolute paths, or RIDs), so it is byte
81+
identical across architectures - which the SDK requires when merging universal (multi-RID) app bundles.
7882
-->
83+
<UsingTask TaskName="_GenerateMauiBlazorAssetManifest"
84+
TaskFactory="RoslynCodeTaskFactory"
85+
AssemblyFile="$(MSBuildToolsPath)\Microsoft.Build.Tasks.Core.dll">
86+
<ParameterGroup>
87+
<Endpoints ParameterType="Microsoft.Build.Framework.ITaskItem[]" Required="true" />
88+
<OutputFile ParameterType="System.String" Required="true" />
89+
</ParameterGroup>
90+
<Task>
91+
<Using Namespace="System" />
92+
<Using Namespace="System.Collections.Generic" />
93+
<Using Namespace="System.IO" />
94+
<Using Namespace="System.Text" />
95+
<Using Namespace="System.Text.RegularExpressions" />
96+
<Code Type="Fragment" Language="cs"><![CDATA[
97+
// Reduce the static web asset endpoints to the minimal fingerprint mapping BlazorWebView needs:
98+
// the fingerprinted route and its logical, non-fingerprinted label. Only primary (non-compressed)
99+
// endpoints that actually carry a fingerprint are relevant; everything else serves directly.
100+
var byRoute = new SortedDictionary<string, string>(StringComparer.Ordinal);
101+
foreach (var endpoint in Endpoints)
102+
{
103+
var selectors = endpoint.GetMetadata("Selectors");
104+
if (!string.IsNullOrEmpty(selectors) && selectors != "[]")
105+
{
106+
// Content-negotiated alternative (gzip/brotli); not a distinct asset.
107+
continue;
108+
}
109+
110+
var properties = endpoint.GetMetadata("EndpointProperties") ?? string.Empty;
111+
if (!Regex.IsMatch(properties, "\"Name\"\\s*:\\s*\"fingerprint\""))
112+
{
113+
// Not a fingerprinted route (the plain route is served directly).
114+
continue;
115+
}
116+
117+
var labelMatch = Regex.Match(properties, "\"Name\"\\s*:\\s*\"label\"\\s*,\\s*\"Value\"\\s*:\\s*\"(?<v>(?:[^\"\\\\]|\\\\.)*)\"");
118+
if (!labelMatch.Success)
119+
{
120+
continue;
121+
}
122+
123+
var route = endpoint.ItemSpec;
124+
var label = labelMatch.Groups["v"].Value;
125+
if (!string.IsNullOrEmpty(route) && !string.IsNullOrEmpty(label))
126+
{
127+
byRoute[route] = label;
128+
}
129+
}
130+
131+
static string JsonEscape(string value) =>
132+
value.Replace("\\", "\\\\").Replace("\"", "\\\"");
133+
134+
var builder = new StringBuilder();
135+
builder.Append("{\"Assets\":[");
136+
var first = true;
137+
foreach (var pair in byRoute)
138+
{
139+
if (!first)
140+
{
141+
builder.Append(',');
142+
}
143+
first = false;
144+
builder.Append("{\"Route\":\"").Append(JsonEscape(pair.Key))
145+
.Append("\",\"Label\":\"").Append(JsonEscape(pair.Value)).Append("\"}");
146+
}
147+
builder.Append("]}");
148+
149+
var content = builder.ToString();
150+
Directory.CreateDirectory(Path.GetDirectoryName(OutputFile));
151+
152+
// Only write when the content changes so incremental builds stay clean.
153+
if (!File.Exists(OutputFile) || File.ReadAllText(OutputFile) != content)
154+
{
155+
File.WriteAllText(OutputFile, content);
156+
}
157+
]]></Code>
158+
</Task>
159+
</UsingTask>
160+
79161
<Target Name="_BundleMauiBlazorAssetManifest"
80162
AfterTargets="ConvertStaticWebAssetsToMauiAssets"
81-
Condition="'$(StaticWebAssetEndpointsBuildManifestPath)' != '' and Exists('$(StaticWebAssetEndpointsBuildManifestPath)')">
163+
DependsOnTargets="ResolveStaticWebAssetsConfiguration"
164+
Condition="'@(StaticWebAssetEndpoint)' != ''">
165+
<PropertyGroup>
166+
<_MauiBlazorAssetManifestFile>$(IntermediateOutputPath)maui\blazor-asset-manifest.json</_MauiBlazorAssetManifestFile>
167+
</PropertyGroup>
168+
<_GenerateMauiBlazorAssetManifest Endpoints="@(StaticWebAssetEndpoint)"
169+
OutputFile="$(_MauiBlazorAssetManifestFile)" />
82170
<ItemGroup>
83-
<MauiAsset Include="$(StaticWebAssetEndpointsBuildManifestPath)">
171+
<MauiAsset Include="$(_MauiBlazorAssetManifestFile)" Condition="Exists('$(_MauiBlazorAssetManifestFile)')">
84172
<Link>_maui/blazor-asset-manifest.json</Link>
85173
<TargetPath>_maui/blazor-asset-manifest.json</TargetPath>
86174
</MauiAsset>

0 commit comments

Comments
 (0)