Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Static content hot reload (CSS only for now) #6097

Merged
merged 10 commits into from
Apr 14, 2022
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ internal class AndroidWebKitWebViewManager : WebViewManager
private static readonly string AppOrigin = $"https://{BlazorWebView.AppHostAddress}/";
private static readonly AUri AndroidAppOriginUri = AUri.Parse(AppOrigin)!;
private readonly AWebView _webview;
private readonly string _contentRootRelativeToAppRoot;
private WebMessagePort[]? _nativeToJSPorts;

/// <summary>
Expand All @@ -32,8 +33,9 @@ internal class AndroidWebKitWebViewManager : WebViewManager
/// <param name="services">A service provider containing services to be used by this class and also by application code.</param>
/// <param name="dispatcher">A <see cref="Dispatcher"/> instance that can marshal calls to the required thread or sync context.</param>
/// <param name="fileProvider">Provides static content to the webview.</param>
/// <param name="contentRootRelativeToAppRoot">Path to the directory containing application content files.</param>
/// <param name="hostPageRelativePath">Path to the host page within the <paramref name="fileProvider"/>.</param>
public AndroidWebKitWebViewManager(AWebView webview!!, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath)
public AndroidWebKitWebViewManager(AWebView webview!!, IServiceProvider services, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string contentRootRelativeToAppRoot, string hostPageRelativePath)
: base(services, dispatcher, new Uri(AppOrigin), fileProvider, jsComponents, hostPageRelativePath)
{
#if WEBVIEW2_MAUI
Expand All @@ -45,6 +47,7 @@ public AndroidWebKitWebViewManager(AWebView webview!!, IServiceProvider services
}
#endif
_webview = webview;
_contentRootRelativeToAppRoot = contentRootRelativeToAppRoot;
}

/// <inheritdoc />
Expand All @@ -59,8 +62,12 @@ protected override void SendMessage(string message)
_webview.PostWebMessage(new WebMessage(message), AndroidAppOriginUri);
}

internal bool TryGetResponseContentInternal(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out IDictionary<string, string> headers) =>
TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers);
internal bool TryGetResponseContentInternal(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out IDictionary<string, string> headers)
{
var defaultResult = TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers);
var hotReloadedResult = StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, uri, ref statusCode, ref content, headers);
return defaultResult || hotReloadedResult;
}

internal void SetUpMessageChannel()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,11 @@ private void StartWebViewCoreIfPossible()
new MauiDispatcher(Services!.GetRequiredService<IDispatcher>()),
fileProvider,
VirtualView.JSComponents,
contentRootDir,
hostPageRelativePath);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager);

VirtualView.BlazorWebViewInitializing(new BlazorWebViewInitializingEventArgs());
VirtualView.BlazorWebViewInitialized(new BlazorWebViewInitializedEventArgs
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,12 @@ private void StartWebViewCoreIfPossible()
new MauiDispatcher(Services!.GetRequiredService<IDispatcher>()),
fileProvider,
VirtualView.JSComponents,
hostPageRelativePath,
contentRootDir,
hostPageRelativePath,
this);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager);

