diff --git a/AspNetCore.sln b/AspNetCore.sln index 28f62ede717c..591baf0d56bf 100644 --- a/AspNetCore.sln +++ b/AspNetCore.sln @@ -1626,6 +1626,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "testassets", "testassets", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WpfTestApp", "src\Components\WebView\Platforms\Wpf\testassets\WpfTestApp\WpfTestApp.csproj", "{036C6BDA-7B69-4E8C-A921-822DA5972A56}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebviewAppShared", "src\Components\WebView\Samples\WebviewAppShared\WebviewAppShared.csproj", "{64C3BAC8-C4F8-466A-9E84-0400EE54B25A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -7685,6 +7687,18 @@ Global {036C6BDA-7B69-4E8C-A921-822DA5972A56}.Release|x64.Build.0 = Release|Any CPU {036C6BDA-7B69-4E8C-A921-822DA5972A56}.Release|x86.ActiveCfg = Release|Any CPU {036C6BDA-7B69-4E8C-A921-822DA5972A56}.Release|x86.Build.0 = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|x64.ActiveCfg = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|x64.Build.0 = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|x86.ActiveCfg = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Debug|x86.Build.0 = Debug|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|Any CPU.Build.0 = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|x64.ActiveCfg = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|x64.Build.0 = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|x86.ActiveCfg = Release|Any CPU + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -8488,6 +8502,7 @@ Global {99EE7769-3C81-477B-B947-0A5CBCD5B27D} = {F0849E7E-61DB-4849-9368-9E7BC125DCB0} {94D0D6F3-8632-41DE-908B-47A787D570FF} = {5241CF68-66A0-4724-9BAA-36DB959A5B11} {036C6BDA-7B69-4E8C-A921-822DA5972A56} = {94D0D6F3-8632-41DE-908B-47A787D570FF} + {64C3BAC8-C4F8-466A-9E84-0400EE54B25A} = {D3B76F4E-A980-45BF-AEA1-EA3175B0B5A1} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {3E8720B3-DBDD-498C-B383-2CC32A054E8F} diff --git a/src/Components/ComponentsNoDeps.slnf b/src/Components/ComponentsNoDeps.slnf index 81037f138f3b..a0f1f200b623 100644 --- a/src/Components/ComponentsNoDeps.slnf +++ b/src/Components/ComponentsNoDeps.slnf @@ -44,6 +44,7 @@ "src\\Components\\WebView\\Platforms\\Wpf\\testassets\\WpfTestApp\\WpfTestApp.csproj", "src\\Components\\WebView\\Samples\\BlazorWinFormsApp\\BlazorWinFormsApp.csproj", "src\\Components\\WebView\\Samples\\BlazorWpfApp\\BlazorWpfApp.csproj", + "src\\Components\\WebView\\Samples\\WebviewAppShared\\WebviewAppShared.csproj", "src\\Components\\WebView\\WebView\\src\\Microsoft.AspNetCore.Components.WebView.csproj", "src\\Components\\WebView\\WebView\\test\\Microsoft.AspNetCore.Components.WebView.Test.csproj", "src\\Components\\Web\\src\\Microsoft.AspNetCore.Components.Web.csproj", diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj b/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj index cafb88085e68..46a1aaa75786 100644 --- a/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/BlazorWinFormsApp.csproj @@ -1,4 +1,4 @@ - + $(DefaultNetCoreTargetFramework)-windows @@ -11,6 +11,10 @@ + + + + PreserveNewest diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor index dc62a9fb5de3..ef4c042ed3ce 100644 --- a/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/Pages/Index.razor @@ -1,5 +1,6 @@ @page "/" @inject AppState AppState +@using WebviewAppShared

Hello, world!

@@ -8,6 +9,9 @@ +

This is a shared component

+ + @code { void IncrementCount() { diff --git a/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html index ebfb7123838b..228867852716 100644 --- a/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html +++ b/src/Components/WebView/Samples/BlazorWinFormsApp/wwwroot/index.html @@ -7,6 +7,7 @@ Blazor WinForms app + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj b/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj index 7aee04cb06ac..093ee74f7c66 100644 --- a/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj +++ b/src/Components/WebView/Samples/BlazorWpfApp/BlazorWpfApp.csproj @@ -7,6 +7,10 @@ false
+ + + + diff --git a/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor index c905713a14e8..e8ceefc23d93 100644 --- a/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor +++ b/src/Components/WebView/Samples/BlazorWpfApp/Pages/Index.razor @@ -1,5 +1,5 @@ @page "/" - +@using WebviewAppShared // NOTE: The full namespace is included here to work around this bug: https://github.com/dotnet/aspnetcore/issues/30851 @inject BlazorWpfApp.AppState AppState @@ -10,6 +10,9 @@ +

This is a shared component

+ + @code { void IncrementCount() { diff --git a/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html index b3e976b666c1..3887aef9712e 100644 --- a/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html +++ b/src/Components/WebView/Samples/BlazorWpfApp/wwwroot/index.html @@ -7,6 +7,7 @@ Blazor WPF app + diff --git a/src/Components/WebView/Samples/WebviewAppShared/ExampleJsInterop.cs b/src/Components/WebView/Samples/WebviewAppShared/ExampleJsInterop.cs new file mode 100644 index 000000000000..e4f19d8ccce7 --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/ExampleJsInterop.cs @@ -0,0 +1,39 @@ +using Microsoft.JSInterop; +using System; +using System.Threading.Tasks; + +namespace WebviewAppShared +{ + // This class provides an example of how JavaScript functionality can be wrapped + // in a .NET class for easy consumption. The associated JavaScript module is + // loaded on demand when first needed. + // + // This class can be registered as scoped DI service and then injected into Blazor + // components for use. + + public class ExampleJsInterop : IAsyncDisposable + { + private readonly Lazy> moduleTask; + + public ExampleJsInterop(IJSRuntime jsRuntime) + { + moduleTask = new(() => jsRuntime.InvokeAsync( + "import", "./_content/WebviewAppShared/exampleJsInterop.js").AsTask()); + } + + public async ValueTask Prompt(string message) + { + var module = await moduleTask.Value; + return await module.InvokeAsync("showPrompt", message); + } + + public async ValueTask DisposeAsync() + { + if (moduleTask.IsValueCreated) + { + var module = await moduleTask.Value; + await module.DisposeAsync(); + } + } + } +} diff --git a/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor b/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor new file mode 100644 index 000000000000..0d9cbc425c97 --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor @@ -0,0 +1,3 @@ +
+ This Blazor component is defined in the WebviewAppShared package. +
diff --git a/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor.css b/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor.css new file mode 100644 index 000000000000..c6afca404296 --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/SharedComponent.razor.css @@ -0,0 +1,6 @@ +.my-component { + border: 2px dashed red; + padding: 1em; + margin: 1em 0; + background-image: url('background.png'); +} diff --git a/src/Components/WebView/Samples/WebviewAppShared/WebviewAppShared.csproj b/src/Components/WebView/Samples/WebviewAppShared/WebviewAppShared.csproj new file mode 100644 index 000000000000..6836c303c123 --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/WebviewAppShared.csproj @@ -0,0 +1,16 @@ + + + + $(DefaultNetCoreTargetFramework) + + + + + + + + + + + + diff --git a/src/Components/WebView/Samples/WebviewAppShared/_Imports.razor b/src/Components/WebView/Samples/WebviewAppShared/_Imports.razor new file mode 100644 index 000000000000..77285129dabe --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/_Imports.razor @@ -0,0 +1 @@ +@using Microsoft.AspNetCore.Components.Web diff --git a/src/Components/WebView/Samples/WebviewAppShared/wwwroot/background.png b/src/Components/WebView/Samples/WebviewAppShared/wwwroot/background.png new file mode 100644 index 000000000000..e15a3bde6e2b Binary files /dev/null and b/src/Components/WebView/Samples/WebviewAppShared/wwwroot/background.png differ diff --git a/src/Components/WebView/Samples/WebviewAppShared/wwwroot/exampleJsInterop.js b/src/Components/WebView/Samples/WebviewAppShared/wwwroot/exampleJsInterop.js new file mode 100644 index 000000000000..ea8d76ad2d12 --- /dev/null +++ b/src/Components/WebView/Samples/WebviewAppShared/wwwroot/exampleJsInterop.js @@ -0,0 +1,6 @@ +// This is a JavaScript module that is loaded on demand. It can export any number of +// functions, and may import other JavaScript modules if required. + +export function showPrompt(message) { + return prompt(message, 'Type anything here'); +} diff --git a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj index af89f25af168..7cf89005ecfb 100644 --- a/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj +++ b/src/Components/WebView/WebView/src/Microsoft.AspNetCore.Components.WebView.csproj @@ -32,6 +32,7 @@ + new StaticWebAssetsFileProvider(cr.BasePath, cr.Path)) + .OfType() // Upcast so we can insert on the resulting list. + .ToList(); + + if (additionalFiles.Count == 0) + { + return systemProvider; + } + else + { + additionalFiles.Insert(0, webRootFileProvider); + return new CompositeFileProvider(additionalFiles); + } + } + + private static string? ResolveRelativeToAssembly() + { + var assembly = Assembly.GetEntryAssembly(); + if (string.IsNullOrEmpty(assembly?.Location)) + { + return null; + } + + var name = Path.GetFileNameWithoutExtension(assembly.Location); + + return Path.Combine(Path.GetDirectoryName(assembly.Location)!, $"{name}.StaticWebAssets.xml"); + } + + internal static class StaticWebAssetsReader + { + private const string ManifestRootElementName = "StaticWebAssets"; + private const string VersionAttributeName = "Version"; + private const string ContentRootElementName = "ContentRoot"; + + internal static IEnumerable Parse(Stream manifest) + { + var document = XDocument.Load(manifest); + if (!string.Equals(document.Root!.Name.LocalName, ManifestRootElementName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid manifest format. Manifest root must be '{ManifestRootElementName}'"); + } + + var version = document.Root.Attribute(VersionAttributeName); + if (version == null) + { + throw new InvalidOperationException($"Invalid manifest format. Manifest root element must contain a version '{VersionAttributeName}' attribute"); + } + + if (version.Value != "1.0") + { + throw new InvalidOperationException($"Unknown manifest version. Manifest version must be '1.0'"); + } + + foreach (var element in document.Root.Elements()) + { + if (!string.Equals(element.Name.LocalName, ContentRootElementName, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException($"Invalid manifest format. Invalid element '{element.Name.LocalName}'. All {StaticWebAssetsLoader.StaticWebAssetsManifestName} child elements must be '{ContentRootElementName}' elements."); + } + if (!element.IsEmpty) + { + throw new InvalidOperationException($"Invalid manifest format. {ContentRootElementName} can't have content."); + } + + var basePath = ParseRequiredAttribute(element, "BasePath"); + var path = ParseRequiredAttribute(element, "Path"); + yield return new ContentRootMapping(basePath, path); + } + } + + private static string ParseRequiredAttribute(XElement element, string attributeName) + { + var attribute = element.Attribute(attributeName); + if (attribute == null) + { + throw new InvalidOperationException($"Invalid manifest format. Missing {attributeName} attribute in '{ContentRootElementName}' element."); + } + return attribute.Value; + } + + internal readonly struct ContentRootMapping + { + public ContentRootMapping(string basePath, string path) + { + BasePath = basePath; + Path = path; + } + + public string BasePath { get; } + public string Path { get; } + } + } + + internal class StaticWebAssetsFileProvider : IFileProvider + { + private static readonly StringComparison FilePathComparison = OperatingSystem.IsWindows() ? + StringComparison.OrdinalIgnoreCase : + StringComparison.Ordinal; + + public StaticWebAssetsFileProvider(string pathPrefix, string contentRoot) + { + BasePath = NormalizePath(pathPrefix); + InnerProvider = new PhysicalFileProvider(contentRoot); + } + + public PhysicalFileProvider InnerProvider { get; } + + public PathString BasePath { get; } + + /// + public IDirectoryContents GetDirectoryContents(string subpath) + { + var modifiedSub = NormalizePath(subpath); + + if (BasePath == "/") + { + return InnerProvider.GetDirectoryContents(modifiedSub); + } + + if (StartsWithBasePath(modifiedSub, out var physicalPath)) + { + return InnerProvider.GetDirectoryContents(physicalPath.Value); + } + else if (string.Equals(subpath, string.Empty) || string.Equals(modifiedSub, "/")) + { + return new StaticWebAssetsDirectoryRoot(BasePath); + } + else if (BasePath.StartsWithSegments(modifiedSub, FilePathComparison, out var remaining)) + { + return new StaticWebAssetsDirectoryRoot(remaining); + } + + return NotFoundDirectoryContents.Singleton; + } + + /// + public IFileInfo GetFileInfo(string subpath) + { + var modifiedSub = NormalizePath(subpath); + + if (BasePath == "/") + { + return InnerProvider.GetFileInfo(subpath); + } + + if (!StartsWithBasePath(modifiedSub, out var physicalPath)) + { + return new NotFoundFileInfo(subpath); + } + else + { + return InnerProvider.GetFileInfo(physicalPath.Value); + } + } + + /// + public IChangeToken Watch(string filter) + { + return InnerProvider.Watch(filter); + } + + private static string NormalizePath(string path) + { + path = path.Replace('\\', '/'); + return path.StartsWith('/') ? path : "/" + path; + } + + private bool StartsWithBasePath(string subpath, out PathString rest) + { + return new PathString(subpath).StartsWithSegments(BasePath, FilePathComparison, out rest); + } + + private class StaticWebAssetsDirectoryRoot : IDirectoryContents + { + private readonly string _nextSegment; + + public StaticWebAssetsDirectoryRoot(PathString remainingPath) + { + // We MUST use the Value property here because it is unescaped. + _nextSegment = remainingPath.Value?.Split("/", StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; + } + + public bool Exists => true; + + public IEnumerator GetEnumerator() + { + return GenerateEnum(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GenerateEnum(); + } + + private IEnumerator GenerateEnum() + { + return new[] { new StaticWebAssetsFileInfo(_nextSegment) } + .Cast().GetEnumerator(); + } + + private class StaticWebAssetsFileInfo : IFileInfo + { + public StaticWebAssetsFileInfo(string name) + { + Name = name; + } + + public bool Exists => true; + + public long Length => throw new NotImplementedException(); + + public string PhysicalPath => throw new NotImplementedException(); + + public DateTimeOffset LastModified => throw new NotImplementedException(); + + public bool IsDirectory => true; + + public string Name { get; } + + public Stream CreateReadStream() + { + throw new NotImplementedException(); + } + } + } + } + } +} +#nullable restore diff --git a/src/Components/WebView/WebView/src/WebViewManager.cs b/src/Components/WebView/WebView/src/WebViewManager.cs index 2a69f87c3e47..ed72ebfa35ef 100644 --- a/src/Components/WebView/WebView/src/WebViewManager.cs +++ b/src/Components/WebView/WebView/src/WebViewManager.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Reflection; using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; @@ -44,6 +45,7 @@ public WebViewManager(IServiceProvider provider, Dispatcher dispatcher, Uri appB _provider = provider ?? throw new ArgumentNullException(nameof(provider)); _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); _appBaseUri = EnsureTrailingSlash(appBaseUri ?? throw new ArgumentNullException(nameof(appBaseUri))); + fileProvider = StaticWebAssetsLoader.UseStaticWebAssets(fileProvider); _staticContentProvider = new StaticContentProvider(fileProvider, appBaseUri, hostPageRelativePath); _ipcSender = new IpcSender(_dispatcher, SendMessage); _ipcReceiver = new IpcReceiver(AttachToPageAsync);