Host Runic Views in a WPF app
On this page
Keep your WPF shell and present one page as a Runic web View with
RunicViewHost. This guide covers the project layout, the page's scripts, the
frontend build, close guards and startup.
It builds on step 3 of Adopt Runic incrementally in WPF.
RunicViewHost, GuardWindowClose and the package-only project layouts below
are new in SDK 0.7.0-preview.7. With 0.7.0-preview.6, compose RunicWebView,
DesktopHost and CreateWpfViewAsync yourself. The WPF web adapter is
experimental (RUNICWPF001); suppress it where you use it.
What stays in WPF #
The WPF application keeps its Application, its windows, its dispatcher, its
DI container, its native dialogs and its navigation. A web View is one more
control in the visual tree. It presents an existing ViewModel, the same object
that a native WPF View can bind to, so the user can switch between the two
without losing a draft.
The hybrid editor example is the complete application this guide quotes. Its notes list and dialogs are WPF; its editor page can switch between WPF controls and a web View.
Choose a project layout #
The build generates the web View's TypeScript client and C# Bridge from your ViewModels. There are two layouts.
Split: a WPF-free model project #
The ViewModels and the web View declaration live in a class library without
WPF. It references Runic.Application.Views, which turns generation on, and it
builds the frontend:
<PropertyGroup>
<OutputType>Library</OutputType>
<TargetFramework>net10.0</TargetFramework>
...
<RunicApplicationFrontendCompositionType>SplitModel.RunicBridgeComposition</RunicApplicationFrontendCompositionType>
<RunicApplicationFrontendDirectory>$(MSBuildProjectDirectory)/../Frontend</RunicApplicationFrontendDirectory>
...
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="$(RunicContainerVersion)" />
<PackageReference Include="Runic.Application.Views" Version="$(RunicPackageVersion)" />
</ItemGroup>
The WPF application references Runic.Application.Views.Wpf and the model
project. It needs no Runic properties: the package's build targets copy the
model project's built frontend, Frontend/dist, to the application's www/
folder:
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
...
<NoWarn>$(NoWarn);RUNICWPF001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(RunicContainerVersion)" />
<PackageReference Include="Runic.Application.Views.Wpf" Version="$(RunicPackageVersion)" />
<ProjectReference Include="../Model/SplitModel.csproj" />
</ItemGroup>
The application declares no web View of its own, so its own generation is
optional. The hybrid example turns it off with
RunicApplicationFrontendGenerationEnabled=false. The application calls the
model project's generated AddRunicViews().
Use the split layout when the ViewModels should also run in tests, in another host or in a later Runic Window: a WPF-free project can't depend on WPF types by accident.
Single: one WPF project #
A single UseWPF project can reference only Runic.Application.Views.Wpf.
The adapter turns generation on, and the default Frontend folder next to the
project receives the generated client:
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
...
<NoWarn>$(NoWarn);RUNICWPF001</NoWarn>
...
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="$(RunicContainerVersion)" />
<PackageReference Include="Runic.Application.Views.Wpf" Version="$(RunicPackageVersion)" />
</ItemGroup>
WPF compiles XAML in a temporary *_wpftmp project first; that pass reuses
the generated code instead of generating it again. The generator still skips
ViewModels and Views that depend on WPF types, so keep the ViewModels a web
View presents free of WPF types in this layout too. A project that declares
Views but has no Frontend folder fails with RUNICBRIDGE014.
Where the Translations catalog goes #
With Runic Translations, the WPF project's
build-time XAML checks (TranslationsXamlCatalog) need to see the catalog,
and ViewModels that use the typed AppText class need to reference it. In the
split layout these pull in different directions:
- Catalog in the model project. ViewModels use
AppTextdirectly. Translations0.6.0-preview.7and later check the WPF project's XAML against a catalog in a directly referenced project. With0.6.0-preview.5, the XAML keys are checked only at run time. - Catalog in the WPF project. The XAML checks also work with
0.6.0-preview.5, but ViewModels in the model project can't useAppText; they receive their texts through an interface that the WPF project implements. - Single project. The catalog, the XAML and the ViewModels share one project, so both work.
Declare the web View #
A web View is a logical RunicView<TViewModel> for an existing ViewModel. The
hybrid example declares it next to its CommunityToolkit.Mvvm editor model:
public sealed partial class EditorWebView : RunicView<EditorViewModel>;
That is enough to generate the client and the Bridge. There is no Runic
Window: the WPF window stays the window. Register the generated
AddRunicViews() with the application's services, next to your own.
The page: webui.js first #
The frontend's index.html loads two classic scripts before its module:
<script src="webui.js"></script>
<script src="runic-desktop-views.js"></script>
<script type="module" src="app.js"></script>
webui.jsis served by the Runic Desktop surface that hosts the page. It connects the page to .NET; there is no file to copy.runic-desktop-views.jsis the Views client for Runic Desktop. TheRunic.Application.Views.Desktoppackage, which the WPF adapter depends on, copies it towww/on build and publish.app.jsis your bundled frontend. It calls the generatedconnect…()function, hereconnectEditor().
Without the first two scripts the page loads, but the generated client can't connect.
How the frontend reaches the output #
On dotnet build, the project that generates the client:
- writes the TypeScript client to
<frontend>/src/generated, - installs the frontend's packages when
package.jsonor the lock file changed, - runs the frontend's
buildscript, and - copies
<frontend>/disttowww/in the build output.
dotnet publish always copies the frontend. In the split layout, the WPF
application copies the model project's dist to its own www/.
RunicViewHost opens www/index.html next to the application by default; its
ContentRoot and EntryPage properties change that. The
Runic.Application.Views build properties
change the frontend folder, the package manager and the build command.
Place a RunicViewHost #
RunicViewHost is a WPF element that presents a model through its web View.
Its content is the fallback, shown with the same DataContext when the web
View can't open, for example without the WebView2 Runtime. The package
declares the XAML namespace https://runic-artifex.eu/xaml/views, with the
prefix rv, so the markup doesn't name the assembly. It is new in SDK
0.7.0-preview.8; with 0.7.0-preview.7, use
clr-namespace:Runic.Application.Views.Wpf;assembly=Runic.Application.Views.Wpf.
<UserControl xmlns:rv="https://runic-artifex.eu/xaml/views"
xmlns:local="clr-namespace:MyApp">
<rv:RunicViewHost Model="{Binding}">
<!-- Shown with the same DataContext when the web View can't open. -->
<local:NativeEditorView />
</rv:RunicViewHost>
</UserControl>
The host needs the application's services. Services is an inherited
property, so set it once on the window, and every host inside, including one
in a DataTemplate, receives it:
RunicViewHost.SetServices(mainWindow, services);
The hybrid example creates the host in code instead, in a page View that
navigation created with its own services and model context. It binds Model
to the page's DataContext and passes the navigation's dispatcher model
context, because WPF and the web View present the same model:
var host = WebHost = new RunicViewHost { Services = _services, ModelContext = _context, Fallback = new NativeEditorView() };
host.SetBinding(RunicViewHost.ModelProperty, new Binding());
host.StateChanged += (_, _) => PresentationError.Text = host.State == RunicViewHostState.Failed
? $"Web editor unavailable: {host.Error?.Message} You can keep editing natively or reload the web editor."
: "";
EditorHost.Content = host;
The host opens when it is loaded and has a Model and Services, and closes
when it is unloaded. It borrows the model, the services and the context: it
never creates or disposes a ViewModel, a scope or a navigation entry.
State, Error and StateChanged report failures, and ReloadAsync()
retries. Closing the host cancels the web View's unfinished operations, such
as a Save started from the page; the model and its draft stay.
Guard the window's close #
Disposing the services at exit retires navigation entries without asking
their departure guards, so a dirty draft would be lost. Set GuardWindowClose on the main window's
NavigationHost to ask the same guards as Back does:
<rn:NavigationDialogHost Region="{Binding Dialog}" />
<!-- Closing the window asks the editor's departure guard, as Back does. -->
<rn:NavigationHost x:Name="Main" Region="{Binding Main}" GuardWindowClose="True" />
Closing the window, with the close button, Alt+F4 or Close(), is then
cancelled while the region has entries, and the region is cleared
instead. Each entry's guard can ask in the dialog region, for example with
LeaveConfirmation.InDialog. When the clear commits, the window closes; when
a guard refuses, it stays open. Two cases skip the guards:
Application.Shutdown()ignores a cancelled close. Close the main window instead of callingShutdown().- WPF doesn't raise
Closingwhen the Windows session ends. HandleApplication.SessionEndingyourself if drafts must survive a log-off.
The guard belongs to the window, not to the loaded host. A host on an unselected tab is unloaded and its View discarded, but the window keeps guarding its region, so a draft on a hidden tab is still asked about:
<!-- CustomersTabView.xaml, the View of a tab in a TabControl bound with NavigationSelector.Region -->
<rn:NavigationHost Region="{Binding Pages}" GuardWindowClose="True" />
Selecting another tab loses nothing: the tab's page region keeps its entries.
When the tab's model owns that region, the switch asks its current page as a
retained departure, which a LeaveConfirmation without askOnRetain allows
without asking; this is new in SDK 0.7.0-preview.8, and before it a tab
switch asked only the tab model. Closing the window clears every region a guarding
host has loaded with, one after another, and stops at the first that a guard
keeps. See the Runic.Navigation.Wpf README,
"Tabs and other hosts that unload".
Start windows in code, not with StartupUri #
With StartupUri, WPF creates the window from XAML right after OnStartup
returns. It doesn't wait for anything that OnStartup awaits, and the window
has no constructor parameters for services. So a window that needs services,
a navigation scope or a translation manager at load time can see none of
them.
The hybrid example therefore has no StartupUri. OnStartup builds the
services, creates the window's scope, shows the window with its regions and
only then navigates:
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
_services = AddServices(new ServiceCollection()).BuildServiceProvider();
_windowScope = _services.CreateScope();
var regions = _windowScope.ServiceProvider.GetRequiredService<AppRegions>();
new MainWindow { DataContext = regions }.Show();
await regions.Main.ResetAsync<NotesListViewModel>();
}
protected override void OnExit(ExitEventArgs e)
{
_windowScope?.Dispose();
_services?.Dispose();
base.OnExit(e);
}
If you keep StartupUri, everything the window needs when it loads must exist
when OnStartup returns: build the services synchronously, give the window
its services from its constructor, and create a Translations manager with the
synchronous CreateManager instead of an awaited CreateManagerAsync. Only
Translations 0.6.0-preview.7 and later have CreateManager.
StartupUri remains fine for a window that needs no services when it loads.
ServiceProvider.Dispose() in OnExit starts the navigator's shutdown but
doesn't wait for it. When owned cleanup must finish, dispose the provider
asynchronously after app.Run() returns in a custom Main, as the
Runic.Navigation.Wpf guide
shows. Never block the dispatcher on DisposeAsync while it runs.
Limits #
- WPF runs on .NET 10 on Windows, and the web View needs the Microsoft Edge
WebView2 Runtime. The projects also compile on Linux and macOS with
EnableWindowsTargeting. - One host presents one surface. WPF elements can't draw over the web View (the usual HWND airspace rule).
- Tab moves into the web page, but not yet back out to the surrounding WPF controls at the page's last tab stop.
- There is no browser fallback: on a machine without WebView2 the host shows its fallback content.
Next steps #
- Adopt Runic incrementally in WPF is the migration path this guide belongs to.
- Pages and navigation explains regions, guards and dialog results.
- The
Runic.Application.Views.Wpf README
lists every
RunicViewHostproperty and the lower-level API.