OpenHabitTracker is a free, open source app for taking Markdown notes, planning tasks, and tracking habits. One codebase, 8 distribution channels. This is everything I had to figure out to ship it.
The previous articles covered why there are so many entry points and how the shared Blazor component library stays platform-agnostic. This article is about what happens after you write the code - the files you need, the gotchas that aren't documented anywhere, and what you have to do on every release.
Why 8 channels?
Each distribution channel has different requirements that forced a separate entry point:
-
Microsoft Store - MAUI (
net9.0-windows) -
Google Play - MAUI (
net9.0-android) -
Apple App Store - MAUI (
net9.0-ios) -
Mac App Store - MAUI (
net9.0-maccatalyst) - Flatpak (Flathub) - Photino - MAUI has no Linux target
- Snap Store - Photino
- Docker Hub + GitHub Container Registry - Blazor Server
- ClickOnce (Windows direct download) - WPF - for users who don't want the Store
- PWA - Blazor WASM
Before your first release (all platforms)
The boring but mandatory stuff - brief because it's all googleable:
- Register as a developer on each platform (Microsoft Partner Center $19 one-time, Google Play Console $25 one-time, Apple Developer Program $99/year, Snap Store free, Flathub free, Docker Hub free)
- Create your app listing on each store with descriptions, screenshots, privacy policy URL
- For Apple: create App IDs, provisioning profiles, and distribution certificates in Apple Developer portal
- For Google: create a keystore and keep it safe - you can never change it after the first upload
- For Microsoft Store: associate your app in Visual Studio to get the publisher identity values
Version numbers - the cross-cutting problem
Before going platform by platform, the version number problem deserves its own section because it's spread across more files than you'd expect, and one of them has a non-obvious constraint.
Files that contain the version number:
-
OpenHabitTracker.Blazor.Maui/OpenHabitTracker.Blazor.Maui.csproj- two separate fields Platforms/Windows/Package.appxmanifestnet.openhabittracker.OpenHabitTracker.yamlnet.openhabittracker.OpenHabitTracker.metainfo.xmlsnapcraft.yamlClickOnceProfile.pubxml-
FolderProfile.pubxml(WASM) VersionHistory.md
The MAUI .csproj has two separate version fields and they serve different purposes:
<ApplicationDisplayVersion>1.2.1</ApplicationDisplayVersion>
<ApplicationVersion>21</ApplicationVersion>
ApplicationDisplayVersion is the human-readable string shown to users. ApplicationVersion is an integer - Android requires it, it must strictly increment on every release, and it cannot be the version string. If you try to use "1.2.1" as the version code, the Android build fails with:
error XA0003: VersionCode 1.2.1 is invalid. It must be an integer value.
So you maintain a separate integer counter alongside your version string. Every release you bump both.
Microsoft Store (MAUI Windows)
First time: Register at Partner Center, pay the one-time fee, create the app reservation, associate the app in Visual Studio (this fills in the publisher identity values), create an MSIX package. (MAUI Windows deployment docs)
Special file: Platforms/Windows/Package.appxmanifest (schema reference)
<Identity
Name="31456Jinjinov.578313437ADBB"
Publisher="CN=63F779A2-C88E-4913-81F0-5E6786C4CD1A"
Version="1.2.1.0" />
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<Capability Name="internetClient"/>
</Capabilities>
The Name and Publisher values come from Partner Center when you associate your app. You can't make them up - they must match exactly what the Store has on record or the upload will be rejected.
runFullTrust is required for MAUI apps because they run as regular Win32 processes, not sandboxed UWP apps.
Every release: Bump Version in Package.appxmanifest, publish:
dotnet publish OpenHabitTracker.Blazor.Maui.csproj -c:Release -f:net9.0-windows10.0.19041.0 -p:SelfContained=true -p:PublishAppxPackage=true
Upload the .msixupload to Partner Center.
Google Play (MAUI Android)
First time: Register at Play Console, pay the one-time fee, create the app, set up the keystore, configure release signing. (MAUI Android Google Play docs)
You can test on an Android emulator before building a release:
dotnet build -t:Run -f:net9.0-android
Special file: Platforms/Android/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/appicon" android:roundIcon="@mipmap/appicon_round" android:supportsRtl="true"></application>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
This file looks minimal, but every permission your app needs must be declared here. Missing a permission and the feature silently fails at runtime. Adding a permission you don't need can cause Play Store review rejections. (Android permission reference)
Every release: Bump ApplicationDisplayVersion and ApplicationVersion (the integer) in .csproj, publish:
dotnet publish -c Release -f:net9.0-android ...
Upload the .aab to Play Console. The integer ApplicationVersion must be higher than the previous release or the upload is rejected.
Apple App Store (MAUI iOS)
First time: Apple Developer Program ($99/year, covers all Apple platforms), create an App ID, create a distribution certificate, create a provisioning profile, install both on your Mac. (MAUI iOS App Store docs, manual provisioning guide)
Apple requires screenshots at exact pixel dimensions or the submission is rejected. Required sizes:
- iPhone 6.7": 1290x2796 or 2796x1290
- iPhone 6.5": 1242x2688 or 1284x2778
- iPhone 5.5": 1242x2208 or 2208x1242
- iPad 12.9" (2nd gen): 2048x2732 or 2732x2048
- iPad 13": 2064x2752 or 2048x2732
You can test on the simulator before building a release:
dotnet build OpenHabitTracker.Blazor.Maui.csproj -t:Run -c:Release -f:net9.0-ios
Special file: Platforms/iOS/Info.plist
<key>CFBundleIdentifier</key>
<string>net.openhabittracker</string>
<key>CFBundleDisplayName</key>
<string>OpenHT</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer> <!-- iPhone -->
<integer>2</integer> <!-- iPad -->
</array>
ITSAppUsesNonExemptEncryption is the one that catches everyone. If you omit it, Apple holds your submission and asks you to answer export compliance questions every single time you submit. Set it to false if your app doesn't use encryption beyond standard HTTPS (which is exempt). (MAUI Info.plist docs)
The signing config lives in the .csproj in a conditional PropertyGroup, not just in the publish command. (MAUI iOS publish CLI docs)
<PropertyGroup Condition="$(TargetFramework.Contains('-ios')) and '$(Configuration)' == 'Release'">
<RuntimeIdentifier>ios-arm64</RuntimeIdentifier>
<CodesignKey>Apple Distribution: Your Name (53V66WG4KU)</CodesignKey>
<CodesignProvision>openhabittracker.ios</CodesignProvision>
</PropertyGroup>
Every release: Publish, upload .ipa via Transporter or Xcode.
dotnet publish OpenHabitTracker.Blazor.Maui.csproj -c:Release -f:net9.0-ios -p:ArchiveOnBuild=true -p:RuntimeIdentifier=ios-arm64 -p:CodesignKey="Apple Distribution: Your Name (53V66WG4KU)" -p:CodesignProvision="openhabittracker.ios"
Mac App Store (MAUI macOS)
First time: Same Apple Developer account, but separate Mac-specific provisioning profile and a second certificate type for the installer package. (MAUI macOS App Store docs, manual provisioning guide)
Required screenshot sizes for Mac App Store: 1280x800, 1440x900, 2560x1600, 2880x1800.
You can test locally before building a release:
dotnet build OpenHabitTracker.Blazor.Maui.csproj -t:Run -c:Release -f:net9.0-maccatalyst
Special file: Platforms/MacCatalyst/Info.plist
<key>CFBundleIdentifier</key>
<string>net.openhabittracker</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026 Jinjinov</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
Same ITSAppUsesNonExemptEncryption caveat as iOS. Also LSApplicationCategoryType - the Mac App Store requires a category, the App Store will reject submission without it. (MAUI Info.plist docs)
Special file: Platforms/MacCatalyst/Entitlements.plist
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
App Sandbox is mandatory for Mac App Store distribution. Without it, Apple rejects the submission outright. With it, you must explicitly declare every capability your app needs - in this case network.client for outgoing connections. Miss one and the feature fails silently inside the sandbox. (MAUI macOS entitlements docs)
The macOS signing config in .csproj requires three separate keys (MAUI macOS publish CLI docs):
<PropertyGroup Condition="$(TargetFramework.Contains('-maccatalyst')) and '$(Configuration)' == 'Release'">
<CodesignKey>Apple Distribution: Your Name (53V66WG4KU)</CodesignKey>
<CodesignProvision>openhabittracker.macos</CodesignProvision>
<CodesignEntitlements>Platforms\MacCatalyst\Entitlements.plist</CodesignEntitlements>
<PackageSigningKey>3rd Party Mac Developer Installer: Your Name (53V66WG4KU)</PackageSigningKey>
<EnableCodeSigning>True</EnableCodeSigning>
<EnablePackageSigning>true</EnablePackageSigning>
<CreatePackage>true</CreatePackage>
<MtouchLink>SdkOnly</MtouchLink>
</PropertyGroup>
Three different certificate types are involved: Apple Distribution (signs the app bundle), 3rd Party Mac Developer Installer (signs the .pkg installer). The certificate names include your team ID in parentheses - they come from Keychain after you install the certificates from Apple Developer portal.
Every release:
dotnet publish OpenHabitTracker.Blazor.Maui.csproj -c:Release -f:net9.0-maccatalyst -p:MtouchLink=SdkOnly -p:CreatePackage=true -p:EnableCodeSigning=true -p:EnablePackageSigning=true -p:CodesignKey="Apple Distribution: Your Name (53V66WG4KU)" -p:CodesignProvision="openhabittracker.macos" -p:CodesignEntitlements="Platforms\MacCatalyst\Entitlements.plist" -p:PackageSigningKey="3rd Party Mac Developer Installer: Your Name (53V66WG4KU)"
Upload .pkg via Transporter.
Flatpak / Flathub (Photino, Linux)
This is the most involved distribution channel. Flatpak builds happen in a network-isolated sandbox - no internet access during build. Every dependency must be pre-declared.
Photino depends on WebKit. On a fresh Linux machine you need this before the app will run at all:
sudo apt-get install libwebkit2gtk-4.1
First time: Apply to Flathub, fork their template repo, set up the app manifest, pass the linter, get reviewed. (Flathub submission guide) Flathub creates a separate GitHub repository for your app's manifest at github.com/flathub/net.openhabittracker.OpenHabitTracker. You maintain a fork at github.com/Jinjinov/net.openhabittracker.OpenHabitTracker.
Special file: net.openhabittracker.OpenHabitTracker.yaml
The Flatpak build manifest. It references your git repository by tag AND commit hash - both must match:
- type: git
url: https://github.com/Jinjinov/OpenHabitTracker.git
tag: 1.2.1
commit: 233c4b8410756159e14f31dd7a4e3607efa53749
It also handles cross-architecture builds through environment variables:
build-options:
arch:
aarch64:
env:
RUNTIME: linux-arm64
x86_64:
env:
RUNTIME: linux-x64
build-commands:
- dotnet publish OpenHabitTracker.Blazor.Photino/... -r $RUNTIME ...
Special file: net.openhabittracker.OpenHabitTracker.metainfo.xml
Flathub validates this file with a linter before merging the PR. It must pass appstream-util validate and flatpak-builder-lint. It contains the app description, release history, and screenshot URLs. A release entry must be added for every version. (AppStream spec)
Special file: net.openhabittracker.OpenHabitTracker.desktop
[Desktop Entry]
Name=OpenHabitTracker
Comment=Take notes, plan tasks, track habits
Exec=OpenHT
Icon=net.openhabittracker.OpenHabitTracker
Type=Application
Categories=Office;
This is the Linux standard for app launchers - how your app appears in GNOME, KDE, etc. The Icon value must match the SVG filename (without extension).
Special file: net.openhabittracker.OpenHabitTracker.svg
Flathub requires an SVG icon, not PNG. This must use the reverse-domain naming convention that matches your app ID.
Special file: nuget-sources.json
The most unique file in the whole project. Because Flatpak builds in a network-isolated sandbox, it cannot download NuGet packages at build time. Every package - including all transitive dependencies - must be pre-declared with its download URL and SHA-512 hash. This file is generated by flatpak-dotnet-generator.py:
python3 flatpak-dotnet-generator.py --dotnet 9 --freedesktop 25.08 nuget-sources.json OpenHabitTracker/OpenHabitTracker.Blazor.Photino/OpenHabitTracker.Blazor.Photino.csproj
The yaml then references it as an offline source:
sources:
- type: git
url: https://github.com/Jinjinov/OpenHabitTracker.git
tag: 1.2.1
commit: 233c4b8410756159e14f31dd7a4e3607efa53749
- ./nuget-sources.json
build-commands:
- dotnet publish ... --source ./nuget-sources --source /usr/lib/sdk/dotnet9/nuget/packages
nuget-sources.json doesn't need to be regenerated every release - only when NuGet packages change.
Every release:
Before opening a PR, validate everything locally. The Flathub linter will catch these too, but it's faster to fix them locally:
desktop-file-validate net.openhabittracker.OpenHabitTracker.desktop
appstream-util validate net.openhabittracker.OpenHabitTracker.metainfo.xml
flatpak run --command=flatpak-builder-lint org.flatpak.Builder manifest net.openhabittracker.OpenHabitTracker.yaml
Do a full local build and run to confirm it works:
flatpak-builder build-dir --user --force-clean --install --repo=repo net.openhabittracker.OpenHabitTracker.yaml
flatpak run --command=flatpak-builder-lint org.flatpak.Builder repo repo
flatpak run net.openhabittracker.OpenHabitTracker
Then submit:
- Create a git tag
- Get the commit hash:
git ls-remote https://github.com/Jinjinov/OpenHabitTracker.git refs/tags/1.2.1 - Update
tagandcommitinnet.openhabittracker.OpenHabitTracker.yaml - Add a release entry to
net.openhabittracker.OpenHabitTracker.metainfo.xml - Push to your fork (
Jinjinov/net.openhabittracker.OpenHabitTracker) - Open a PR to
flathub/net.openhabittracker.OpenHabitTracker - The Flathub bot builds and tests it - wait for
✅ Test build succeeded - If the test build fails: push a fix, update the tag and commit in the yaml, then comment in the PR:
bot, build net.openhabittracker.OpenHabitTracker - Merge the PR
- Sync your fork back from the upstream flathub repo so it stays up to date
Snap Store (Photino, Linux)
First time: Register at snapcraft.io, register the app name, install Snapcraft and LXD. Snapcraft uses LXD to build in an isolated container - you can't build snaps without it:
sudo snap install snapcraft --classic
sudo snap install lxd
sudo lxd init --auto
sudo usermod -aG lxd $USER
newgrp lxd
Special file: snapcraft.yaml (snapcraft.yaml reference)
name: openhabittracker
base: core24
confinement: strict
version: '1.2.1'
parts:
openhabittracker:
source: .
plugin: dotnet
dotnet-version: "9.0"
override-build: |
dotnet publish OpenHabitTracker.Blazor.Photino/OpenHabitTracker.Blazor.Photino.csproj -c Release -f net9.0 -r linux-x64 -p:PublishSingleFile=true -p:SelfContained=true -o $SNAPCRAFT_PART_INSTALL
chmod 0755 $SNAPCRAFT_PART_INSTALL/OpenHT
apps:
openhabittracker:
extensions: [gnome]
command: OpenHT
plugs:
- hardware-observe
- home
- removable-media
- network
plugs are the snap equivalent of Android permissions - they declare what the app can access. (Snap interfaces reference) extensions: [gnome] pulls in GNOME libraries and is required for GTK-based apps (Photino uses WebKit which is part of the GNOME stack).
confinement: strict means the snap is fully sandboxed. During development you use confinement: devmode and then switch to strict for release.
Every release:
snapcraft pack --debug
If the pack fails, clean the build cache and retry:
snapcraft clean openhabittracker
snapcraft pack --debug
Test locally before uploading:
sudo snap install openhabittracker_1.2.1_amd64.snap --dangerous --devmode
snap run openhabittracker
Upload and verify:
snapcraft login
snapcraft upload --release=stable openhabittracker_1.2.1_amd64.snap
snapcraft status openhabittracker
Docker Hub + GitHub Container Registry (Blazor Server)
First time: Docker Hub account, GitHub account (for GHCR), set up the Dockerfile, test the image locally. Authenticate to both registries before pushing:
docker login
echo <GitHubToken> | docker login ghcr.io -u YourUsername --password-stdin
The GitHub token needs write:packages scope. Generate it at GitHub → Settings → Developer settings → Personal access tokens.
Special file: Dockerfile
Multi-stage build - SDK image to compile, ASP.NET runtime image to run. (Docker multi-stage build docs)
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY ["OpenHabitTracker/OpenHabitTracker.csproj", "OpenHabitTracker/"]
COPY ["OpenHabitTracker.Blazor.Web/OpenHabitTracker.Blazor.Web.csproj", "OpenHabitTracker.Blazor.Web/"]
# ... other projects
RUN dotnet restore "OpenHabitTracker.Blazor.Web/OpenHabitTracker.Blazor.Web.csproj"
COPY . .
RUN dotnet publish "OpenHabitTracker.Blazor.Web.csproj" -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "OpenHT.dll"]
Only copying .csproj files first and running dotnet restore before copying the rest is intentional - it lets Docker cache the NuGet restore layer so rebuilds are fast when only source files change.
Special file: docker-compose.yml
This ships to end users, not just for building. Users run docker compose up with this file. It maps environment variables to appsettings.json values so users can set their credentials without modifying the image:
services:
openhabittracker:
image: jinjinov/openhabittracker:latest
ports:
- "5000:8080"
environment:
- AppSettings__UserName=${APPSETTINGS_USERNAME}
- AppSettings__Email=${APPSETTINGS_EMAIL}
- AppSettings__Password=${APPSETTINGS_PASSWORD}
- AppSettings__JwtSecret=${APPSETTINGS_JWT_SECRET}
volumes:
- ./.OpenHabitTracker:/app/.OpenHabitTracker
Every release:
docker compose build
docker tag openhabittracker jinjinov/openhabittracker:1.2.1
docker push jinjinov/openhabittracker:1.2.1
docker tag openhabittracker jinjinov/openhabittracker:latest
docker push jinjinov/openhabittracker:latest
docker tag openhabittracker ghcr.io/jinjinov/openhabittracker:1.2.1
docker push ghcr.io/jinjinov/openhabittracker:1.2.1
docker tag openhabittracker ghcr.io/jinjinov/openhabittracker:latest
docker push ghcr.io/jinjinov/openhabittracker:latest
(GitHub Container Registry docs)
WPF + ClickOnce (Windows direct download)
ClickOnce is for users who want a classical Windows installer experience without going through the Microsoft Store.
First time: Configure publish settings in Visual Studio, set up the bootstrapper. (ClickOnce deployment docs)
Special file: Properties/PublishProfiles/ClickOnceProfile.pubxml
<ApplicationVersion>1.2.1.0</ApplicationVersion>
<PublishProtocol>ClickOnce</PublishProtocol>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<SelfContained>False</SelfContained>
<BootstrapperPackage Include="Microsoft.NetCore.DesktopRuntime.9.0.x86">
<Install>true</Install>
<ProductName>.NET Desktop Runtime 9.0.0 (x86)</ProductName>
</BootstrapperPackage>
SelfContained=False + the bootstrapper means the installer checks for .NET Desktop Runtime and downloads it if missing. This keeps the installer small.
Every release: Bump ApplicationVersion, publish via Visual Studio ClickOnce, zip the output, FTP upload to the download server.
WASM / PWA (Blazor WebAssembly)
First time: Set up IIS with the URL Rewrite module (required for SPA routing - without it, any direct URL that isn't the root returns 404). (Blazor WASM IIS hosting docs)
Special file: wwwroot/manifest.json (web app manifest spec)
{
"name": "OpenHabitTracker",
"short_name": "OpenHT",
"id": "./",
"start_url": "./",
"display": "standalone",
"background_color": "#808080",
"theme_color": "#808080",
"icons": [
{ "src": "/icons/icon-512.png", "type": "image/png", "sizes": "512x512" },
{ "src": "/icons/icon-192.png", "type": "image/png", "sizes": "192x192" }
]
}
This makes the app installable as a PWA. display: standalone hides the browser chrome. Without the 512px icon, Chrome won't offer the install prompt.
Special file: wwwroot/service-worker.published.js
The dev version (service-worker.js) is a stub that always fetches from the network. The published version caches all .dll, .wasm, .js, .css, and asset files on first install for offline support. (Blazor PWA docs)
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/ ];
Special file: Properties/PublishProfiles/FolderProfile.pubxml
<PublishUrl>C:\inetpub\wwwroot</PublishUrl>
<DeleteExistingFiles>false</DeleteExistingFiles> <!-- NEVER SET IT TO true! IT WILL DELETE C:\inetpub\wwwroot FOLDER! -->
The danger comment is real. DeleteExistingFiles=true in a publish profile pointed at C:\inetpub\wwwroot will delete the entire folder and everything in it before copying the published output.
Every release: Publish to folder (directly to C:\inetpub\wwwroot), then FTP upload to the server.
The result
8 channels, roughly 15 special files, one codebase. The most time-consuming part on every release is keeping the version number consistent across all these files. The most time-consuming part on the first release is Apple - not because it's hard once you understand it, but because the documentation is scattered and the error messages are unhelpful.
Flatpak is the most technically interesting because of the offline build sandbox and the nuget-sources.json workflow. Flatpak has good official documentation for .NET at docs.flatpak.org/en/latest/dotnet.html - but it still took me a while to put all the pieces together for a real app with many dependencies.
OpenHabitTracker is open source - all the files shown here are in the repo.
Top comments (0)