if (RootComponents != null)
{
foreach (var rootComponent in RootComponents)
Expand Down
30 changes: 20 additions & 10 deletions src/BlazorWebView/src/Maui/Windows/WinUIWebViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ internal class WinUIWebViewManager : WebView2WebViewManager
{
private readonly WebView2Control _webview;
private readonly string _hostPageRelativePath;
private readonly string _contentRootDir;
private readonly string _contentRootRelativeToAppRoot;
private static readonly bool _isPackagedApp;

static WinUIWebViewManager()
Expand All @@ -43,23 +43,23 @@ static WinUIWebViewManager()
/// <param name="dispatcher">A <see cref="Dispatcher"/> instance that can marshal calls to the required thread or sync context.</param>
/// <param name="fileProvider">Provides static content to the webview.</param>
/// <param name="jsComponents">The <see cref="JSComponentConfigurationStore"/>.</param>
/// <param name="hostPageRelativePath">Path to the host page within the <paramref name="fileProvider"/>.</param>
/// <param name="contentRootDir">Path to the directory containing application content files.</param>
/// <param name="contentRootRelativeToAppRoot">Path to the directory containing application content files.</param>
/// <param name="hostPagePathWithinFileProvider">Path to the host page within the <paramref name="fileProvider"/>.</param>
/// <param name="webViewHandler">The <see cref="BlazorWebViewHandler" />.</param>
public WinUIWebViewManager(
WebView2Control webview,
IServiceProvider services,
Dispatcher dispatcher,
IFileProvider fileProvider,
JSComponentConfigurationStore jsComponents,
string hostPageRelativePath,
string contentRootDir,
string contentRootRelativeToAppRoot,
string hostPagePathWithinFileProvider,
BlazorWebViewHandler webViewHandler)
: base(webview, services, dispatcher, fileProvider, jsComponents, hostPageRelativePath, webViewHandler)
: base(webview, services, dispatcher, fileProvider, jsComponents, contentRootRelativeToAppRoot, hostPagePathWithinFileProvider, webViewHandler)
{
_webview = webview;
_hostPageRelativePath = hostPageRelativePath;
_contentRootDir = contentRootDir;
_hostPageRelativePath = hostPagePathWithinFileProvider;
_contentRootRelativeToAppRoot = contentRootRelativeToAppRoot;
}

/// <inheritdoc />
Expand Down Expand Up @@ -103,12 +103,11 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe
{
relativePath = _hostPageRelativePath;
}
relativePath = Path.Combine(_contentRootDir, relativePath.Replace('/', '\\'));
relativePath = Path.Combine(_contentRootRelativeToAppRoot, relativePath.Replace('/', '\\'));
statusCode = 200;
statusMessage = "OK";
var contentType = StaticContentProvider.GetResponseContentTypeOrDefault(relativePath);
headers = StaticContentProvider.GetResponseHeaders(contentType);
var headerString = GetHeaderString(headers);
IRandomAccessStream? stream = null;
if (_isPackagedApp)
{
Expand All @@ -133,8 +132,19 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe
await stream.WriteAsync(memStream.GetWindowsRuntimeBuffer());
}
}

var hotReloadedContent = Stream.Null;
if (StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, requestUri, ref statusCode, ref hotReloadedContent, headers))
{
stream = new InMemoryRandomAccessStream();
var memStream = new MemoryStream();
hotReloadedContent.CopyTo(memStream);
await stream.WriteAsync(memStream.GetWindowsRuntimeBuffer());
}

if (stream != null)
{
var headerString = GetHeaderString(headers);
eventArgs.Response = _coreWebView2Environment!.CreateWebResourceResponse(
stream,
statusCode,
Expand Down
3 changes: 3 additions & 0 deletions src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,11 @@ private void StartWebViewCoreIfPossible()
new MauiDispatcher(Services!.GetRequiredService<IDispatcher>()),
fileProvider,
VirtualView.JSComponents,
contentRootDir,
hostPageRelativePath);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager);

