Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Microsoft.Maui-dev.sln
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.DeviceTes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.UnitTests\MauiBlazorWebView.UnitTests.csproj", "{00E708AA-402D-4BB5-AD88-348274F8E1DF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.ExternalHandler.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.ExternalHandler.UnitTests\MauiBlazorWebView.ExternalHandler.UnitTests.csproj", "{536778E4-FAAA-4A16-A961-388AA91EA813}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SharedSource", "SharedSource", "{4F2926C8-43AB-4328-A735-D9EAD699F81D}"
ProjectSection(SolutionItems) = preProject
src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs = src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs
Expand Down Expand Up @@ -461,6 +463,10 @@ Global
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.Build.0 = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.Build.0 = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.ActiveCfg = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.Build.0 = Release|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
Expand Down Expand Up @@ -727,6 +733,7 @@ Global
{A9C514B9-1EE2-4A12-8E8A-CE16D87545C3} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
{5FEA7500-0ACE-4C26-9A7B-2EB3958CBBC6} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{00E708AA-402D-4BB5-AD88-348274F8E1DF} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{536778E4-FAAA-4A16-A961-388AA91EA813} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{4F2926C8-43AB-4328-A735-D9EAD699F81D} = {ED7F28E0-D0AF-417D-983D-3D874EEE8554}
{5B56A734-D53C-4635-A53E-F889FCFCDD66} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
{E8728693-3537-4007-A4DB-9F9634548755} = {E8AD265B-3C67-4640-AC58-A522F9FB3361}
Expand Down
1 change: 1 addition & 0 deletions Microsoft.Maui-mac.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"src\\BlazorWebView\\samples\\WebViewAppShared\\WebViewAppShared.csproj",
"src\\BlazorWebView\\src\\Maui\\Microsoft.AspNetCore.Components.WebView.Maui.csproj",
"src\\BlazorWebView\\tests\\DeviceTests\\MauiBlazorWebView.DeviceTests.csproj",
"src\\BlazorWebView\\tests\\MauiBlazorWebView.ExternalHandler.UnitTests\\MauiBlazorWebView.ExternalHandler.UnitTests.csproj",
"src\\BlazorWebView\\tests\\MauiBlazorWebView.UnitTests\\MauiBlazorWebView.UnitTests.csproj",
"src\\Controls\\Foldable\\src\\Controls.Foldable.csproj",
"src\\Controls\\Maps\\src\\Controls.Maps.csproj",
Expand Down
7 changes: 7 additions & 0 deletions Microsoft.Maui-vscode.sln
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.DeviceTes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.UnitTests\MauiBlazorWebView.UnitTests.csproj", "{00E708AA-402D-4BB5-AD88-348274F8E1DF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.ExternalHandler.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.ExternalHandler.UnitTests\MauiBlazorWebView.ExternalHandler.UnitTests.csproj", "{536778E4-FAAA-4A16-A961-388AA91EA813}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SharedSource", "SharedSource", "{4F2926C8-43AB-4328-A735-D9EAD699F81D}"
ProjectSection(SolutionItems) = preProject
src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs = src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs
Expand Down Expand Up @@ -408,6 +410,10 @@ Global
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.Build.0 = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.Build.0 = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.ActiveCfg = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.Build.0 = Release|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
Expand Down Expand Up @@ -635,6 +641,7 @@ Global
{A9C514B9-1EE2-4A12-8E8A-CE16D87545C3} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
{5FEA7500-0ACE-4C26-9A7B-2EB3958CBBC6} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{00E708AA-402D-4BB5-AD88-348274F8E1DF} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{536778E4-FAAA-4A16-A961-388AA91EA813} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{4F2926C8-43AB-4328-A735-D9EAD699F81D} = {ED7F28E0-D0AF-417D-983D-3D874EEE8554}
{5B56A734-D53C-4635-A53E-F889FCFCDD66} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
{E8728693-3537-4007-A4DB-9F9634548755} = {E8AD265B-3C67-4640-AC58-A522F9FB3361}
Expand Down
1 change: 1 addition & 0 deletions Microsoft.Maui-windows.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"src\\BlazorWebView\\src\\WindowsForms\\Microsoft.AspNetCore.Components.WebView.WindowsForms.csproj",
"src\\BlazorWebView\\src\\Wpf\\Microsoft.AspNetCore.Components.WebView.Wpf.csproj",
"src\\BlazorWebView\\tests\\DeviceTests\\MauiBlazorWebView.DeviceTests.csproj",
"src\\BlazorWebView\\tests\\MauiBlazorWebView.ExternalHandler.UnitTests\\MauiBlazorWebView.ExternalHandler.UnitTests.csproj",
"src\\BlazorWebView\\tests\\MauiBlazorWebView.UnitTests\\MauiBlazorWebView.UnitTests.csproj",
"src\\Controls\\Foldable\\src\\Controls.Foldable.csproj",
"src\\Controls\\Maps\\src\\Controls.Maps.csproj",
Expand Down
7 changes: 7 additions & 0 deletions Microsoft.Maui.sln
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.DeviceTes
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.UnitTests\MauiBlazorWebView.UnitTests.csproj", "{00E708AA-402D-4BB5-AD88-348274F8E1DF}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MauiBlazorWebView.ExternalHandler.UnitTests", "src\BlazorWebView\tests\MauiBlazorWebView.ExternalHandler.UnitTests\MauiBlazorWebView.ExternalHandler.UnitTests.csproj", "{536778E4-FAAA-4A16-A961-388AA91EA813}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SharedSource", "SharedSource", "{4F2926C8-43AB-4328-A735-D9EAD699F81D}"
ProjectSection(SolutionItems) = preProject
src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs = src\BlazorWebView\src\SharedSource\AutoCloseOnReadCompleteStream.cs
Expand Down Expand Up @@ -450,6 +452,10 @@ Global
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{00E708AA-402D-4BB5-AD88-348274F8E1DF}.Release|Any CPU.Build.0 = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Debug|Any CPU.Build.0 = Debug|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.ActiveCfg = Release|Any CPU
{536778E4-FAAA-4A16-A961-388AA91EA813}.Release|Any CPU.Build.0 = Release|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B56A734-D53C-4635-A53E-F889FCFCDD66}.Debug|Any CPU.Deploy.0 = Debug|Any CPU
Expand Down Expand Up @@ -708,6 +714,7 @@ Global
{A9C514B9-1EE2-4A12-8E8A-CE16D87545C3} = {1614D1A4-5C3D-4D5B-8C89-426E37A564EF}
{5FEA7500-0ACE-4C26-9A7B-2EB3958CBBC6} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{00E708AA-402D-4BB5-AD88-348274F8E1DF} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{536778E4-FAAA-4A16-A961-388AA91EA813} = {A9C514B9-1EE2-4A12-8E8A-CE16D87545C3}
{4F2926C8-43AB-4328-A735-D9EAD699F81D} = {ED7F28E0-D0AF-417D-983D-3D874EEE8554}
{5B56A734-D53C-4635-A53E-F889FCFCDD66} = {C564DDD6-DE79-45CD-88EA-3F690481572A}
{E8728693-3537-4007-A4DB-9F9634548755} = {E8AD265B-3C67-4640-AC58-A522F9FB3361}
Expand Down
157 changes: 157 additions & 0 deletions docs/design/BlazorWebViewExternalBackends.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# External BlazorWebView backends

`BlazorWebView` ships built-in handlers for Android, iOS, MacCatalyst and Windows. A third-party
package can supply a handler for any other platform by implementing
`Microsoft.AspNetCore.Components.WebView.Maui.IBlazorWebViewHandler` and registering it with
`IMauiBlazorWebViewBuilder.UsePlatformHandler`.

This document describes the public seams such a backend needs. Everything here is usable from a
package that only references the shipped MAUI NuGet packages — no `InternalsVisibleTo`, no
reflection, and no copied source. `src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests`
is an in-repo assembly that is deliberately *not* granted `InternalsVisibleTo` and exercises each of
these seams the way an external backend would.

## Registration

```csharp
builder.Services
.AddMauiBlazorWebView()
.UsePlatformHandler<MyPlatformBlazorWebViewHandler>();
```

Registration is last-registration-wins through the MAUI handler collection, so call
`UsePlatformHandler` *after* `AddMauiBlazorWebView()` and after any downstream library that calls
`AddMauiBlazorWebView()` again.

## Dispatcher

A `WebViewManager` needs a Blazor `Dispatcher`. Rather than writing an adapter, construct MAUI's
public `MauiDispatcher` over the `IDispatcher` from the handler's services, so Blazor's thread
affinity matches the rest of MAUI on that platform:

```csharp
var dispatcher = new MauiDispatcher(Services!.GetRequiredService<IDispatcher>());
```

## Surfacing the native web view

`BlazorWebViewInitializedEventArgs` declares a strongly typed `WebView` property only for the target
frameworks that MAUI has a built-in backend for. A backend for any other platform supplies the
native control through the constructor instead:

```csharp
VirtualView.BlazorWebViewInitializing(new BlazorWebViewInitializingEventArgs());
VirtualView.BlazorWebViewInitialized(new BlazorWebViewInitializedEventArgs(PlatformView));
```

App code then reads it from the platform-neutral, read-only `PlatformWebView` property:

```csharp
blazorWebView.BlazorWebViewInitialized += (s, e) =>
{
var native = (MyPlatformWebView)e.PlatformWebView!;
};
```

`PlatformWebView` is deliberately read-only and write-once: only the handler raising the event can
supply the value, so one event subscriber cannot change what later subscribers observe. On target
frameworks where the strongly typed `WebView` property exists, both properties report the same
instance, and `WebView` reports `null` if the stored value is not of that platform's web view type.
The property is scoped to the MAUI package; the WPF and WinForms BlazorWebView packages keep only
their existing strongly typed `WebView` property.

## Root components

`RootComponent.AddToWebViewManagerAsync` and `RootComponent.RemoveFromWebViewManagerAsync` apply the
validation that MAUI's built-in handlers rely on (a `Selector` is required for both, and a
`ComponentType` is required to add). A backend should call them rather than reimplementing the
validation, so that error messages and ordering stay consistent across platforms:

