Skip to content

Commit

Permalink
Properly account for configurable content roots
Browse files Browse the repository at this point in the history
  • Loading branch information
SteveSandersonMS committed Apr 14, 2022
1 parent 01b7314 commit 2f2f7c0
Show file tree
Hide file tree
Showing 10 changed files with 122 additions and 86 deletions.
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 @@ -62,7 +65,7 @@ protected override void SendMessage(string message)
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(uri, ref statusCode, ref content, headers);
var hotReloadedResult = StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, uri, ref statusCode, ref content, headers);
return defaultResult || hotReloadedResult;
}

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

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager, WebKitWebViewClient.AppOrigin);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ private void StartWebViewCoreIfPossible()
new MauiDispatcher(Services!.GetRequiredService<IDispatcher>()),
fileProvider,
VirtualView.JSComponents,
hostPageRelativePath,
contentRootDir,
hostPageRelativePath,
this);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager, WebView2WebViewManager.AppOrigin);
Expand Down
22 changes: 11 additions & 11 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,25 +43,25 @@ 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 />
protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRequestedEventArgs eventArgs)
{
Expand Down Expand Up @@ -103,7 +103,7 @@ 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);
Expand Down Expand Up @@ -134,7 +134,7 @@ protected override async Task HandleWebResourceRequest(CoreWebView2WebResourceRe
}

var hotReloadedContent = Stream.Null;
if (StaticContentHotReloadManager.TryReplaceResponseContent(requestUri, ref statusCode, ref hotReloadedContent, headers))
if (StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, requestUri, ref statusCode, ref hotReloadedContent, headers))
{
stream = new InMemoryRandomAccessStream();
var memStream = new MemoryStream();
Expand Down
1 change: 1 addition & 0 deletions src/BlazorWebView/src/Maui/iOS/BlazorWebViewHandler.iOS.cs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ private void StartWebViewCoreIfPossible()
new MauiDispatcher(Services!.GetRequiredService<IDispatcher>()),
fileProvider,
VirtualView.JSComponents,
contentRootDir,
hostPageRelativePath);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager, AppOrigin);
Expand Down
7 changes: 5 additions & 2 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 @@ -58,7 +61,7 @@ protected override void NavigateCore(Uri absoluteUri)
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(uri, ref statusCode, ref content, headers);
var hotReloadedResult = StaticContentHotReloadManager.TryReplaceResponseContent(_contentRootRelativeToAppRoot, uri, ref statusCode, ref content, headers);
return defaultResult || hotReloadedResult;
}

