Lemon.Templates.Wpf
1.6.0
dotnet new install Lemon.Templates.Wpf@1.6.0
Material Design Desktop Starters (WPF ยท Avalonia)
Two dotnet new templates for Material Design desktop apps with dependency injection, SQLite-backed settings & jobs, navigation, localization and file logging:
| Template | UI | Runs on | Short name |
|---|---|---|---|
| WPF | WPF + Material Design In XAML | Windows | lemon-wpf |
| Avalonia | Avalonia + Material.Avalonia | Windows and macOS | lemon-avalonia |
Both ship in the same NuGet package (Lemon.Templates.Wpf) and share the same architecture; most of this README applies to both, and Avalonia template (Windows / macOS) lists what differs.
๐ Table of contents
- Overview
- Features
- Tech stack
- Architecture notes
- Avalonia template (Windows / macOS)
- Getting started
- Using as a
dotnet newtemplate - Publish template to NuGet
- Configuration
- Troubleshooting
- Repository layout
- Contributing
- License
๐ Overview
This repository is intended as a clean, generic baseline for line-of-business style WPF applications: a single main window with a side navigation shell, region-based content, theme persistence, local file logging, and a Hangfire dashboard hosted in-process (Kestrel on loopback) with SQLite storage shared with other app data.
It is also packaged as .NET project templates (.template.config/template.json for WPF, templates/avalonia/.template.config/template.json for Avalonia) so you can install them locally or ship them in a NuGet template package.
โจ Features
| Area | Description |
|---|---|
| Home | Landing page shown on first launch: application name and version, quick-start tiles that jump to each built-in page, technology summary and external links. Registered as a top-level menu entry with no children. |
| Settings โ Theme | Light/dark base theme, primary/secondary colors, Material swatches; preferences persisted to SQLite (AppThemePreferencesSqliteStore). |
| Settings โ Language | Runtime language switching (English / ็ฎไฝไธญๆ) with no restart; choice persisted to SQLite (app_language). |
| Logs โ Local-Logs | View tail of Serilog rolling file logs under the application Logs folder, with a status line reporting path, size and truncation. |
| Tools โ Cron | Embedded Hangfire Dashboard (WebView2) against local storage; sample recurring job (sample-heartbeat) for demonstration. Optional โ see template symbols. |
| Tray icon | Optional close-to-tray via H.NotifyIcon.Wpf: closing hides the window, double-click the icon or Open brings it back, Exit quits. Comes with a single-instance guard. |
| Check for updates | Title-bar button that reads the latest release from a GitHub repository or a JSON manifest (such as Next.Hub's) and offers the package for the running platform (Windows / macOS, x64 / arm64); a zip package is downloaded, installed in place and the app restarted, without a browser. Present only when Update:Enabled is true and a source is configured; can also check quietly on start-up. Generated projects include a tag-triggered release workflow. See Check for updates. |
| Desktop shortcut | Title-bar button (Windows) that puts a shortcut to the running executable on the desktop, named after App_DisplayName in AppStrings.resx, after asking; one of the same name is replaced. Written through IShellLinkW, so non-ASCII names work on any system code page. Hidden on macOS and when the app runs under dotnet App.dll. |
| Splash | Lightweight splash on startup. |
| Navigation | Pages discovered via [NavigationRegister("Group/Name", ...)] and grouped menus built at runtime; a single-segment key ("Home") registers a top-level page with no children. Menu labels resolved from resources. |
| Localization | .resx-backed strings, {loc:Localize Key} markup extension, live culture switching. |
| Tests | xUnit suite over navigation registration, localization, theme packing and path resolution. |
๐ ๏ธ Tech stack
| Layer | Libraries / runtime |
|---|---|
| UI | WPF, MaterialDesignThemes |
| MVVM | CommunityToolkit.Mvvm (source generators, RelayCommand, etc.) |
| Composition | Volo.Abp (AbpAutofacModule, AbpBackgroundJobsHangfireModule), Autofac-backed DI |
| Background jobs | Hangfire + Hangfire.Storage.SQLite, embedded ASP.NET Core host for dashboard |
| WebView | Microsoft.Web.WebView2 (Hangfire UI inside WPF) |
| Data | Microsoft.Data.Sqlite |
| Logging | Serilog (async file sink, rolling daily) |
| Localization | System.Resources satellite assemblies (Resources/AppStrings*.resx) |
| Tests | xUnit |
| Build | Central Package Management (Directory.Packages.props), shared conventions (Directory.Build.props), .editorconfig, warnings-as-errors |
| Target | net10.0-windows |
Dependency policy. Every package is pinned to a released version on nuget.org โ no pre-release, and in particular no CI-feed builds, which stop resolving once the producing feed rotates them. The toolchain is released too:
net10.0-windowsbuilds on the GA .NET 10 SDK.
๐๏ธ Architecture notes
WpfModule(ABP module) registers configuration, SQLiteJobStorage, theme and localization services, the Hangfire dashboard host, keyed/navigation services, and wires ViewModelLocator onFrameworkElement.Loaded.- Single SQLite file (default:
%LocalApplicationData%\<AssemblyName>\<AssemblyName>.db) holds the Hangfire schema and app tables (app_theme,app_language). Path is overridable via configuration. - Regions: main content uses
RegionManagerAttachedwith a centralINavigationServicemapping route names to views. - ViewModel lifetime:
ViewModelLocator.AutoWireViewModelcreates a DI scope per view;ViewModelLocator.ReleaseViewModeldisposes the ViewModel and its scope. Release is driven by the navigation layer (INavigationService.RemoveView, and content replacement in aContentControlregion) rather than byUnloaded, which WPF also raises when a view is only temporarily detached. - Shutdown: with the tray icon, closing the window (title bar, Alt+F4, taskbar) only hides it; tray โ Exit calls
App.Quit(), which marks the app as exiting and callsApplication.Shutdown()(without the tray, closing asks and then does the same), soApp.OnExitruns the ABP shutdown (stopping the Hangfire server and dashboard) beforeLog.CloseAndFlush(). AvoidEnvironment.Exit, which skips all of it and loses buffered log entries. - Localization:
LocalizationServiceresolvesResources/AppStrings*.resxfor the active culture and raisesBinding.IndexerNameon change, which is what lets{loc:Localize Key}bindings refresh without a restart. Menu labels come fromMenu_<RouteName>keys, so routing keys stay culture-independent.
๐ Avalonia template (Windows / macOS)
src/Lemon.Template.Avalonia is the same application rebuilt on Avalonia 12 so a generated project runs
on Windows and macOS from one code base (net10.0, no -windows TFM). Pages, navigation, dialogs,
theming, localization, SQLite stores, ABP/Autofac and Hangfire are ported one-to-one; the view models and
services are largely unchanged.
| Layer | Avalonia template |
|---|---|
| UI | Avalonia 12, Material.Avalonia, Material.Icons.Avalonia, DialogHost.Avalonia |
| Bindings | Compiled bindings by default (x:DataType on every view) |
| Tray | Avalonia's built-in TrayIcon โ notification area on Windows, menu bar extra on macOS |
| Tests | xUnit v3 + Avalonia.Headless.XUnit ([AvaloniaFact]), runs on Windows and macOS |
| Target | net10.0 |
What differs from the WPF template
| Area | WPF | Avalonia |
|---|---|---|
| Modal window dialogs | IDialogService.ShowDialog(name, params, callback), IHostDialogService.ShowWindow(...) (synchronous) |
await IDialogService.ShowWindowAsync(name, params) โ an Avalonia modal window cannot block the caller. In-window DialogHost dialogs (ShowDialogAsync) are unchanged. |
| Tools โ Cron | Hangfire dashboard embedded with WebView2 | Shows the dashboard URL and an Open in browser button: WebView2 is Windows-only, and the system browser works the same on both platforms with no extra native dependency. |
| Window chrome | Borderless window with drawn min/max/close buttons | Client area extends into the title bar with WindowDecorations="BorderOnly". Windows gets the template's own min/max/close buttons; macOS keeps its native traffic lights. WindowDecorationProperties.ElementRole marks the title strip (TitleBar, so the OS handles drag, double-click and snapping), the caption buttons, and every clickable control on the strip (User). Closing asks for confirmation whichever way it is requested (caption button, Alt+F4, the red button); tray Exit, Cmd+Q and OS shutdown do not ask. |
| Theme switching | PaletteHelper on MDIX's BundledTheme |
CustomMaterialTheme with BaseTheme="Inherit": light / dark is only Application.RequestedThemeVariant, and primary / secondary are the theme's Color properties. (MaterialTheme rebuilds its palette from an enum asynchronously on every switch, which undid the switch.) On a dark base the mid primary is replaced by its light shade for contrast, as MDIX's ColorAdjustment does. |
| Fonts | MDIX default | Bundled in Assets/Fonts, so every Windows and macOS machine renders the same: Noto Sans SC Regular / Medium / Bold (static OTFs from notofonts/noto-cjk) as UiFont, and Cascadia Mono (from google/fonts) as CodeFont, applied to every window. Both are SIL OFL 1.1; the licence texts ship next to the executable in Licenses/Fonts. Material.Avalonia's default Roboto has no CJK glyphs, which split mixed Chinese / Latin text across two fonts. Static weights rather than the variable Noto file because Avalonia does not apply a variable font's weight axis (it renders everything at the default Thin). Cost: about 26 MB per app. |
| Log files | <app folder>/Logs |
<LocalApplicationData>/<AssemblyName>/Logs (%LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS) โ an .app bundle is not writable. |
| Desktop shortcut | --EnableDesktopShortcut |
Same option, Windows only (.lnk via COM); a no-op on macOS. |
| Localization refresh | LocalizationService raises "Item[]" |
Raises "Item", the name Avalonia's indexer bindings listen for (covered by LocalizeExtensionTests). |
| Visibility converters | BoolToVisibility, InverseBoolToVisibility, CountToVisibility |
Avalonia has no Visibility: bind IsVisible directly ({Binding !Flag} / {Binding !!Items.Count}), or use CountToBoolConverter. |
Run from source
dotnet run --project src/Lemon.Template.Avalonia/Lemon.Template.Avalonia.csproj
dotnet test tests/Lemon.Template.Avalonia.Tests/Lemon.Template.Avalonia.Tests.csproj
On macOS use those two commands rather than building the whole solution: Lemon.Template.Wpf.sln also
contains the WPF projects, which only build on Windows. The headless view tests write the frames they render
to renders/ next to the test assembly, which is a quick way to check a page's look on a machine you are not
sitting at.
Publishing
dotnet publish src/Lemon.Template.Avalonia/Lemon.Template.Avalonia.csproj -c Release -r win-x64 --self-contained
dotnet publish src/Lemon.Template.Avalonia/Lemon.Template.Avalonia.csproj -c Release -r osx-arm64 --self-contained
Use osx-x64 for Intel Macs. To get a double-clickable .app bundle, run this on a Mac:
bash src/Lemon.Template.Avalonia/Packaging/macOS/bundle.sh
It publishes, assembles <AppName>.app with Packaging/macOS/Info.plist, generates the icon with sips /
iconutil, and signs the bundle ad hoc so it launches locally. Set CFBundleIdentifier in Info.plist to
your own reverse-DNS name first. Shipping to other Macs additionally needs a Developer ID signature and
notarization, which the script does not do.
๐ช Getting started
Prerequisites
- Windows SDK / .NET SDK supporting .NET 10 and WPF (
net10.0-windows). - WebView2 Runtime (usually present on modern Windows; required for the Hangfire tools page).
Run from source
git clone https://github.com/tracyma-05/Lemon.Template.Wpf
cd Lemon.Template.Wpf # or your fork folder name
dotnet restore
dotnet build src/Lemon.Template.Wpf/Lemon.Template.Wpf.csproj -c Release
dotnet run --project src/Lemon.Template.Wpf/Lemon.Template.Wpf.csproj -c Release
Run the tests:
dotnet test
Open the solution in Visual Studio / Rider if you prefer an IDE workflow.
Build conventions
Directory.Build.props and Directory.Packages.props apply to every project in the repository:
- Package versions are centralized.
PackageReferenceentries carry noVersion; add or bump a dependency inDirectory.Packages.propsonly. - Warnings are errors (
TreatWarningsAsErrors), with .NET analyzers on. Style/IDE rules are kept atsuggestionin.editorconfigso formatting preferences never block a build. - NuGet diagnostics stay warnings (
NU1507,NU1901-NU1904): a new advisory, or a machine with more than one configured feed, should not turn a green build red without a source change. - Two files opt out of nullable analysis with
#nullable disableand a comment explaining why:Infrastructures/Dialogs/ParametersBase.csandParametersExtensions.cs. They are ports of Prism's parameter bag, whose contract deliberately returnsdefaultfor a missing key.
Continuous integration
.github/workflows/ci.yml runs these jobs:
- Build and test (Windows) โ restore, build the whole solution in
Release(WPF and Avalonia), run both test suites, upload the.trx. - Build and test (macOS, Avalonia) โ run the Avalonia test suite on
macos-latest, publish anosx-arm64build, and upload the test results plus the frames the headless view tests rendered. - Pack โ build the template package once; every round-trip below installs that exact
.nupkg. - Template round-trip โ
lemon-wpfon Windows,lemon-avaloniaon Windows and macOS: scaffold with default options and with every optional feature disabled, build both, run the scaffolded tests, assert the disabled features left no files/dependencies/conditional markers behind (and that neither template leaks the other's sources). The Avalonia leg also builds a scaffold with the Windows-only desktop shortcut on.
The round-trip exists because building this repository does not prove the templates work: the template engine strips conditional blocks that the repository compiles with enabled.
๐ฆ Using as a dotnet new template
Install from a local clone of this repository (the folder that contains .template.config); this registers both lemon-wpf and lemon-avalonia:
dotnet new install .
The repository root Lemon.Template.Wpf.sln is the full developer solution (both apps, both test projects, the template pack and the publisher tool). Each template ships a slimmer solution that only loads its app, its test project (unless --IncludeTests false) and a few shared files, so dotnet test on the generated solution runs the testsโsee packaging/Lemon.Template.Wpf.sln and packaging/Lemon.Template.Avalonia.sln (paths are written for the generated layout, not for opening from packaging/ on disk).
Create a new project (default sourceName is Lemon.Template.Wpf; replace -n / -o with your app name):
dotnet new lemon-wpf -n MyCompany.MyApp -o MyCompany.MyApp
or, for Windows and macOS (default sourceName Lemon.Template.Avalonia):
dotnet new lemon-avalonia -n MyCompany.MyApp -o MyCompany.MyApp
Options
| Option | Default | Effect |
|---|---|---|
--EnableHangfire |
true |
Hangfire job server and the dashboard page (embedded with WebView2 in WPF, opened in the browser in Avalonia). Off also drops the Microsoft.AspNetCore.App framework reference, WebView2 (WPF) and both Hangfire packages, and omits Services/Hangfire, Views/Tools and ViewModels/Tools. |
--EnableTrayIcon |
true |
Close hides the window in the tray instead of quitting (a one-time balloon says so); double-click the icon to reopen, right-click for Open / Exit. The WPF app also allows one instance per session โ a second launch brings the running one forward. WPF uses H.NotifyIcon.Wpf; Avalonia uses its built-in TrayIcon (a menu bar extra on macOS, where a click opens the menu). |
--EnableDesktopShortcut |
false |
Recreates a desktop shortcut on every launch. Off by default because silently writing to the user's desktop is surprising for a fresh app. Windows only; the Avalonia app skips it on macOS. |
--IncludeTests |
true |
The xUnit test project under tests/. |
--skipRestore |
false |
Skip the implicit dotnet restore after creation. |
A minimal shell with no background jobs and no tray icon:
dotnet new lemon-wpf -n MyCompany.MyApp --EnableHangfire false --EnableTrayIcon false
The packaged solutions load the application project only; add the
test project with dotnet sln add tests/*/*.csproj if you want it in the same solution.
Uninstall when you no longer need the template:
dotnet new uninstall <path-to-this-repo>
To publish the template on NuGet, this repo includes Lemon.Template.Wpf.TemplatePack.csproj (package id Lemon.Templates.Wpf) and a small publisher tool under tools/NuGet.TemplatePublisher that uses NuGet.Protocol + NuGet.Packaging to dotnet pack and push. See Publish template to NuGet.
๐ก Publish template to NuGet
Two paths ship side by side: CI (recommended โ nuget.org's Trusted Publishing, no stored key) and the local publisher tool (API key, unchanged).
From CI (Trusted Publishing)
Push a vX.Y.Z tag โ the publish job in .github/workflows/ci.yml runs after build, test and the template round-trip, downloads the .nupkg that job packed, and pushes it. Tag v1.0.2 must match Version in common.props; the job fails the check otherwise.
git tag v1.0.2 && git push origin v1.0.2
workflow_dispatch with Push the packed template to nuget.org checked does the same from a branch (no version check).
The job asks nuget.org for a short-lived key over OIDC (NuGet/login, needs id-token: write). nuget.org binds that policy to this workflow file and the production environment, so renaming ci.yml or the environment breaks the exchange โ update the policy in nuget.org โ Trusted Publishing alongside any rename.
Repository settings it reads:
| Setting | Kind | Purpose |
|---|---|---|
NUGET_USER |
variable | nuget.org account the policy belongs to. Defaults to tracy.ma. |
NUGET_USE_TRUSTED_PUBLISHING |
variable | Set to false to skip OIDC and use the API key. |
NUGET_API_KEY |
secret | Fallback key, used whenever OIDC yields nothing. |
If neither credential is available the job fails rather than skipping the push silently.
Locally (API key)
- Copy
tools/NuGet.TemplatePublisher/secret.json.exampletotools/NuGet.TemplatePublisher/secret.jsonand setNuGetPublish:ApiKey(this path is gitignored). - Bump
Versionincommon.propswhen you ship a new template (the template packโsPackageVersionfollows$(Version)). Optionally overrideNuGetPublish:PackageVersioninappsettings.jsonor via env (NUGET_PUBLISH__*) for a one-off without editingcommon.props. - From the repository root:
dotnet run --project tools/NuGet.TemplatePublisher/NuGet.TemplatePublisher.csproj -c Release
The tool resolves the repo root, resolves the template pack csproj (configured path, default Lemon.Template.Wpf.TemplatePack.csproj at the root, or a single *TemplatePack*.csproj there), runs dotnet build on Lemon.Template.Wpf.sln so the latest sources compile (set SkipPreBuild to true to skip), then dotnet pack, verifies the .nupkg with PackageArchiveReader, and pushes via PackageUpdateResource.
After indexing on nuget.org, install with:
dotnet new install Lemon.Templates.Wpf
To pin a specific package version, use @ (not ::, which is deprecated):
dotnet new install Lemon.Templates.Wpf@1.0.1
โ๏ธ Configuration
appsettings.json (copied to output directory):
| Key | Purpose |
|---|---|
App:SqliteDatabasePath |
Optional. Absolute path, or path relative to the app base directory. If empty, the database is created under %LocalApplicationData%\<AssemblyName>\. |
HangfireDashboard:Url |
Optional. Base URL for the embedded Kestrel host (e.g. http://127.0.0.1:5088). If empty, http://127.0.0.1:0 is used (dynamic port). |
Update:Enabled |
false by default. Turns the check-for-updates feature on. |
Update:Provider |
Manifest (default) reads Update:Url; GitHub reads the latest release of Update:GitHubRepository. |
Update:Url |
Absolute http(s) URL of the update manifest, e.g. Next.Hub's โฆ/api/app/desktop-apps/<key>/latest. |
Update:GitHubRepository |
owner/repo (or its https://github.com/owner/repo address) for the GitHub provider. |
Update:CheckOnStartup |
true by default. Check quietly once the main window is shown; a dialog appears only when a newer version exists. |
Update:AutoInstall |
true by default. Update and restart downloads a zip package, verifies it and swaps it in; false always opens the download in the browser instead. See Automatic install. |
Serilog writes rolling files to Logs/log-YYYYMMDD.txt, which is also where the Logs โ Local-Logs page
reads from: under the application base directory (AppContext.BaseDirectory) in the WPF app, and under
<LocalApplicationData>/<AssemblyName>/Logs in the Avalonia app (see
What differs).
Minimum levels are set in App.OnStartup and differ per configuration: Release records Warning and
above, Debug records Information and above. The Microsoft namespace is capped at Warning in both.
User state lives in the shared SQLite database: app_theme (base theme + primary/secondary ARGB) and
app_language (selected culture name).
Check for updates
Both templates ship the same check. The title-bar button appears only when Update:Enabled is true and
the chosen provider is configured, so an app that never publishes updates shows no dead button. A release
can carry one package per platform; the app picks the one for the machine it runs on (win-x64,
win-arm64, osx-arm64, osx-x64, โฆ), falls back to the x64 build on arm64 (Windows on Arm emulation,
Rosetta 2), then to a package for any platform, and otherwise opens the release page.
Two kinds per platform. A release can also carry a self-contained build next to the framework-dependent
one, marked -full (win-x64-full, file MyApp-1.2.0-win-x64-full.zip; self-contained in a file name
works too). The app tells which kind it is by where the .NET runtime loads from (the shared install, or its
own folder / single-file bundle) and updates from its own kind: a self-contained install never receives a
package that needs the runtime installed, and a framework-dependent one only falls back to -full when its
own kind is missing. A release with no -full package for an operating system does not tell the kinds
apart there (releases from before this, and macOS bundles, which are always self-contained), and a
self-contained install takes its packages as they are.
GitHub Releases โ nothing to host:
"Update": {
"Enabled": true,
"Provider": "GitHub",
"GitHubRepository": "owner/myapp"
}
The app reads https://api.github.com/repos/owner/myapp/releases/latest (drafts and pre-releases are
skipped). The tag is the version (v1.2.0), the release body the notes, and each asset is filed under the
platform its name mentions: MyApp-1.2.0-win-x64.zip, MyApp-1.2.0-osx-arm64.zip; other tools' spellings
(windows, macos, darwin, amd64, x86_64, aarch64, universal) work too, and an installer (.msi,
.exe, .dmg, .pkg) wins over an archive for the same platform. The repository must be public:
anonymous requests are what the app makes, limited by GitHub to 60 an hour per IP address.
Update manifest โ any static host, or Next.Hub:
"Update": {
"Enabled": true,
"Url": "https://example.com/hub-api/api/app/desktop-apps/myapp/latest"
}
Update:Url must return the latest release as JSON (property names are case-insensitive, links may be
relative to the manifest URL):
{
"version": "1.2.0",
"downloads": {
"win-x64": "https://example.com/downloads/MyApp-1.2.0-win-x64.zip",
"osx-arm64": "https://example.com/downloads/MyApp-1.2.0-osx-arm64.zip",
"osx-x64": "https://example.com/downloads/MyApp-1.2.0-osx-x64.zip"
},
"downloadUrl": "https://example.com/downloads/MyApp-1.2.0-win-x64.zip",
"pageUrl": "https://example.com/myapp",
"releaseNotes": "- Fixed โฆ",
"publishedAt": "2026-09-26T08:00:00Z"
}
versionis required, plus at least one ofdownloads,downloadUrlorpageUrl.versionaccepts1.2,1.2.3,1.2.3.4and a leadingv; SemVer suffixes (-beta,+sha) are ignored. It is compared with the app's own version, i.e. theVersionproperty of the project.- With
downloads, the package comes from it anddownloadUrlis ignored (it is there for clients from template 1.2, which only know one link). A machine with no matching package is sent topageUrl, never to another platform's file. - Only http(s) links are accepted: Download opens them in the default browser, and Update and restart downloads them.
- A start-up check never shows an error: offline or unreachable servers are only logged. Clicking the button reports "up to date", "new version" or a readable failure. A red dot on the button marks a pending update.
Automatic install
When the package for this computer is a zip and the app folder can be written to, the dialog's button is Update and restart instead of Download:
- The app downloads the zip (progress bar, Cancel), checks its SHA-256 when the release publishes one,
and unpacks it into a work folder next to the app:
.MyApp.updatebesideMyApp\on Windows,.MyApp.app.updatebeside the bundle on macOS. - It starts the new version from there with
--apply-update โฆand exits. - The new version, in that mode, waits for the old process to end, renames the app folder (or
.appbundle) to a backup, copies itself in, then copies back every file only the old installation had โ logs, a local database, files the package does not ship. The result is the same as unzipping the package over the old folder, so a shippedappsettings.jsonreplaces the local one. - It starts the updated app and exits; the updated app deletes the work folder.
If anything fails, the backup is renamed back and the old version started again, so the user is never left without a working app; the reason ends up in the app log ("The last update did not install"). The usual cause is another process holding a file in the app folder (a terminal whose working directory is inside it, for example).
It falls back to opening the download in the browser when the package is an installer (.msi, .exe,
.dmg, .pkg, โฆ) or a web page, when the app runs from a folder the user cannot write to (Program Files,
an admin-owned /Applications), when started through dotnet App.dll, when Update:AutoInstall is
false, and after a failed attempt (the button then reads Download).
Checksums are optional. In the manifest, a downloads entry can be an object with its own checksum, and a
top-level sha256 belongs to downloadUrl (and to the only downloads entry when there is exactly one,
which is how Next.Hub answers). GitHub Releases provide a digest for every uploaded asset, which is used
automatically.
"downloads": {
"win-x64": { "url": "https://example.com/downloads/MyApp-1.2.0-win-x64.zip", "sha256": "โฆ" }
}
Keep machine-specific settings out of files the package ships (the package's appsettings.json wins), and
keep data out of the app folder where you can: the Avalonia template already writes logs and the database
under the user's application-data folder.
Publishing releases from GitHub Actions
A generated project contains .github/workflows/release.yml. Push a version tag and it builds the app,
creates a GitHub Release with the packages, and optionally publishes the same packages to Next.Hub:
git tag v1.2.0
git push origin v1.2.0
- The tag becomes the app's
Version(-p:Version=1.2.0) and, on macOS, the bundle version, so the running app and the update check agree on the number. - WPF builds
win-x64(framework-dependent) andwin-x64-full(self-contained). Avalonia builds the same two, plusosx-arm64andosx-x64.appbundles (self-contained, viaPackaging/macOS/bundle.sh, zipped withditto). Drop a matrix row to ship only one kind. - The workflow's own artifacts only hand the packages from the build jobs to the release job: they need a GitHub login to download and expire, so the app never points at them.
- Next.Hub (optional): create a publish token on the app's page in Next.Hub, then add the secret
NEXT_HUB_TOKENand the variablesNEXT_HUB_URL(API base, e.g.https://example.com/hub-api/) andNEXT_HUB_APP(the app key) to the repository..github/scripts/publish-next-hub.shuploads each package in chunks and publishes them as one version with a package per platform. - macOS signing: the bundle is signed ad hoc, so Gatekeeper blocks it after a browser download; users open it with right-click โ Open. Distributing without that step needs a Developer ID certificate and notarization, which the workflow does not do.
Adding a page
- Add a
UserControlunderViews/, and a matching<Name>ViewModelunderViewModels/โ the naming convention inViewModelLocatorwires them up automatically. - Declare a route constant in
Commons/Constants.cs("Group/Name"plus a"GroupIcon/PageIcon"pair). For a page that should sit at the top level with no children, use a single-segment key and a single icon (seeConstants.Home). - Annotate the view with
[NavigationRegister(Constants.MyPage, Constants.MainRegion, typeof(UserControl), Constants.MyPageIcon, DisplayOrder = 30)]. - Add
Menu_<Group>/Menu_<Name>entries to everyResources/AppStrings*.resx(non-alphanumeric characters in the route name become_). Without them the menu falls back to the raw route name.
To navigate from code โ and keep the side-menu highlight in sync โ inject IMenuNavigator and call
NavigateTo(Constants.MyPage); INavigationService on its own only swaps the region content. The start-up
page is the NavigateTo call in MainWindowViewModel's constructor.
Adding a localized string
Add the key to Resources/AppStrings.resx and every AppStrings.<culture>.resx, then reference it as
{loc:Localize My_Key} in XAML, or ILocalizationService.GetString / Format in a view model. A key that
is missing from the neutral file renders as [My_Key] so the gap is visible rather than blank.
:lifebuoy: Troubleshooting
| Symptom | Cause / fix |
|---|---|
| Tools โ Cron is blank (WPF) | The WebView2 Runtime is missing. Install the Evergreen runtime. |
| Tools โ Cron says the dashboard is not running (Avalonia) | The loopback Kestrel host failed to start โ usually a fixed HangfireDashboard:Url whose port is taken. Leave it empty for a dynamic port; the log has the details. |
| macOS: "the app is damaged" / cannot be opened | The bundle was downloaded (quarantined) without a Developer ID signature and notarization. For a local test build, xattr -dr com.apple.quarantine <App>.app; to distribute, sign and notarize. |
dotnet build of the solution fails on macOS |
Expected: the solution includes the WPF projects. Build or test the Avalonia projects directly (see the Avalonia Run from source commands). |
NETSDK1057 / SDK not found |
net10.0-windows needs the .NET 10 SDK. Install it from dotnet.microsoft.com. |
NU1507 warning about package sources |
Central Package Management wants a single feed, or package source mapping in your NuGet.config. Deliberately left as a warning so the template still restores on machines with an internal mirror. |
| A theme or language change is not remembered | The preferences tables live in the shared SQLite file; check App:SqliteDatabasePath and that the folder is writable. Failures are logged as warnings rather than thrown. |
Recurring job '<name>' can't be scheduled at startup |
A recurring job persisted by an earlier build references a type that no longer exists. Startup now removes such entries automatically and logs a warning; if it recurs, the job type exists but fails to deserialize its arguments. |
๐ Repository layout
Lemon.Template.Wpf/
โโโ .template.config/ # dotnet new template manifest (symbols, excludes, post actions)
โโโ .templateignore # Files excluded when packing/installing the template
โโโ .editorconfig # Formatting + analyzer severity policy
โโโ Directory.Build.props # Shared build conventions (imports common.props)
โโโ Directory.Packages.props # Central Package Management: every dependency version
โโโ common.props # Package metadata + shipped Version
โโโ templates/avalonia/.template.config/ # lemon-avalonia manifest (reads the repo root via "source": "../../")
โโโ packaging/ # Consumer .sln files used only inside the templates (not the full dev solution)
โโโ Lemon.Template.Wpf.TemplatePack.csproj # NuGet template pack (PackageType=Template), both templates
โโโ src/Lemon.Template.Wpf/ # Main WPF application
โ โโโ Commons/ # Shared constants (routes, regions, icons)
โ โโโ Infrastructures/ # DI extensions, navigation, dialogs, localization, data paths, shell
โ โโโ Resources/ # AppStrings.resx (+ per-culture satellites)
โ โโโ Services/ # Theming, localization store, Hangfire host, cron sample jobs
โ โโโ Themes/ # Control templates and shared styles (incl. Navigation.xaml)
โ โโโ Views/ / ViewModels/ # UI + MVVM
โ โโโ appsettings.json
โโโ src/Lemon.Template.Avalonia/ # The same app on Avalonia (Windows / macOS); same folder structure
โ โโโ Packaging/macOS/ # Info.plist + bundle.sh for a .app bundle
โโโ tests/Lemon.Template.Wpf.Tests/ # xUnit suite
โโโ tests/Lemon.Template.Avalonia.Tests/ # xUnit v3 + Avalonia headless suite
โโโ tools/
โ โโโ NuGet.TemplatePublisher/ # Pack + push to NuGet (NuGet.Protocol / NuGet.Packaging)
โโโ CHANGELOG.md
โโโ CONTRIBUTING.md
โโโ LICENSE.txt
๐ค Contributing
Issues and pull requests are welcome. For larger changes, please open an issue first to discuss direction (keeps the template intentionally small and easy to fork).
See CONTRIBUTING.md for build/test commands, conventions, and how to verify a change to the template itself. Notable changes are recorded in CHANGELOG.md.
๐ License
This project is licensed under the MIT License โ see LICENSE.txt.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.6.0 | 86 | 9/28/2026 |
| 1.5.1 | 88 | 9/27/2026 |
| 1.5.0 | 82 | 9/27/2026 |
| 1.4.1 | 83 | 9/27/2026 |
| 1.4.0 | 88 | 9/27/2026 |
| 1.3.1 | 82 | 9/26/2026 |
| 1.3.0 | 86 | 9/26/2026 |
| 1.2.1 | 91 | 9/26/2026 |
| 1.2.0 | 86 | 9/26/2026 |
| 1.1.0 | 85 | 9/26/2026 |
| 1.0.6 | 199 | 7/26/2026 |
| 1.0.5 | 178 | 7/26/2026 |
| 1.0.4 | 290 | 6/1/2026 |
| 1.0.3 | 265 | 5/13/2026 |
| 1.0.2 | 268 | 5/12/2026 |
| 1.0.1 | 263 | 5/12/2026 |
| 1.0.0 | 259 | 5/12/2026 |