```csharp
foreach (var rootComponent in VirtualView.RootComponents)
{
// Before the page is attached this completes synchronously.
_ = rootComponent.AddToWebViewManagerAsync(_webViewManager);
}
```

Call both methods on the `WebViewManager.Dispatcher` thread, and keep handling
`RootComponentsCollection.CollectionChanged` so components added or removed after startup are
applied to the manager.

## Static content hot reload

Static content hot reload (serving updated `wwwroot` assets, most notably CSS, without restarting
the app) is exposed through `BlazorWebViewStaticContentHotReload`. A backend participates in two
places, mirroring what the built-in handlers do — the built-in MAUI handlers call this same public
seam.

Once, after creating the `WebViewManager` and before navigating:

```csharp
_ = BlazorWebViewStaticContentHotReload.TryAttachToWebViewManager(_webViewManager);
```

`TryAttachToWebViewManager` returns `null` when hot reload is not supported by the current runtime
and nothing was attached, or a `Task` that completes when the notifier root component has been
registered. Awaiting it is optional when attaching before navigation, because registration completes
synchronously until a page is attached. Attaching is idempotent per `WebViewManager` instance:
repeat calls return the task from the first attach rather than failing on the notifier's fixed root
component selector.

And from the disconnect or disposal path, before disposing the `WebViewManager`:

