Skip to content

feat: Multi Window Support#72

Closed
0xApp wants to merge 1 commit intoDotNetExtension:mainfrom
0xApp:main
Closed

feat: Multi Window Support#72
0xApp wants to merge 1 commit intoDotNetExtension:mainfrom
0xApp:main

Conversation

@0xApp
Copy link

@0xApp 0xApp commented Feb 26, 2026

Multi Window Support

Summary

  • Add IWindowManager service for opening and managing multiple desktop windows, each with an independent Blazor component tree
  • Add Razor component for declarative window management (open on render, close on dispose)
  • Refactor BlazorDesktopWindow to accept WindowOptions POCO instead of reading IConfiguration directly, enabling child windows with custom settings
  • Add multi-window demo page to BlazorDesktop.Sample showcasing both service-based and component-based APIs
  • Update README with multi-window documentation, API reference, and updated examples

New files

  • Hosting/WindowOptions.cs - Per-window configuration POCO with FromConfiguration() factory
  • Hosting/DesktopWindowHandle.cs - Lightweight handle to a managed window (Id, IsMainWindow, Closed event)
  • Services/IWindowManager.cs - Public interface: OpenAsync(), CloseAsync(), MainWindow, events
  • Services/WindowManager.cs - Internal implementation with ConcurrentDictionary tracking, WPF Dispatcher marshaling
  • Components/DesktopWindow.cs - Blazor component wrapping IWindowManager for declarative usage

Modified files

  • Wpf/BlazorDesktopWindow.cs - Added internal constructor accepting WindowOptions + RootComponentMappingCollection; replaced all IConfiguration reads with _options field
  • Hosting/BlazorDesktopHostBuilder.cs - Replaced BlazorDesktopWindow singleton with IWindowManager/WindowManager singleton
  • Services/BlazorDesktopService.cs - Creates BlazorDesktopWindow manually on STA thread; registers with WindowManager

Breaking change

BlazorDesktopWindow is no longer registered in the DI container. Code that injected it directly must use IWindowManager.MainWindow.NativeWindow instead.

Test plan

  • dotnet build BlazorDesktop.sln succeeds with 0 warnings/errors
  • Run BlazorDesktop.Sample, verify main window works identically to before
  • Navigate to Multi-Window page, click "Open Window (Service)" - child window opens with correct title/size and independent counter
  • Click "Open Window (Component)" - toggle opens/closes a declarative child window
  • Close a child window via X button - Closed event fires, window count updates, main app stays open
  • Close main window - all child windows close and app exits

@0xApp 0xApp mentioned this pull request Feb 26, 2026
1 task
@0xApp 0xApp changed the title Multi Window Support feat: Multi Window Support Feb 26, 2026
@russkyc
Copy link

russkyc commented Feb 28, 2026

This is how i would imagine #42 would be api-wise, @andrewbabbittdev what do you think?

@andrewbabbittdev
Copy link
Collaborator

andrewbabbittdev commented Feb 28, 2026

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.

@andrewbabbittdev
Copy link
Collaborator

Took a peek over things, and yeah would still like to see a design doc for this.

As I stated in #42

I'd like to start taking API proposals to figure out how this feature should actually function before we start any coding work as there are quite a few ways this could be implemented and each have their own pros and cons.

On top of implementing this feature, I would also like for the proposed solution to address #55 and #65

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 #app which is not ideal, we let users configure this in Program.cs as Blazor Wasm lets users do.

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 NavigationManager in the docs and noticed it was abstract, meaning we could extend it to add our own navigate method that does the navigation in a new window. This is a pattern users in Blazor are already familiar with, and would be ideal from an API design perspective.

