barbatos-wpf-essentials
Barbatos.Wpf.Core Essentials (UseEssentials facades) - AppInfo/PublisherInfo/AppId metadata, Preferences, SecureStorage (DPAPI), VersionTracking, Connectivity, DeviceDisplay, DeviceIdentity, Launcher,
SKILL.md.claude/skills/barbatos-wpf-essentials/SKILL.md
GitHubBarbatos.Wpf.Core Essentials
The .NET MAUI Essentials surface ported to unpackaged WPF, shipped inside Barbatos.Wpf.Core.
Every module is a public interface plus a static facade over one shared instance, implemented
with Win32/.NET APIs because WinRT contracts are unavailable to a plain unpackaged app. Not
covered here: composing the host and the ConfigureXxx desktop features (barbatos-wpf-host),
and the AI key / MCP token stores that sit on SecureStorage (barbatos-mcp-ai).
Sources of truth
- Docs: README Essentials (with
AppInfo/PublisherInfo,
installer,
DeviceIdentity) and
API-REFERENCE
(the
ApplicationModelthroughStoragenamespaces). The code wins when they disagree:src/Barbatos.Wpf.Core/Essentials/<Module>/andsrc/Barbatos.Wpf.Core/Hosting/EssentialsExtensions.cs. - Sample
samples/Barbatos.Wpf.Core.Sample/: the.csproj(identity metadata),GreetingService.cs(injecting every info service),MainViewModel.cs(Preferences, SecureStorage,ConnectivityChanged, Email),WpfProgram.cs(ConfigureEssentials); templatetemplates/Combined/project-templates/barbatos-wpf-app/BarbatosWpfApp.csproj. - Tests
tests/Barbatos.Wpf.Core.UnitTests/:<Module>Tests.cs,EssentialsExtensionsTests.cs(registration),ModuleInit.cs(facade warm-up). - reference.md - read when you need what each module's Windows code really does, every metadata key and fallback, the on-disk paths, the installer mapping, the full Permissions list with a custom-permission example, or which exceptions escape.
How the modules are wired
- Inject the interface (
IAppInfo,IPreferences,ISecureStorage,IVersionTracking,IConnectivity,ILauncher, ...) in services and ViewModels, asGreetingService.csdoes; call the facade (AppInfo.Name,Preferences.Get(...)) only where there is no container. UseEssentials()registers each interface asTryAddSingleton<IXxx>(sp => Xxx.Current)(or.Default), so the injected object is the facade's instance (pinned byEssentialsExtensionsTests.RegisteredServicesMatchTheStaticFacades). It runs insideWpfApp.CreateBuilder(); withCreateBuilder(useDefaults: false)nothing is registered and Jump List activation is not detected until you callbuilder.UseEssentials().- Replacing an implementation:
UseEssentials()has already run whenWpfProgramgets the builder, so an app-sideTryAdd*is a no-op. Register withbuilder.Services.AddSingleton<...>()(last registration wins). Only injected consumers see the replacement: facades cannot be swapped (SetCurrent/SetDefaultare internal), and library code built on facades keeps the built-in - storage paths,VersionTracking,DeviceIdentity. - Namespaces:
Barbatos.Wpf.ApplicationModel(+.Communicationfor Email, Contacts),Barbatos.Wpf.Devices(+.Sensorsfor Geolocation),Barbatos.Wpf.Networking,Barbatos.Wpf.Storage- per module in reference.md. - Facades use MAUI's unlocked lazy singleton
(
currentImplementation ??= new AppInfoImplementation()), which is not thread-safe on first access. Two first touches racing (UI thread plus a background job) can build twoPreferencesImplementations that overwrite each other's file. Touch a facade first on the UI thread during startup.
App identity: AppInfo and PublisherInfo
Configure from the exe project's csproj (values are read from Assembly.GetEntryAssembly()).
For a standalone app:
<PropertyGroup>
<Product>My App</Product> <!-- AppInfo.Name -->
<Company>Contoso</Company> <!-- PublisherInfo.Name, part of the data path -->
<Copyright>Copyright (c) Contoso</Copyright> <!-- PublisherInfo.Copyright -->
<Version>1.2.0</Version> <!-- AssemblyVersion and FileVersion derive from it -->
</PropertyGroup>
<ItemGroup>
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.AppInfo.AppId" Value="Contoso.MyApp" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.PublisherInfo.Website" Value="https://contoso.example" />
<AssemblyMetadata Include="Barbatos.Wpf.ApplicationModel.PublisherInfo.SupportEmail" Value="support@contoso.example" />
</ItemGroup>
- Set
AppIdexplicitly from the first release and never change it. Without metadata it falls back to the assembly title, then the assembly name, so renaming the project silently moves the data folder.PublisherInfo.Name+AppIddecide%LocalAppData%\{Publisher}\{AppId}\, which holds Preferences, SecureStorage,AppDataDirectory, version history and the DeviceIdentity install id;AppIdalso salts the hardware fingerprint and names the single-instance mutex. Changing either one (a rebrand of<Company>included) strands every existing user's data. AppIdneed not be a GUID. Use a GUID when it doubles as an MSI product code; otherwise a readableContoso.MyApp, since it becomes a folder name.AppInfo.AppGuidis an[Obsolete]alias ofAppId, and the...AppInfo.AppGuidmetadata key a deprecated fallback read when theAppIdkey is absent (AppInfoTests.ObsoleteAppGuidStillForwardsToAppId). Rename call sites toAppId; do not delete the alias (CLAUDE.md obsoletion rule).Website/SupportUrl/SupportEmailhave no MSBuild property: metadata only, elsenull.AppInfo.Versionreads the entry assembly'sAssemblyName.Version, which always has four parts:VersionStringreads"1.2.0.0". ShowAppInfo.Version.ToString(3).BuildStringisVersion.Revision, normally"0".- The SDK derives
AssemblyVersionfrom<Version>with any prerelease suffix stripped (<Version>1.3.0-beta.1</Version>gives1.3.0.0, verified with a probe project), andFileVersiondefaults to it, so<Version>alone is right even for a prerelease. Writing<AssemblyVersion>$(Version)</AssemblyVersion>with a prerelease<Version>fails the build withCS7034: set<AssemblyVersion>/<FileVersion>only to a numeric value, and only when something else already set them. - The
...AppInfo.Versionmetadata override goes throughVersion.Parse: a prerelease value such as1.2.0-betathrowsFormatExceptionon every read. - Inside this repo
Directory.Build.propssets<Product>,<Copyright>,<Version>and<AssemblyVersion>for every project: a new sample overrides all four,<AssemblyVersion>with a numeric value (FileVersionfollows it unless set), as the Core sample does, or Explorer andAppInfo.Versionreport the library's version.
Choosing where to persist
| Need | Use |
|---|---|
| Small app-owned values: launch count, last tab, window size, "don't show again" | IPreferences |
| Tokens, passwords, refresh tokens, licence keys | ISecureStorage |
A value a ConfigureXxx feature reads from Barbatos:<Feature> (tray on/off, keep awake) |
A JSON file added to builder.Configuration - Preferences is not a configuration source (barbatos-wpf-host, sample SettingsStore.cs) |
| Files, databases, logs, caches | FileSystem.AppDataDirectory / FileSystem.CacheDirectory |
| State shared between open windows while running | An Apsu store (barbatos-apsu-store) |
Preferences
// samples/Barbatos.Wpf.Core.Sample/MainViewModel.cs
LaunchCount = _preferences.Get("Sample.LaunchCount", 0) + 1;
_preferences.Set("Sample.LaunchCount", LaunchCount);
- Types:
string,int,bool,long,double,float,DateTime,DateTimeOffset.IPreferences.Set<T>with anything else throwsNotSupportedException; store enums asint/string, objects as JSON strings. Setting anullstring removes the key. sharedNamegives a separate container in the same file.Clear()without one clears only the default container (version-tracking history survives).- Loaded once per process; every
Set/Remove/Clearrewrites the whole file. Do not write on every slider tick - save on commit. - Two running processes each keep their own copy and the last writer wins, silently dropping
the other's changes. An app that can be launched twice uses
ConfigureSingleInstance(). - A corrupt file is read as empty without an error, and the next write replaces it.
- Plain-text JSON on disk: nothing secret goes here.
SecureStorage
string? token;
try
{
token = await _secureStorage.GetAsync("Auth.RefreshToken");
}
catch (CryptographicException)
{
// DPAPI cannot decrypt a blob written under another Windows user or machine profile.
_secureStorage.Remove("Auth.RefreshToken");
token = null;
}
- Strings only. A null/whitespace key or a
nullvalue throwsArgumentNullExceptionat the call (delete withRemove). TheAsyncmethods complete synchronously. - DPAPI current-user scope with a fixed, public entropy: it protects against other Windows accounts and a copied file, not against other code running as the same user.
RemoveAll()clears the whole file, including the DeviceIdentity install id and the default AI key / MCP token stores. A "sign out" button removes its own keys by name.
FileSystem
AppDataDirectory/CacheDirectoryare...\{AppId}\Dataand...\Cache, created on first access; nothing cleans the cache, the app owns its size.OpenAppPackageFileAsync(name)opens a file next to the exe (AppContext.BaseDirectory), so it needsCopyToOutputDirectory(the sample'sappsettings.jsonitem) and throwsFileNotFoundExceptionwhen missing (DirectoryNotFoundExceptionfor a missing subfolder) - probe withAppPackageFileExistsAsync. WPFResourceitems are not reachable this way.
VersionTracking: first run and upgrades
builder.ConfigureEssentials(essentials => essentials.UseVersionTracking());
// later, after the main window is shown (ShowWelcome/ShowWhatsNew are the app's own)
if (_versionTracking.IsFirstLaunchEver)
ShowWelcome();
else if (_versionTracking.IsFirstLaunchForCurrentVersion)
ShowWhatsNew(_versionTracking.PreviousVersion, _versionTracking.CurrentVersion);
- The launch is recorded when the instance is constructed (first access, or resolving
IVersionTracking).UseVersionTracking()forces that duringBuild(), so a run that never reads version tracking is still recorded.Track()is idempotent. - The flags are computed once and stay
truefor the whole process. The version is recorded before you show anything; if "shown" must survive a crash, keep your own Preferences flag. CurrentVersion/PreviousVersionare four-partAppInfo.VersionStringvalues; compare them asVersion, not as strings. A downgrade also setsIsFirstLaunchForCurrentVersion.IsFirstLaunchForCurrentBuildfollowsBuildString(the revision, usually"0"), so it does not fire on ordinary upgrades. Use the version flags unless you stamp the fourth part.
Connectivity
NetworkAccess.Internetmeans an up, non-loopback, non-tunnel adapter has a default gateway. Nothing is probed (a captive portal still readsInternet;ConstrainedInternetis never reported): use it for UI hints and treat the real request's failure as the truth.ConnectivityChangedruns OS listeners only while subscribed and is raised on the UI thread, only on a real change. The facade lives for the process, so a handler on a short-lived ViewModel keeps it alive - unsubscribe when its window closes (the sample's is a singleton).- The facade's
Connectivity.ConnectionProfilesis de-duplicated; the injectedIConnectivity.ConnectionProfilesis not (two Ethernet adapters yield two entries).
DeviceDisplay and DeviceInfo
MainDisplayInfodescribes the monitor of the active window (elseMainWindow), and isdefault(DisplayInfo)(all zeros) until a window has a handle. Read it fromLoaded, as the sample'sMainWindowdoes viaMainViewModel.RefreshDisplayInfo().Width/Heightare physical pixels;Densityis window DPI / 96 (1.5 at 150 %), so WPF units areWidth / Density.MainDisplayInfoChangedfires onSystemEvents.DisplaySettingsChanged(a display-settings change such as resolution), not when a window moves to another monitor.KeepScreenOnholdsSetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED)on a background thread of its own whiletrue, never on the caller's thread. Execution state is per thread and Windows combines all threads, so it stacks withIKeepAwakeService(which holdsES_SYSTEM_REQUIREDon the UI thread) and with the app's own calls; turning either off leaves the other in force, and it may be set and cleared from any thread. For a persisted toggle preferConfigureKeepAwakewithKeepAwakeOptions.KeepDisplayOn(barbatos-wpf-host); for a temporary "while presenting",DeviceDisplay.KeepScreenOn.DeviceInfo.Versionis the OS version: Windows 11 still reports major version 10 (build 22000+).Platformis alwaysDevicePlatform.WPF;IdiomandDeviceTypeare heuristics.
DeviceIdentity: per-machine licensing
var instanceId = await _deviceIdentity.GetInstanceIdAsync();
var fingerprint = await _deviceIdentity.GetHardwareFingerprintAsync();
| Member | What it is | Changes when |
|---|---|---|
GetInstanceIdAsync() |
Random GUID created on first use, kept in SecureStorage |
securestorage.dat is lost (data cleared, new Windows user or machine), SecureStorage.RemoveAll(), AppId/<Company> change |
GetHardwareFingerprintAsync() |
64-char uppercase hex SHA-256 of AppId + board/BIOS serials + CPU id via WMI |
Hardware swap, AppId change; falls back to Environment.MachineName when WMI is locked down |
- An ordinary uninstall/reinstall leaves
%LocalAppData%- and the instance id - in place; do not describe it as "resets on reinstall". Use it alone when an activation may reset with the app's data; add the fingerprint when deleting that data or switching Windows user must not reset a per-machine activation count. To reset it deliberately, remove theBarbatos.Wpf.DeviceIdentity.InstanceIdkey fromISecureStorage. - Raw serials are never stored or returned and the
AppIdsalt makes fingerprints incomparable across apps - keep both properties. Both values are still personal data once a server links them to a customer (README privacy note). No client-side IP lookup: the licence server already sees the caller's address, and IP is a soft signal (NAT, VPN). - The fingerprint does WMI work once per process on the thread pool: load it on demand, as
GreetingService.GetDeviceIdentityDescriptionAsyncdoes.GetInstanceIdAsyncserializes its read-or-create, so concurrent first calls in one process get the same id; two processes starting at once can still each create one (last write wins on disk).
Launcher and Email
await _launcher.OpenAsync("https://example.com");
await _launcher.OpenAsync(new OpenFileRequest("Logs", FileSystem.AppDataDirectory));
await Email.ComposeAsync("Bug report", body, PublisherInfo.SupportEmail!);
LauncherisProcess.StartwithUseShellExecute = true. Thestringoverloads build aUri, so a relative path throwsUriFormatException- open files and folders withOpenFileRequest. The result istruewheneverProcess.Startdid not throw - including a hand-off to an already-running or COM-activated app, where noProcesscomes back - andfalsewhen the shell refused the target (no handler, a missing file).falseis a real failure worth reporting.CanOpenAsyncchecksHKEY_CLASSES_ROOT\<scheme>for a handler;TryOpenAsyncisCanOpenAsyncthen open.Emailis Simple MAPI through its Unicode entry pointMAPISendMailW: it opens the default desktop mail client's compose window, and Vietnamese or other non-code-page text survives (a client implementing only the ANSI call gets the stub's conversion, the one case where?can still appear).IsComposeSupportedis alwaystrue; whether a client answers is only known by calling. With no usable clientComposeAsyncthrowsFeatureNotSupportedException- catch it and offer amailto:link throughLauncher. A user cancelling the compose window is a normal completion.EmailBodyFormat.HtmlthrowsFeatureNotSupportedException. Attachments areEmailAttachment(fullPath); one that does not exist throwsFileNotFoundExceptionbefore any client starts. Any other MAPI error is anInvalidOperationExceptionnaming the code.
try
{
await _email.ComposeAsync("Bug report", body, supportAddress);
}
catch (FeatureNotSupportedException)
{
await _launcher.OpenAsync($"mailto:{supportAddress}?subject={Uri.EscapeDataString("Bug report")}");
}
AppActions: the taskbar Jump List
// samples/Barbatos.Wpf.Core.Sample/WpfProgram.cs
builder.ConfigureEssentials(essentials => essentials
.AddAppAction("open", "Open", "Show the main window")
.OnAppAction(action => LogLifecycleEvent($"App action activated ({action.Id})"))
.UseVersionTracking());
- A click launches a new process with
BARBATOS_APP_ACTIONS-<base64 id>on its command line.UseEssentials()matches it in theWpfLifecycle.OnStartupevent against the actions set in that process, whichConfigureEssentialsapplies duringBuild(). Actions added later withAppActions.SetAsyncare not recognised on relaunch unless they are declared again at startup. - Handle activations with
.OnAppAction(...)inConfigureEssentials, or withAppActions.OnAppAction +=placed beforebase.OnStartup(e)(the sample'sApp.xaml.csdoes). The lifecycle event fires insidebase.OnStartup(e), so a subscription after it never sees the activation. The handler runs before the main window exists - showing or activatingApplication.Current.MainWindowfrom it finds no window on a cold start: record the action id and act on it afterMainWindow.Show(), asApp.OnStartupin the sample does. - With
ConfigureSingleInstance()and the app already running, the new process exits insideBuild()beforeOnStartup: the action is lost and the running instance only receivesSecondInstanceLaunched(no arguments). Only cold-start activations work in that combination. SetAsyncreplaces any Jump List declared inApp.xaml, hides the Recent/Frequent categories and needs a runningApplication; failures insideConfigureEssentialsare logged as "App Actions could not be applied." and never stop startup.GetAsyncreturns the last set list from this process - WPF'sJumpListcannot be read back.
Installer publishing, InstallDate and InstallLocation
AppInfo.InstallDate/InstallLocation read ...\Uninstall\{name}, the name being the
Barbatos.Wpf.ApplicationModel.AppInfo.UninstallRegistryKey metadata or, by default, AppId
verbatim; both are null (never an exception) when not installed - normal under the IDE.
Inno Setup always names its key {AppId}_is1: put that in UninstallRegistryKey and keep
AppId clean, since it is also the data-folder name. MSI/WiX keys by product code: make
AppId that GUID. Lookup order, date format and the [Setup] mapping:
reference.md.
Not available on Windows: Contacts, Geolocation, some Permissions
ContactsandGeolocationqueries throwFeatureNotSupportedExceptionby design (they need WinRT contracts and packaging); do not "fix" them with stubs returning fake data. The type surface exists so MAUI-shared code compiles;Geolocation.IsEnabledisfalseandLocation.CalculateDistance(...)works.- A real implementation is app code,
builder.Services.AddSingleton<IContacts, MyGraphContacts>();, seen only by injectedIContacts/IGeolocationconsumers; the facades keep throwing. Permissionsis static and generic, with no DI interface.ContactsRead,ContactsWrite,LocationWhenInUse,LocationAlways,MicrophoneandSensorsthrow; every other type returnsGrantedwithout checking anything -PostNotificationssays nothing about toasts (readINotificationService.Availability,barbatos-wpf-host). For a real check, subclassPermissions.BasePlatformPermission(reference.md).
Hard rules
- Identity lives in the exe's csproj, never in DI: replacing
IAppInforenames the single-instance mutex but not the data folder.AppIdand<Company>freeze at first release. - Secrets go to
SecureStorage, neverPreferences;SecureStorage.RemoveAll()is not a sign-out. - Replace an Essentials service with
AddSingleton, notTryAdd*;FeatureNotSupportedExceptionmodules stay honest, and a real implementation is an app-side registration. - Per CLAUDE.md, no
InternalsVisibleToto reachSetCurrent/SetDefault: tests hit the real per-user store with unique keys ($"Test.{Guid.NewGuid():N}", as inPreferencesTests).
Symptom -> cause -> fix
| Symptom | Cause | Fix |
|---|---|---|
| Settings, secrets or what's-new history gone after a rename or rebrand | Data path is {Company}\{AppId}; AppId fell back to the assembly name, or <Company> changed |
Set AppId metadata; restore the old values or copy the old folder once |
| Two apps in one solution report the same name/publisher | <Product>/<Company> set in a shared Directory.Build.props |
Set them per project, or use the ...AppInfo.Name / ...PublisherInfo.Name metadata |
About box shows 1.2.0.0; or AppInfo.Version throws FormatException |
Four-part assembly version; non-numeric ...AppInfo.Version metadata |
AppInfo.Version.ToString(3); numeric metadata or none |
IsFirstLaunchForCurrentBuild never true after an update |
BuildString is the revision, usually 0 |
Use IsFirstLaunchForCurrentVersion |
InstallDate null after an Inno Setup install |
Key is {AppId}_is1 |
UninstallRegistryKey metadata |
| A preference "saved" earlier is gone | Second process rewrote the file, or a corrupt file was read as empty | ConfigureSingleInstance(); never hand-edit preferences.dat |
SecureStorage.GetAsync throws CryptographicException |
Blob from another user/machine profile | Catch, Remove the key, ask again |
| Licence server sees a new install after "sign out" | SecureStorage.RemoveAll() deleted the instance id |
Remove only your own keys |
| Online indicator says Internet but requests fail | Gateway heuristic, no probe | Trust the request result |
| ViewModel never collected after its window closes | Handler left on a facade event (ConnectivityChanged, MainDisplayInfoChanged, OnAppAction) |
Unsubscribe on close |
MainDisplayInfo is all zeros |
No window handle yet | Read it in Loaded |
| Jump List click does nothing | Handler subscribed after base.OnStartup; action not declared in ConfigureEssentials; or app already running under single instance |
OnAppAction in ConfigureEssentials (or subscribe before base.OnStartup); declare at startup; accept the single-instance limit |
| Jump List handler runs but the window does not come forward | Handler acted on MainWindow during startup, before it exists |
Record the id; act after MainWindow.Show() |
Email.ComposeAsync throws FeatureNotSupportedException |
No Simple MAPI client answered (none installed or default), or an HTML body | Catch it and offer a mailto: link via Launcher |
Launcher.OpenAsync returns false |
The shell refused the target: no handler, missing file | Report it - a hand-off to a running app already returns true |
Replacement service ignored (Contacts.PickContactAsync() still throws, own IPreferences unused) |
Facades ignore DI; an app-side TryAdd* loses to UseEssentials() in CreateBuilder() |
Inject the interface; register with AddSingleton |
Adding or changing an Essentials module (library work)
Load barbatos-package-authoring as well. Then:
src/Barbatos.Wpf.Core/Essentials/<Module>/: publicIXxx, a public staticXxxfacade (Current/Defaultvia??=, aninternal staticSetCurrent/SetDefault), an internalXxxImplementationon Win32/.NET - not WinRT - whose<remarks>name the MAUI counterpart.- Register it in
UseEssentials()asTryAddSingleton<IXxx>(sp => Xxx.Current). Unsupported on Windows: keep the type surface, throwFeatureNotSupportedExceptionnaming the WinRT API. - Tests: warm the facade in
tests/Barbatos.Wpf.Core.UnitTests/ModuleInit.cs(the??=race under parallel xUnit), add anAssert.Same(Xxx.Current, wpfApp.Services.GetService<IXxx>())line toEssentialsExtensionsTests, then the threeAppInfoTests.csshapes - cached, registered andAssert.Sameas the facade, absent without defaults. Run per TFM (barbatos-wpf-testing). - Docs: the README Essentials table and section, the API-REFERENCE namespace section
(
barbatos-docs-sync), a line in the sample'sGreetingService.cs/MainViewModel.cs, and a row in this skill's reference.md. - A new metadata key or a changed fallback chain is a behaviour change for shipped apps: update
the template csproj comments too (
barbatos-templates).
Related skills
barbatos-wpf-host-WpfProgram, DI, lifecycle events, single instance, keep awake, notifications, settings files forBarbatos:<Feature>sections.barbatos-mcp-ai(keys and tokens onISecureStorage),barbatos-apsu-store(in-memory shared state),barbatos-package-authoring,barbatos-wpf-testing,barbatos-docs-sync,barbatos-templates.