if (RootComponents != null)
{
foreach (var rootComponent in RootComponents)
Expand Down
13 changes: 10 additions & 3 deletions src/BlazorWebView/src/Maui/iOS/IOSWebViewManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal class IOSWebViewManager : WebViewManager
{
private readonly BlazorWebViewHandler _blazorMauiWebViewHandler;
private readonly WKWebView _webview;
private readonly string _contentRootRelativeToAppRoot;

/// <summary>
/// Initializes a new instance of <see cref="IOSWebViewManager"/>
Expand All @@ -30,8 +31,9 @@ internal class IOSWebViewManager : WebViewManager
/// <param name="dispatcher">A <see cref="Dispatcher"/> instance instance that can marshal calls to the required thread or sync context.</param>
/// <param name="fileProvider">Provides static content to the webview.</param>
/// <param name="jsComponents">Describes configuration for adding, removing, and updating root components from JavaScript code.</param>
/// <param name="contentRootRelativeToAppRoot">Path to the directory containing application content files.</param>
/// <param name="hostPageRelativePath">Path to the host page within the fileProvider.</param>
public IOSWebViewManager(BlazorWebViewHandler blazorMauiWebViewHandler!!, WKWebView webview!!, IServiceProvider provider, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string hostPageRelativePath)
public IOSWebViewManager(BlazorWebViewHandler blazorMauiWebViewHandler!!, WKWebView webview!!, IServiceProvider provider, Dispatcher dispatcher, IFileProvider fileProvider, JSComponentConfigurationStore jsComponents, string contentRootRelativeToAppRoot, string hostPageRelativePath)
: base(provider, dispatcher, new Uri(BlazorWebViewHandler.AppOrigin), fileProvider, jsComponents, hostPageRelativePath)
{
if (provider.GetService<MauiBlazorMarkerService>() is null)
Expand All @@ -43,6 +45,7 @@ public IOSWebViewManager(BlazorWebViewHandler blazorMauiWebViewHandler!!, WKWebV

_blazorMauiWebViewHandler = blazorMauiWebViewHandler;
_webview = webview;
_contentRootRelativeToAppRoot = contentRootRelativeToAppRoot;

InitializeWebView();
}
Expand All @@ -55,8 +58,12 @@ protected override void NavigateCore(Uri absoluteUri)
_webview.LoadRequest(request);
}

internal bool TryGetResponseContentInternal(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out IDictionary<string, string> headers) =>
TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers);
internal bool TryGetResponseContentInternal(string uri, bool allowFallbackOnHostPage, out int statusCode, out string statusMessage, out Stream content, out IDictionary<string, string> headers)
{
var defaultResult = TryGetResponseContent(uri, allowFallbackOnHostPage, out statusCode, out statusMessage, out content, out headers);
var hotReloadedResult = StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, uri, ref statusCode, ref content, headers);
return defaultResult || hotReloadedResult;
}

/// <inheritdoc />
protected override void SendMessage(string message)
Expand Down
160 changes: 160 additions & 0 deletions src/BlazorWebView/src/SharedSource/StaticContentHotReloadManager.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Reflection.Metadata;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;

[assembly: MetadataUpdateHandler(typeof(Microsoft.AspNetCore.Components.WebView.StaticContentHotReloadManager))]