STAThread (#65) still ideally needs to be resolved as a part of this.

@0xApp
Copy link
Author

0xApp commented Mar 7, 2026

API Proposal: Multi-Window Support, Window Configuration & STA Thread Safety

Addresses: #42 (Multi-Window Support), #55 (Window Configuration Accessibility), #65 (STA Thread Safety)
Target: BlazorDesktop 10.0 (.NET 10)


1. Problem Statement

BlazorDesktop currently has three interrelated gaps:

  1. No multi-window support ([Nice to have] Multi Window Support #42) — Apps are limited to a single window. There is no way to open, manage, or navigate between multiple windows.
  2. Window configuration is inaccessible (implement the use of CenterScreen #55) — Properties like WindowStartupLocation cannot be safely set from Program.cs because the WPF window lives on a separate STA thread.
  3. STA thread errors (Add support for fullscreen mode configuration without STA thread issues #65) — Attempting to configure the window before RunAsync() throws cross-thread exceptions because BlazorDesktopService spawns the WPF Application on a background STA thread, making it unreachable from the main thread.

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

  • Familiar Blazor patterns — Use URL-based routing (StartPath), NavigationManager, and standard configuration binding. Developers coming from Blazor WebAssembly or Server should feel at home.
  • Configuration at the right time — Allow window properties to be set before the window is shown, without requiring consumers to understand WPF threading.
  • Flexible multi-window — Support both URL-routed windows (primary) and component-typed windows (escape hatch), with programmatic, declarative, and navigation-based APIs.
  • Minimal breaking changes — Existing Program.cs patterns continue to work. New capabilities are additive.

3. Proposed API Surface

3.1 Window Configuration via ConfigurationBinder.Bind()

Rationale: The current ConfigureWindowBuilder fluent API and WindowDefaults flat key system (window:title, window:width, etc.) are custom abstractions over configuration. The standard .NET pattern is to use ConfigurationBinder.Bind() with a strongly-typed options class and a structured configuration section.

3.1.1 WindowOptions (revised)

namespace BlazorDesktop.Hosting;

/// <summary>
/// Configuration options for a desktop window. Can be bound from IConfiguration.
/// </summary>
public class WindowOptions
{
    public string? Title { get; set; }
    public int? Height { get; set; }
    public int? Width { get; set; }
    public int? MinHeight { get; set; }
    public int? MinWidth { get; set; }
    public int? MaxHeight { get; set; }
    public int? MaxWidth { get; set; }
    public bool? Frame { get; set; }
    public bool? Resizable { get; set; }
    public string? Icon { get; set; }

    // --- New properties addressing #55 and #65 ---

    /// <summary>
    /// The startup location of the window. Defaults to OS default behavior.
    /// </summary>
    public WindowStartupLocation? StartupLocation { get; set; }

    /// <summary>
    /// The initial window state (Normal, Minimized, Maximized).
    /// </summary>
    public WindowState? State { get; set; }

    /// <summary>
    /// The window style (None, SingleBorderWindow, etc.).
    /// Use None for kiosk/fullscreen scenarios.
    /// </summary>
    public WindowStyle? Style { get; set; }

    /// <summary>
    /// Whether the window should be topmost.
    /// </summary>
    public bool? Topmost { get; set; }

    /// <summary>
    /// The initial start path for URL-based routing in this window.
    /// Maps to BlazorWebView.StartPath.
    /// </summary>
    public string? StartPath { get; set; }
}

3.1.2 Configuration Binding (appsettings.json)

Move from flat keys to a structured Window section:

{
  "Window": {
    "Title": "My App",
    "Width": 1366,
    "Height": 768,
    "StartupLocation": "CenterScreen",
    "State": "Normal",
    "Frame": true,
    "Resizable": true,
    "Icon": "app-icon.ico"
  }
}

3.1.3 Binding in the Host Builder

Replace ConfigureWindowBuilder and WindowDefaults with standard configuration binding:

// Internal: BlazorDesktopHostBuilder binds the "Window" section
var windowOptions = new WindowOptions();
configuration.GetSection("Window").Bind(windowOptions);
Services.AddSingleton(windowOptions);

3.1.4 Programmatic Configuration in Program.cs

var builder = BlazorDesktopHostBuilder.CreateDefault(args);

builder.RootComponents.Add<Routes>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

// Configure the main window — applied before the window is shown.
// This replaces builder.Window.UseTitle(...) etc.
builder.ConfigureWindow(window =>
{
    window.Title = "My Desktop App";
    window.Width = 1920;
    window.Height = 1080;
    window.StartupLocation = WindowStartupLocation.CenterScreen;
    window.State = WindowState.Maximized;
    window.Style = WindowStyle.None;       // Kiosk mode (#65)
    window.Topmost = true;
    window.Resizable = false;
});

await builder.Build().RunAsync();

The ConfigureWindow delegate runs after Bind(), so programmatic values override appsettings. This is the standard Configure<T> layering pattern.

3.1.5 Migration Path

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: ConfigureWindowBuilder and WindowDefaults are removed. The flat window:* keys in appsettings are replaced by a structured Window section. 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 NavigationManager scoped to that window's WebView.
  • No special "child component" registration — just use @page directives.

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.cs exactly 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 Router in Routes.razor handles navigation to the correct page via StartPath.
  • Component-typed child windows (escape hatch): A single root component is mounted at the selector specified in index.html (default #app). No Router is 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: NavigationManager in each window is scoped to that window's BlazorWebView. The DesktopNavigationManager for the main window opens child windows. Each child window also gets its own DesktopNavigationManager that 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 @if logic.
  • 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 BlazorWebView with 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 a StartPath. 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

  1. Per-window DI scope: Should each child window get its own IServiceScope? This would allow per-window scoped services (e.g., per-window NavigationManager). The main window currently uses the root scope. Recommendation: yes, create a scope per window.

  2. 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 IWindowManager events are sufficient.

  3. 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 an WindowOptions.Owner property to opt out.

  4. 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.

  5. 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)

@andrewbabbittdev
Copy link
Collaborator

@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).

@DotNetExtension DotNetExtension locked and limited conversation to collaborators Mar 7, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants