Skip to content

architecture

github-actions[bot] edited this page Aug 25, 2026 · 2 revisions

Architecture

How It Works

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Avalonia      │    │  Blazor Server   │    │     Photino     │
│   (Desktop      │◄──►│  (Web UI &       │◄──►│   (WebView      │
│    Framework)   │    │   Components)    │    │    Hosting)     │
└─────────────────┘    └──────────────────┘    └─────────────────┘
        ▲                        ▲                        ▲
        │                        │                        │
        ▼                        ▼                        ▼
┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Native OS     │    │    MudBlazor     │    │   File System   │
│   Integration   │    │   (Material UI)  │    │     Access      │
└─────────────────┘    └──────────────────┘    └─────────────────┘

How blazor.web.js Is Served

In .NET 10, blazor.web.js ships in the Microsoft.AspNetCore.App.Internal.Assets NuGet package. Its MSBuild targets register it as a static web asset — but only for OutputType=Exe projects using Microsoft.NET.Sdk.Web. Desktop apps use OutputType=WinExe, so the framework skips them entirely regardless of SDK choice.

CheapAvaloniaBlazor supplies the file itself, in three layers. Each one covers a case the layer above it cannot reach, and the app only needs one of them to succeed.

1. Build and publish (PackageReference consumers). Build/CheapAvaloniaBlazor.targets adds the script as Content targeting wwwroot/_framework/blazor.web.js, sourced from the restored Microsoft.AspNetCore.App.Internal.Assets package. Because it is Content and not a copy into the output folder, it survives dotnet publish and travels to machines with no SDK and no package cache. This is the normal path.

2. NuGet cache extraction (developer machines, ProjectReference). BlazorFrameworkExtractor runs at startup, picks the cached version matching the running runtime, and copies it into wwwroot/_framework/. MSBuild props and targets from a package are not imported through a ProjectReference, so the samples in this repo rely on this layer.

3. Embedded copy (everything else). A copy of the script is baked into CheapAvaloniaBlazor.dll at build time. When layers 1 and 2 both come up empty the extractor writes it out to wwwroot/_framework/, and if the install directory is read-only a middleware serves it straight from the assembly at /_framework/blazor.web.js. The DLL always ships with the app, so this layer cannot be missing.

UseStaticFiles() serves whatever landed on disk; the embedded middleware sits directly behind it and only answers when nothing else did.

The embedded copy is frozen at the version CheapAvaloniaBlazor was packed against, so on layer 3 an app running a newer 10.0.x runtime gets a slightly older bootstrapper. That is fine: blazor.web.js negotiates over a protocol that is stable across a major version. Layers 1 and 2 always match the app's own package graph, and layer 3 only runs when neither could supply anything at all.

Before 3.4.1 only layer 2 existed. A published app on a clean machine has no NuGet cache, so the script 404'd and the window rendered blank white with no error — the app was running and serving its HTML the whole time.

Technology Stack

Component Technology Version
UI Layer Blazor Server + MudBlazor 9.5.0
Desktop Framework Avalonia 12.0.4
WebView Host Photino.NET 4.0.16
Backend ASP.NET Core .NET 10.0
Interop Custom desktop services Built-in

Cross-Platform Compatibility

Feature Windows Linux macOS
Blazor UI / MudBlazor Tested Untested Untested
File Dialogs Tested Untested Untested
Window Management Tested Untested Untested
Clipboard Tested Untested Untested
Desktop Toasts Tested Untested Untested
System Tray Tested Varies by DE Untested
Minimize to Tray (hide window) Tested Fallback to minimize Fallback to minimize
System Notifications (JS) Tested Untested Untested
Settings Persistence Tested Untested Untested
App Lifecycle Events Tested Untested Untested
Theme Detection Tested Untested Untested
Global Hotkeys Tested Tested (D-Bus/X11) Not supported
Native Menu Bar Tested Not supported Not supported
Multi-Window / Child Windows Tested Untested Untested
Modal Dialogs (parent disable) Tested Not supported Not supported

Minimize to Tray uses Windows user32.dll P/Invoke to fully hide the window. On Linux/macOS, the window falls back to a regular minimize (taskbar icon stays visible). System Tray behavior on Linux depends on the desktop environment's support for Avalonia's TrayIcon API.

Services Overview

Service Lifetime Purpose
IDesktopInteropService Scoped File dialogs, window control, clipboard, system paths
INotificationService Singleton Desktop toasts + OS system notifications
ISystemTrayService Singleton Tray icon, context menu, minimize/restore to tray
ISettingsService Singleton JSON settings persistence (key-value + typed sections)
IAppLifecycleService Singleton Window lifecycle events (minimize, maximize, focus, close)
IThemeService Singleton OS dark/light mode detection and runtime change tracking
IHotkeyService Singleton System-wide global hotkeys (Windows + Linux, IsSupported for detection)
IMenuBarService Singleton Native Win32 menu bar (Windows only, IsSupported for detection)
IWindowService Singleton Child windows, modal dialogs, inter-window messaging
IDiagnosticLoggerFactory Singleton Conditional diagnostic logging
PhotinoMessageHandler Singleton JavaScript ↔ C# bridge communication

Project Structure

MyDesktopApp/
├── Program.cs                 # Application entry point
├── Components/
│   ├── App.razor             # HTML document root (loads scripts, CSS, renders Routes)
│   ├── Routes.razor          # Blazor router configuration
│   └── MainLayout.razor      # Main application layout
├── Pages/
│   ├── Index.razor           # Home page
│   └── Files.razor           # File management page
├── wwwroot/                  # Static web assets
│   └── css/
└── Services/                 # Your business logic
    └── IMyService.cs

Clone this wiki locally