Expand Down
139 changes: 78 additions & 61 deletions src/BlazorWebView/src/SharedSource/StaticContentHotReloadManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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;
Expand All @@ -24,100 +25,94 @@ public static void UpdateContent(string assemblyName, string relativePath, byte[

internal static class StaticContentHotReloadManager
{
private delegate void ContentUpdatedHandler(string url);
private delegate void ContentUpdatedHandler(string assemblyName, string relativePath);
private static event ContentUpdatedHandler? OnContentUpdated;
private static string AppOrigin = default!;

private static readonly Dictionary<string, (string? ContentType, byte[] Content)> _updatedContentByAbsoluteUrl = new(StringComparer.Ordinal);
private static string ApplicationAssemblyName { get; } =
#if MAUI
Application.Context.PackageName;
#else
Assembly.GetEntryAssembly()!.GetName().Name!;
#endif

private static readonly string StaticContentHotReloadModuleSource = @"
export function notifyContentUpdated(url) {
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 notifyContentUpdated(urlWithinOrigin) {
const allLinkElems = Array.from(document.querySelectorAll('link[rel=stylesheet]'));
const matchingLinkElems = allLinkElems.filter(x => x.href === url);
const absoluteUrl = document.location.origin + urlWithinOrigin;
const matchingLinkElems = allLinkElems.filter(x => x.href === absoluteUrl);
// If we can't find a matching link element, that probably means it's a CSS file imported via @import
// from some other CSS file. We can't know which other file imports it, so refresh them all.
const linkElemsToUpdate = matchingLinkElems.length > 0 || !url.endsWith('.css')
const linkElemsToUpdate = matchingLinkElems.length > 0 || !absoluteUrl.endsWith('.css')
? matchingLinkElems
: allLinkElems;
linkElemsToUpdate.forEach(tag => tag.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)
{
var absoluteUrl = GetAbsoluteUrlForStaticContent(assemblyName, relativePath);
_updatedContentByAbsoluteUrl[absoluteUrl] = (ContentType: null, Content: content);
OnContentUpdated?.Invoke(absoluteUrl);
_updatedContent[(assemblyName, relativePath)] = (ContentType: null, Content: content);
OnContentUpdated?.Invoke(assemblyName, relativePath);
}

public static void AttachToWebViewManagerIfEnabled(WebViewManager manager, string appOrigin)
public static void AttachToWebViewManagerIfEnabled(WebViewManager manager, string assemblyName, string contentRoot)
{
if (MetadataUpdater.IsSupported)
{
AppOrigin = appOrigin;
_updatedContentByAbsoluteUrl[AppOrigin + "_framework/static-content-hot-reload.js"] =
(ContentType: "text/javascript", Content: Encoding.UTF8.GetBytes(StaticContentHotReloadModuleSource));

manager.AddRootComponentAsync(typeof(StaticContentUpdater), "body::after", ParameterView.Empty);
var parameters = new Dictionary<string, object?> { { nameof(StaticContentUpdater.ContentRoot), contentRoot } };
manager.AddRootComponentAsync(typeof(StaticContentUpdater), "body::after", ParameterView.FromDictionary(parameters));
}
}

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

return true;
}

return true;
}
else
{
return false;
}

return false;
}

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

private static string GetAbsoluteUrlForStaticContent(string assemblyName, string relativePath)
private static (string AssemblyName, string RelativePath) GetAssemblyNameAndRelativePath(string requestAbsoluteUri, string appContentRoot)
{
// This logic might not cover every circumstance if the developer customizes the host page path
// or is doing something custom with static web assets. However it should cover any mainstream
// case with single projects and RCLs. We may have to allow for other cases in the future, or
// may have to receive different information from tooling.

// Since scoped CSS bundles might not have a wwwroot prefix, normalize by removing it.
// Whether this is really needed depends on tooling implementations that are not yet known.
const string wwwrootPrefix = "wwwroot/";
if (relativePath.StartsWith(wwwrootPrefix, StringComparison.Ordinal))
var requestPath = new Uri(requestAbsoluteUri).AbsolutePath.Substring(1);
if (ContentUrlRegex.Match(requestPath) is { Success: true } match)
{
relativePath = relativePath.Substring(wwwrootPrefix.Length);
// 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}");
}

if (relativePath.StartsWith("/"))
else if (requestPath.StartsWith("_framework/", StringComparison.Ordinal))
{
relativePath = relativePath.Substring(1);
return (ApplicationAssemblyName, requestPath);
}

// SWA convention for RCLs
// Note that on Android, entryAssembly will be null, so we have no way to know if the file comes from an RCL or not.
// As a temporary stage, Android will treat all content as if it is *not* from an RCL, which unfortunately means we
// won't be able to hot-reload CSS from an RCL on Android.
var entryAssembly = Assembly.GetEntryAssembly();
if (entryAssembly is not null
&& !string.Equals(assemblyName, entryAssembly.GetName().Name, StringComparison.Ordinal))
else
{
relativePath = $"_content/{assemblyName}/{relativePath}";
return (ApplicationAssemblyName, Path.Combine(appContentRoot, requestPath).Replace('\\', '/'));
}

return AppOrigin + relativePath;
}

// To provide a consistent way of transporting the data across all platforms,
Expand All @@ -126,9 +121,11 @@ private static string GetAbsoluteUrlForStaticContent(string assemblyName, string
// by injecting this headless root component.
private sealed class StaticContentUpdater : IComponent, IDisposable
{
private ILogger _logger = default!;

[Inject] private IJSRuntime JSRuntime { get; set; } = default!;
[Inject] private ILoggerFactory LoggerFactory { get; set; } = default!;
private ILogger _logger = default!;
[Parameter] public string ContentRoot { get; set; } = default!;

public void Attach(RenderHandle renderHandle)
{
Expand All @@ -141,27 +138,47 @@ public void Dispose()
OnContentUpdated -= NotifyContentUpdated;
}

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

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

if (string.Equals(assemblyName, ApplicationAssemblyName, StringComparison.Ordinal))
{
if (relativePath.StartsWith(ContentRoot + "/", StringComparison.Ordinal))
{
var pathWithinContentRoot = relativePath.Substring(ContentRoot.Length);
await module.InvokeVoidAsync("notifyContentUpdated", pathWithinContentRoot);
}
}
else
{
if (relativePath.StartsWith("wwwroot/", StringComparison.Ordinal))
{
var pathWithinContentRoot = relativePath.Substring("wwwroot/".Length);
await module.InvokeVoidAsync("notifyContentUpdated", $"/_content/{assemblyName}/{pathWithinContentRoot}");
}
}

}
catch (Exception ex)
{
_logger.LogError(ex, $"Failed to notify about static content update to {url}.");
_logger.LogError(ex, $"Failed to notify about static content update to {relativePath}.");
}
}

public Task SetParametersAsync(ParameterView parameters)
=> Task.CompletedTask;
{
parameters.SetParameterProperties(this);
return Task.CompletedTask;
}
}
}
}
Loading

0 comments on commit 2f2f7c0

Please sign in to comment.