Barbatos.Wpf.Core
2.4.0
dotnet add package Barbatos.Wpf.Core --version 2.4.0
NuGet\Install-Package Barbatos.Wpf.Core -Version 2.4.0
<PackageReference Include="Barbatos.Wpf.Core" Version="2.4.0" />
<PackageVersion Include="Barbatos.Wpf.Core" Version="2.4.0" />
<PackageReference Include="Barbatos.Wpf.Core" />
paket add Barbatos.Wpf.Core --version 2.4.0
#r "nuget: Barbatos.Wpf.Core, 2.4.0"
#:package Barbatos.Wpf.Core@2.4.0
#addin nuget:?package=Barbatos.Wpf.Core&version=2.4.0
#tool nuget:?package=Barbatos.Wpf.Core&version=2.4.0
Barbatos.Wpf.Core
A modern application host and ready-made platform services for WPF
WpfApp/WpfAppBuilder give plain desktop WPF a real application host - dependency
injection, configuration, logging, and lifecycle events - plus a broad set of ready-made
platform services (AppInfo, Preferences, SecureStorage, Connectivity, ...)
registered in the same container.
📖 Documentation Menu
- Getting Started
- Hosting
- Essentials
- Optional desktop features
- Dialogs
- SplashScreen
- Periodic services
- State management (Barbatos.Wpf.Apsu)
- AI chat + MCP (Barbatos.Wpf.Mcp)
- Ecosystem
- API Reference
- Community
Getting Started
Introduction
What is Barbatos.Wpf.Core?
Traditional WPF apps wire up Application_Startup, a hand-rolled DI container, and ad hoc
static helpers for things like app info, preferences, or connectivity. Barbatos.Wpf.Core
gives you WpfApp/WpfAppBuilder — a real IHostApplicationBuilder with dependency
injection, configuration, logging, and lifecycle events — plus a dozen ready-made platform
services (AppInfo, Preferences, SecureStorage, Connectivity, Launcher, ...)
registered in the same container.
Familiar from .NET MAUI? The hosting model and Essentials-style services below are deliberately shaped like MAUI's own
MauiApp/MauiAppBuilderand Essentials APIs wherever a Windows equivalent exists - a head start if you've used them before, though nothing here requires that background.
Prerequisites
The rest of the documentation assumes basic familiarity with C#, .NET Dependency Injection,
Microsoft.Extensions.Hosting, and WPF.
Quick Start
Add the package via NuGet:
dotnet add package Barbatos.Wpf.Core
This library ships as a single package — Hosting and every module described below are part
of Barbatos.Wpf.Core, there is nothing else to install.
Your app's <TargetFramework> must include the Windows SDK version suffix —
net8.0-windows10.0.17763.0 / net9.0-windows10.0.17763.0 / net10.0-windows10.0.17763.0 —
not the bare net8.0-windows form. A Target Framework Moniker (TFM) like
net10.0-windows10.0.17763.0 has three parts: net10.0 (the .NET version), -windows (opts
into Windows-only APIs — Win32, WPF, the registry, ... — beyond the cross-platform BCL), and
10.0.17763.0 (the Windows SDK version the -windows APIs are projected from). That last
part isn't cosmetic: dropping it silently falls back to the windows7.0 baseline, which has
no WinRT projections at all — and this library's toast notification feature
(Microsoft.Toolkit.Uwp.Notifications, which needs Windows.UI.Notifications) fails to
compile without them. A mismatched TFM here surfaces as a NuGet restore error at build time
(NU1201: Project Barbatos.Wpf.Core is not compatible with net10.0-windows7.0 ...), not a
runtime issue — confirmed by trying it. Barbatos.Wpf.Aquarius has no such requirement (no
WinRT dependency) and works fine with the bare net8.0-windows/etc. form.
Hosting
App composition (the WpfProgram pattern)
1. Derive your App from WpfApplication
App.xaml:
<hosting:WpfApplication x:Class="MyApp.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:hosting="clr-namespace:Barbatos.Wpf.Hosting;assembly=Barbatos.Wpf.Core" />
App.xaml.cs:
public partial class App : WpfApplication
{
protected override WpfApp CreateWpfApp() => WpfProgram.CreateWpfApp();
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
MainWindow = Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
}
2. Compose the host
public static class WpfProgram
{
public static WpfApp CreateWpfApp()
{
var builder = WpfApp.CreateBuilder();
builder.Configuration.AddJsonFile("appsettings.json", optional: true);
builder.ConfigureLifecycleEvents(events => events.AddWpf(wpf => wpf
.OnStartup((app, args) => { /* ... */ })
.OnWindowCreated(window => { /* ... */ })
.OnWindowClosed((window, args) => { /* ... */ })));
builder.Services.AddSingleton<IGreetingService, GreetingService>();
builder.Services.AddSingleton<MainViewModel>();
builder.Services.AddSingleton<MainWindow>();
return builder.Build();
}
}
WpfAppBuilder implements IHostApplicationBuilder, so builder.Services is a standard
IServiceCollection, builder.Configuration is a ConfigurationManager registered as
IConfiguration, and builder.Logging registers real logging services (or a no-op
ILogger<T>/ILoggerFactory pair if you never touch it, so consumers never receive
null).
Lifecycle events
Application events (OnStartup, OnActivated, OnDeactivated, OnSessionEnding,
OnDispatcherUnhandledException, OnExit) and window events (OnWindowCreated,
OnWindowLoaded, OnWindowActivated, OnWindowDeactivated, OnWindowStateChanged,
OnWindowClosing, OnWindowClosed) are surfaced through ILifecycleEventService, and every
window gets its own service scope (with IWpfInitializeScopedService support).
builder.ConfigureLifecycleEvents(events => events.AddWpf(wpf => wpf
.OnStartup((app, args) => logger.LogInformation("Started with args: {Args}", args.Args))
.OnWindowClosing((window, args) => { /* prompt to save, or cancel via args.Cancel */ })));
IWpfInitializeService runs once during Build(); IWpfInitializeScopedService runs once
per window scope — both are the WPF counterparts of IMauiInitializeService/
IMauiInitializeScopedService.
Dispatching
IDispatcher, IDispatcherTimer, and IDispatcherProvider, backed by the WPF Dispatcher,
registered app-wide (singleton) and per-window (scoped), plus the DispatchAsync /
DispatchIfRequiredAsync extension helpers.
Configuring the hosting environment
WpfHostEnvironment (the WPF counterpart of MauiHostEnvironment/ASP.NET Core's
HostingEnvironment) resolves EnvironmentName the same way
Microsoft.Extensions.Hosting.HostBuilder does: WpfAppBuilder adds an environment
variables configuration source with the DOTNET_ prefix as the very first (lowest-priority)
entry in builder.Configuration, then reads HostDefaults.EnvironmentKey from it once,
before any of your own configuration sources run. That means:
Setting the
DOTNET_ENVIRONMENTenvironment variable (e.g.DOTNET_ENVIRONMENT=Staging) before launching the app setsbuilder.Environment.EnvironmentName, with no code required.Any configuration source you add that also sets the
environmentkey overrides it — including sources added before you first readbuilder.Environment:var builder = WpfApp.CreateBuilder(); builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?> { [HostDefaults.EnvironmentKey] = "Staging", }); // builder.Environment.EnvironmentName == "Staging" from here on.Unlike the WPF counterpart's previous behavior,
EnvironmentNameis a normal settable property —builder.Environment.EnvironmentName = "Staging";works directly, and the value is fixed onceBuild()has read it (it does not keep re-reading the environment variable).WpfHostEnvironmentalso exposesHostEnvironmentEnvExtensionsfromMicrosoft.Extensions.Hostingfor free —builder.Environment.IsDevelopment(),.IsProduction(),.IsEnvironment("Staging"), etc.
A common pattern is loading an environment-specific settings file:
var builder = WpfApp.CreateBuilder();
builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true);
Essentials
A broad set of platform services, each following the same interface + static facade design
(e.g. AppInfo.Current, Preferences.Default) so you can inject the interface for
testability or just call the static facade directly. Where a module needs OS integration,
the Windows implementation goes straight to the nearest Win32/.NET equivalent rather than
WinRT/UWP APIs (unavailable to a plain unpackaged WPF app). All of it is registered in the
container by UseEssentials(), part of the builder defaults:
| Module | Static facade | Interface | Windows implementation |
|---|---|---|---|
| App info | AppInfo |
IAppInfo |
Assembly metadata/attributes; theme from the AppsUseLightTheme registry value; packaging via GetCurrentPackageFullName; InstallDate/InstallLocation from the installer's Uninstall registry entry, if any |
| Publisher info | PublisherInfo |
IPublisherInfo |
Assembly metadata, falling back to AssemblyCompanyAttribute |
| Device identity | DeviceIdentity |
IDeviceIdentity |
See License enforcement: DeviceIdentity below — a desktop-specific addition |
| Device info | DeviceInfo |
IDeviceInfo |
BIOS registry (Model/Manufacturer); a Chromium-style tablet-mode heuristic for Idiom |
| File system | FileSystem |
IFileSystem |
%LocalAppData%\{Publisher}\{AppId}\{Data,Cache}, created on first access |
| Preferences | Preferences |
IPreferences |
A local JSON file, guarded by a lock around file I/O |
| Secure storage | SecureStorage |
ISecureStorage |
DPAPI (ProtectedData, current-user scope) instead of the WinRT DataProtectionProvider |
| Version tracking | VersionTracking |
IVersionTracking |
Pure C#, no platform-specific code (built on Preferences + AppInfo) |
| Connectivity | Connectivity |
IConnectivity |
System.Net.NetworkInformation instead of the WinRT NetworkInformation API |
| Device display | DeviceDisplay |
IDeviceDisplay |
A Win32 monitor-info P/Invoke block against the active WPF window; KeepScreenOn via SetThreadExecutionState; change notifications via SystemEvents.DisplaySettingsChanged |
Email |
IEmail |
Simple MAPI (MAPISendMail) |
|
| Launcher | Launcher |
ILauncher |
Process.Start(UseShellExecute: true) instead of the WinRT Windows.System.Launcher; CanOpenAsync checks HKEY_CLASSES_ROOT for a registered scheme handler |
| Contacts | Contacts |
IContacts |
Throws FeatureNotSupportedException — see note below |
| Geolocation | Geolocation |
IGeolocation |
Throws FeatureNotSupportedException — see note below; Location's distance math is fully functional |
| App actions | AppActions |
IAppActions |
The taskbar Jump List (System.Windows.Shell.JumpList) instead of the WinRT Windows.UI.StartScreen.JumpList |
| Permissions | Permissions.CheckStatusAsync<T>() / RequestAsync<T>() |
(none — generic static API, no DI) | Most permissions report Granted (no manifest/capability concept for an unpackaged app); ContactsRead/ContactsWrite/LocationWhenInUse/LocationAlways/Microphone/Sensors throw FeatureNotSupportedException — see Permissions below |
var name = AppInfo.Name;
var publisher = PublisherInfo.Name;
var isOnline = Connectivity.NetworkAccess == NetworkAccess.Internet;
Preferences.Set("launch_count", Preferences.Get("launch_count", 0) + 1);
await SecureStorage.SetAsync("api_token", token);
await Email.ComposeAsync("Subject", "Body", "someone@example.com");
await Launcher.OpenAsync("https://example.com");
Configuring AppInfo and PublisherInfo
AppInfo and PublisherInfo never require any setup — their fallback chains always resolve
to something — but you should configure them explicitly for any app you plan to ship,
because FileSystem.AppDataDirectory and FileSystem.CacheDirectory are derived from
PublisherInfo.Name and AppInfo.AppId (%LocalAppData%\{PublisherInfo.Name}\{AppInfo.AppId}\...).
This library targets .NET 8+ SDK-style projects only, so configuration is a plain
<PropertyGroup> in the csproj — the same well-known properties Visual Studio's Project ▸
Properties ▸ Package page already exposes, with no AssemblyInfo.cs file or ClickOnce
settings involved:
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<Product>My App</Product>
<Company>Contoso</Company>
<Version>1.2.3</Version>
<Copyright>Copyright © 2026 Contoso</Copyright>
</PropertyGroup>
The SDK turns each of these into the matching assembly attribute at build time (<Product>
→ AssemblyProductAttribute, <Company> → AssemblyCompanyAttribute, <Version> →
AssemblyVersionAttribute, <Copyright> → AssemblyCopyrightAttribute), which is exactly
what the fallback chains below read — so setting these four properties is normally all you
need:
| Property | Standard fallback | Standard csproj property |
|---|---|---|
AppInfo.Name |
AssemblyProductAttribute, then AssemblyTitleAttribute (<Title>), then the assembly name |
<Product> |
AppInfo.Version/VersionString |
The assembly's own Version |
<Version> (or <AssemblyVersion> for finer control) |
PublisherInfo.Name |
AssemblyCompanyAttribute |
<Company> |
PublisherInfo.Copyright |
AssemblyCopyrightAttribute |
<Copyright> |
AppInfo.AppId and PublisherInfo.Website/SupportUrl/SupportEmail have no standard
MSBuild-property equivalent — there is no built-in "app id"/"website"/"support URL"/"support
email" assembly attribute at all. For
those (or to override any of the properties above with a value that differs from
<Product>/<Company>/<Copyright>), set the Barbatos.Wpf.ApplicationModel.* assembly
metadata directly — it is always checked before the standard fallback:
<ItemGroup>
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.AppInfo.AppId" Value="{C6BB69DE-7E6B-43E3-83AD-AC46E1B0570D}" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.PublisherInfo.Website" Value="https://contoso.example" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.PublisherInfo.SupportUrl" Value="https://contoso.example/support" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.PublisherInfo.SupportEmail" Value="support@contoso.example" />
</ItemGroup>
AppId does not have to be a GUID — com.contoso.myapp or Contoso.MyApp work just as
well. Use a GUID when you want it to double as an MSI product code (see
Publishing with an installer); use a readable string
otherwise, since it also becomes the app's storage folder name.
| Property | Metadata key (explicit override) |
|---|---|
AppInfo.AppId |
Barbatos.Wpf.ApplicationModel.AppInfo.AppId |
(the Uninstall subkey AppInfo.InstallDate/InstallLocation read from) |
Barbatos.Wpf.ApplicationModel.AppInfo.UninstallRegistryKey (defaults to AppId — see Reading back InstallDate and InstallLocation) |
AppInfo.Name |
Barbatos.Wpf.ApplicationModel.AppInfo.Name |
AppInfo.Version/VersionString |
Barbatos.Wpf.ApplicationModel.AppInfo.Version |
PublisherInfo.Name |
Barbatos.Wpf.ApplicationModel.PublisherInfo.Name |
PublisherInfo.Website |
Barbatos.Wpf.ApplicationModel.PublisherInfo.Website (no standard fallback — null when unset) |
PublisherInfo.SupportUrl |
Barbatos.Wpf.ApplicationModel.PublisherInfo.SupportUrl (no standard fallback — null when unset) |
PublisherInfo.SupportEmail |
Barbatos.Wpf.ApplicationModel.PublisherInfo.SupportEmail (no standard fallback — null when unset) |
PublisherInfo.Copyright |
Barbatos.Wpf.ApplicationModel.PublisherInfo.Copyright |
Reading the values back — for example on an About screen — goes through the facades instead of raw reflection:
var appName = AppInfo.Name;
var version = AppInfo.VersionString;
var publisher = PublisherInfo.Name;
var copyright = PublisherInfo.Copyright;
Important:
AppInfo.AppId(not a store package identifier on this platform — it is simply the stable identifier used to derive the app's storage folder) should stay constant across releases. Changing it after shipping movesPreferences,SecureStorage, andFileSystem.AppDataDirectoryto a new folder, effectively discarding every existing user's stored data. If you ship an installer, consider matching it to the installer's own application identifier — see Publishing with an installer below.
AppGuidis deprecated: this property used to be calledAppInfo.AppGuid, back when it was expected to always hold a GUID.AppGuid(and theBarbatos.Wpf.ApplicationModel.AppInfo.AppGuidmetadata key) still resolve to the exact same value, so nothing breaks and no storage folder moves — but the property is marked[Obsolete]and will be removed in a future release. Rename both toAppIdwhen convenient.Multi-project solutions:
<Product>/<Company>/<Copyright>are easy to accidentally set in a sharedDirectory.Build.props— every app in the solution would then report the sameAppInfo.Name/PublisherInfo.Name/PublisherInfo.Copyright. Set them per-project (or use theBarbatos.Wpf.ApplicationModel.AppInfo.Name/PublisherInfo.Namemetadata override per-project) if that's not what you want.
Publishing with an installer
AppInfo/PublisherInfo map cleanly onto the fields a Windows installer needs, because both
are ultimately backed by the same standard assembly attributes an installer's version-reading
tooling already understands. For Inno Setup 6.4
specifically (the same mapping applies to WiX/MSI, which uses the identical Windows
Uninstall registry convention):
[Setup] directive |
Registry value under the installer's Uninstall key |
Barbatos.Wpf.Core source |
|---|---|---|
AppId |
(the key name itself, plus _is1 — see below) |
AppInfo.AppId, plus the ...AppInfo.UninstallRegistryKey metadata key for the suffixed form — see Reading back InstallDate and InstallLocation |
AppName |
DisplayName |
AppInfo.Name (<Product>) |
AppVersion |
DisplayVersion |
AppInfo.VersionString (<Version>) |
AppPublisher |
Publisher |
PublisherInfo.Name (<Company>) |
AppPublisherURL |
URLInfoAbout |
PublisherInfo.Website |
AppSupportURL |
HelpLink |
PublisherInfo.SupportUrl |
AppUpdatesURL |
URLUpdateInfo |
(no dedicated property — reuse PublisherInfo.Website, or a custom metadata key of your own) |
; setup.iss
#define AppGUID "C6BB69DE-7E6B-43E3-83AD-AC46E1B0570D" ; matches AppInfo.AppId, without braces
#define Publisher "Contoso" ; matches PublisherInfo.Name
#define AppName "My App" ; matches AppInfo.Name
[Setup]
AppId={{{#AppGUID}}
AppName={#AppName}
AppPublisher={#Publisher}
AssemblyVersionvs. the file's native version resource: Inno Setup'sGetVersionNumbers()(used above to auto-extractAppVersionfrom the built.exe) reads the Win32FileVersionresource embedded in the PE file, not .NET'sAssemblyVersion— these are two different concepts that usually carry the same value only because the SDK defaults<FileVersion>from<Version>. If you ever set<AssemblyVersion>to something fixed (a common practice to avoid binding-redirect churn) while letting<Version>/<FileVersion>increment per build,AppInfo.VersionString(which readsAssemblyVersion) and what your installer reports from the.exefile will diverge — setBarbatos.Wpf.ApplicationModel.AppInfo.Versionexplicitly if you need them to stay in lockstep.
Reading back InstallDate and InstallLocation
AppInfo.InstallDate and AppInfo.InstallLocation read the installed copy's own uninstall
registry entry (the one from the table above) back:
var when = AppInfo.InstallDate; // DateTime? — null if not found
var where = AppInfo.InstallLocation; // string? — null if not found
The subkey looked up is ...\Uninstall\{AppInfo.AppId} by default, using the name verbatim
— no suffix is appended or guessed (a GUID-shaped name is normalised to the braced {...} form
the registry convention uses, so C6BB… and {C6BB…} both work). Both properties try every
plausible location an installer might have used: HKEY_LOCAL_MACHINE (a machine-wide install)
and HKEY_CURRENT_USER (a per-user install, e.g. Inno Setup's PrivilegesRequired=lowest),
each in both the 64-bit and 32-bit registry view (the 32-bit view transparently redirects to
WOW6432Node). They resolve to null — never throw — when no entry of that exact name exists
anywhere, which includes the ordinary "not installed through an installer yet" case (running
from the IDE/dotnet run).
When your installer registers itself under a name that isn't AppId, point the lookup at the
real name with the UninstallRegistryKey metadata key instead — AppId stays clean, and this
library never has to guess installer-specific naming conventions:
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.AppInfo.AppId" Value="{C6BB69DE-7E6B-43E3-83AD-AC46E1B0570D}" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.AppInfo.UninstallRegistryKey" Value="{C6BB69DE-7E6B-43E3-83AD-AC46E1B0570D}_is1" />
Inno Setup users, this is you. Inno Setup does not name its uninstall key after its
AppIddirectly — it appends_is1and offers no directive to change or remove that suffix (AppId=MyProgram→...\Uninstall\MyProgram_is1; if you never setAppIdit defaults toAppName). This has been its behaviour since version 1.3. So an Inno-installed app needs theUninstallRegistryKeyoverride above — set it to yourAppIdplus_is1.Putting the suffix here rather than in
AppInfo.AppIdmatters:AppIdalso becomes the storage folder name (%LocalAppData%\{Publisher}\{AppId}\), and it can never be changed after shipping without stranding every existing user's stored data.Source: the
[Setup]: AppIdand[Setup]: CreateUninstallRegKeytopics inISetup.chm, shipped with Inno Setup itself (help source, web version).
MSI/WiX needs no override: its uninstall key is the product code, so setting AppInfo.AppId
to that GUID is all it takes.
Other fields the installer's uninstall entry carries — EstimatedSize, DisplayIcon,
NoModify/NoRepair, the Inno Setup: * bookkeeping keys — describe the installed copy
rather than something an app typically needs to read back about itself, so they stay outside
AppInfo's scope.
App actions and version tracking
AppActions (taskbar Jump List shortcuts) and VersionTracking.Track() are wired up
through ConfigureEssentials:
builder.ConfigureEssentials(essentials => essentials
.AddAppAction("open", "Open", "Show the main window")
.OnAppAction(action =>
{
if (action.Id == "open")
App.ShowMainWindow();
})
.UseVersionTracking());
Clicking a Jump List entry launches a new process with an encoded command-line argument;
UseEssentials() checks for it on the WpfLifecycle.OnStartup event and raises
AppActions.OnAppAction — a relaunch-argument activation flow adapted to WPF's native Jump
List API instead of Windows.UI.StartScreen.JumpList. Because
System.Windows.Shell.JumpList has no way to read back what the OS is currently showing
(unlike WinRT's JumpList.LoadCurrentAsync()), AppActions.GetAsync() returns the actions
last set by SetAsync() in this process rather than querying the OS.
Contacts and Geolocation
A real Windows implementation of Contacts/Geolocation needs WinRT contracts
(Windows.ApplicationModel.Contacts.ContactPicker,
Windows.Devices.Geolocation.Geolocator) that require a TargetFramework with WinRT
projections (net8.0-windows10.0.19041.0-style) and, for some APIs, MSIX packaging —
neither of which this plain net8.0-windows/net9.0-windows/net10.0-windows WPF library
uses. Rather than pull in that machinery, both modules still ship their full type surface
(IContacts/Contact/ContactEmail/ContactPhone,
IGeolocation/Location/GeolocationRequest/...) — useful if you share code with a .NET
MAUI project, since it compiles unchanged either way — but every member of the Windows
implementation throws FeatureNotSupportedException instead of doing a real lookup. If you
need a real implementation (for example Geolocation via the classic
Win32 Location API, or Contacts via Microsoft Graph), register your own after the builder is
created — a later AddSingleton call takes precedence over the TryAddSingleton that
UseEssentials() performs, so code that resolves IContacts/IGeolocation through
constructor injection will get your implementation:
builder.Services.AddSingleton<IContacts, MyGraphContacts>();
The static Contacts.PickContactAsync()/Geolocation.GetLocationAsync() facades are
independent of DI and will keep using the built-in
FeatureNotSupportedException-throwing implementation regardless — prefer the injected
IContacts/IGeolocation interface if you register a replacement.
Permissions
Permissions is a generic, DI-free static surface — there is no IPermissions interface to
register; each permission type (Permissions.Camera, Permissions.Microphone, ...) is just
a type argument to CheckStatusAsync<TPermission>()/RequestAsync<TPermission>():
var status = await Permissions.CheckStatusAsync<Permissions.Camera>();
if (status != PermissionStatus.Granted)
status = await Permissions.RequestAsync<Permissions.Camera>();
Every permission type below is present as a nested type of Permissions: Battery,
Bluetooth, CalendarRead/CalendarWrite, Camera, ContactsRead/ContactsWrite,
Flashlight, LaunchApp, LocationWhenInUse/LocationAlways, Maps, Media,
Microphone, NearbyWifiDevices, NetworkState, Phone, Photos/PhotosAddOnly,
PostNotifications, Reminders, Sensors, Sms, Speech, StorageRead/StorageWrite,
Vibrate. Two behaviors on WPF:
- Most permissions report
PermissionStatus.Granted. An unpackaged desktop WPF app has noAppxManifest.xmlcapability declarations to check, and Windows itself doesn't gateCamera,CalendarRead,Phone,StorageRead, etc. behind a runtime consent prompt outside a packaged/store app —EnsureDeclared()is a no-op for the same reason. ContactsRead,ContactsWrite,LocationWhenInUse,LocationAlways,Microphone, andSensorsthrowFeatureNotSupportedException. A real check for these six needs WinRT device-access contracts (ContactManager,Geolocator,DeviceAccessInformation,MediaCapture) that need WinRT projections and, for some APIs, MSIX packaging — the same machinery this library already opts out of forContactsandGeolocation. Rather than silently reportGrantedfor something it can't actually verify, these six are honest about it instead.
If you need a real permission check (for example reading the Windows privacy consent
registry, or a custom capability broker), subclass Permissions.BasePlatformPermission and
pass your subclass as TPermission.
License enforcement: DeviceIdentity
Apple and Google both prohibit collecting raw hardware IDs (IMEI, serial numbers) for
ordinary commercial apps, so cross-platform mobile frameworks (including .NET MAUI's own
DeviceInfo) never expose a device identifier or network address in the first place.
DeviceIdentity exists for the desktop-specific case this rules out: enforcing a per-machine
license/activation limit.
This section explains what the API does and why it's shaped this way — it is not legal advice. Both members below are still personal data under most privacy laws (GDPR, EU/EEA; Vietnam's Decree 13/2023/NĐ-CP; CCPA, California; ...) once your license server associates them with a customer record (an email, a purchase). Using this API still requires a privacy policy disclosure and a lawful basis for processing — have your specific case reviewed by counsel, especially if you sell internationally.
string installId = await DeviceIdentity.GetInstanceIdAsync();
string fingerprint = await DeviceIdentity.GetHardwareFingerprintAsync();
| Member | What it is | Survives reinstall? |
|---|---|---|
GetInstanceIdAsync() |
A random GUID generated on first use, persisted via SecureStorage. Identifies "this install", not the physical machine. |
No |
GetHardwareFingerprintAsync() |
A SHA-256 hash of a few motherboard/BIOS/CPU identifiers (read via WMI), salted with AppInfo.AppId. |
Yes |
Two design choices keep this narrower than the raw hardware-ID approach a lot of licensing code reaches for by default:
- The hardware serials themselves are never stored or transmitted — only a one-way SHA-256 hash of them. Your license server can tell "this is the same machine as last time" without ever holding a reversible hardware identifier.
- The hash is salted with
AppInfo.AppId, so it's scoped to this app — the same machine produces a different fingerprint for a different app, the same way Apple'sIdentifierForVendoris scoped to a developer rather than being a single ID usable to correlate a device across unrelated apps.
Use GetInstanceIdAsync() alone if you don't need to survive a reinstall (the least invasive
option). Use GetHardwareFingerprintAsync() (or both, keyed together) if a user should not be
able to reset a per-machine activation count by simply reinstalling — this is the same
technique commercial license managers (FlexNet, Reprise, ...) use.
Network address: for the same purpose (e.g. flagging one license activating from an
unusual number of locations), resist the urge to call a third-party IP-lookup service (like
api.ipify.org) from the client — that shares "this app, this machine, right now" with an
extra party you don't control, on top of whatever your own license server already sees. Your
license-check API already receives the caller's public IP as part of the HTTP request itself
(e.g. HttpContext.Connection.RemoteIpAddress in ASP.NET Core) — no client-side code needed.
Treat it as a soft signal, not a hard block: NAT/CGNAT means multiple legitimate users can
share one public IP, and VPNs/mobile networks/dynamic IPs mean the same legitimate user's IP
changes over time.
Optional desktop features
Opt-in features cover the typical "settings screen" of a desktop app. Each one is only
registered when its Configure... method is called, binds its own configuration section
(file values override code values), and exposes a service for runtime (UI) toggling:
| Feature | Builder method | Options section | Runtime service | Enabled by default? |
|---|---|---|---|---|
| Single instance (block duplicate launches) | ConfigureSingleInstance(...) |
Barbatos:SingleInstance |
ISingleInstanceService |
Yes |
Run on startup (registry Run key) |
ConfigureRunOnStartup() |
Barbatos:RunOnStartup |
IRunOnStartupService |
No |
System tray icon (Shell_NotifyIcon) |
ConfigureTrayIcon(...) |
Barbatos:TrayIcon |
ITrayIconService |
No |
Keep computer awake (SetThreadExecutionState) |
ConfigureKeepAwake() |
Barbatos:KeepAwake |
IKeepAwakeService |
No |
| Push notifications (adaptive toast, images/buttons/navigation) | ConfigureNotifications() |
Barbatos:Notifications |
INotificationService |
Yes |
| Periodic background services | ConfigurePeriodicServices<T>() |
Barbatos:PeriodicServices |
IPeriodicServiceScheduler |
Yes |
| Realtime push notification listener (SignalR) | ConfigurePushNotifications() |
Barbatos:PushNotifications |
IPushNotificationService |
Yes |
Keyboard shortcuts (in-app + RegisterHotKey global hotkeys) |
ConfigureInputSystem(...) |
Barbatos:InputSystem |
IInputSystemService |
Yes |
"Enabled by default" means what happens the moment you call the Configure... method with no
further setup — every feature is still opt-in at the builder level (call the method, or the
service isn't registered at all).
builder.ConfigureSingleInstance();
builder.ConfigureRunOnStartup();
builder.ConfigureKeepAwake();
builder.ConfigureTrayIcon(options =>
{
options.MenuItems.Add(new TrayMenuItem("Open", App.ShowMainWindow));
options.MenuItems.Add(new TrayMenuItem("Exit", App.ExitApplication));
});
builder.ConfigureNotifications();
builder.ConfigureInputSystem(options =>
{
var shortcuts = new InputActionMap("AppShortcuts");
shortcuts.AddAction("QuickEntry")
.AddKeyBinding(Key.K, ModifierKeys.Control) // in-app only
.AddGlobalBinding(Key.Space, ModifierKeys.Control | ModifierKeys.Alt); // works even minimized
options.ActionMaps.Add(shortcuts);
});
// after Build(): app.Services.GetRequiredService<IInputSystemService>().FindAction("QuickEntry")!.Performed += (_, _) => ShowQuickEntry();
And from a configuration file:
{
"Barbatos": {
"SingleInstance": { "Enabled": true, "ActivateMainWindow": true },
"RunOnStartup": { "Enabled": true },
"TrayIcon": { "Enabled": true, "ToolTip": "My app" },
"KeepAwake": { "Enabled": true, "KeepDisplayOn": false },
"Notifications": { "Enabled": true },
"InputSystem": { "Enabled": true }
}
}
Notes:
- Single instance is keyed by
AppInfo.AppId(see Configuring AppInfo and PublisherInfo) via a namedMutex, scoped to the current user's session (not machine-wide). A second launch detects the running instance duringBuild()— before any window is created — signals it, and terminates immediately viaEnvironment.Exit(0); the first instance'sSecondInstanceLaunchedevent fires on the UI thread, and (unless you setActivateMainWindow = false) itsApplication.Current.MainWindowis automatically restored, shown, and brought to the foreground. This has no .NET MAUI counterpart — mobile platforms are inherently single-instance. KeepAwakeprevents idle sleep while still letting the display turn off (setKeepDisplayOnto also keep the display on); the sleep block is released when the host is disposed.- The tray icon supports a context menu, tooltip, click/double-click events, and balloon
notifications (
ShowBalloonTip); the sample uses it together with theOnWindowClosinglifecycle event to implement minimize-to-tray, showing a balloon each time the window is hidden so a hidden window doesn't look identical to a closed app. RunOnStartupstate is persisted by the OS registry; the other toggles can be persisted by writing their configuration sections back to a user settings file that is loaded viabuilder.Configuration.AddJsonFile(...)— seeSettingsStorein the sample.- Notifications are pushed as full adaptive Windows toast notifications (via the Windows
Community Toolkit's
ToastContentBuilder/ToastNotificationManagerCompat), which also appear in the notification center — no Start menu shortcut or manual COM registration required, even for a plain, non-packaged WPF app.INotificationService.Show(title, message, severity)is a no-op whileIsEnabledisfalse, so a single settings toggle can silence every call site.- For richer content, use
Show(NotificationContent):ImagePathrenders an inline "hero" image, andButtonsadds up to five action buttons — each one either raisesActivatedwith an opaqueArgumentspayload (NotificationButton(text, arguments)) for the app to navigate on, or opens a URL/protocol directly (NotificationButton(text, launchUri)), without waking the app at all. NotificationContent.Argumentsis the same navigation payload, but for clicking the notification body itself (as opposed to a button); read it back fromNotificationActivatedEventArgs.Argumentsin theActivatedhandler to route the user to the relevant place in the app.- Per-project branding:
NotificationOptions.IconPathsets a persistent circular app logo overlay shown on every notification (defaults to the executable's icon); per-call visuals (image, buttons, navigation) are set perNotificationContent/Showcall, so each feature of the app can shape its own notifications independently. - Windows silently drops a toast it isn't allowed to show (e.g. the user turned
notifications off in Settings > System > Notifications) instead of raising an error, so
Show(...)alone can't tell you it was blocked. CheckINotificationService.Availability(a liveNotificationAvailabilityread, not cached — call it whenever you need current state, e.g. when the window is activated) to detect this and show your own in-app fallback, and callOpenSystemSettings()to deep-link the user to the fix. The sample's "Notifications" row demonstrates this: the description turns into a warning and an "Open notification settings" button appears wheneverAvailability != Enabled.
- For richer content, use
IPushNotificationServiceis a client for a realtime push-notification server — it listens for incoming notifications and displays each one throughINotificationServiceabove (the primary path), falling back to a small in-appIPushNotificationFallbackPresenterwindow wheneverAvailability != Enabled,IsEnabled == false, or showing the real toast throws. It does not include a server.- Two layers, deliberately split:
IPushNotificationServiceitself knows nothing about how notifications actually arrive — it only depends onIPushNotificationTransport, a minimal contract (StartAsync/StopAsync/NotificationReceivedraw JSON text, no hub URLs or RPC method names). The bundled default,SignalRPushNotificationTransport, owns every SignalR-specific detail (hub URL, method names, the handshake payload) itself, configured through its ownSignalRPushNotificationOptions— none of that leaks into the transport-agnosticPushNotificationOptions/IPushNotificationServicesurface. That split is what makes swapping in a different delivery mechanism possible without reshaping anything else: implementIPushNotificationTransportyourself and register it before callingConfigurePushNotifications—services.AddSingleton<IPushNotificationTransport, MyTransport>()(TryAddSingletoninsideConfigurePushNotificationsthen skips the SignalR default), same swap-in pattern asIPushNotificationFallbackPresenter/INotificationPlatformelsewhere in this library. Worth being explicit about the limits of this: Firebase Cloud Messaging and Windows Notification Service don't map onto a client-managed persistent connection the way SignalR does — they're token-registration-plus-OS-push-channel systems, not something an app callsStartAsyncon and receives RPC-style callbacks from. Plugging one in still means writing a realIPushNotificationTransportfor that mechanism's actual delivery model (token exchange, platform channel subscription, ...); this library only guarantees the seam exists and stays free of SignalR assumptions, not a ready-made implementation for every backend. - The default payload type,
PushNotification, implementsIPushNotification(Title,Body,ImageUrl,Action) — deserialize your own server's JSON shape into your own type instead by implementingIPushNotificationand registering it withConfigurePushNotifications<TNotification>(). NotificationReceivedalways reportsReceivedAt(the local wall-clock time the client processed the notification) andUsedFallback, regardless of which display path fired — so you always know when something arrived even if you never touch the UI event.- A notification's
Action(Url/Setting/Route/None) is dispatched automatically forUrl/Setting(opened viaLauncher);RouteraisesRouteRequestedinstead, since only your app knows its own screens/navigation. - The default transport's own
HandshakeMethodName/NotificationMethodName/AcknowledgeMethodName(defaulting to"RegisterDevice"/"ReceiveNotification"/"AcknowledgeNotification") are configurable viaConfigurePushNotifications'sconfigureSignalRparameter (or theBarbatos:PushNotifications:SignalRconfig section) to match whatever hub method names your specific server actually uses. SetAppKeythere too if your server authenticates clients. - Delivery is at-least-once, and the client makes that invisible. Every notification with a
ReceiptIdis acknowledged back to the server as soon as it has been displayed, which is what lets a server keep an offline queue and stop re-sending. Because an acknowledgement can be lost to a dropped connection, the server will occasionally re-deliver something already shown, so the service remembers the lastPushNotificationOptions.DeduplicationHistorySizereceipt ids and shows each notification only once — while still re-acknowledging the repeat, since the repeat is itself evidence the first acknowledgement never landed. - The handshake is re-sent after every automatic reconnect. SignalR issues a new connection id on reconnect, so a server that maps connections to devices sees the reconnected client as an anonymous stranger until it identifies itself again. Without this, a client that briefly lost its network would silently stop receiving anything until the app was restarted.
- Two layers, deliberately split:
- InputSystem unifies three binding kinds under one
InputAction:KeyBinding(in-app, requires keyboard focus — optionally scoped to a specificFrameworkElementviaInputActionMap.Scopeinstead of the whole app),GlobalKeyBinding(a true OS-level hotkey viaRegisterHotKey, fires even while unfocused/minimized), andChordBinding(an in-app sequence of combinations pressed one after another within a timeout — VS Code's "leader key" shortcuts, e.g.Ctrl+KthenCtrl+P). An action can carry aKeyBindingand aGlobalKeyBindingfor the same combination — the samePerformedevent fires from either source. In practice only the global path ever actually fires from a real keystroke while the app is focused: onceRegisterHotKeyclaims a combination, Windows routes every press of it intoWM_HOTKEYexclusively, system-wide, for as long as the registration holds — including while the registering app's own window has focus — so the ordinaryWM_KEYDOWN/WM_KEYUPsequence theKeyBindinghalf relies on simply never arrives for that exact combination in the first place; there is no double-firing to suppress because the two paths structurally cannot both fire for one physical keystroke. TheKeyBindinghalf is really a fallback for the (rare) case the global registration itself fails, e.g. another app already owns that exact combination. Matching is always exact (ModifierKeys.Nonenever matches with a modifier held, like WPF's own nativeKeyGesture), andIInputInteraction(HoldInteraction,TapInteraction,MultiTapInteraction,PressInteraction) reinterprets raw press/release into gestures like "held for 2 seconds" — conceptually ported from Unity's Input System (Actions/Bindings/ Interactions), narrowed to what a keyboard-only desktop app actually needs: no Composite Bindings (WPF's ownModifierKeysflags enum already collapses a modifier+key chord into one binding), no Control Schemes (there's only one device family), no Processors/Device abstraction layer/.inputactionsfile format/haptics/XR/mobile — none of that carries over.GlobalKeyBindingspecifically cannot carry an interaction, and there is no global counterpart ofChordBindingeither:RegisterHotKey/WM_HOTKEYhas no "released" message and delivers a single edge-triggered notification per press, a hard OS constraint rather than a design choice — a sequence is tracked entirely from WPF's own key events, which never arrive while unfocused in the first place. Bindings can be changed at runtime — e.g. for a user-facing "customize your shortcuts" screen — viaInputAction.RemoveBinding/ClearBindings; aKeyBinding/ChordBindingchange takes effect on the very next keystroke automatically, while aGlobalKeyBindingchange additionally needsIInputSystemService.RefreshBindings()to actually re-register with the OS.
Dialogs
IDialogService (registered by ConfigureDialogs()) centralizes showing and tracking child
windows ("dialogs") so owner assignment, duplicate-open prevention, and graceful bulk-close
behave consistently no matter where in the app a dialog is opened from. It has no .NET MAUI
counterpart — MAUI's page-based navigation model has no equivalent of WPF's multi-window/owner
model — and closes three well-known WPF footguns:
builder.ConfigureDialogs();
builder.Services.AddTransient<AboutWindow>();
// In a button's click handler, resolved via constructor injection of IDialogService:
_dialogService.Show<AboutWindow>(); // non-modal, double-click-safe
_dialogService.ShowDialog<AboutWindow>(); // modal, blocks until closed
_dialogService.Show(dialog, owner: someWindow, key: "customer-42", closeOthers: true);
Owner assignment
Not setting Window.Owner explicitly lets Windows decide Z-order/activation on its own, which
is what causes dialogs to end up "under" an unrelated foreground application, or two
concurrently-open dialogs fighting over which one visually owns the other. Show/ShowDialog
always resolve and set Owner exactly once, before a fresh window is ever shown:
- Pass
owner:explicitly when you know it (e.g. always owned byMainWindow, or by the window a dialog was opened from). - Otherwise it defaults to
IDialogService.ActiveWindow— the most recently activated window this service has seen (every dialog shown through it, plusApplication.MainWindow, tracked opportunistically the first time it's observed) — never the operating system's notion of the active window, which is what lets an unrelated external application end up as a dialog's owner. This is also what makes "a dialog opened from another dialog" work correctly without any extra code: since the dialog you opened from is the currently active window, a new dialog shown from inside it is owned by it, not byMainWindow.
Closing other dialogs, without losing in-progress work
Show/ShowDialog's closeOthers: true closes every other dialog currently tracked by this
service before showing the new one; CloseAll()/Close(key) do the same on demand (e.g. from
a "Close all windows" menu command). All of these are graceful: each dialog still gets a
chance to veto via its own Closing event (e.Cancel = true), so in-progress/unsaved work is
never silently discarded — CloseAll()/Close(key) return false if anything vetoed, and
closeOthers/CloseAll() leave a vetoing dialog open rather than forcing it shut.
This graceful behavior also applies to DialogOptions.CascadeCloseOwnedDialogs (default:
true). Plain WPF already closes a window's owned dialogs when it closes — but
unconditionally, ignoring each owned dialog's own Closing veto (verified: an owned
window's e.Cancel = true does not stop it from being force-closed once its owner closes).
That is exactly the data-loss risk this option prevents: with it enabled, closing a window
first closes the dialogs it owns itself, giving each one (recursively, however many owned
dialogs deep) a real, respected veto — and if any of them refuse, the owner's own close is
cancelled too, so the whole chain stays open together instead of tearing down partway through.
Preventing a double-click from opening a dialog twice
Calling Window.Show() (not ShowDialog()) from a button's click handler is what opens a
second instance on a rapid double-click, since Show() doesn't block — this is the most common
way this bug happens in practice. Show/ShowDialog key each dialog by key (defaults to the
window type's full name) and, if a dialog with that key is already tracked as open, activate
the existing instance instead of showing a duplicate — Show returns false in that case so
you can tell the two apart if you need to. Pass a more specific key (e.g. including an entity
id) when you deliberately want several instances of the same window type open at once, one per
key — an "edit customer" dialog, for example, where different customers should be editable
simultaneously but the same customer twice should just refocus the existing window.
SplashScreen
This is fresh work, not a port: on Windows, a splash screen for a packaged (MSIX) app is
just a build-time asset pipeline generating an AppxManifest entry, with zero runtime C# logic
needed — but that manifest is stripped entirely for unpackaged apps, the deployment model this
library targets, so there's nothing to build on from that approach here. WpfApplication gets
a splash screen hook of its own instead (a lifecycle hook you override, plus a plain settings
object) rather than the DI-based Configure...() pattern the other features use — a splash
screen has to show before the dependency injection container even exists.
public partial class App : WpfApplication
{
protected override SplashScreenOptions GetSplashScreenOptions() => new()
{
AppName = "My App", // defaults to AppInfo.Name
LogoSource = "pack://application:,,,/Assets/logo.png",
Tagline = "Loading your workspace...",
SponsorLogos =
{
new SplashScreenLogo("pack://application:,,,/Assets/sponsor1.png", "Sponsor Inc.", "https://sponsor.example.com"),
},
RelatedLinks =
{
new SplashScreenLink("My Other App", "Also by this publisher", "https://example.com/other-app"),
},
MinimumDisplayDuration = TimeSpan.FromSeconds(1.5), // default
};
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
await CloseSplashScreenAsync(); // waits out MinimumDisplayDuration, then closes the splash
MainWindow = Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
}
GetSplashScreenOptions() returning a non-null value is what turns the splash screen on — the
built-in SplashWindow is shown immediately in OnStartup, before CreateWpfApp() runs, so
it actually covers slow startup work (a slow IWpfInitializeService, for example). Sponsor
logos and related links are each individually clickable (opens their LinkUrl/Url via
Launcher) when one is provided, and hidden entirely when their list is empty.
Purely synchronous startup work still blocks the UI thread as usual, so the splash screen
(and its progress indicator) will not animate while it runs — same as any WPF window. Move
slow work to an async continuation, awaited before CloseSplashScreenAsync(), if you need the
splash to stay responsive/animated while it happens.
Loading important data before the main window ever appears
A slow query for something the main window needs (a workspace summary, a license check, ...)
often ends up wired to MainWindow's own Loaded event or its ViewModel's mount hook — which
means the window pops up empty first, then that content flashes in a moment later. The splash
screen is already sitting there for exactly this: resolve the ViewModel and await its slow work
before closing the splash and showing the window, instead of after:
protected override async void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
// MainViewModel is registered as a singleton, so MainWindow's own constructor resolves
// this exact same, already-loaded instance below - nothing to pass through by hand.
var mainViewModel = Services.GetRequiredService<MainViewModel>();
await mainViewModel.LoadWorkspaceSummaryAsync();
await CloseSplashScreenAsync(); // a no-op wait - the query above already took longer
// than MinimumDisplayDuration, so nothing left to wait out
MainWindow = Services.GetRequiredService<MainWindow>();
MainWindow.Show();
}
samples/Barbatos.Wpf.Core.Sample demonstrates this end to end: MainViewModel.LoadWorkspaceSummaryAsync()
simulates a slow query (a 3-second delay standing in for a real database read/license check/cache
warm-up) and sets WorkspaceSummary, which MainWindow.xaml's header displays - already there
the instant the window appears, never an empty flash followed by a pop-in.
This is a straightforward reordering, not a new API - the only two things that make it work are
already true of any DI-registered ViewModel: it must be a singleton (a scoped/transient
registration would hand MainWindow's constructor a second, still-unloaded instance instead of
the one already awaited above), and the slow work has to be something you can genuinely await
(if your "slow work" is synchronous, an IWpfInitializeService - covered by the splash the same
way, since it also runs before CreateWpfApp() - is the better fit; see "Hosting" above).
Weigh this against just showing the window immediately and letting that one section show its own loading state instead (a spinner, a skeleton) - blocking the entire app's startup on one query is the right call when nothing else in the window is meaningfully usable without it, but if most of the window works fine without that data, an in-place loading indicator for just that section keeps the rest of the app interactive sooner.
Full customization
For full control over the UI instead of the built-in layout, override CreateSplashScreen()
and return any Window you like — implement ISplashScreen on it too if you still want
MinimumDisplayDuration support:
protected override Window CreateSplashScreen() => new MyOwnSplashWindow();
The default implementation of CreateSplashScreen() is what creates the built-in
SplashWindow from GetSplashScreenOptions(); overriding GetSplashScreenOptions() is enough
for most apps; overriding CreateSplashScreen() bypasses it entirely.
Showing it conditionally
GetSplashScreenOptions()/CreateSplashScreen() are just plain methods called once per launch
in OnStartup — returning null from either one means no splash screen for that launch, so any
condition you can express in code works, with no extra API needed:
protected override SplashScreenOptions? GetSplashScreenOptions()
{
// Only on this device's very first-ever launch.
if (!VersionTracking.IsFirstLaunchEver)
return null;
return new SplashScreenOptions { Tagline = "Welcome!", MinimumDisplayDuration = TimeSpan.FromSeconds(3) };
}
A few other common conditions, same pattern:
// Once per app update, not every launch.
if (!VersionTracking.IsFirstLaunchForCurrentVersion) return null;
// Opt out via a command-line flag (e.g. unattended/automation runs). GetSplashScreenOptions()
// takes no parameters, so use Environment.GetCommandLineArgs() rather than OnStartup's own
// StartupEventArgs.Args.
if (Environment.GetCommandLineArgs().Contains("--no-splash")) return null;
// Opt out via a user-facing setting.
if (!Preferences.Get("ShowSplashScreen", true)) return null;
Both methods run before CreateWpfApp() — the dependency injection container does not
exist yet at this point, so only the static Essentials facades (VersionTracking, AppInfo,
PublisherInfo, Preferences, ...) are usable inside them, not Services.GetRequiredService<...>().
VersionTracking/Preferences work standalone for exactly this reason - they only depend on
each other and on AppInfo, never on the host.
Avoiding flicker: minimum display duration
SplashScreenOptions.MinimumDisplayDuration (default: 1.5 seconds) keeps the splash screen
visible for at least that long from the moment it is shown, regardless of how quickly the rest
of startup finishes — this is what avoids a jarring flash on a fast machine, at the deliberate
cost of the splash screen acting like an "ad slot" for at least that long. The clock starts
before CreateWpfApp() runs, so a slow startup is only ever waited out, never delayed
further: CloseSplashScreenAsync() computes the remaining time and only awaits if there still
is any.
Periodic services
IWpfPeriodicService is the recurring counterpart of IWpfInitializeService: implement it,
register it, and it runs on the application dispatcher according to its PeriodicSchedule -
a class carrying a start time, an end time, a recurrence frequency, and a description.
public sealed class SyncService : IWpfPeriodicService
{
public string Name => "Sync";
public PeriodicSchedule Schedule => new()
{
Frequency = PeriodicFrequency.Custom,
Interval = TimeSpan.FromMinutes(5), // default, configurable
Description = "Synchronizes local data with the server.",
};
public async Task ExecuteAsync(IServiceProvider services, CancellationToken ct)
{
await Task.Run(() => { /* heavy work off the UI thread */ }, ct);
}
}
builder.ConfigurePeriodicServices<SyncService>();
// or: builder.Services.AddSingleton<IWpfPeriodicService, SyncService>();
// builder.ConfigurePeriodicServices();
Frequency is Once, Hourly, Daily, Weekly, Monthly, or Custom. Daily, Weekly and
Monthly are calendar-anchored - they run at a specific wall-clock TimeOfDay, on specific
DaysOfWeek or a specific DayOfMonth, the same way a calendar reminder or a Windows Task
Scheduler trigger would - not simply "every N days from whenever the app happened to start":
public PeriodicSchedule Schedule => new()
{
Frequency = PeriodicFrequency.Weekly,
DaysOfWeek = WeekDays.Monday | WeekDays.Thursday,
TimeOfDay = new TimeSpan(9, 0, 0), // 09:00
Description = "Sends the weekly digest email.",
};
Hourly and Custom are plain fixed-duration repeats instead (Custom requires a positive
Interval). StartTime/EndTime bound when a schedule is allowed to produce an occurrence at
all - both are optional; Once runs a single time at StartTime (or immediately if unset).
The schedule can be configured in three ways (file overrides code, as a whole schedule per service; UI wins at runtime):
Code — the service's own
Scheduleproperty.File — the
Barbatos:PeriodicServicessection:{ "Barbatos": { "PeriodicServices": { "Enabled": true, "Schedules": { "Sync": { "Frequency": "Custom", "Interval": "00:05:00" } } } } }UI — through
IPeriodicServiceScheduler:scheduler.UpdateSchedule("Sync", new PeriodicSchedule { ... })reschedules immediately;SetEnabled(bool)starts/stops all services;Servicesexposes live status (schedule, next run time, last run, run count, whether it has completed) andServiceExecutedreports every run, including failures.
Failed executions are logged and do not stop the schedule; a tick is skipped while the
previous run is still in progress; the cancellation token passed to ExecuteAsync is
cancelled when the host is disposed.
Registering services after startup
Every example above registers SyncService at host-build time via DI, but
IPeriodicServiceScheduler also exposes Register/Unregister, which work at any time after
the host has already started - not only while the builder is being configured:
scheduler.Register(new SyncService());
scheduler.Unregister("Sync");
This doesn't let a settings UI conjure new behavior out of nothing - ExecuteAsync is still
ordinary code written by a developer - but that code no longer has to be wired up only at
startup. A plugin loaded later, or a factory parameterized by something the end user picked in a
settings UI, can hand the scheduler a new IWpfPeriodicService at any time.
State management (Barbatos.Wpf.Apsu)
A lightweight state-management layer for WPF: a store is an ObservableObject-derived
class holding state as ordinary properties, plus batched change notification, an explicit
reset hook, computed getters that can span several stores, plugins, and a middleware-style
action interceptor chain that can observe, time, retry, or outright block an action call.
Familiar from Vue's Pinia? The design borrows its shape from Pinia's own
defineStore/getters/actions/plugins model - a head start if you've used it before, though nothing here requires that background.
public class CounterStore : StoreBase
{
int _count;
readonly Getter<string> _summary;
public CounterStore() => _summary = new Getter<string>(() => $"Count is {Count}", this);
public int Count
{
get => _count;
set => SetProperty(ref _count, value);
}
public string Summary => _summary.Value; // computed(), cached until StateChanged fires
[Action]
public virtual void Increment() => Count++;
public override void Reset() => Count = 0;
}
builder.Services.AddStore<CounterStore>();
// Resolved via DI (a ViewModel's constructor, typically) like any other registered service:
counterStore.Increment();
var summary = counterStore.Summary; // "Count is 1"
Stores
StoreBase (abstract, derives from CommunityToolkit.Mvvm.ComponentModel.ObservableObject) is
the base every store derives from:
| Member | Role |
|---|---|
| State | Ordinary properties set via SetProperty |
StateChanged (event EventHandler?) |
Fires once per property set outside Patch, or once per Patch call no matter how many properties it touched |
Patch(Action<StoreBase> mutate) |
Batches several property changes into a single StateChanged notification |
Reset() (virtual) |
Restores initial state - must be overridden; the default throws NotSupportedException so a forgotten override fails loudly instead of silently doing nothing |
Register a store with AddStore<TStore>() - it becomes a singleton, created lazily on
first resolution and cached for the container's lifetime after that, keyed by TStore itself.
A store's constructor can take further dependencies - other services, or other stores,
resolved by the container the same way any other constructor dependency is. Register its local
interceptors in the same call by chaining Add on the builder it hands you - one AddStore
call handles the store and every local interceptor it needs, instead of a separate
AddStoreInterceptor<TStore>(...) call per interceptor plus a trailing AddStore<TStore>():
builder.Services.AddStore<OrderStore>(i => i
.Add<NonEmptyCartGuardInterceptor>()
.Add(sp => new CheckoutCooldownInterceptor(TimeSpan.FromSeconds(3), sp.GetRequiredService<ApsuActivityLog>())));
Omit the callback entirely (the default) for a store that only needs global interceptors, or
none at all - see Actions and action interceptors below
for what each Add overload does.
Getters
Getter<T> is a lazily-computed, cached value, invalidated whenever any of its declared
dependency stores raises StateChanged.
readonly Getter<string> _summary;
public OrderStore(UserStore userStore)
{
_summary = new Getter<string>(
() => $"{OrderCount} order(s) for {userStore.UserName}",
this, userStore); // combines two stores - list every store the callback reads
}
public string Summary => _summary.Value;
Every store the callback reads must be listed explicitly as a dependencies argument, since
C# has no proxy-based reactivity to auto-track that on its own - this is a coarse invalidation
(any state change on any listed dependency recomputes the getter, not just a change to the
specific property it actually reads), a deliberate simplification over a full
dependency-tracking system.
Actions and action interceptors
A [Action]-marked public virtual method is interceptable, shaped as ASP.NET Core-style
middleware: a chain of interceptors that can run code before and after the action, retry it,
or short-circuit it entirely. Interception only activates when at least one
IStoreActionInterceptor applies to that store; a store with no interceptors registered never
pays for the proxy.
public sealed class LoggingInterceptor : IStoreActionInterceptor
{
public async Task<object?> InterceptAsync(StoreActionContext context, StoreActionDelegate next)
{
Console.WriteLine($"[{context.Store.GetType().Name}] {context.ActionName} starting");
var result = await next(); // call first - see the warning below
Console.WriteLine($"[{context.Store.GetType().Name}] {context.ActionName} done");
return result;
}
}
// Global - wraps every store's actions. DI-constructed - no `new` needed, since
// LoggingInterceptor's own constructor only needs other registered services (if any):
builder.Services.AddSingleton<IStoreActionInterceptor, LoggingInterceptor>();
// Local - wraps only CounterStore's actions, registered together with the store in one call.
// Add<T>() is DI-constructed the same way the global registration above is:
builder.Services.AddStore<CounterStore>(i => i.Add<ThrottleInterceptor>());
An interceptor that needs a value the container can't supply on its own (a duration, a
threshold, ...) alongside a service it can isn't a special case - Add has a factory overload
too, still getting the service DI-resolved, explicit about the one input that has to come from
the call site instead of the container:
builder.Services.AddStore<CounterStore>(i => i.Add(sp =>
new ThrottleInterceptor(TimeSpan.FromSeconds(1), sp.GetRequiredService<IClock>())));
Chain as many Add calls as the store needs local interceptors - Add<TInterceptor>()
(DI-constructed), Add(instance) (already-built), and Add(factory) (built lazily, a service
plus a raw value) mix freely in the same chain. For registering a local interceptor separately
from AddStore instead - e.g. from a different composition step - the standalone
AddStoreInterceptor<TStore, TInterceptor>() (and its instance/factory overloads) remain
available too.
Both kinds compose into a single chain per store: every global interceptor runs, in
registration order, outside every local one - the same nesting a global ASP.NET Core
middleware has relative to endpoint-specific behavior. Registering more than one interceptor for
the same store (global or local) stacks them, innermost by registration order. Not calling
next short-circuits the whole chain - the action itself never runs, not just observed after
the fact:
public Task<object?> InterceptAsync(StoreActionContext context, StoreActionDelegate next)
{
if (context.ActionName == nameof(UserStore.SelectUser) &&
context.Arguments[0] is int userId && userId is < 1 or > 10)
{
return Task.FromResult<object?>(null); // blocked - SelectUser's body never runs
}
return next();
}
StoreActionContext.Arguments carries the actual call arguments in declaration order, so a
condition can be about what an action was called with, not just the store's current state.
Call next as your first meaningful step if you're going to call it at all - never await
anything genuinely asynchronous before calling it. Castle DynamicProxy's underlying
Proceed() (what next eventually reaches) is not safe to call from a continuation that
already crossed a real await suspension - doing so hangs the action forever instead of
throwing (confirmed by isolating and killing the stuck process during this feature's own
testing). Awaiting real asynchronous work after next() returns (post-processing, e.g.
flushing a log) is fully supported - only work ordered before it is unsafe. A synchronous
action's interceptor chain still runs through this same async shape, but the proxy blocks on
it (GetAwaiter().GetResult()) since there's no Task to hand back to a synchronous caller -
safe as long as every interceptor in that chain only does synchronous work, but risks the
classic WPF sync-over-async deadlock if one doesn't. Pair genuinely asynchronous interceptor
logic with a Task-returning action to avoid this entirely.
Combine with OnDispatcherUnhandledException (see Lifecycle events) to give
a store action's error and any other unhandled exception one shared reporting path, since an
interceptor can catch around next() and still let the exception propagate:
public sealed class ErrorReportingInterceptor : IStoreActionInterceptor
{
public async Task<object?> InterceptAsync(StoreActionContext context, StoreActionDelegate next)
{
try
{
return await next();
}
catch (Exception ex)
{
Report(context.Store.GetType().Name, context.ActionName, ex); // same sink OnDispatcherUnhandledException uses
throw;
}
}
}
A store using interceptors must be non-sealed with exactly one public constructor
(interception works by generating a runtime subclass via Castle DynamicProxy that overrides every
[Action]-marked method) - AddStore<TStore>() throws a descriptive InvalidOperationException
at first resolution otherwise, rather than failing silently.
Removing an interceptor at runtime
Register with the out InterceptorHandle overload instead of the plain one to be able to
remove an interceptor later without rebuilding the host - axios's
interceptors.request.eject(id):
builder.Services.AddStoreInterceptor(new LoggingInterceptor(), out var loggingHandle);
// Local interceptors have the same overload: AddStoreInterceptor<TStore>(interceptor, out handle)
// Later, e.g. from a settings toggle:
loggingHandle.Eject();
Ejecting doesn't remove the interceptor from the chain structurally - its slot just becomes a
transparent pass-through straight to the next interceptor (or the action itself) from that call
onward, as if it had never been registered. A call already in flight when Eject() runs keeps
using the interceptor; every call that starts afterward skips it. There's no "re-attach" -
construct and register a new interceptor if you need the behavior back. Most interceptors don't
need this - it's opt-in per interceptor, not automatic.
Plugins
IStorePlugin extends every store with cross-cutting behavior:
public sealed class PersistencePlugin : IStorePlugin
{
public void Apply(StoreBase store)
{
if (store is CounterStore counter)
counter.StateChanged += (_, _) => Preferences.Set("count", counter.Count);
}
}
builder.Services.AddSingleton<IStorePlugin, PersistencePlugin>();
Every registered plugin's Apply runs once per store, right after that store's own
constructor has already run, in plugin-registration order. A plugin can read/write state or
subscribe to StateChanged, but cannot transparently intercept an action call (use an
IStoreActionInterceptor for that instead).
Registry
IStoreRegistry (resolved automatically - there's normally no need to depend on it directly)
exposes every store actually resolved so far, for cross-cutting introspection (e.g. a debug
panel listing live stores). It exists purely for introspection, not caching - the DI container
itself is already what caches and singleton-izes each store.
See the Barbatos.Wpf.Apsu namespace in the API Reference for the full member
list. Two worked examples ship with this repository:
samples/Barbatos.Wpf.Core.Sample-CounterStore(state, a getter, two actions) plusSampleActionLoggingInterceptor, a single global interceptor also called directly from theOnDispatcherUnhandledExceptionlifecycle hook, showing the two error paths share one policy.samples/Barbatos.Wpf.Aquarius.Sample(ApsuInteropDemoView/ApsuInteropDemoViewModel) - a richer scenario against a real API (jsonplaceholder.typicode.com):UserStore/OrderStorewatching each other's state and calling across stores, a getter combining both, and a full 3-global + 3-local interceptor chain (logging, timing, audit trail, argument-based validation, a state-based guard, and a cooldown) demonstrating short-circuiting at every layer.
AI chat + MCP (Barbatos.Wpf.Mcp)
Every modern AI-capable desktop app now ships MCP (Model Context
Protocol) support, so it can call out to external tools through
a standard, LLM-agnostic protocol. ConfigureMcp() gives any WPF app built on Barbatos.Wpf.Core
the same capability - connect to any number of MCP servers, and chat with an LLM that
automatically gets every connected server's tools.
Bring-your-own-key (BYOK), deliberately: this feature exists so the application publisher
never pays for LLM usage - the application's own end user supplies their own Gemini, Claude,
ChatGPT, or other-provider API key at runtime (typically through a settings screen you build),
and IAiApiKeyProvider stores it via SecureStorage (DPAPI-encrypted, never written to a config
file or sent anywhere but that provider).
builder.ConfigureMcp(
options => options.Servers.Add(new McpServerDescriptor
{
Name = "MyTools",
TransportKind = McpTransportKind.Stdio,
Command = "dotnet",
Arguments = { "dnx", "NuGet.Mcp.Server", "--version", "1.4.16", "--yes" },
// Most real servers are configured through the environment, like the `env` block of a
// Claude Desktop/Cursor server entry. Set InheritEnvironmentVariables = false to start
// the server with an empty environment instead, so none of this process's own secrets
// reach a third-party server binary.
EnvironmentVariables = { ["NUGET_SOURCE"] = "https://api.nuget.org/v3/index.json" },
}),
configureProvider: options =>
{
// The providers this app wants to offer - a settings UI provider picker would read
// options.Providers back out (see the sample's MainViewModel.AiProviders). Any string
// works as Key/Provider, not just these four - see "Providers" below.
options.Providers.Add(new AiProviderDescriptor { Key = "openai", Provider = "openai", Model = "gpt-5.2" });
options.Providers.Add(new AiProviderDescriptor { Key = "gemini", Provider = "gemini", Model = "gemini-3.5-flash", Endpoint = "https://generativelanguage.googleapis.com/v1beta/openai/" });
options.Providers.Add(new AiProviderDescriptor { Key = "anthropic", Provider = "anthropic", Model = "claude-opus-4-8" });
options.Provider = "gemini"; // active on startup
options.Model = "gemini-3.5-flash";
options.Endpoint = "https://generativelanguage.googleapis.com/v1beta/openai/";
});
// Anywhere in the app, after the end user has entered their own key (e.g. from a settings screen):
await apiKeyProvider.SetApiKeyAsync("gemini", theUsersOwnApiKey);
// Switch to a different catalog entry (looks up Provider/Model/Endpoint from options.Providers):
aiChatClientFactory.SelectProvider("anthropic");
// Then just chat - every connected MCP server's tools are merged in automatically:
var response = await aiChatService.GetResponseAsync([new ChatMessage(ChatRole.User, "What's new in Newtonsoft.Json?")]);
Console.WriteLine(response.Text);
Hosted servers that need a sign-in (OAuth)
The interesting MCP servers are hosted ones - your issue tracker, your notes, your repos - and
those want the end user to sign in rather than paste a token. McpServerDescriptor.OAuth turns on
the full MCP authorization flow, and because everything in it is discoverable, an empty options
object is genuinely all most servers need:
await mcpServerRegistry.AddServerAsync(new McpServerDescriptor
{
Name = "Linear",
TransportKind = McpTransportKind.Http,
Endpoint = "https://mcp.example.com/mcp",
OAuth = new McpOAuthOptions(), // discovered: auth server, scopes, client registration
});
That single call gets the server's 401 challenge, finds its authorization server, registers this
app as an OAuth client (no client ID to provision by hand), opens the user's real browser for
consent, catches the redirect on a loopback port, and exchanges the code - the OAuth 2.1 / RFC 8252
native-app flow, so your app never handles the user's password. The tokens are then persisted
DPAPI-encrypted, so the next app start reconnects without asking again.
Fill properties in only where a particular server forces you to - ClientId for one that has no
dynamic registration, Scopes for one that publishes none, RedirectUri for one that demands a
fixed registered URL. Two seams are swappable by registering your own before ConfigureMcp():
| Type | Default | Replace it when |
|---|---|---|
IMcpAuthorizationHandler |
BrowserMcpAuthorizationHandler - system browser + loopback listener |
you want consent in an embedded WebView2 window instead of the user's browser |
IMcpTokenStore |
SecureStorageMcpTokenStore - DPAPI, current-user scope |
tokens belong in Credential Manager or a key vault |
// "Sign out of this server": forget the tokens so the next connect asks again. Independent of
// RemoveServerAsync, so a user can switch accounts without losing the server entry.
await mcpServerRegistry.SignOutAsync("Linear");
// Denied consent, a closed browser tab, or a timeout arrives as McpAuthorizationException -
// an ordinary outcome to report, not a crash. It also lands in McpServerStatus.LastError.
The sample's "AI Chat + MCP" tab has this wired to a text box and two buttons, so you can point it at a real hosted server and watch the whole round trip.
| Type | Role |
|---|---|
IMcpServerRegistry |
Connects to MCP servers (stdio child process or HTTP), aggregates their tools. Runtime-mutable (AddServerAsync/RemoveServerAsync/SignOutAsync), not just a fixed config list - build your own "add an MCP server" settings UI on top if your app should let the end user do that themselves, the same way Claude Desktop/Cursor do. |
IAiApiKeyProvider |
Resolves/stores the end user's own API key, keyed per provider string (case-insensitively) so switching providers never loses a different provider's key. The default implementation is SecureStorage-backed; swap in Credential Manager/a key vault by registering your own before calling ConfigureMcp. |
IMcpAuthorizationHandler |
The interactive half of a server's OAuth flow - sends the user to consent and returns what the authorization server redirects back. Defaults to the system browser plus a loopback listener; see "Hosted servers that need a sign-in" above. |
IMcpTokenStore |
Persists each server's OAuth tokens (DPAPI-encrypted by default) so a consented server reconnects silently after a restart. IMcpServerRegistry.SignOutAsync is what clears them. |
IAiChatClientFactory |
The "which provider" seam (structurally the same role IPushNotificationTransport plays for push notifications) - builds/caches the Microsoft.Extensions.AI.IChatClient for whichever provider/model is currently selected. Inject this directly instead of IAiChatService if you want a raw chat client with no MCP tools merged in. |
IAiModelCatalog |
The "which model" counterpart - asks a provider which models it currently offers, using the end user's own stored key, so a settings UI can fill a picker instead of hardcoding a list that ages (see "Models" below). Optional: a model name typed by hand always works, since Model is a free string. |
IAiChatService |
The one-shot facade - merges every connected server's tools into ChatOptions.Tools automatically before delegating to IAiChatClientFactory, so callers never wire MCP tools in by hand. GetResponseAsync/GetStreamingResponseAsync. No memory between calls, and tools run without asking. |
IAiAgentFactory |
The agent layer (see below) - builds a Microsoft Agent Framework AIAgent over the same provider and the same MCP servers, adding conversation memory and end-user tool approval. |
IAiSessionStore |
Persists an agent's conversation across app restarts. File-backed by default; swap in your own the same way as IAiApiKeyProvider. |
Agents (Microsoft Agent Framework)
IAiChatService is a single request/response call. That is fine for "summarize this", but a
conversational assistant needs two things it does not have: it forgets everything between calls,
and it runs whatever tool the model asks for the instant it is asked. IAiAgentFactory builds a
Microsoft Agent Framework AIAgent over the same
BYOK provider and the same connected MCP servers, adding exactly those two things.
Both layers stay registered and neither replaces the other - keep using IAiChatService for
one-shot requests, and use an agent for the conversational surface.
builder.ConfigureMcp(configureAgent: options =>
{
options.Name = "Assistant";
options.Instructions = "You help the user manage their project files. Be concise.";
});
var agent = await agentFactory.GetAgentAsync();
var session = await agent.CreateSessionAsync(); // carries history across turns
var response = await agent.RunAsync("What's new in Newtonsoft.Json?", session);
// Under the default AiToolApprovalMode.Always, a run that wants a tool comes back paused
// instead of answering - nothing has run yet.
while (response.GetToolApprovalRequests() is { Count: > 0 } pending)
{
// ToolCall is typed as the ToolCallContent base; the name lives on FunctionCallContent,
// which is what every MCP tool call actually is.
var name = (pending[0].ToolCall as FunctionCallContent)?.Name ?? pending[0].ToolCall.CallId;
var allow = MessageBox.Show(
$"Let the assistant run '{name}'?", "Tool approval", MessageBoxButton.YesNo) == MessageBoxResult.Yes;
response = await agent.RunAsync(pending.CreateApprovalResponse(allow), session);
}
Console.WriteLine(response.Text);
// Same conversation next launch:
await sessionStore.SaveAsync("last-chat", agent, session);
var restored = await sessionStore.LoadAsync("last-chat", agent) ?? await agent.CreateSessionAsync();
The returned AIAgent is Microsoft's own type, not a Barbatos wrapper, so everything the Agent
Framework documents about running agents applies directly - RunAsync, RunStreamingAsync,
structured output via RunAsync<T>, and so on.
- Tool approval defaults to on (
AiToolApprovalMode.Always). An MCP server is third-party code whose tools this library cannot inspect for side effects, and in an app that lets end users add their own servers it is not even code the publisher chose. PassingalwaysApprove: truetoCreateApprovalResponse(a "Don't ask again" checkbox) records a standing rule for the rest of the session, so this costs one prompt per distinct tool rather than one per call. Setoptions.ToolApproval = AiToolApprovalMode.Neverfor the older auto-execute behavior. - The agent is rebuilt when its inputs change - a different provider/model/API key, or an MCP
server connecting or disconnecting. Ask
GetAgentAsync()per conversation rather than holding one forever; anAgentSessionsurvives those rebuilds. CreateAgentAsync(descriptor)builds a second, differently-scoped agent (a summarizer, a code reviewer) with its own instructions, its own tools, and its own approval policy, without disturbing the default one.- Saved sessions contain the conversation itself, which for a desktop assistant can easily
include personal data. The default store writes plain JSON under
IFileSystem.AppDataDirectory- per-user, but not encrypted. BackIAiSessionStorewithISecureStorageor your own encrypted store if that is not enough for your app.
Providers
AiProviderOptions.Provider is a plain string, not a fixed enum - baking every provider name an
app might ever want into a shared library enum would need a new release for each one. This
library only ever draws one real technical distinction: (case-insensitively) "anthropic" uses
the official Anthropic .NET SDK (that SDK's own documentation notes it is still versioned 10+ but
currently in beta, with possible breaking changes in minor/patch releases - this repository pins
an exact version in Directory.Packages.props, no floating ranges, so a breaking change is only
ever absorbed when this repository deliberately bumps that pin); every other string is assumed
OpenAI-wire-compatible, covering:
"openai"(or any other spelling you like) - ChatGPT, via the official OpenAI API. LeaveEndpointunset.- Google Gemini - via Gemini's own OpenAI-compatible endpoint -
set
Endpointto"https://generativelanguage.googleapis.com/v1beta/openai/". No separate Gemini SDK dependency. - Any other OpenAI-wire-compatible endpoint - a self-hosted model via Ollama/LM Studio/vLLM,
OpenRouter, a proxy, or a future provider this library has never heard of - set
Endpointyourself. Does not cover Azure OpenAI, which needs its own client for correct auth/routing.
AiProviderOptions.Providers is an optional catalog of provider choices your app wants to
offer (e.g. in a settings UI's provider picker) - seeded the same way McpOptions.Servers seeds
MCP servers. It is purely a catalog: Barbatos.Wpf.Mcp itself never reads it automatically -
IAiChatClientFactory.SelectProvider(key) is what actually looks an entry up and switches to it,
calling UpdateProvider with that entry's own Provider/Model/Endpoint. Leave it empty if
your app hardcodes its own list instead, or only ever supports one provider - Provider/Model/
Endpoint directly on AiProviderOptions work with or without a Providers catalog behind them.
Models (IAiModelCatalog)
Models are released far more often than most desktop apps ship, which is why
AiProviderOptions.Model is a free string: a model published this morning is reachable by typing
its name, with no new release of your app. IAiModelCatalog is the optional upgrade on top of
that - it asks the provider which models it currently offers, using the end user's own stored key,
so a settings screen can populate a picker instead of hardcoding a list that ages:
// "Refresh models" in a settings screen. The endpoint is whatever this provider chats through -
// listing has to be pointed at the same place, or it describes a different service.
var models = await modelCatalog.GetModelsAsync("openai");
var geminiModels = await modelCatalog.GetModelsAsync(
"gemini", "https://generativelanguage.googleapis.com/v1beta/openai/");
foreach (var model in models)
Console.WriteLine(model.DisplayName ?? model.Id); // model.Id is what UpdateProvider wants
Behind the string it splits exactly like the chat path: "anthropic" goes through Anthropic's own
SDK (paginated, and rich enough to fill in DisplayName/MaxInputTokens/MaxOutputTokens),
everything else through GET {endpoint}/models, a route OpenRouter, Ollama, LM Studio, vLLM and
Gemini's OpenAI-compatible endpoint all answer.
Worth knowing before wiring it to a picker:
- An API key must already be stored for the provider being asked - this is a real authenticated
call, so the list can only be fetched after the end user saves their key, not while the fields
are blank. Without one it throws
InvalidOperationExceptionnaming the provider, rather than surfacing a raw 401. - Nothing is cached - every call is a network round trip. Unlike a chat client, a model list has no event that could invalidate it (a provider can publish a model at any moment), so caching here would mostly serve stale answers to the one call a user makes explicitly to get fresh ones. Drive it from a button and keep the result in your view model.
- The list is raw - unfiltered and unsorted, the same way
IAiChatServicepasses message content through untransformed. An OpenAI-compatible endpoint lists embedding, audio and image models next to chat ones with no reliable flag telling them apart, and providers return their own order. - Not every endpoint implements it - a narrow proxy that only serves chat completions will throw here. Keep a free-text model entry as the fallback (the sample's model box is an editable ComboBox for exactly this reason): the typed name is what always works.
Attachments (images, documents)
GetResponseAsync/GetStreamingResponseAsync take Microsoft.Extensions.AI.ChatMessage as-is,
so sending an image or a document alongside the text is just building a ChatMessage with more
than one AIContent in it - Barbatos.Wpf.Mcp has no separate "attachment" API to learn:
using Microsoft.Extensions.AI;
// A local file, embedded inline as base64 - works for any provider, no hosting/upload step.
// MediaType is a plain MIME type; it's sent through as-is, not inspected or validated.
var imageBytes = await File.ReadAllBytesAsync(screenshotPath);
var response = await aiChatService.GetResponseAsync(
[
new ChatMessage(ChatRole.User,
[
new TextContent("What's in this screenshot?"),
new DataContent(imageBytes, "image/png"),
]),
]);
// A document works the same way - just a different media type:
var pdfBytes = await File.ReadAllBytesAsync(reportPath);
var docResponse = await aiChatService.GetResponseAsync(
[
new ChatMessage(ChatRole.User,
[
new TextContent("Summarize this document."),
new DataContent(pdfBytes, "application/pdf"),
]),
]);
// Already-hosted content can be referenced by URL instead of embedding bytes - prefer
// DataContent above for local files picked from the end user's machine.
var byUrl = new ChatMessage(ChatRole.User,
[
new TextContent("What's in this image?"),
new UriContent("https://example.com/photo.jpg", "image/jpeg"),
]);
A couple of things worth knowing before relying on this:
- Which media types a given provider/model actually accepts varies (e.g. PDF support isn't
universal) - check that provider's own docs;
Barbatos.Wpf.Mcpdoesn't transform, downscale, or reject anything, it passesChatMessage.Contentsstraight to the underlying SDK. ChatMessage.Textonly ever reflectsTextContent- if you log or displaymessage.Text(e.g. building a transcript UI), an attachedDataContent/UriContentwon't show up in it; walkmessage.Contentsyourself if the UI needs to indicate "this message had an attachment."
Configuration
{
"Barbatos": {
"Mcp": {
"Enabled": true,
"Provider": { "Provider": "gemini", "Model": "gemini-3.5-flash" },
"Agent": { "Instructions": "Be concise.", "ToolApproval": "Always" }
}
}
}
Barbatos:Mcp:Servers and Barbatos:Mcp:Provider:Providers aren't practical to express as flat
config keys (each server/provider entry has several fields, and array-index config keys like
Providers:0:Key are unwieldy to hand-author), so both are more commonly seeded from code via
options.Servers.Add(...)/options.Providers.Add(...), as shown above - the configuration
section still exists for the parts that are practical to override from a file (Enabled,
Provider, Model, Endpoint).
A few behaviors worth knowing before relying on this feature:
IAiChatServiceexecutes tool calls automatically, with no confirmation step - it enablesMicrosoft.Extensions.AI's function-invocation middleware by default, the same way Claude Desktop/Cursor auto-execute MCP tool calls. If your app needs a confirmation step before a tool actually runs (for example because end users can add their own untrusted MCP servers), useIAiAgentFactoryinstead - its defaultAiToolApprovalMode.Alwayspauses the run and hands the pending call back to you, which is what the "Agents" section above is about.- Tool name collisions: if two connected servers expose a same-named tool, the one from whichever server connected most recently wins - a warning is logged when this happens.
- A failed server connection is still tracked -
IMcpServerRegistry.Serversincludes it withIsConnected = falseandLastErrorset, rather than disappearing;AddServerAsyncalso rethrows the exception, so a caller doesn't have to pollServersjust to notice a failure. - Runtime-mutable state does not persist itself - like every other runtime-mutable feature in
this library (
ITrayIconService,IPeriodicServiceScheduler), anIMcpServerRegistry.AddServerAsync/IAiChatClientFactory.UpdateProvidercall made from a settings UI is not written back to a config file automatically; persist it yourself (seeSettingsStorein the sample) if it should survive a restart.
Ecosystem
Unlike modular libraries split across many NuGet packages, Barbatos.Wpf.Core ships as a single package — Hosting, all Essentials modules, and every optional desktop feature are included; there is nothing else to install.
Repository layout
src/Barbatos.Wpf.Core— the library.samples/Barbatos.Wpf.Core.Sample— a complete sample application showing DI, configuration, host environment, Essentials usage, and the live lifecycle event log.tests/Barbatos.Wpf.Core.UnitTests— the unit test suite covering hosting and Essentials.
API Reference
The library contains a rich set of primitives spanning Hosting (Barbatos.Wpf.Hosting,
Barbatos.Wpf.LifecycleEvents, Barbatos.Wpf.Dispatching) and Essentials
(Barbatos.Wpf.ApplicationModel, Barbatos.Wpf.Devices, Barbatos.Wpf.Storage,
Barbatos.Wpf.Networking, ...).
Due to the extensive nature of the library's interfaces, classes, and properties, the full API Reference has been moved to a dedicated document modeled after Microsoft's official .NET documentation format.
👉 Read the Full API Reference 👈
In the full reference, you will find comprehensive documentation for every namespace, interface, static facade, and options class described above.
Community
Maintainers
- Pham The Hung (@StHung)
Support
For support, please open a GitHub issue. We welcome bug reports, feature requests, and questions.
License
This project is licensed under the terms of the MIT open source license. Please refer to the LICENSE file for the full terms.
You can use it in private and commercial projects. Keep in mind that you must include a copy of the license in your project.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0-windows10.0.17763 is compatible. net9.0-windows was computed. net9.0-windows10.0.17763 is compatible. net10.0-windows was computed. net10.0-windows10.0.17763 is compatible. |
-
net10.0-windows10.0.17763
- Anthropic (>= 12.36.1)
- Castle.Core (>= 5.2.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Agents.AI (>= 1.15.0)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.0)
- Microsoft.Extensions.AI (>= 10.8.3)
- Microsoft.Extensions.AI.Abstractions (>= 10.8.3)
- Microsoft.Extensions.AI.OpenAI (>= 10.8.3)
- Microsoft.Extensions.Configuration (>= 10.0.3)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.3)
- Microsoft.Extensions.Configuration.EnvironmentVariables (>= 10.0.3)
- Microsoft.Extensions.DependencyInjection (>= 10.0.3)
- Microsoft.Extensions.Diagnostics.Abstractions (>= 10.0.3)
- Microsoft.Extensions.FileProviders.Physical (>= 10.0.3)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Logging (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.3)
- Microsoft.Toolkit.Uwp.Notifications (>= 7.1.3)
- ModelContextProtocol.Core (>= 2.2.0)
- OpenAI (>= 2.12.0)
- System.ClientModel (>= 1.14.0)
- System.Management (>= 8.0.0)
-
net8.0-windows10.0.17763
- Anthropic (>= 12.36.1)
- Castle.Core (>= 5.2.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Agents.AI (>= 1.15.0)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.0)
- Microsoft.Extensions.AI (>= 10.8.3)
- Microsoft.Extensions.AI.Abstractions (>= 10.8.3)
- Microsoft.Extensions.AI.OpenAI (>= 10.8.3)
- Microsoft.Extensions.Configuration (>= 10.0.3)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.3)
- Microsoft.Extensions.Configuration.EnvironmentVariables (>= 10.0.3)
- Microsoft.Extensions.DependencyInjection (>= 10.0.3)
- Microsoft.Extensions.Diagnostics.Abstractions (>= 10.0.3)
- Microsoft.Extensions.FileProviders.Physical (>= 10.0.3)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Logging (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.3)
- Microsoft.Toolkit.Uwp.Notifications (>= 7.1.3)
- ModelContextProtocol.Core (>= 2.2.0)
- OpenAI (>= 2.12.0)
- System.ClientModel (>= 1.14.0)
- System.Management (>= 8.0.0)
-
net9.0-windows10.0.17763
- Anthropic (>= 12.36.1)
- Castle.Core (>= 5.2.1)
- CommunityToolkit.Mvvm (>= 8.4.2)
- Microsoft.Agents.AI (>= 1.15.0)
- Microsoft.AspNetCore.SignalR.Client (>= 10.0.0)
- Microsoft.Extensions.AI (>= 10.8.3)
- Microsoft.Extensions.AI.Abstractions (>= 10.8.3)
- Microsoft.Extensions.AI.OpenAI (>= 10.8.3)
- Microsoft.Extensions.Configuration (>= 10.0.3)
- Microsoft.Extensions.Configuration.Binder (>= 10.0.3)
- Microsoft.Extensions.Configuration.EnvironmentVariables (>= 10.0.3)
- Microsoft.Extensions.DependencyInjection (>= 10.0.3)
- Microsoft.Extensions.Diagnostics.Abstractions (>= 10.0.3)
- Microsoft.Extensions.FileProviders.Physical (>= 10.0.3)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.3)
- Microsoft.Extensions.Logging (>= 10.0.3)
- Microsoft.Extensions.Options (>= 10.0.3)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.3)
- Microsoft.Toolkit.Uwp.Notifications (>= 7.1.3)
- ModelContextProtocol.Core (>= 2.2.0)
- OpenAI (>= 2.12.0)
- System.ClientModel (>= 1.14.0)
- System.Management (>= 8.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Barbatos.Wpf.Core:
| Package | Downloads |
|---|---|
|
Barbatos.Migration.Wpf
WPF integration for Barbatos.Migration. Wires the migration engine into Barbatos.Wpf's application host - data directory from IFileSystem, target version from AppInfo, first-launch detection from IVersionTracking - and marshals progress onto the UI thread for a splash screen. |
GitHub repositories
This package is not used by any popular GitHub repositories.