```csharp
_ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webViewManager);
```

`TryDetachFromWebViewManager` returns `null` when nothing was attached, so it is safe to call
unconditionally and is idempotent. Detaching is not required purely to avoid a leak — the attachment
is tracked weakly and the notifier unsubscribes when its root component is disposed — but it *is*
required for a later `TryAttachToWebViewManager` on the same manager to take effect, which matters
for handlers that can be disconnected and reconnected.

And while resolving each static content request:

```csharp
if (BlazorWebViewStaticContentHotReload.TryGetUpdatedStaticContent(
_contentRootRelativeToAppRoot, requestAbsoluteUri, out var hotReloaded, out var hotReloadedContentType))
{
originalContent?.Dispose();
statusCode = 200;
content = hotReloaded!;
if (hotReloadedContentType is not null)
{
headers["Content-Type"] = hotReloadedContentType;
}
}
```

This is a query, not a mutation: it reports content and lets the handler apply it to whatever
response state it owns. The returned stream is fresh per call and the caller is responsible for
disposing it, along with any content it had already resolved. The in-box Android, iOS, Tizen and
Windows web view managers apply hot-reloaded content through exactly this call, so the public seam
is the same code path MAUI itself ships on.

Both members are inert when hot reload is unavailable — that is, when
`System.Reflection.Metadata.MetadataUpdater.IsSupported` is `false` — so they can be called
unconditionally. Static content hot reload is distinct from Razor component hot reload, which needs
no handler participation.

