Conversation
|
This is how i would imagine #42 would be api-wise, @andrewbabbittdev what do you think? |
|
Mmmm generally prefer to start with a design proposal before starting on a PR @russkyc I would still like to see one filled out to go with this PR. It is under the issue templates. I just got back from vacation so I wont have time to go over this for a little as I catch up on work and other stuff. |
|
Took a peek over things, and yeah would still like to see a design doc for this. As I stated in #42
While moving to options for configuration is nice, it really doesn't get us closer to where we want to be; which is better accessibility to window configuration. Instead we could probably just use configuration binding directly to the windows then pass the window directly as the configure object: https://learn.microsoft.com/en-us/dotnet/api/microsoft.extensions.configuration.configurationbinder.bind?view=net-10.0-pp Root components in this PR are being configured statically as This does bring the question of how these should be done as well, as there are multiple options with different tradeoffs (again, this is why we ask for design proposals BEFORE starting coding work 🙂). This also gets into how navigation works with Blazor. You have a user select a specific component to display, but what about url based routing? Why not just setup child windows the same way as the main window and use the BlazorWebView.StartPath property instead to let the user route to a specific page in the newly created window? I also just looked at STAThread (#65) still ideally needs to be resolved as a part of this. |
API Proposal: Multi-Window Support, Window Configuration & STA Thread SafetyAddresses: #42 (Multi-Window Support), #55 (Window Configuration Accessibility), #65 (STA Thread Safety) 1. Problem StatementBlazorDesktop currently has three interrelated gaps:
These three issues share a root cause: the window lifecycle is opaque to consumers. The proposed API redesigns window creation, configuration, and management as first-class concepts. 2. Design Goals
3. Proposed API Surface3.1 Window Configuration via
|
| Before (current) | After (proposed) |
|---|---|
builder.Window.UseTitle("Foo") |
builder.ConfigureWindow(w => w.Title = "Foo") |
builder.Window.UseWidth(1920) |
builder.ConfigureWindow(w => w.Width = 1920) |
config["window:title"] in appsettings |
"Window": { "Title": "..." } in appsettings |
WindowOptions.FromConfiguration(config) |
config.GetSection("Window").Bind(options) |
ConfigureWindowBuilder class |
Removed |
WindowDefaults class |
Removed (section name is "Window") |
Breaking change:
ConfigureWindowBuilderandWindowDefaultsare removed. The flatwindow:*keys in appsettings are replaced by a structuredWindowsection. This is a clean break for a major version (10.0).
3.2 STA Thread Safety (#65)
Root cause: BlazorDesktopService.StartAsync() spawns a background STA thread for WPF. The WindowOptions are read on that thread when constructing BlazorDesktopWindow. Consumers cannot access the WPF window from the main thread.
Solution: All window configuration is expressed as data (WindowOptions) before the WPF thread starts. BlazorDesktopService applies the fully-resolved WindowOptions when constructing the window on the STA thread. No WPF objects are exposed to the main thread at build time.
Main Thread STA Thread
----------- ----------
builder.ConfigureWindow(...) ---> WindowOptions (POCO, thread-safe)
builder.Build() ---> Host built, options finalized
app.RunAsync() ---> BlazorDesktopService.StartAsync()
|
+--> new BlazorDesktopWindow(options)
| applies Title, State, Style,
| StartupLocation, Topmost, etc.
|
+--> app.Run()
This fully resolves #65 — consumers never touch WPF objects from the wrong thread. All window properties (including WindowStartupLocation, WindowState, WindowStyle) are set during InitializeWindow() on the STA thread, before the window is shown.
3.3 Multi-Window Support (#42)
3.3.1 Core Approach: URL-Routed Windows with StartPath
Each window is a full BlazorWebView with its own Router. Child windows navigate to a Blazor page via StartPath, exactly like the main window. This means:
- All Blazor routing, layouts, and components work identically across windows.
- Each window has its own
NavigationManagerscoped to that window's WebView. - No special "child component" registration — just use
@pagedirectives.
3.3.2 IWindowManager (revised)
namespace BlazorDesktop.Services;
public interface IWindowManager
{
/// <summary>
/// Handle to the main application window.
/// </summary>
DesktopWindowHandle MainWindow { get; }
/// <summary>
/// All currently open windows (including main).
/// </summary>
IReadOnlyList<DesktopWindowHandle> Windows { get; }
/// <summary>
/// Opens a new window that navigates to the specified path.
/// The window gets its own Router, NavigationManager, and full Blazor pipeline.
/// This is the PRIMARY API for multi-window.
/// </summary>
/// <param name="startPath">The URL path to navigate to (e.g., "/settings").</param>
/// <param name="configure">Optional window configuration.</param>
/// <returns>A handle to the opened window.</returns>
Task<DesktopWindowHandle> OpenAsync(
string startPath,
Action<WindowOptions>? configure = null);
/// <summary>
/// Opens a new window hosting a specific component directly.
/// Escape hatch for scenarios where URL routing is not desired.
/// The component renders at the root selector without a Router.
/// </summary>
/// <typeparam name="TComponent">The root component type.</typeparam>
/// <param name="configure">Optional window configuration.</param>
/// <param name="parameters">Optional component parameters.</param>
/// <returns>A handle to the opened window.</returns>
Task<DesktopWindowHandle> OpenAsync<TComponent>(
Action<WindowOptions>? configure = null,
IDictionary<string, object?>? parameters = null)
where TComponent : IComponent;
/// <summary>
/// Closes a child window.
/// </summary>
Task CloseAsync(DesktopWindowHandle handle);
event EventHandler<DesktopWindowHandle>? WindowOpened;
event EventHandler<DesktopWindowHandle>? WindowClosed;
}Key difference from current PR: The primary OpenAsync overload takes a string startPath, not a component type. This sets BlazorWebView.StartPath on the new window, which causes Blazor's Router to resolve the matching @page component. The component-typed overload remains as an escape hatch.
3.3.3 How URL-Routed Windows Work Internally
OpenAsync("/settings", opts => { opts.Title = "Settings"; })
|
+--> STA Dispatcher.InvokeAsync:
|
+--> new BlazorDesktopWindow(services, env, options, rootComponents)
| rootComponents = same as main window (Routes + HeadOutlet)
| BlazorWebView.StartPath = "/settings"
|
+--> window.Show()
|
+--> Router resolves @page "/settings" --> SettingsPage.razor
Each child window uses the same root component mappings as the main window (the Routes component with Router, HeadOutlet, etc.). The StartPath property tells the BlazorWebView which URL to navigate to initially, so the Router resolves the correct page. This is identical to how Blazor WebAssembly works with different URLs.
3.3.4 Root Component Configuration
Root components should not be hardcoded to #app. The proposed design:
- Main window: Root components are configured in
Program.csexactly as today —builder.RootComponents.Add<Routes>("#app"). This matches Blazor WebAssembly conventions. - URL-routed child windows: Reuse the same root component mappings from the builder. The
RouterinRoutes.razorhandles navigation to the correct page viaStartPath. - Component-typed child windows (escape hatch): A single root component is mounted at the selector specified in
index.html(default#app). NoRouteris involved; the component renders directly.
Users control the selector through their index.html, not through hardcoded framework code.
3.3.5 DesktopWindowHandle (revised)
namespace BlazorDesktop.Hosting;
public sealed class DesktopWindowHandle
{
/// <summary>
/// Unique identifier for this window.
/// </summary>
public string Id { get; }
/// <summary>
/// Whether this is the main application window.
/// </summary>
public bool IsMainWindow { get; }
/// <summary>
/// The start path this window was opened with (null for component-typed windows).
/// </summary>
public string? StartPath { get; }
/// <summary>
/// The resolved WindowOptions for this window.
/// </summary>
public WindowOptions Options { get; }
/// <summary>
/// Occurs when the window is closed.
/// </summary>
public event EventHandler? Closed;
}3.4 NavigationManager Extension
Rationale: Blazor developers are familiar with NavigationManager.NavigateTo(). Adding a NavigateToNewWindow() extension provides a natural API for the common case of "open this URL in a new window."
namespace BlazorDesktop.Services;
/// <summary>
/// Extension methods for NavigationManager to support multi-window navigation.
/// </summary>
public static class NavigationManagerExtensions
{
/// <summary>
/// Navigates to the specified URI in a new desktop window.
/// </summary>
/// <param name="navigation">The NavigationManager instance.</param>
/// <param name="uri">The URI to navigate to in the new window.</param>
/// <param name="configure">Optional window configuration.</param>
/// <returns>A handle to the opened window.</returns>
public static Task<DesktopWindowHandle> NavigateToNewWindowAsync(
this NavigationManager navigation,
string uri,
Action<WindowOptions>? configure = null);
}3.4.1 How It Accesses Services
NavigationManager is abstract and doesn't expose IServiceProvider. The extension method cannot directly resolve IWindowManager. Two options:
Option A: Custom DesktopNavigationManager (Recommended)
Register a custom NavigationManager subclass that holds a reference to IWindowManager:
namespace BlazorDesktop.Services;
internal class DesktopNavigationManager : WebViewNavigationManager
{
private readonly IWindowManager _windowManager;
public DesktopNavigationManager(IWindowManager windowManager)
{
_windowManager = windowManager;
}
/// <summary>
/// Navigates to the specified URI in a new desktop window.
/// </summary>
public Task<DesktopWindowHandle> NavigateToNewWindowAsync(
string uri,
Action<WindowOptions>? configure = null)
{
return _windowManager.OpenAsync(uri, configure);
}
}This is registered in DI, replacing the default NavigationManager:
// In BlazorDesktopHostBuilder.InitializeDefaultServices()
Services.AddScoped<DesktopNavigationManager>();
Services.AddScoped<NavigationManager>(sp => sp.GetRequiredService<DesktopNavigationManager>());Usage in components:
@inject DesktopNavigationManager Navigation
<button @onclick="OpenSettings">Settings</button>
@code {
private async Task OpenSettings()
{
await Navigation.NavigateToNewWindowAsync("/settings", opts =>
{
opts.Title = "Settings";
opts.Width = 600;
opts.Height = 400;
});
}
}Option B: Extension method resolving from a static/ambient service locator
This is an anti-pattern and is not recommended. Included only for completeness.
Recommendation: Option A. It follows the Blazor pattern of custom NavigationManager implementations (e.g., WebViewNavigationManager for MAUI) and avoids service locator anti-patterns.
Note:
NavigationManagerin each window is scoped to that window'sBlazorWebView. TheDesktopNavigationManagerfor the main window opens child windows. Each child window also gets its ownDesktopNavigationManagerthat could open further child windows (if desired).
3.5 Declarative <DesktopWindow> Component (Revised)
The <DesktopWindow> component allows opening windows from Razor markup. Revised to support StartPath:
namespace BlazorDesktop.Components;
public sealed class DesktopWindow : ComponentBase, IAsyncDisposable
{
[Inject] private IWindowManager WindowManager { get; set; } = default!;
/// <summary>
/// The URL path to navigate to in the new window.
/// Mutually exclusive with ComponentType. One must be provided.
/// </summary>
[Parameter] public string? StartPath { get; set; }
/// <summary>
/// The root component type (escape hatch — prefer StartPath for routing).
/// </summary>
[Parameter] public Type? ComponentType { get; set; }
// Window options as parameters
[Parameter] public string? Title { get; set; }
[Parameter] public int? Width { get; set; }
[Parameter] public int? Height { get; set; }
[Parameter] public int? MinWidth { get; set; }
[Parameter] public int? MinHeight { get; set; }
[Parameter] public int? MaxWidth { get; set; }
[Parameter] public int? MaxHeight { get; set; }
[Parameter] public bool? Frame { get; set; }
[Parameter] public bool? Resizable { get; set; }
[Parameter] public string? Icon { get; set; }
[Parameter] public WindowStartupLocation? StartupLocation { get; set; }
[Parameter] public WindowState? State { get; set; }
[Parameter] public WindowStyle? Style { get; set; }
[Parameter] public bool? Topmost { get; set; }
/// <summary>
/// Optional parameters for ComponentType (ignored when using StartPath).
/// </summary>
[Parameter] public IDictionary<string, object?>? Parameters { get; set; }
/// <summary>
/// Called when the window is closed.
/// </summary>
[Parameter] public EventCallback OnClosed { get; set; }
}Usage:
@* URL-routed window (preferred) *@
@if (_showSettings)
{
<DesktopWindow StartPath="/settings"
Title="Settings" Width="600" Height="400"
StartupLocation="WindowStartupLocation.CenterScreen"
OnClosed="@(() => _showSettings = false)" />
}
@* Component-typed window (escape hatch) *@
@if (_showPreview)
{
<DesktopWindow ComponentType="typeof(PreviewPanel)"
Title="Preview" Width="800" Height="600"
Parameters="@_previewParams"
OnClosed="@(() => _showPreview = false)" />
}3.6 Evaluation: <Window> Razor Component Model
The idea of a <Window> component where the window itself is a Razor component:
@* MainWindow.razor *@
<Window Height="200" Width="500" Center="true" />Pros
- Declarative and intuitive for simple cases.
- Window visibility can be controlled with standard Razor
@iflogic. - Properties can be data-bound.
Cons
- Fundamental mismatch with WPF threading: Razor components render on the Blazor render thread (inside WebView2). WPF windows must be created and configured on the STA/Dispatcher thread. A
<Window>component would need to marshal every property change across threads, adding complexity and potential race conditions. - Lifecycle confusion: Component lifecycle (
OnInitialized,OnParametersSet,Dispose) doesn't map cleanly to WPF window lifecycle (Loaded,Closing,Closed). When does the window actually open —OnInitialized?OnAfterRender? What happens if parameters change after the window is open? - Two render trees: Each window has its own
BlazorWebViewwith its own render tree. The parent<Window>component lives in the parent window's render tree. Changes to<Window>parameters would need to cross both the thread boundary and the render tree boundary. - Doesn't compose well with routing: If each window has a
Router, you don't need a component wrapper — you just need aStartPath. The<Window>component adds a layer without clear benefit over<DesktopWindow StartPath="...">. - Registration complexity:
builder.AddWindows<App>()implies scanning the component tree for<Window>elements, which Blazor doesn't support — components are discovered at render time, not at build time.
Recommendation
Do not pursue the <Window> component model as the primary API. The <DesktopWindow> component (Section 3.5) already provides declarative window management with Razor @if logic, without the threading and lifecycle complications. It's a thin wrapper over IWindowManager, keeping the complexity in the right layer.
The <Window> model could be revisited in a future version if Blazor gains better primitives for out-of-tree rendering, but for 10.0 it would add significant complexity for marginal ergonomic benefit.
4. Complete Usage Examples
4.1 Basic App (addresses #55, #65)
Program.cs:
using BlazorDesktop.Hosting;
using MyApp.Components;
using Microsoft.AspNetCore.Components.Web;
var builder = BlazorDesktopHostBuilder.CreateDefault(args);
builder.RootComponents.Add<Routes>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Safe window configuration — no STA thread issues
builder.ConfigureWindow(window =>
{
window.StartupLocation = WindowStartupLocation.CenterScreen; // #55
window.State = WindowState.Maximized; // #65
window.Style = WindowStyle.None; // #65 kiosk
window.Topmost = true;
});
if (builder.HostEnvironment.IsDevelopment())
{
builder.UseDeveloperTools();
}
await builder.Build().RunAsync();appsettings.json:
{
"Window": {
"Title": "Kiosk Dashboard",
"StartupLocation": "CenterScreen",
"State": "Maximized",
"Style": "None",
"Topmost": true
}
}4.2 Multi-Window with URL Routing
Program.cs:
var builder = BlazorDesktopHostBuilder.CreateDefault(args);
builder.RootComponents.Add<Routes>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
await builder.Build().RunAsync();Pages/Home.razor:
@page "/"
@inject DesktopNavigationManager Navigation
<h1>Home</h1>
<button @onclick="OpenSettings">Open Settings</button>
<button @onclick="OpenDashboard">Open Dashboard</button>
@code {
private async Task OpenSettings()
{
await Navigation.NavigateToNewWindowAsync("/settings", opts =>
{
opts.Title = "Settings";
opts.Width = 600;
opts.Height = 400;
opts.StartupLocation = WindowStartupLocation.CenterScreen;
});
}
private async Task OpenDashboard()
{
await Navigation.NavigateToNewWindowAsync("/dashboard", opts =>
{
opts.Title = "Live Dashboard";
opts.Width = 1200;
opts.Height = 800;
});
}
}Pages/Settings.razor:
@page "/settings"
<h1>Settings</h1>
<p>This page works in both the main window and child windows.</p>4.3 Declarative Multi-Window
@page "/workspace"
@using BlazorDesktop.Components
<h1>Workspace</h1>
<button @onclick="() => _showToolbox = !_showToolbox">Toggle Toolbox</button>
<button @onclick="() => _showPreview = !_showPreview">Toggle Preview</button>
@if (_showToolbox)
{
<DesktopWindow StartPath="/toolbox"
Title="Toolbox" Width="300" Height="600"
OnClosed="@(() => _showToolbox = false)" />
}
@if (_showPreview)
{
<DesktopWindow StartPath="/preview"
Title="Preview" Width="800" Height="600"
OnClosed="@(() => _showPreview = false)" />
}
@code {
private bool _showToolbox;
private bool _showPreview;
}4.4 Service-Based (IWindowManager directly)
@page "/manager-demo"
@inject IWindowManager WindowManager
<h1>Window Manager</h1>
<p>Open windows: @WindowManager.Windows.Count</p>
<button @onclick="OpenViaPath">Open via Path</button>
<button @onclick="OpenViaComponent">Open via Component</button>
@code {
private async Task OpenViaPath()
{
await WindowManager.OpenAsync("/reports", opts =>
{
opts.Title = "Reports";
});
}
private async Task OpenViaComponent()
{
// Escape hatch: render a component directly without routing
await WindowManager.OpenAsync<CustomViewer>(opts =>
{
opts.Title = "Viewer";
opts.Width = 500;
opts.Height = 400;
}, new Dictionary<string, object?> { ["DocumentId"] = 42 });
}
}5. Internal Implementation Notes
5.1 Window Creation Flow (URL-Routed)
IWindowManager.OpenAsync("/settings", configure)
|
+--> Build WindowOptions (apply configure delegate)
|
+--> Dispatcher.InvokeAsync:
| |
| +--> rootComponents = builder's RootComponentMappingCollection (shared)
| |
| +--> window = new BlazorDesktopWindow(services, env, options, rootComponents)
| | BlazorWebView.StartPath = "/settings"
| | BlazorWebView.HostPage = "index.html"
| | (Router resolves @page "/settings" at runtime)
| |
| +--> Apply StartupLocation, State, Style, Topmost from options
| +--> window.Owner = mainWindow (optional)
| +--> window.Show()
|
+--> Track handle in ConcurrentDictionary
+--> Raise WindowOpened event
+--> Return DesktopWindowHandle
5.2 Window Creation Flow (Component-Typed)
IWindowManager.OpenAsync<CustomViewer>(configure, parameters)
|
+--> Build WindowOptions
|
+--> Dispatcher.InvokeAsync:
| |
| +--> rootComponents = new RootComponentMappingCollection()
| | rootComponents.Add(typeof(CustomViewer), "#app", parameters)
| |
| +--> window = new BlazorDesktopWindow(services, env, options, rootComponents)
| | (No StartPath — component renders directly at #app)
| |
| +--> window.Show()
|
+--> Track + Return handle
5.3 Service Registration Changes
// BlazorDesktopHostBuilder.InitializeDefaultServices()
Services.AddHttpClient();
Services.AddWpfBlazorWebView();
Services.AddSingleton<WebViewInstaller>();
Services.AddSingleton<Application>();
Services.AddSingleton<IWindowManager, WindowManager>();
Services.AddHostedService<BlazorDesktopService>();
// New: bind WindowOptions from configuration
Services.AddSingleton(sp =>
{
var options = new WindowOptions();
sp.GetRequiredService<IConfiguration>().GetSection("Window").Bind(options);
return options;
});
// New: ConfigureWindow callback support
// (Applied after Bind, before window creation)5.4 BlazorDesktopWindow Changes
// In InitializeWindow(), apply new properties:
if (_options.StartupLocation.HasValue)
WindowStartupLocation = _options.StartupLocation.Value;
if (_options.State.HasValue)
WindowState = _options.State.Value;
if (_options.Style.HasValue)
WindowStyle = _options.Style.Value;
if (_options.Topmost.HasValue)
Topmost = _options.Topmost.Value;
// In InitializeWebView(), support StartPath:
if (!string.IsNullOrEmpty(_options.StartPath))
WebView.StartPath = _options.StartPath;6. Open Questions
-
Per-window DI scope: Should each child window get its own
IServiceScope? This would allow per-window scoped services (e.g., per-windowNavigationManager). The main window currently uses the root scope. Recommendation: yes, create a scope per window. -
Window-to-window communication: Should the framework provide a built-in mechanism (e.g., a message bus) for cross-window communication, or leave this to userland (shared singleton services, events)? Recommendation: leave to userland for 10.0; singleton services and
IWindowManagerevents are sufficient. -
Child window ownership: Should child windows always be owned by the main window (
window.Owner = mainWindow)? This affects z-ordering and minimize behavior. Recommendation: default to owned, with anWindowOptions.Ownerproperty to opt out. -
Window persistence/restore: Should window position/size be remembered across app restarts? This was mentioned in implement the use of CenterScreen #55. Recommendation: out of scope for 10.0; can be built in userland with the current API.
-
Deprecation timeline for
ConfigureWindowBuilder/WindowDefaults: Since this is a major version, these can be removed outright rather than deprecated. Confirm this is acceptable.
7. Summary of Changes
| Area | Current | Proposed |
|---|---|---|
| Window config API | ConfigureWindowBuilder fluent + flat keys |
ConfigurationBinder.Bind() + ConfigureWindow() delegate |
| Config format | "window:title" flat keys |
"Window": { "Title": ... } structured section |
| STA safety | Consumers must use Dispatcher | All config is data; framework applies on STA thread |
| Window properties | Title, size, frame, resize, icon | + StartupLocation, State, Style, Topmost, StartPath |
| Multi-window primary API | Component-typed OpenAsync<T>() |
URL-routed OpenAsync(startPath) |
| Multi-window escape hatch | — | Component-typed OpenAsync<T>() retained |
| Navigation integration | None | DesktopNavigationManager.NavigateToNewWindowAsync() |
| Declarative component | <DesktopWindow ComponentType=...> |
<DesktopWindow StartPath=...> (+ ComponentType as fallback) |
| Root components | Hardcoded #app in child windows |
Reuse builder's mappings for URL-routed; #app default for component-typed |
<Window> component model |
Not implemented | Evaluated and deferred (see Section 3.6) |
|
@0xApp You seem to have thrown a AI generated answer that missed what I talked about in my previous message especially around the binding API and removing a custom WindowOptions class. I also asked for a new issue to be opened using the design document template, which this isn't following at all. Now I have no problem with AI usage, but it's missing the mark here and not what I asked. I'm gonna close this, I'm still open to contribution on this however. Open an issue with the design document template, fill it out by hand, and then maybe use AI to review it and find gaps (I'll likely do this with Opus 4.6). |
Multi Window Support
Summary
New files
Modified files
Breaking change
BlazorDesktopWindow is no longer registered in the DI container. Code that injected it directly must use IWindowManager.MainWindow.NativeWindow instead.
Test plan