diff --git a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs index 30012f97764d..3377db3daa7a 100644 --- a/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs +++ b/src/BlazorWebView/src/Maui/Android/WebKitWebViewClient.cs @@ -1,4 +1,6 @@ -using System; +using System; +using System.Collections.Generic; +using System.IO; using System.Runtime.Versioning; using Android.Content; using Android.Runtime; @@ -21,6 +23,9 @@ internal class WebKitWebViewClient : WebViewClient private static readonly Uri AppOriginUri = new(AppOrigin); private readonly BlazorWebViewHandler? _webViewHandler; + // Android does not store WebResourceResponse instances returned from ShouldInterceptRequest in Chromium's + // HTTP cache. Keep explicitly cacheable static responses in a bounded per-WebView cache instead. + private readonly StaticContentResponseCache _staticContentResponseCache = new(); public WebKitWebViewClient(BlazorWebViewHandler webViewHandler) { @@ -100,7 +105,7 @@ private bool ShouldOverrideUrlLoadingCore(IWebResourceRequest? request) } // 2. Check if the request is for a Blazor resource - response = GetResponse(requestUri, _webViewHandler?.Logger); + response = GetResponse(request, requestUri, _webViewHandler?.Logger); if (response is not null) { return response; @@ -113,9 +118,33 @@ private bool ShouldOverrideUrlLoadingCore(IWebResourceRequest? request) return base.ShouldInterceptRequest(view, request); } - private WebResourceResponse? GetResponse(string requestUri, ILogger? logger) + private WebResourceResponse? GetResponse(IWebResourceRequest request, string requestUri, ILogger? logger) { + if (!Uri.TryCreate(requestUri, UriKind.Absolute, out var uri) || !AppOriginUri.IsBaseOf(uri)) + { + return null; + } + + StaticContentCacheRequestBehavior? cacheRequestBehavior = null; + if (_staticContentResponseCache.TryGet(requestUri, out var cachedResponse)) + { + cacheRequestBehavior = StaticContentResponseCachePolicy.GetRequestBehavior(request.Method, request.RequestHeaders); + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Default) + { + var cachedRequestUri = QueryStringHelper.RemovePossibleQueryString(requestUri); + logger?.HandlingWebRequest(cachedRequestUri); + logger?.ResponseContentBeingSent(cachedRequestUri, cachedResponse.StatusCode); + return CreateWebResourceResponse(cachedResponse); + } + + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Refresh) + { + _staticContentResponseCache.Remove(requestUri); + } + } + var allowFallbackOnHostPage = AppOriginUri.IsBaseOfPage(requestUri); + var originalRequestUri = requestUri; requestUri = QueryStringHelper.RemovePossibleQueryString(requestUri); logger?.HandlingWebRequest(requestUri); @@ -127,8 +156,43 @@ private bool ShouldOverrideUrlLoadingCore(IWebResourceRequest? request) { var contentType = headers["Content-Type"]; + // By default local caching is disabled so that user scripts are always re-executed. Applications can + // opt specific resources into caching via BlazorWebView.StaticContentCacheControlProvider. + // The original (unstripped) URI is passed so the provider can act on query strings (e.g. img.png?v=2). + // See https://github.com/dotnet/maui/issues/8279 + var cacheControlOverride = StaticContentCacheControl.ResolveOverride(_webViewHandler?.VirtualView, originalRequestUri, contentType, logger); + if (cacheControlOverride is not null) + { + headers["Cache-Control"] = cacheControlOverride; + } + logger?.ResponseContentBeingSent(requestUri, statusCode); + if (statusCode == 200 && + headers.TryGetValue("Cache-Control", out var cacheControl) && + StaticContentResponseCachePolicy.TryGetCacheLifetime(cacheControl, out var cacheLifetime)) + { + cacheRequestBehavior ??= StaticContentResponseCachePolicy.GetRequestBehavior(request.Method, request.RequestHeaders); + if (cacheRequestBehavior != StaticContentCacheRequestBehavior.Disabled) + { + if (StaticContentResponseBuffer.TryBuffer(content, originalRequestUri, logger, out var cachedContent, out var responseContent)) + { + var responseToCache = new StaticContentResponse( + originalRequestUri, + contentType, + statusCode, + statusMessage, + headers, + cachedContent, + StaticContentResponseCachePolicy.GetExpiration(cacheLifetime)); + + _staticContentResponseCache.Set(responseToCache); + } + + return new WebResourceResponse(contentType, "UTF-8", statusCode, statusMessage, headers, responseContent); + } + } + return new WebResourceResponse(contentType, "UTF-8", statusCode, statusMessage, headers, content); } else @@ -139,6 +203,15 @@ private bool ShouldOverrideUrlLoadingCore(IWebResourceRequest? request) return null; } + private static WebResourceResponse CreateWebResourceResponse(StaticContentResponse response) + => new( + response.ContentType, + "UTF-8", + response.StatusCode, + response.StatusMessage, + new Dictionary(response.Headers, StringComparer.OrdinalIgnoreCase), + new MemoryStream(response.Content, writable: false)); + public override void OnPageFinished(AWebView? view, string? url) { base.OnPageFinished(view, url); @@ -234,11 +307,12 @@ private void RunBlazorStartupScripts(AWebView view) protected override void Dispose(bool disposing) { - base.Dispose(disposing); if (disposing) { - //_webViewManager = null; + _staticContentResponseCache.Clear(); } + + base.Dispose(disposing); } private class JavaScriptValueCallback : Java.Lang.Object, IValueCallback diff --git a/src/BlazorWebView/src/Maui/BlazorWebView.cs b/src/BlazorWebView/src/Maui/BlazorWebView.cs index 3cd69927be08..2b57dc86d42f 100644 --- a/src/BlazorWebView/src/Maui/BlazorWebView.cs +++ b/src/BlazorWebView/src/Maui/BlazorWebView.cs @@ -1,6 +1,6 @@ using System; -using System.Threading.Tasks; using System.Runtime.Versioning; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Web; using Microsoft.Extensions.FileProviders; using Microsoft.Maui; @@ -67,6 +67,24 @@ public string StartPath /// public RootComponentsCollection RootComponents { get; } + /// + /// Gets or sets a callback that determines the Cache-Control header value used for static content + /// (such as images, fonts, or stylesheets) served from the app's content root. + /// + /// By default no callback is set and all served content uses no-cache, max-age=0, must-revalidate, + /// no-store, which disables WebView caching. Provide a callback to opt specific resources into caching, + /// which can avoid repeated file reads and reduce image reload flicker when navigating between pages. + /// Return or an empty string from the callback to keep the default behavior for a + /// given request. Cache entries remain subject to platform limits, expiration, and eviction. + /// + /// + /// The callback is invoked from the platform's request handling, which may run on a background thread, so it + /// must not access UI state directly. If the callback throws, the exception is logged and the request falls + /// back to the default header. + /// + /// + public Func? StaticContentCacheControlProvider { get; set; } + /// /// Allows customizing how links are opened. /// By default, opens internal links in the webview and external links in an external app. diff --git a/src/BlazorWebView/src/Maui/BlazorWebViewStaticContentRequest.cs b/src/BlazorWebView/src/Maui/BlazorWebViewStaticContentRequest.cs new file mode 100644 index 000000000000..5b10f5d5391a --- /dev/null +++ b/src/BlazorWebView/src/Maui/BlazorWebViewStaticContentRequest.cs @@ -0,0 +1,36 @@ +using System; + +namespace Microsoft.AspNetCore.Components.WebView.Maui +{ + /// + /// Describes a request for static content served by a . An instance is passed to the + /// callback set on so the application can decide + /// which Cache-Control header value to send for the resource. + /// + public sealed class BlazorWebViewStaticContentRequest + { + /// + /// Initializes a new instance of the class. + /// + /// The absolute URI of the requested static content. + /// The resolved MIME content type of the requested static content. + public BlazorWebViewStaticContentRequest(Uri uri, string contentType) + { + ArgumentNullException.ThrowIfNull(uri); + ArgumentNullException.ThrowIfNull(contentType); + + Uri = uri; + ContentType = contentType; + } + + /// + /// Gets the absolute URI of the requested static content. + /// + public Uri Uri { get; } + + /// + /// Gets the resolved MIME content type of the requested static content, for example image/png. + /// + public string ContentType { get; } + } +} diff --git a/src/BlazorWebView/src/Maui/IBlazorWebView.cs b/src/BlazorWebView/src/Maui/IBlazorWebView.cs index 4fe3b0e85620..dc1fef3bc265 100644 --- a/src/BlazorWebView/src/Maui/IBlazorWebView.cs +++ b/src/BlazorWebView/src/Maui/IBlazorWebView.cs @@ -29,6 +29,16 @@ public string StartPath /// RootComponentsCollection RootComponents { get; } + /// + /// Gets a callback that returns the Cache-Control header value to use for a static content request + /// served by the , or to use the default (which disables + /// caching). Returning or an empty string from the callback for a given request also + /// falls back to the default for that request. The callback may be invoked on a background thread, so it + /// must not access UI state directly. If the callback throws, the exception is logged and the request falls + /// back to the default header. Cache entries remain subject to platform limits, expiration, and eviction. + /// + Func? StaticContentCacheControlProvider => null; + /// /// Gets the . /// diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt index 6d5e2df8808e..48363f2319ce 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-android/PublicAPI.Unshipped.txt @@ -6,3 +6,10 @@ Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.TryDispatchAs static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder, System.Func! factory) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! static Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions.UsePlatformHandler(this Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! builder) -> Microsoft.AspNetCore.Components.WebView.Maui.IMauiBlazorWebViewBuilder! override Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewHandler.ConnectHandler(Android.Webkit.WebView! platformView) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt index 4ba43186bc90..cba6dd1e4ef1 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-ios/PublicAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.CreateFileProvider(string! contentRootDir) -> Microsoft.Extensions.FileProviders.IFileProvider! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt index 4ba43186bc90..cba6dd1e4ef1 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-maccatalyst/PublicAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.CreateFileProvider(string! contentRootDir) -> Microsoft.Extensions.FileProviders.IFileProvider! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt index 4ba43186bc90..cba6dd1e4ef1 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-tizen/PublicAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.CreateFileProvider(string! contentRootDir) -> Microsoft.Extensions.FileProviders.IFileProvider! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt index 4ba43186bc90..cba6dd1e4ef1 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net-windows/PublicAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.CreateFileProvider(string! contentRootDir) -> Microsoft.Extensions.FileProviders.IFileProvider! diff --git a/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt b/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt index 4ba43186bc90..cba6dd1e4ef1 100644 --- a/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt +++ b/src/BlazorWebView/src/Maui/PublicAPI/net/PublicAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.get -> System.Func? +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebView.StaticContentCacheControlProvider.set -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.BlazorWebViewStaticContentRequest(System.Uri! uri, string! contentType) -> void +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.ContentType.get -> string! +Microsoft.AspNetCore.Components.WebView.Maui.BlazorWebViewStaticContentRequest.Uri.get -> System.Uri! +Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebView.StaticContentCacheControlProvider.get -> System.Func? Microsoft.AspNetCore.Components.WebView.Maui.MauiBlazorWebViewBuilderExtensions Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler.CreateFileProvider(string! contentRootDir) -> Microsoft.Extensions.FileProviders.IFileProvider! diff --git a/src/BlazorWebView/src/Maui/StaticContentCacheControl.cs b/src/BlazorWebView/src/Maui/StaticContentCacheControl.cs new file mode 100644 index 000000000000..b51b2fa92fde --- /dev/null +++ b/src/BlazorWebView/src/Maui/StaticContentCacheControl.cs @@ -0,0 +1,61 @@ +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.WebView.Maui +{ + internal static class StaticContentCacheControl + { + // Historical default that disables all WebView caching of served content so that user scripts are always + // re-executed. It is applied unless the application opts a resource into caching via + // BlazorWebView.StaticContentCacheControlProvider. See https://github.com/dotnet/maui/issues/8279 + internal const string Default = "no-cache, max-age=0, must-revalidate, no-store"; + + // Returns the application-provided Cache-Control override for the request, or null to use the default. + internal static string? ResolveOverride(IBlazorWebView? blazorWebView, string requestUri, string contentType, ILogger? logger) + { + var provider = blazorWebView?.StaticContentCacheControlProvider; + if (provider is null) + { + return null; + } + + // The request handlers run on background threads, so guard against a malformed URI rather than letting + // an unexpected UriFormatException surface as a crash. If parsing fails we keep the default header. + if (!Uri.TryCreate(requestUri, UriKind.Absolute, out var uri)) + { + return null; + } + + string? cacheControl; + try + { + cacheControl = provider(new BlazorWebViewStaticContentRequest(uri, contentType)); + } + catch (Exception ex) + { + // The provider is arbitrary application code invoked from the native request-handling path. On Windows + // it runs inside an async void handler, where an escaped exception would also skip deferral.Complete() + // and hang the request. A faulty provider must not take down static asset serving, so keep the default. + logger?.StaticContentCacheControlProviderFailed(requestUri, ex); + return null; + } + + // An empty or whitespace-only string is deliberately treated like null (keep the default): such a + // Cache-Control value is non-standard and engine-dependent, and is more likely an accidental result of + // string manipulation than an intentional opt-in. Explicit directives are the supported way to enable caching. + if (string.IsNullOrWhiteSpace(cacheControl)) + { + return null; + } + + // Values containing CR/LF are also rejected: some platforms concatenate the value into a raw response + // header block, so a stray newline would produce a malformed response or allow header injection. + if (cacheControl.Contains('\r', StringComparison.Ordinal) || cacheControl.Contains('\n', StringComparison.Ordinal)) + { + return null; + } + + return cacheControl; + } + } +} diff --git a/src/BlazorWebView/src/Maui/StaticContentResponseCache.cs b/src/BlazorWebView/src/Maui/StaticContentResponseCache.cs new file mode 100644 index 000000000000..01c0c9436307 --- /dev/null +++ b/src/BlazorWebView/src/Maui/StaticContentResponseCache.cs @@ -0,0 +1,386 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.WebView.Maui +{ + internal sealed class StaticContentResponseCache + { + internal const int MaxEntrySize = 8 * 1024 * 1024; + private const int MaxEntryCount = 256; + private const long MaxTotalSize = 32L * 1024 * 1024; + + private readonly object _lock = new(); + private readonly Dictionary> _entries = new(StringComparer.Ordinal); + private readonly LinkedList _leastRecentlyUsed = new(); + private long _totalSize; + + public bool TryGet(string requestUri, out StaticContentResponse cachedResponse) + { + lock (_lock) + { + if (!_entries.TryGetValue(requestUri, out var node)) + { + cachedResponse = null!; + return false; + } + + if (node.Value.ExpiresAt <= DateTimeOffset.UtcNow) + { + Remove(node); + cachedResponse = null!; + return false; + } + + _leastRecentlyUsed.Remove(node); + _leastRecentlyUsed.AddLast(node); + cachedResponse = node.Value; + return true; + } + } + + public void Set(StaticContentResponse cachedResponse) + { + if (cachedResponse.Content.Length > MaxEntrySize) + { + return; + } + + lock (_lock) + { + if (_entries.TryGetValue(cachedResponse.RequestUri, out var existingNode)) + { + Remove(existingNode); + } + + while (_leastRecentlyUsed.Count >= MaxEntryCount || + (_leastRecentlyUsed.Count > 0 && _totalSize + cachedResponse.Content.Length > MaxTotalSize)) + { + Remove(_leastRecentlyUsed.First!); + } + + var node = _leastRecentlyUsed.AddLast(cachedResponse); + _entries.Add(cachedResponse.RequestUri, node); + _totalSize += cachedResponse.Content.Length; + } + } + + public void Remove(string requestUri) + { + lock (_lock) + { + if (_entries.TryGetValue(requestUri, out var node)) + { + Remove(node); + } + } + } + + public void Clear() + { + lock (_lock) + { + _entries.Clear(); + _leastRecentlyUsed.Clear(); + _totalSize = 0; + } + } + + private void Remove(LinkedListNode node) + { + _entries.Remove(node.Value.RequestUri); + _leastRecentlyUsed.Remove(node); + _totalSize -= node.Value.Content.Length; + } + } + + internal sealed class StaticContentResponse + { + public StaticContentResponse( + string requestUri, + string contentType, + int statusCode, + string statusMessage, + IDictionary headers, + byte[] content, + DateTimeOffset expiresAt) + { + RequestUri = requestUri; + ContentType = contentType; + StatusCode = statusCode; + StatusMessage = statusMessage; + Headers = new Dictionary(headers, StringComparer.OrdinalIgnoreCase); + Content = content; + ExpiresAt = expiresAt; + } + + public string RequestUri { get; } + public string ContentType { get; } + public int StatusCode { get; } + public string StatusMessage { get; } + public Dictionary Headers { get; } + public byte[] Content { get; } + public DateTimeOffset ExpiresAt { get; } + } + + internal static class StaticContentResponseCachePolicy + { + public static StaticContentCacheRequestBehavior GetRequestBehavior( + string? method, + IEnumerable>? headers) + { + if (!string.Equals(method, "GET", StringComparison.OrdinalIgnoreCase)) + { + return StaticContentCacheRequestBehavior.Disabled; + } + + var behavior = StaticContentCacheRequestBehavior.Default; + if (headers is not null) + { + foreach (var header in headers) + { + if (string.Equals(header.Key, "Range", StringComparison.OrdinalIgnoreCase) || + string.Equals(header.Key, "Authorization", StringComparison.OrdinalIgnoreCase)) + { + return StaticContentCacheRequestBehavior.Disabled; + } + + if (string.Equals(header.Key, "Cache-Control", StringComparison.OrdinalIgnoreCase) && + CacheControlHeaderValue.TryParse(header.Value, out var cacheControl)) + { + if (cacheControl.NoStore) + { + return StaticContentCacheRequestBehavior.Disabled; + } + + if (cacheControl.NoCache || + (cacheControl.MaxAge is TimeSpan maxAge && maxAge <= TimeSpan.Zero)) + { + behavior = StaticContentCacheRequestBehavior.Refresh; + } + } + + if (string.Equals(header.Key, "Pragma", StringComparison.OrdinalIgnoreCase) && + ContainsDirective(header.Value, "no-cache")) + { + behavior = StaticContentCacheRequestBehavior.Refresh; + } + } + } + + return behavior; + } + + public static bool TryGetCacheLifetime(string cacheControl, out TimeSpan cacheLifetime) + { + cacheLifetime = default; + + if (!CacheControlHeaderValue.TryParse(cacheControl, out var parsedCacheControl) || + parsedCacheControl.NoStore || + parsedCacheControl.NoCache || + parsedCacheControl.MaxAge is not TimeSpan maxAge || + maxAge <= TimeSpan.Zero) + { + return false; + } + + cacheLifetime = maxAge; + return true; + } + + public static DateTimeOffset GetExpiration(TimeSpan cacheLifetime) + { + var now = DateTimeOffset.UtcNow; + var maximumLifetime = DateTimeOffset.MaxValue - now; + return cacheLifetime >= maximumLifetime + ? DateTimeOffset.MaxValue + : now + cacheLifetime; + } + + private static bool ContainsDirective(string value, string directive) + { + foreach (var item in value.Split(',')) + { + if (string.Equals(item.Trim(), directive, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + } + + internal enum StaticContentCacheRequestBehavior + { + Disabled, + Default, + Refresh, + } + + internal static class StaticContentResponseBuffer + { + public static bool TryBuffer( + Stream content, + string requestUri, + ILogger? logger, + out byte[] cachedContent, + out Stream responseContent) + { + var buffer = new MemoryStream(); + var copyBuffer = new byte[81920]; + + try + { + if (content.CanSeek && content.Length - content.Position > StaticContentResponseCache.MaxEntrySize) + { + cachedContent = Array.Empty(); + responseContent = content; + return false; + } + + int bytesRead; + while ((bytesRead = content.Read(copyBuffer, 0, copyBuffer.Length)) != 0) + { + buffer.Write(copyBuffer, 0, bytesRead); + if (buffer.Length > StaticContentResponseCache.MaxEntrySize) + { + cachedContent = Array.Empty(); + responseContent = new ConcatenatedReadStream( + new MemoryStream(buffer.ToArray(), writable: false), + content); + return false; + } + } + + content.Dispose(); + cachedContent = buffer.ToArray(); + responseContent = new MemoryStream(cachedContent, writable: false); + return true; + } + catch (IOException ex) + { + logger?.LogWarning(ex, "Unable to buffer static content response for {Url}; serving it without caching.", requestUri); + cachedContent = Array.Empty(); + responseContent = new ConcatenatedReadStream( + new MemoryStream(buffer.ToArray(), writable: false), + content); + return false; + } + catch + { + content.Dispose(); + throw; + } + finally + { + buffer.Dispose(); + } + } + + public static async Task<(bool IsBuffered, byte[] CachedContent, Stream ResponseContent)> TryBufferAsync( + Stream content, + string requestUri, + ILogger? logger) + { + var buffer = new MemoryStream(); + var copyBuffer = new byte[81920]; + + try + { + if (content.CanSeek && content.Length - content.Position > StaticContentResponseCache.MaxEntrySize) + { + return (false, Array.Empty(), content); + } + + int bytesRead; + while ((bytesRead = await content.ReadAsync(copyBuffer.AsMemory())) != 0) + { + buffer.Write(copyBuffer, 0, bytesRead); + if (buffer.Length > StaticContentResponseCache.MaxEntrySize) + { + return ( + false, + Array.Empty(), + new ConcatenatedReadStream( + new MemoryStream(buffer.ToArray(), writable: false), + content)); + } + } + + content.Dispose(); + var cachedContent = buffer.ToArray(); + return (true, cachedContent, new MemoryStream(cachedContent, writable: false)); + } + catch (IOException ex) + { + logger?.LogWarning(ex, "Unable to buffer static content response for {Url}; serving it without caching.", requestUri); + return ( + false, + Array.Empty(), + new ConcatenatedReadStream( + new MemoryStream(buffer.ToArray(), writable: false), + content)); + } + catch + { + content.Dispose(); + throw; + } + finally + { + buffer.Dispose(); + } + } + + private sealed class ConcatenatedReadStream : Stream + { + private readonly Stream _prefix; + private readonly Stream _remainder; + + public ConcatenatedReadStream(Stream prefix, Stream remainder) + { + _prefix = prefix; + _remainder = remainder; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + var bytesRead = _prefix.Read(buffer, offset, count); + return bytesRead != 0 ? bytesRead : _remainder.Read(buffer, offset, count); + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _prefix.Dispose(); + _remainder.Dispose(); + } + + base.Dispose(disposing); + } + } + } +} diff --git a/src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs b/src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs index c6f5d1fa59f1..8dc3d310a6fd 100644 --- a/src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs +++ b/src/BlazorWebView/src/Maui/Tizen/BlazorWebViewHandler.Tizen.cs @@ -4,6 +4,8 @@ using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Maui; using Microsoft.Maui.Dispatching; using Microsoft.Maui.Handlers; @@ -48,6 +50,10 @@ public partial class BlazorWebViewHandler : ViewHandler> s_webviewHandlerTable = new(StringComparer.Ordinal); private TizenWebViewManager? _webviewManager; + private readonly StaticContentResponseCache _staticContentResponseCache = new(); + + private ILogger? _logger; + internal ILogger Logger => _logger ??= Services!.GetService>() ?? NullLogger.Instance; private bool RequiredStartupPropertiesSet => //_webview != null && @@ -80,6 +86,7 @@ protected override void DisconnectHandler(NWebView platformView) platformView.PageLoadFinished -= OnLoadFinished; base.DisconnectHandler(platformView); s_webviewHandlerTable.Remove(GetHashCode().ToString()); + _staticContentResponseCache.Clear(); } @@ -121,25 +128,83 @@ private void OnRequestInterceptCallback(WebHttpRequestInterceptor interceptor) var url = interceptor.Url; if (url.StartsWith(AppOrigin)) { + var cacheRequestBehavior = StaticContentResponseCachePolicy.GetRequestBehavior(interceptor.Method, interceptor.Headers); + if (_staticContentResponseCache.TryGet(url, out var cachedResponse)) + { + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Default) + { + var cachedRequestUri = QueryStringHelper.RemovePossibleQueryString(url); + Logger.HandlingWebRequest(cachedRequestUri); + Logger.ResponseContentBeingSent(cachedRequestUri, cachedResponse.StatusCode); + interceptor.SetResponse(GetHeaderString(cachedResponse), cachedResponse.Content); + return; + } + + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Refresh) + { + _staticContentResponseCache.Remove(url); + } + } + var allowFallbackOnHostPage = url.EndsWith("/"); + var originalUrl = url; url = QueryStringHelper.RemovePossibleQueryString(url); if (_webviewManager!.TryGetResponseContentInternal(url, allowFallbackOnHostPage, out var statusCode, out var statusMessage, out var content, out var headers)) { - var header = $"HTTP/1.0 200 OK\r\n"; - foreach (var item in headers) + // By default local caching is disabled so that user scripts are always re-executed. Applications can + // opt specific resources into caching via BlazorWebView.StaticContentCacheControlProvider. + // The original (unstripped) URI is passed so the provider can act on query strings (e.g. img.png?v=2). + // See https://github.com/dotnet/maui/issues/8279 + var contentType = headers.TryGetValue("Content-Type", out var resolvedContentType) ? resolvedContentType : string.Empty; + var cacheControlOverride = StaticContentCacheControl.ResolveOverride(VirtualView, originalUrl, contentType, Logger); + if (cacheControlOverride is not null) { - header += $"{item.Key}:{item.Value}\r\n"; + headers["Cache-Control"] = cacheControlOverride; } - header += "\r\n"; - MemoryStream memstream = new MemoryStream(); - content.CopyTo(memstream); - interceptor.SetResponse(header, memstream.ToArray()); + + using var memstream = new MemoryStream(); + using (content) + { + content.CopyTo(memstream); + } + var contentBytes = memstream.ToArray(); + if (statusCode == 200 && + cacheRequestBehavior != StaticContentCacheRequestBehavior.Disabled && + contentBytes.Length <= StaticContentResponseCache.MaxEntrySize && + headers.TryGetValue("Cache-Control", out var cacheControl) && + StaticContentResponseCachePolicy.TryGetCacheLifetime(cacheControl, out var cacheLifetime)) + { + _staticContentResponseCache.Set(new StaticContentResponse( + originalUrl, + contentType, + statusCode, + statusMessage, + headers, + contentBytes, + StaticContentResponseCachePolicy.GetExpiration(cacheLifetime))); + } + + interceptor.SetResponse(GetHeaderString(statusCode, statusMessage, headers), contentBytes); return; } } interceptor.Ignore(); } + private static string GetHeaderString(StaticContentResponse response) + => GetHeaderString(response.StatusCode, response.StatusMessage, response.Headers); + + private static string GetHeaderString(int statusCode, string statusMessage, IDictionary headers) + { + var header = $"HTTP/1.0 {statusCode} {statusMessage}\r\n"; + foreach (var item in headers) + { + header += $"{item.Key}:{item.Value}\r\n"; + } + + return header + "\r\n"; + } + private void StartWebViewCoreIfPossible() { if (!RequiredStartupPropertiesSet || diff --git a/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs b/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs index 0ed8171e6d02..e8b782a7c063 100644 --- a/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs +++ b/src/BlazorWebView/src/Maui/Windows/BlazorWebViewHandler.Windows.cs @@ -31,6 +31,11 @@ protected override void DisconnectHandler(WebView2Control platformView) { if (_webviewManager != null) { + if (_webviewManager is WinUIWebViewManager winUIWebViewManager) + { + winUIWebViewManager.ClearStaticContentCache(); + } + // Start the disposal... var disposalTask = _webviewManager? .DisposeAsync() diff --git a/src/BlazorWebView/src/Maui/Windows/StaticContentProvider.cs b/src/BlazorWebView/src/Maui/Windows/StaticContentProvider.cs index e033f9303a08..b0068a3def67 100644 --- a/src/BlazorWebView/src/Maui/Windows/StaticContentProvider.cs +++ b/src/BlazorWebView/src/Maui/Windows/StaticContentProvider.cs @@ -27,7 +27,7 @@ internal static IDictionary GetResponseHeaders(string contentTyp => new Dictionary(StringComparer.Ordinal) { { "Content-Type", contentType }, - { "Cache-Control", "no-cache, max-age=0, must-revalidate, no-store" }, + { "Cache-Control", StaticContentCacheControl.Default }, }; internal class FileExtensionContentTypeProvider diff --git a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs index 7fc2db874e80..594ec3fdf08c 100644 --- a/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs +++ b/src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs @@ -11,7 +11,6 @@ using Microsoft.Maui.Storage; using Microsoft.Web.WebView2.Core; using Windows.ApplicationModel; -using Windows.Storage.Streams; using WebView2Control = Microsoft.UI.Xaml.Controls.WebView2; namespace Microsoft.AspNetCore.Components.WebView.Maui @@ -28,6 +27,7 @@ internal class WinUIWebViewManager : WebView2WebViewManager private readonly string _contentRootRelativeToAppRoot; private static readonly bool _isPackagedApp; private readonly ILogger _logger; + private readonly StaticContentResponseCache _staticContentResponseCache = new(); static WinUIWebViewManager() { @@ -85,6 +85,27 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe return; } + StaticContentCacheRequestBehavior? cacheRequestBehavior = null; + if (_staticContentResponseCache.TryGet(url, out var cachedResponse)) + { + cacheRequestBehavior = StaticContentResponseCachePolicy.GetRequestBehavior( + eventArgs.Request.Method, + GetCacheRequestHeaders(eventArgs.Request.Headers)); + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Default) + { + var cachedRequestUri = QueryStringHelper.RemovePossibleQueryString(url); + _logger.HandlingWebRequest(cachedRequestUri); + _logger.ResponseContentBeingSent(cachedRequestUri, cachedResponse.StatusCode); + eventArgs.Response = CreateWebResourceResponse(cachedResponse); + return; + } + + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Refresh) + { + _staticContentResponseCache.Remove(url); + } + } + // 2. If this is an app request, then assume the request is for a Blazor resource. var requestUri = QueryStringHelper.RemovePossibleQueryString(url); if (new Uri(requestUri) is Uri uri) @@ -107,7 +128,13 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe // brings in a default implementation. if (relativePath != null && string.Equals(relativePath, "_framework/blazor.modules.json", StringComparison.Ordinal) && - await TryServeFromFolderAsync(eventArgs, allowFallbackOnHostPage: false, requestUri, relativePath)) + await TryServeFromFolderAsync( + eventArgs, + allowFallbackOnHostPage: false, + requestUri, + url, + relativePath, + cacheRequestBehavior)) { _logger.ResponseContentBeingSent(requestUri, 200); } @@ -117,6 +144,33 @@ await TryServeFromFolderAsync(eventArgs, allowFallbackOnHostPage: false, request // First, call into WebViewManager to see if it has a framework file for this request. It will // fall back to an IFileProvider, but on WinUI it's always a NullFileProvider, so that will never // return a file. + ApplyStaticContentCacheControlOverride(headers, url); + if (statusCode == 200 && + headers.TryGetValue("Cache-Control", out var cacheControl) && + StaticContentResponseCachePolicy.TryGetCacheLifetime(cacheControl, out var cacheLifetime)) + { + cacheRequestBehavior ??= StaticContentResponseCachePolicy.GetRequestBehavior( + eventArgs.Request.Method, + GetCacheRequestHeaders(eventArgs.Request.Headers)); + if (cacheRequestBehavior != StaticContentCacheRequestBehavior.Disabled) + { + var bufferedResponse = await StaticContentResponseBuffer.TryBufferAsync(content, url, _logger); + if (bufferedResponse.IsBuffered) + { + _staticContentResponseCache.Set(new StaticContentResponse( + url, + headers["Content-Type"], + statusCode, + statusMessage, + headers, + bufferedResponse.CachedContent, + StaticContentResponseCachePolicy.GetExpiration(cacheLifetime))); + } + + content = bufferedResponse.ResponseContent; + } + } + var headerString = GetHeaderString(headers); _logger.ResponseContentBeingSent(requestUri, statusCode); eventArgs.Response = _coreWebView2Environment!.CreateWebResourceResponse(content.AsRandomAccessStream(), statusCode, statusMessage, headerString); @@ -127,7 +181,9 @@ await TryServeFromFolderAsync( eventArgs, allowFallbackOnHostPage, requestUri, - relativePath); + url, + relativePath, + cacheRequestBehavior); } // Notify WebView2 that the deferred (async) operation is complete and we set a response. @@ -142,11 +198,27 @@ await TryServeFromFolderAsync( _logger.LogDebug("Request for {Url} was not handled.", url); } + // By default local caching is disabled so that user scripts are always re-executed. Applications can + // opt specific resources into caching via BlazorWebView.StaticContentCacheControlProvider. + // The original (unstripped) URI is passed so the provider can act on query strings (e.g. img.png?v=2). + // See https://github.com/dotnet/maui/issues/8279 + private void ApplyStaticContentCacheControlOverride(IDictionary headers, string originalRequestUri) + { + var contentType = headers.TryGetValue("Content-Type", out var resolvedContentType) ? resolvedContentType : string.Empty; + var cacheControlOverride = StaticContentCacheControl.ResolveOverride(_handler.VirtualView, originalRequestUri, contentType, _logger); + if (cacheControlOverride is not null) + { + headers["Cache-Control"] = cacheControlOverride; + } + } + private async Task TryServeFromFolderAsync( CoreWebView2WebResourceRequestedEventArgs eventArgs, bool allowFallbackOnHostPage, string requestUri, - string relativePath) + string originalRequestUri, + string relativePath, + StaticContentCacheRequestBehavior? cacheRequestBehavior) { // If the path does not end in a file extension (or is empty), it's most likely referring to a page, // in which case we should allow falling back on the host page. @@ -159,7 +231,7 @@ private async Task TryServeFromFolderAsync( var statusMessage = "OK"; var contentType = StaticContentProvider.GetResponseContentTypeOrDefault(relativePath); var headers = StaticContentProvider.GetResponseHeaders(contentType); - IRandomAccessStream? stream = null; + byte[]? contentBytes = null; if (_isPackagedApp) { var winUIItem = await Package.Current.InstalledLocation.TryGetItemAsync(relativePath); @@ -167,7 +239,7 @@ private async Task TryServeFromFolderAsync( if (winUIItem != null) { using var contentStream = await Package.Current.InstalledLocation.OpenStreamForReadAsync(relativePath); - stream = await CopyContentToRandomAccessStreamAsync(contentStream); + contentBytes = await ReadContentAsync(contentStream); } } else @@ -176,24 +248,45 @@ private async Task TryServeFromFolderAsync( if (path is not null && File.Exists(path)) { using var contentStream = File.OpenRead(path); - stream = await CopyContentToRandomAccessStreamAsync(contentStream); + contentBytes = await ReadContentAsync(contentStream); } } var hotReloadedContent = Stream.Null; if (StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, requestUri, ref statusCode, ref hotReloadedContent, headers)) { - stream = await CopyContentToRandomAccessStreamAsync(hotReloadedContent); + contentBytes = await ReadContentAsync(hotReloadedContent); } - if (stream != null) + if (contentBytes != null) { + ApplyStaticContentCacheControlOverride(headers, originalRequestUri); + if (statusCode == 200 && + headers.TryGetValue("Cache-Control", out var cacheControl) && + StaticContentResponseCachePolicy.TryGetCacheLifetime(cacheControl, out var cacheLifetime)) + { + cacheRequestBehavior ??= StaticContentResponseCachePolicy.GetRequestBehavior( + eventArgs.Request.Method, + GetCacheRequestHeaders(eventArgs.Request.Headers)); + if (cacheRequestBehavior != StaticContentCacheRequestBehavior.Disabled) + { + _staticContentResponseCache.Set(new StaticContentResponse( + originalRequestUri, + contentType, + statusCode, + statusMessage, + headers, + contentBytes, + StaticContentResponseCachePolicy.GetExpiration(cacheLifetime))); + } + } + var headerString = GetHeaderString(headers); _logger.ResponseContentBeingSent(requestUri, statusCode); eventArgs.Response = _coreWebView2Environment!.CreateWebResourceResponse( - stream, + new MemoryStream(contentBytes, writable: false).AsRandomAccessStream(), statusCode, statusMessage, headerString); @@ -207,13 +300,31 @@ private async Task TryServeFromFolderAsync( return false; - async Task CopyContentToRandomAccessStreamAsync(Stream content) + static async Task ReadContentAsync(Stream content) { using var memStream = new MemoryStream(); await content.CopyToAsync(memStream); - var randomAccessStream = new InMemoryRandomAccessStream(); - await randomAccessStream.WriteAsync(memStream.GetWindowsRuntimeBuffer()); - return randomAccessStream; + return memStream.ToArray(); + } + } + + internal void ClearStaticContentCache() => _staticContentResponseCache.Clear(); + + private CoreWebView2WebResourceResponse CreateWebResourceResponse(StaticContentResponse response) + => _coreWebView2Environment!.CreateWebResourceResponse( + new MemoryStream(response.Content, writable: false).AsRandomAccessStream(), + response.StatusCode, + response.StatusMessage, + GetHeaderString(response.Headers)); + + private static IEnumerable> GetCacheRequestHeaders(CoreWebView2HttpRequestHeaders headers) + { + foreach (var headerName in new[] { "Range", "Authorization", "Cache-Control", "Pragma" }) + { + if (headers.Contains(headerName)) + { + yield return new KeyValuePair(headerName, headers.GetHeader(headerName)); + } } } diff --git a/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs b/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs index 86f4786ad9c0..b709e9500210 100644 --- a/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs +++ b/src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.IO; using System.Runtime.Versioning; @@ -24,6 +25,7 @@ namespace Microsoft.AspNetCore.Components.WebView.Maui public partial class BlazorWebViewHandler : ViewHandler { private IOSWebViewManager? _webviewManager; + private readonly StaticContentResponseCache _staticContentResponseCache = new(); internal static string AppOrigin { get; } = "app://" + BlazorWebView.AppHostAddress + "/"; internal static Uri AppOriginUri { get; } = new(AppOrigin); @@ -137,6 +139,7 @@ private void MessageReceived(Uri uri, string message) protected override void DisconnectHandler(WKWebView platformView) { platformView.StopLoading(); + _staticContentResponseCache.Clear(); if (_webviewManager != null) { @@ -288,24 +291,72 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask } // 2. If this is an app request, then assume the request is for a Blazor resource. + StaticContentCacheRequestBehavior? cacheRequestBehavior = null; + if (_webViewHandler._staticContentResponseCache.TryGet(url, out var cachedResponse)) + { + cacheRequestBehavior = StaticContentResponseCachePolicy.GetRequestBehavior( + urlSchemeTask.Request.HttpMethod, + GetRequestHeaders(urlSchemeTask.Request)); + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Default) + { + var cachedRequestUri = QueryStringHelper.RemovePossibleQueryString(url); + logger.HandlingWebRequest(cachedRequestUri); + logger.ResponseContentBeingSent(cachedRequestUri, cachedResponse.StatusCode); + SendResponse(urlSchemeTask, cachedResponse); + return; + } + + if (cacheRequestBehavior == StaticContentCacheRequestBehavior.Refresh) + { + _webViewHandler._staticContentResponseCache.Remove(url); + } + } + var responseBytes = GetResponseBytes(url, out var contentType, statusCode: out var statusCode); if (statusCode == 200) { - using (var dic = new NSMutableDictionary()) + // By default local caching is disabled so that user scripts are always re-executed. Applications can + // opt specific resources into caching via BlazorWebView.StaticContentCacheControlProvider. + // The original (unstripped) URI is passed so the provider can act on query strings (e.g. img.png?v=2). + // See https://github.com/dotnet/maui/issues/8279 + var cacheControl = StaticContentCacheControl.ResolveOverride(_webViewHandler.VirtualView, url, contentType, logger) + ?? StaticContentCacheControl.Default; + + var cacheLifetime = default(TimeSpan); + var shouldCache = StaticContentResponseCachePolicy.TryGetCacheLifetime(cacheControl, out cacheLifetime); + if (shouldCache) { - dic.Add((NSString)"Content-Length", (NSString)responseBytes.Length.ToString(CultureInfo.InvariantCulture)); - dic.Add((NSString)"Content-Type", (NSString)contentType); - // Disable local caching. This will prevent user scripts from executing correctly. - dic.Add((NSString)"Cache-Control", (NSString)"no-cache, max-age=0, must-revalidate, no-store"); - if (urlSchemeTask.Request.Url != null) - { - using var response = new NSHttpUrlResponse(urlSchemeTask.Request.Url, statusCode, "HTTP/1.1", dic); - urlSchemeTask.DidReceiveResponse(response); - } + cacheRequestBehavior ??= StaticContentResponseCachePolicy.GetRequestBehavior( + urlSchemeTask.Request.HttpMethod, + GetRequestHeaders(urlSchemeTask.Request)); + shouldCache = cacheRequestBehavior != StaticContentCacheRequestBehavior.Disabled; + } + if (shouldCache) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Content-Type"] = contentType, + ["Cache-Control"] = cacheControl, + }; + var response = new StaticContentResponse( + url, + contentType, + statusCode, + "OK", + headers, + responseBytes, + StaticContentResponseCachePolicy.GetExpiration(cacheLifetime)); + + _webViewHandler._staticContentResponseCache.Set(response); + SendResponse(urlSchemeTask, response); + } + else + { + SendResponse(urlSchemeTask, statusCode, contentType, cacheControl, responseBytes); } - urlSchemeTask.DidReceiveData(NSData.FromArray(responseBytes)); - urlSchemeTask.DidFinish(); + + return; } // 3. If the request is not handled by the app nor is it a local source, then we let the WKWebView @@ -315,6 +366,56 @@ public void StartUrlSchemeTask(WKWebView webView, IWKUrlSchemeTask urlSchemeTask logger.LogDebug("Request for {Url} was not handled.", url); } + private static IEnumerable> GetRequestHeaders(NSUrlRequest request) + { + var headers = request.Headers; + if (headers is null) + { + yield break; + } + + foreach (var key in headers.Keys) + { + var value = headers[key]; + if (key is not null && value is not null) + { + yield return new KeyValuePair(key.ToString(), value.ToString()); + } + } + } + + private static void SendResponse(IWKUrlSchemeTask urlSchemeTask, StaticContentResponse response) + => SendResponse( + urlSchemeTask, + response.StatusCode, + response.ContentType, + response.Headers["Cache-Control"], + response.Content); + + private static void SendResponse( + IWKUrlSchemeTask urlSchemeTask, + int statusCode, + string contentType, + string cacheControl, + byte[] content) + { + using (var headers = new NSMutableDictionary()) + { + headers.Add((NSString)"Content-Length", (NSString)content.Length.ToString(CultureInfo.InvariantCulture)); + headers.Add((NSString)"Content-Type", (NSString)contentType); + headers.Add((NSString)"Cache-Control", (NSString)cacheControl); + if (urlSchemeTask.Request.Url != null) + { + using var urlResponse = new NSHttpUrlResponse(urlSchemeTask.Request.Url, statusCode, "HTTP/1.1", headers); + urlSchemeTask.DidReceiveResponse(urlResponse); + } + } + + using var data = NSData.FromArray(content); + urlSchemeTask.DidReceiveData(data); + urlSchemeTask.DidFinish(); + } + private byte[] GetResponseBytes(string? url, out string contentType, out int statusCode) { var allowFallbackOnHostPage = AppOriginUri.IsBaseOfPage(url); diff --git a/src/BlazorWebView/src/SharedSource/Log.cs b/src/BlazorWebView/src/SharedSource/Log.cs index 8865618cb3dd..f39898172a06 100644 --- a/src/BlazorWebView/src/SharedSource/Log.cs +++ b/src/BlazorWebView/src/SharedSource/Log.cs @@ -61,4 +61,7 @@ internal static partial class Log [LoggerMessage(EventId = 18, Level = LogLevel.Debug, Message = "Created WebKit WKWebView.")] public static partial void CreatedWebKitWKWebView(this ILogger logger); + + [LoggerMessage(EventId = 19, Level = LogLevel.Error, Message = "The StaticContentCacheControlProvider threw an exception for request '{requestUri}'. Falling back to the default Cache-Control header.")] + public static partial void StaticContentCacheControlProviderFailed(this ILogger logger, string requestUri, Exception exception); } diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs new file mode 100644 index 000000000000..9d604d21d2b2 --- /dev/null +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.StaticContentCaching.cs @@ -0,0 +1,655 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.WebView.Maui; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Maui.MauiBlazorWebView.DeviceTests.Components; +using Xunit; + +namespace Microsoft.Maui.MauiBlazorWebView.DeviceTests.Elements; + +public partial class BlazorWebViewTests +{ + const string CacheControlTestFilePath = "cache-control-test.txt"; + const string CacheControlTestFileContents = "static asset used by the cache-control tests"; + const string CacheControlTestImagePath = "cache-control-test.svg"; + const string CacheControlTestImageContents = """ + + + + + + + + + + + .NET MAUI + cached static image + + """; + + // Each test fetches a unique URL (path + query): the WebView HTTP cache is shared for the app origin across + // BlazorWebView instances, so a response cached by one test must not be able to satisfy another test's fetch and + // skip its provider invocation. The cacheable override test additionally uses a per-run nonce because that cache + // also persists across runs on a device, so a fixed URL could be served from a prior run's max-age=3600 response. + + [Fact] + public async Task StaticContentCacheControlProviderCanOverrideCacheControlHeader() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvokedForTarget = false; + + var cacheControl = await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + providerInvokedForTarget = true; + return "max-age=3600"; + } + return null; + }, + fetchQueryString: $"?test=override&nonce={nonce}"); + + // The nonce makes this a guaranteed cache miss, so a passing header assertion cannot come from a stale cached + // response: the provider must have run for the requested resource. + Assert.True(providerInvokedForTarget, "The provider was not invoked for the requested resource - the response was likely served from the WebView cache."); + Assert.Equal("max-age=3600", cacheControl); + } + + [Fact] + public async Task StaticContentCacheControlProviderAllowsRepeatedRequestToUseWebViewCache() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvocationCount = 0; + var fileReadCount = 0; + + var cacheControl = await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + return "public, max-age=3600"; + } + return null; + }, + fetchQueryString: $"?test=repeated-request&nonce={nonce}", + fetchCount: 2, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal("public, max-age=3600", cacheControl); + Assert.Equal(1, providerInvocationCount); + Assert.Equal(1, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderDoesNotCacheNoStoreResponse() + { + var providerInvocationCount = 0; + var fileReadCount = 0; + + var cacheControl = await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + return "no-store"; + } + return null; + }, + fetchQueryString: "?test=repeated-no-store", + fetchCount: 2, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal("no-store", cacheControl); + Assert.Equal(2, providerInvocationCount); + Assert.Equal(2, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderExpiresResponse() + { + var providerInvocationCount = 0; + var fileReadCount = 0; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + return "public, max-age=1"; + } + return null; + }, + fetchQueryString: "?test=expired", + fetchCount: 2, + delayBetweenFetchesMilliseconds: 1200, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal(2, providerInvocationCount); + Assert.Equal(2, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderDoesNotCacheNoCacheResponse() + { + var providerInvocationCount = 0; + var fileReadCount = 0; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + return "no-cache, max-age=3600"; + } + return null; + }, + fetchQueryString: "?test=repeated-no-cache", + fetchCount: 2, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal(2, providerInvocationCount); + Assert.Equal(2, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderHonorsRequestNoCache() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvocationCount = 0; + var fileReadCount = 0; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + return "public, max-age=3600"; + } + return null; + }, + fetchQueryString: $"?test=request-no-cache&nonce={nonce}", + fetchCount: 3, + noCacheRequestIndex: 1, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal(2, providerInvocationCount); + Assert.Equal(2, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderRefreshRemovesStaleResponse() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvocationCount = 0; + var fileReadCount = 0; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + return Interlocked.Increment(ref providerInvocationCount) == 1 + ? "public, max-age=3600" + : "no-store"; + } + return null; + }, + fetchQueryString: $"?test=refresh-no-store&nonce={nonce}", + fetchCount: 3, + noCacheRequestIndex: 1, + fileOpened: path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }); + + Assert.Equal(3, providerInvocationCount); + Assert.Equal(3, fileReadCount); + } + + [Fact] + public async Task StaticContentCacheControlProviderAuthorizationDisablesRefreshRegardlessOfHeaderOrder() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvocationCount = 0; + var fileReadCount = 0; + + EnsureHandlerCreated(builder => + { + builder.Services.AddMauiBlazorWebView(); + }); + + var blazorWebView = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + { CacheControlTestFilePath, CacheControlTestFileContents }, + }, + StaticContentCacheControlProvider = request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + return Interlocked.Increment(ref providerInvocationCount) == 2 + ? "no-store" + : "public, max-age=3600"; + } + return null; + }, + FileContentsOverride = path => + { + if (path.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + return $"content-{Interlocked.Increment(ref fileReadCount)}"; + } + return null; + }, + }; + + blazorWebView.RootComponents.Add(new RootComponent + { + ComponentType = typeof(NoOpComponent), + Selector = "#app" + }); + + string results = null; + + await AttachAndRun(blazorWebView, async handler => + { + var platformWebView = ((BlazorWebViewHandler)handler).PlatformView; + + await WebViewHelpers.WaitForWebViewReady(platformWebView); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + + results = await WebViewHelpers.ExecuteAsyncScriptAndWaitForResult(platformWebView, + $$""" + const requestUrl = '/{{CacheControlTestFilePath}}?test=request-header-order&nonce={{nonce}}'; + const first = await (await fetch(requestUrl)).text(); + const authorizedRefresh = await (await fetch(requestUrl, { + headers: { + 'Cache-Control': 'no-cache', + 'Authorization': 'Bearer cache-test' + } + })).text(); + const cached = await (await fetch(requestUrl)).text(); + return [first, authorizedRefresh, cached].join('|'); + """); + }); + + Assert.Equal("content-1|content-2|content-1", results); + Assert.Equal(2, providerInvocationCount); + Assert.Equal(2, fileReadCount); + } + +#if ANDROID + [Fact] + public async Task StaticContentCacheControlProviderReusesAndroidImageAfterDecodedCachePressure() + { + var nonce = Guid.NewGuid().ToString("N"); + var providerInvocationCount = 0; + var fileReadCount = 0; + + EnsureHandlerCreated(builder => + { + builder.Services.AddMauiBlazorWebView(); + }); + + var blazorWebView = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + WidthRequest = 320, + HeightRequest = 440, + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + { CacheControlTestImagePath, CacheControlTestImageContents }, + }, + FileOpened = path => + { + if (path.EndsWith(CacheControlTestImagePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref fileReadCount); + } + }, + StaticContentCacheControlProvider = request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestImagePath, StringComparison.Ordinal)) + { + Interlocked.Increment(ref providerInvocationCount); + Thread.Sleep(500); + return "public, max-age=3600"; + } + return null; + }, + }; + + blazorWebView.RootComponents.Add(new RootComponent + { + ComponentType = typeof(NoOpComponent), + Selector = "#app" + }); + + ImageLoadTimings timings = null; + + await AttachAndRun(blazorWebView, async handler => + { + var platformWebView = ((BlazorWebViewHandler)handler).PlatformView; + + await WebViewHelpers.WaitForWebViewReady(platformWebView); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + + timings = await WebViewHelpers.ExecuteAsyncScriptAndWaitForResult(platformWebView, + $$""" + const imageUrl = '/{{CacheControlTestImagePath}}?test=image-reinsert&nonce={{nonce}}'; + const container = document.createElement('div'); + container.style.width = '320px'; + container.style.height = '200px'; + document.body.appendChild(container); + + async function loadImage() { + const image = new Image(); + image.style.width = '320px'; + image.style.height = '200px'; + const loaded = new Promise((resolve, reject) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', () => reject(new Error('Image failed to load')), { once: true }); + }); + const started = performance.now(); + image.src = imageUrl; + container.replaceChildren(image); + await loaded; + if (image.decode) { + await image.decode(); + } + return performance.now() - started; + } + + async function loadChurnImage(url, host) { + const image = new Image(); + image.style.width = '800px'; + image.style.height = '500px'; + const loaded = new Promise((resolve, reject) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', () => reject(new Error('Churn image failed to load')), { once: true }); + }); + image.src = url; + host.appendChild(image); + await loaded; + if (image.decode) { + await image.decode(); + } + } + + function createChurnImage(index) { + const canvas = document.createElement('canvas'); + canvas.width = 800; + canvas.height = 500; + const context = canvas.getContext('2d'); + const gradient = context.createLinearGradient(0, 0, 800, 500); + gradient.addColorStop(0, 'hsl(' + index * 47 % 360 + ',80%,45%)'); + gradient.addColorStop(1, 'hsl(' + index * 83 % 360 + ',80%,65%)'); + context.fillStyle = gradient; + context.fillRect(0, 0, 800, 500); + context.fillStyle = 'white'; + context.font = 'bold 120px sans-serif'; + context.fillText(String(index), 280, 300); + return canvas.toDataURL('image/png'); + } + + const firstLoadMilliseconds = await loadImage(); + container.replaceChildren(); + const churnHost = document.createElement('div'); + churnHost.style.cssText = 'position:fixed;left:-10000px;top:0;width:800px;height:500px'; + document.body.appendChild(churnHost); + for (let churnIndex = 0; churnIndex < 64; churnIndex++) { + await loadChurnImage(createChurnImage(churnIndex), churnHost); + } + await new Promise(resolve => setTimeout(resolve, 300)); + churnHost.remove(); + await new Promise(resolve => setTimeout(resolve, 300)); + const secondLoadMilliseconds = await loadImage(); + return { firstLoadMilliseconds, secondLoadMilliseconds }; + """); + }); + + Assert.NotNull(timings); + Output.WriteLine($"Image load timings: first={timings.firstLoadMilliseconds:F1}ms, cached={timings.secondLoadMilliseconds:F1}ms"); + Assert.Equal(1, providerInvocationCount); + Assert.Equal(1, fileReadCount); + Assert.True( + timings.firstLoadMilliseconds - timings.secondLoadMilliseconds >= 250, + $"Expected the cached image load to avoid the simulated 500ms source delay. First: {timings.firstLoadMilliseconds:F1}ms; second: {timings.secondLoadMilliseconds:F1}ms."); + } + + sealed class ImageLoadTimings + { + public double firstLoadMilliseconds { get; set; } + public double secondLoadMilliseconds { get; set; } + } + +#endif + + [Fact] + public async Task StaticContentCacheControlProviderReturningNullKeepsDefaultNoStore() + { + // Returning null from the provider must preserve the historical default so that the change is non-breaking. + var cacheControl = await GetServedCacheControlHeaderAsync(_ => null, fetchQueryString: "?test=null-provider"); + + Assert.Contains("no-store", cacheControl, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderReturningEmptyStringKeepsDefaultNoStore() + { + // An empty string is treated the same as null: an empty Cache-Control header value is non-standard and + // more likely accidental than an intentional opt-in, so the safe default is preserved. + var cacheControl = await GetServedCacheControlHeaderAsync(_ => string.Empty, fetchQueryString: "?test=empty-provider"); + + Assert.Contains("no-store", cacheControl, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderReturningWhitespaceKeepsDefaultNoStore() + { + // A whitespace-only value is treated the same as null/empty: it is a non-standard, meaningless Cache-Control + // value that is far more likely an accidental result of string manipulation than an intentional opt-in. + var cacheControl = await GetServedCacheControlHeaderAsync(_ => " ", fetchQueryString: "?test=whitespace-provider"); + + Assert.Contains("no-store", cacheControl, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderReturningValueWithNewlinesKeepsDefaultNoStore() + { + // Values containing CR/LF are rejected in favor of the default: some platforms concatenate the value into + // a raw response header block, where a newline would produce a malformed response or allow header injection. + var cacheControl = await GetServedCacheControlHeaderAsync(_ => "max-age=3600\r\nX-Injected: 1", fetchQueryString: "?test=newline-provider"); + + Assert.Contains("no-store", cacheControl, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderThrowingKeepsDefaultNoStore() + { + // A provider that throws must not crash or hang static asset serving: the exception is caught and logged, + // and the request falls back to the historical default header. + var cacheControl = await GetServedCacheControlHeaderAsync( + _ => throw new InvalidOperationException("provider failure"), + fetchQueryString: "?test=throwing-provider"); + + Assert.Contains("no-store", cacheControl, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderReceivesResolvedContentType() + { + string observedContentType = null; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + observedContentType = request.ContentType; + } + return null; + }, + fetchQueryString: "?test=content-type"); + + Assert.Equal("text/plain", observedContentType); + } + + [Fact] + public async Task StaticContentCacheControlProviderReceivesQueryString() + { + // The provider must receive the original request URI including the query string so that apps can make + // cache-busting decisions based on versioned URLs (e.g. img.png?v=2). The query is only stripped when + // resolving the file on disk. See https://github.com/dotnet/maui/issues/8279 + Uri observedUri = null; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith(CacheControlTestFilePath, StringComparison.Ordinal)) + { + observedUri = request.Uri; + } + return null; + }, + fetchQueryString: "?v=2"); + + Assert.NotNull(observedUri); + Assert.Contains("v=2", observedUri.Query, StringComparison.Ordinal); + } + + [Fact] + public async Task StaticContentCacheControlProviderReceivesQueryStringForFolderServedContent() + { + // On WinUI, _framework/blazor.modules.json is served through the folder-serving path + // (WinUIWebViewManager.TryServeFromFolderAsync) rather than the in-memory file provider that backs the + // other static assets in these tests. That path must also pass the original request URI (including the + // query string) to the provider, otherwise apps cannot make cache-busting decisions for folder-served + // content. See https://github.com/dotnet/maui/issues/8279 + Uri observedUri = null; + + await GetServedCacheControlHeaderAsync( + request => + { + if (request.Uri.AbsolutePath.EndsWith("blazor.modules.json", StringComparison.Ordinal)) + { + observedUri = request.Uri; + } + return null; + }, + fetchPath: "_framework/blazor.modules.json", + fetchQueryString: "?v=2"); + + Assert.NotNull(observedUri); + Assert.Contains("v=2", observedUri.Query, StringComparison.Ordinal); + } + + private async Task GetServedCacheControlHeaderAsync( + Func provider, + string fetchPath = CacheControlTestFilePath, + string fetchQueryString = "", + int fetchCount = 1, + int delayBetweenFetchesMilliseconds = 0, + int noCacheRequestIndex = -1, + Action fileOpened = null) + { + EnsureHandlerCreated(builder => + { + builder.Services.AddMauiBlazorWebView(); + }); + + var blazorWebView = new BlazorWebViewWithCustomFiles + { + HostPage = "wwwroot/index.html", + CustomFiles = new Dictionary + { + { "index.html", TestStaticFilesContents.DefaultMauiIndexHtmlContent }, + { CacheControlTestFilePath, CacheControlTestFileContents }, + }, + StaticContentCacheControlProvider = provider, + FileOpened = fileOpened, + }; + + blazorWebView.RootComponents.Add(new RootComponent + { + ComponentType = typeof(NoOpComponent), + Selector = "#app" + }); + + string cacheControl = null; + + await AttachAndRun(blazorWebView, async handler => + { + var blazorWebViewHandler = handler as BlazorWebViewHandler; + var platformWebView = blazorWebViewHandler.PlatformView; + + await WebViewHelpers.WaitForWebViewReady(platformWebView); + await WebViewHelpers.WaitForControlDiv(platformWebView, controlValueToWaitFor: "Static"); + + cacheControl = await WebViewHelpers.ExecuteAsyncScriptAndWaitForResult(platformWebView, + $$""" + let cacheControl = null; + for (let requestIndex = 0; requestIndex < {{fetchCount}}; requestIndex++) { + const requestOptions = requestIndex === {{noCacheRequestIndex}} + ? { headers: { 'Cache-Control': 'no-cache' } } + : undefined; + const response = await fetch('/{{fetchPath}}{{fetchQueryString}}', requestOptions); + cacheControl = response.headers.get('cache-control'); + await response.text(); + if (requestIndex + 1 < {{fetchCount}} && {{delayBetweenFetchesMilliseconds}} > 0) { + await new Promise(resolve => setTimeout(resolve, {{delayBetweenFetchesMilliseconds}})); + } + } + return cacheControl; + """); + }); + + return cacheControl; + } +} diff --git a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.cs b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.cs index 62b02014d689..681b0ad4a956 100644 --- a/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.cs +++ b/src/BlazorWebView/tests/DeviceTests/Elements/BlazorWebViewTests.cs @@ -1,6 +1,10 @@ +using System; using System.Collections.Generic; +using System.IO; +using System.Text; using Microsoft.AspNetCore.Components.WebView.Maui; using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; using WebViewAppShared; using Xunit.Abstractions; @@ -19,6 +23,8 @@ public BlazorWebViewTests(ITestOutputHelper output) sealed class BlazorWebViewWithCustomFiles : BlazorWebView { public Dictionary CustomFiles { get; set; } + public Action FileOpened { get; set; } + public Func FileContentsOverride { get; set; } public override IFileProvider CreateFileProvider(string contentRootDir) { @@ -30,12 +36,75 @@ public override IFileProvider CreateFileProvider(string contentRootDir) fileContentsMap: CustomFiles, // The contentRoot is ignored here because in WinForms it would include the absolute physical path to the app's content, which this provider doesn't care about contentRoot: null); + IFileProvider customFileProvider = FileOpened is null && FileContentsOverride is null + ? inMemoryFiles + : new ObservingFileProvider(inMemoryFiles, FileOpened, FileContentsOverride); var baseFileProvider = base.CreateFileProvider(contentRootDir); return baseFileProvider == null - ? inMemoryFiles - : new CompositeFileProvider(inMemoryFiles, baseFileProvider); + ? customFileProvider + : new CompositeFileProvider(customFileProvider, baseFileProvider); + } + + sealed class ObservingFileProvider : IFileProvider + { + readonly IFileProvider _inner; + readonly Action _fileOpened; + readonly Func _fileContentsOverride; + + public ObservingFileProvider(IFileProvider inner, Action fileOpened, Func fileContentsOverride) + { + _inner = inner; + _fileOpened = fileOpened; + _fileContentsOverride = fileContentsOverride; + } + + public IDirectoryContents GetDirectoryContents(string subpath) => _inner.GetDirectoryContents(subpath); + + public IFileInfo GetFileInfo(string subpath) + => new ObservingFileInfo(_inner.GetFileInfo(subpath), subpath, _fileOpened, _fileContentsOverride); + + public IChangeToken Watch(string filter) => _inner.Watch(filter); + } + + sealed class ObservingFileInfo : IFileInfo + { + readonly IFileInfo _inner; + readonly string _subpath; + readonly Action _fileOpened; + readonly Func _fileContentsOverride; + + public ObservingFileInfo( + IFileInfo inner, + string subpath, + Action fileOpened, + Func fileContentsOverride) + { + _inner = inner; + _subpath = subpath; + _fileOpened = fileOpened; + _fileContentsOverride = fileContentsOverride; + } + + public bool Exists => _inner.Exists; + public long Length => _inner.Length; + public string PhysicalPath => _inner.PhysicalPath; + public string Name => _inner.Name; + public DateTimeOffset LastModified => _inner.LastModified; + public bool IsDirectory => _inner.IsDirectory; + + public Stream CreateReadStream() + { + _fileOpened?.Invoke(_subpath); + var contentsOverride = _fileContentsOverride?.Invoke(_subpath); + if (contentsOverride is not null) + { + return new MemoryStream(Encoding.UTF8.GetBytes(contentsOverride)); + } + + return _inner.CreateReadStream(); + } } } diff --git a/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs b/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs index 5be373be3f36..102b82094404 100644 --- a/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs +++ b/src/BlazorWebView/tests/DeviceTests/WebViewHelpers.Shared.cs @@ -73,7 +73,17 @@ await ExecuteScriptAsync(webView, // Deserialize the result from controlDiv if (TryDeserialize(result, out var value)) + { + // A bare string result is double-encoded: the page JSON.stringify's it and the platform + // bridge serializes it again when read back, so a single deserialize leaves a wrapping + // layer of quotes. Peel that one bridge-added layer so callers get the raw value. + if (value is string str) + { + if (TryDeserialize(str, out var peeled) && peeled is not null) + return (T)(object)peeled; + } return value; + } // sometimes the result is serialized by the platform, so we need to deserialize it as a string first if (TryDeserialize(result, out var resultString))