## What is intentionally not public

The static content **response cache** and its policy helpers (`StaticContentResponseCache`,
`StaticContentResponseCachePolicy`, `StaticContentCacheControl`, `QueryStringHelper`) remain
internal. They implement the caching behavior that `BlazorWebView.StaticContentCacheControlProvider`
opts into, but the storage shape, eviction, entry-size limits and `Cache-Control`/`Pragma` parsing
are implementation details we want to keep free to change. An external backend is expected to
implement its own request caching, and to call
`IBlazorWebView.StaticContentCacheControlProvider` — which *is* public — to let apps influence the
`Cache-Control` header it emits. If you need the shared cache itself, please open an issue
describing the scenario rather than duplicating the internals.

## Windows note

On Windows, a handler that owns a `WebView2` must either expose it directly as its `PlatformView`,
so the framework can close it when the window is destroyed, or close the wrapped control from its
own disconnect logic. This preserves the built-in workaround for microsoft-ui-xaml issue 6872.
1 change: 1 addition & 0 deletions eng/helix.proj
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
<XUnitProject Include="$(RepoRoot)src/Essentials/test/UnitTests/Essentials.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/Graphics/tests/Graphics.Tests/Graphics.Tests.csproj" />
<XUnitProject Include="$(RepoRoot)src/BlazorWebView/tests/MauiBlazorWebView.UnitTests/MauiBlazorWebView.UnitTests.csproj" />
<XUnitProject Include="$(RepoRoot)src/BlazorWebView/tests/MauiBlazorWebView.ExternalHandler.UnitTests/MauiBlazorWebView.ExternalHandler.UnitTests.csproj" />
</ItemGroup>

<!-- Prepare the staging directory for MSBuild test payloads - runs early during Restore -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,22 @@ 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(_contentRootRelativeToAppRoot, uri, ref statusCode, ref content, headers);

// Deliberately goes through the same public seam an external backend uses, so the in-box
// handlers dogfood it. The caller owns the response, so applying the content is done here.
var hotReloadedResult = BlazorWebViewStaticContentHotReload.TryGetUpdatedStaticContent(
_contentRootRelativeToAppRoot, uri, out var hotReloadedContent, out var hotReloadedContentType);
if (hotReloadedResult)
{
statusCode = 200;
content?.Dispose();
content = hotReloadedContent!;
if (hotReloadedContentType is not null)
{
headers["Content-Type"] = hotReloadedContentType;
}
}

return defaultResult || hotReloadedResult;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@ protected override void DisconnectHandler(AWebView platformView)
{
// Dispose this component's contents so that user-written disposal logic and Blazor disposal logic will complete.

// Detach before disposal so a reconnected handler can attach the notifier again.
_ = BlazorWebViewStaticContentHotReload.TryDetachFromWebViewManager(_webviewManager);

Comment on lines +122 to +124
// Start the disposal...
var disposalTask = _webviewManager?
.DisposeAsync()
Expand Down Expand Up @@ -187,7 +190,7 @@ private void StartWebViewCoreIfPossible()
hostPageRelativePath,
Logger);

StaticContentHotReloadManager.AttachToWebViewManagerIfEnabled(_webviewManager);
_ = BlazorWebViewStaticContentHotReload.TryAttachToWebViewManager(_webviewManager);

VirtualView.BlazorWebViewInitializing(new BlazorWebViewInitializingEventArgs());
VirtualView.BlazorWebViewInitialized(new BlazorWebViewInitializedEventArgs
Expand Down
Loading
Loading