2024-06-01
WPF without XAML
People assume WPF means XAML. It doesn't have to — you can drive the whole thing from C# if you accept a bit of ceremony and stop pretending Application is magic.
Novolis does not use this. The studios are Avalonia. Frank.Wpf is the older box, and this post is about that box.
What actually boots
`Application.Run` is the message pump. The pump wants an STA thread. A worker project gives you the generic host: config, logging, DI, and a place to hang a `BackgroundService`.
The host starts on Main. The window does not. Run does not return until the dispatcher shuts down, so if you call it on Main the host never finishes starting, and the only way out is to kill the process. Give the window a thread.
| Piece | Job |
|---|---|
Host.CreateApplicationBuilder | Logging, config, DI. This is what the worker template emits now. |
WindowHost | Starts the STA thread. Stays off it. |
Application.Run | The dispatcher loop. |
Why bother
I don't like XAML. Even with MVVM it is fiddly XML that fights you the moment you want real nesting, dynamic trees, or anything that is not a designer-friendly toy demo.
| Approach | Upside | Downside |
|---|---|---|
| XAML + MVVM | Designer tooling, markup and styles split apart | Verbosity, and merge-dictionary rabbit holes |
| C# trees | Refactoring works. A dynamic tree is just objects. | The construction code is long |
| Worker + host | Same DI and config as a headless service | One extra thread, and you have to mean it |
Three ways to get this wrong
- Scoped window. The scope ends, the window is still on screen, and the next resolve builds another one. One process, one
Application, oneMainWindow. Register both once. - `async void` on a click. The exception never reaches you.
- `Environment.Exit`. The host does not stop. It gets shot. Close the window with
IHostApplicationLifetime.StopApplication. When the host is stopping, callApplication.ShutdownthroughDispatcher.InvokeAsync.BeginInvokewill not take the method on its own.
The project
On Windows:
dotnet new worker -n Demo.WpfApplication -f net10.0Point the target at net10.0-windows and turn WPF on. Delete the generated Worker.cs. Keep the hosting package. The worker SDK does not reference it for you, and without it IHost is just a name. The version is whatever the template wrote.
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0-windows</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UseWpf>true</UseWpf>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.13" />
</ItemGroup>
</Project>App
Still an Application. No XAML file, and no StartupUri. The host owns startup.
using System.Windows;
namespace Demo.WpfApplication;
public sealed class App : Application
{
}Window
The logger comes from DI. The tree is a button.
using System.Windows;
using System.Windows.Controls;
using Microsoft.Extensions.Logging;
namespace Demo.WpfApplication;
public sealed class MainWindow : Window
{
public MainWindow(ILogger<MainWindow> logger)
{
Title = "WPF without XAML";
Width = 420;
Height = 220;
WindowStartupLocation = WindowStartupLocation.CenterScreen;
var button = new Button { Content = "Log something" };
button.Click += (_, _) => logger.LogInformation("Something");
Content = button;
}
}Window host
Construct the Window on the STA thread. Doing it earlier throws. ExecuteAsync returns as soon as that thread is running, so the host can finish starting.
using System.Threading;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Demo.WpfApplication;
public sealed class WindowHost(IServiceProvider services, IHostApplicationLifetime lifetime) : BackgroundService
{
protected override Task ExecuteAsync(CancellationToken stoppingToken)
{
var thread = new Thread(() => Run(stoppingToken))
{
IsBackground = true,
Name = "WPF"
};
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
return Task.CompletedTask;
}
private void Run(CancellationToken stoppingToken)
{
var app = services.GetRequiredService<App>();
var window = services.GetRequiredService<MainWindow>();
stoppingToken.Register(() =>
{
if (app.Dispatcher.HasShutdownStarted)
return;
app.Dispatcher.InvokeAsync(app.Shutdown);
});
window.Closed += (_, _) => lifetime.StopApplication();
app.Run(window);
}
}Program
The template's Program.cs is top-level statements. Those cannot carry [STAThread], and the entry point is still a COM citizen, so this file has a real Main. CreateApplicationBuilder is the host the template uses. Logging is already registered. The pump is the other thread.
using System;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace Demo.WpfApplication;
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<App>();
builder.Services.AddSingleton<MainWindow>();
builder.Services.AddHostedService<WindowHost>();
builder.Build().Run();
}
}The WPF overview is here if you want the desktop model from Microsoft's side: WPF. The worker template is documented with the generic host worker notes.