namespace Microsoft.AspNetCore.Components.WebView
{
internal static class StaticContentHotReloadManager
{
private delegate void ContentUpdatedHandler(string assemblyName, string relativePath);

private readonly static Regex ContentUrlRegex = new Regex("^_content/(?<AssemblyName>[^/]+)/(?<RelativePath>.*)");
private static event ContentUpdatedHandler? OnContentUpdated;

private static string? ApplicationAssemblyName { get; } = Assembly.GetEntryAssembly()?.GetName().Name;

private static readonly Dictionary<(string? AssemblyName, string RelativePath), (string? ContentType, byte[] Content)> _updatedContent = new()
{
{ (ApplicationAssemblyName, "_framework/static-content-hot-reload.js"), ("text/javascript", Encoding.UTF8.GetBytes(@"
export function notifyCssUpdated() {
const allLinkElems = Array.from(document.querySelectorAll('link[rel=stylesheet]'));
allLinkElems.forEach(elem => elem.href += '');
}
")) }
};

/// <summary>
/// MetadataUpdateHandler event. This is invoked by the hot reload host via reflection.
/// </summary>
public static void UpdateContent(string assemblyName, string relativePath, byte[] content)
{
_updatedContent[(assemblyName, relativePath)] = (ContentType: null, Content: content);

// On some platforms (Android), the information about the application assembly name is lost
// at compile-time. As a workaround, we treat the app assembly name as null, and treat all
// hot reloaded files as being in both an RCL and the app assembly. This works fine if the
// names don't clash, but means that RCL files can override an app file if the name does clash.
// TODO: Either have the tooling tell us whether this is the main application project, or
// have MAUI somehow retain the information about application assembly name to runtime.
Copy link
Member Author

Choose a reason for hiding this comment

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

Note that I've put in a request to have an extra parameter to give us this information, so I might be able to remove this workaround soon.

if (ApplicationAssemblyName is null)
{
_updatedContent[(null, relativePath)] = (ContentType: null, Content: content);
}

OnContentUpdated?.Invoke(assemblyName, relativePath);
}

public static void AttachToWebViewManagerIfEnabled(WebViewManager manager)
{
if (MetadataUpdater.IsSupported)
{
manager.AddRootComponentAsync(typeof(StaticContentChangeNotifier), "body::after", ParameterView.Empty);
}
}

public static bool TryReplaceResponseContent(string contentRootRelativePath, string requestAbsoluteUri, ref int responseStatusCode, ref Stream responseContent, IDictionary<string, string> responseHeaders)
{
if (MetadataUpdater.IsSupported)
{
var (assemblyName, relativePath) = GetAssemblyNameAndRelativePath(requestAbsoluteUri, contentRootRelativePath);
if (_updatedContent.TryGetValue((assemblyName, relativePath), out var values))
{
responseStatusCode = 200;
responseContent = new MemoryStream(values.Content);
if (!string.IsNullOrEmpty(values.ContentType))
{
responseHeaders["Content-Type"] = values.ContentType;
}

return true;
}
}

return false;
}

private static (string? AssemblyName, string RelativePath) GetAssemblyNameAndRelativePath(string requestAbsoluteUri, string appContentRoot)
{
var requestPath = new Uri(requestAbsoluteUri).AbsolutePath.Substring(1);
if (ContentUrlRegex.Match(requestPath) is { Success: true } match)
{
// For RCLs (i.e., URLs of the form _content/assembly/path), we assume the content root within the
// RCL to be "wwwroot" since we have no other information. If this is not the case, content within
// that RCL will not be hot-reloadable.
return (match.Groups["AssemblyName"].Value, $"wwwroot/{match.Groups["RelativePath"].Value}");
}
else if (requestPath.StartsWith("_framework/", StringComparison.Ordinal))
{
return (ApplicationAssemblyName, requestPath);
}
else
{
return (ApplicationAssemblyName, Path.Combine(appContentRoot, requestPath).Replace('\\', '/'));
}
}

// To provide a consistent way of transporting the data across all platforms,
// we can use the existing IJSRuntime. In turn we can get an instance of this
// that's always attached to the currently-loaded page (if it's a Blazor page)
// by injecting this headless root component.
private sealed class StaticContentChangeNotifier : IComponent, IDisposable
{
private ILogger _logger = default!;

[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private ILoggerFactory LoggerFactory { get; set; } = default!;

public void Attach(RenderHandle renderHandle)
{
_logger = LoggerFactory.CreateLogger<StaticContentChangeNotifier>();
OnContentUpdated += NotifyContentUpdated;
}

public void Dispose()
{
OnContentUpdated -= NotifyContentUpdated;
}

private void NotifyContentUpdated(string assemblyName, string relativePath)
{
// It handles its own errors
_ = NotifyContentUpdatedAsync(assemblyName, relativePath);
}

private async Task NotifyContentUpdatedAsync(string assemblyName, string relativePath)
{
try
{
await using var module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./_framework/static-content-hot-reload.js");

// In the future we might want to hot-reload other content types such as images, but currently the tooling is
// only expected to notify about CSS files. If it notifies us about something else, we'd need different JS logic.
if (string.Equals(".css", Path.GetExtension(relativePath), StringComparison.Ordinal))
{
// We could try to supply the URL of the modified file, so the JS-side logic could only update the affected
// stylesheet. This would reduce flicker. However, this involves hardcoding further details about URL conventions
// (e.g., _content/AssemblyName/Path) and accounting for configurable content roots. To reduce the chances of
// CSS hot reload being broken by customizations, we'll have the JS-side code refresh all stylesheets.
await module.InvokeVoidAsync("notifyCssUpdated");
}
}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to notify about static content update to {relativePath}.");
}
}

public Task SetParametersAsync(ParameterView parameters)
=> Task.CompletedTask;
}
}
}
Loading