<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Urban</title>
    <description>The latest articles on DEV Community by Urban (@jinjinov).</description>
    <link>https://dev.to/jinjinov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2006393%2Fdb9ed87b-6b02-4b58-9b9e-4cacd4295b04.png</url>
      <title>DEV Community: Urban</title>
      <link>https://dev.to/jinjinov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jinjinov"/>
    <language>en</language>
    <item>
      <title>How I release a Blazor app to 8 distribution channels</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Sat, 04 Apr 2026 09:03:06 +0000</pubDate>
      <link>https://dev.to/jinjinov/how-i-release-a-blazor-app-to-8-distribution-channels-550f</link>
      <guid>https://dev.to/jinjinov/how-i-release-a-blazor-app-to-8-distribution-channels-550f</guid>
      <description>&lt;p&gt;&lt;a href="https://openhabittracker.net/" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;The previous articles covered &lt;a href="https://dev.to/jinjinov/how-i-use-the-same-blazor-code-for-wasm-windows-linux-macos-ios-android-without-a-single-if-2bjk"&gt;why there are so many entry points&lt;/a&gt; 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.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why 8 channels?
&lt;/h2&gt;

&lt;p&gt;Each distribution channel has different requirements that forced a separate entry point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Store&lt;/strong&gt; - MAUI (&lt;code&gt;net9.0-windows&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Play&lt;/strong&gt; - MAUI (&lt;code&gt;net9.0-android&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple App Store&lt;/strong&gt; - MAUI (&lt;code&gt;net9.0-ios&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mac App Store&lt;/strong&gt; - MAUI (&lt;code&gt;net9.0-maccatalyst&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flatpak (Flathub)&lt;/strong&gt; - Photino - MAUI has no Linux target&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snap Store&lt;/strong&gt; - Photino&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Hub + GitHub Container Registry&lt;/strong&gt; - Blazor Server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ClickOnce (Windows direct download)&lt;/strong&gt; - WPF - for users who don't want the Store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PWA&lt;/strong&gt; - Blazor WASM&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Before your first release (all platforms)
&lt;/h2&gt;

&lt;p&gt;The boring but mandatory stuff - brief because it's all googleable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Register as a developer on each platform (&lt;a href="https://partner.microsoft.com/en-us/dashboard" rel="noopener noreferrer"&gt;Microsoft Partner Center&lt;/a&gt; $19 one-time, &lt;a href="https://play.google.com/console/" rel="noopener noreferrer"&gt;Google Play Console&lt;/a&gt; $25 one-time, &lt;a href="https://developer.apple.com/programs/" rel="noopener noreferrer"&gt;Apple Developer Program&lt;/a&gt; $99/year, &lt;a href="https://snapcraft.io/account" rel="noopener noreferrer"&gt;Snap Store&lt;/a&gt; free, &lt;a href="https://flathub.org/" rel="noopener noreferrer"&gt;Flathub&lt;/a&gt; free, &lt;a href="https://hub.docker.com/" rel="noopener noreferrer"&gt;Docker Hub&lt;/a&gt; free)&lt;/li&gt;
&lt;li&gt;Create your app listing on each store with descriptions, screenshots, privacy policy URL&lt;/li&gt;
&lt;li&gt;For Apple: create App IDs, provisioning profiles, and distribution certificates in Apple Developer portal&lt;/li&gt;
&lt;li&gt;For Google: create a keystore and keep it safe - you can never change it after the first upload&lt;/li&gt;
&lt;li&gt;For Microsoft Store: associate your app in Visual Studio to get the publisher identity values&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Version numbers - the cross-cutting problem
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Files that contain the version number:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;OpenHabitTracker.Blazor.Maui/OpenHabitTracker.Blazor.Maui.csproj&lt;/code&gt; - two separate fields&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Platforms/Windows/Package.appxmanifest&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;net.openhabittracker.OpenHabitTracker.yaml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;net.openhabittracker.OpenHabitTracker.metainfo.xml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;snapcraft.yaml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;ClickOnceProfile.pubxml&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FolderProfile.pubxml&lt;/code&gt; (WASM)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;VersionHistory.md&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MAUI &lt;code&gt;.csproj&lt;/code&gt; has two separate version fields and they serve different purposes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ApplicationDisplayVersion&amp;gt;&lt;/span&gt;1.2.1&lt;span class="nt"&gt;&amp;lt;/ApplicationDisplayVersion&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;ApplicationVersion&amp;gt;&lt;/span&gt;21&lt;span class="nt"&gt;&amp;lt;/ApplicationVersion&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ApplicationDisplayVersion&lt;/code&gt; is the human-readable string shown to users. &lt;code&gt;ApplicationVersion&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error XA0003: VersionCode 1.2.1 is invalid. It must be an integer value.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So you maintain a separate integer counter alongside your version string. Every release you bump both.&lt;/p&gt;




&lt;h2&gt;
  
  
  Microsoft Store (MAUI Windows)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; 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. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/windows/deployment/overview" rel="noopener noreferrer"&gt;MAUI Windows deployment docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Platforms/Windows/Package.appxmanifest&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://learn.microsoft.com/en-us/uwp/schemas/appxpackage/uapmanifestschema/schema-root" rel="noopener noreferrer"&gt;schema reference&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Identity&lt;/span&gt;
  &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"31456Jinjinov.578313437ADBB"&lt;/span&gt;
  &lt;span class="na"&gt;Publisher=&lt;/span&gt;&lt;span class="s"&gt;"CN=63F779A2-C88E-4913-81F0-5E6786C4CD1A"&lt;/span&gt;
  &lt;span class="na"&gt;Version=&lt;/span&gt;&lt;span class="s"&gt;"1.2.1.0"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;Capabilities&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;rescap:Capability&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"runFullTrust"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;Capability&lt;/span&gt; &lt;span class="na"&gt;Name=&lt;/span&gt;&lt;span class="s"&gt;"internetClient"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/Capabilities&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;Name&lt;/code&gt; and &lt;code&gt;Publisher&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;runFullTrust&lt;/code&gt; is required for MAUI apps because they run as regular Win32 processes, not sandboxed UWP apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt; Bump &lt;code&gt;Version&lt;/code&gt; in &lt;code&gt;Package.appxmanifest&lt;/code&gt;, publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet publish OpenHabitTracker.Blazor.Maui.csproj &lt;span class="nt"&gt;-c&lt;/span&gt;:Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-windows10.0.19041.0 &lt;span class="nt"&gt;-p&lt;/span&gt;:SelfContained&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:PublishAppxPackage&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload the &lt;code&gt;.msixupload&lt;/code&gt; to Partner Center.&lt;/p&gt;




&lt;h2&gt;
  
  
  Google Play (MAUI Android)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Register at Play Console, pay the one-time fee, create the app, set up the keystore, configure release signing. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/android/deployment/publish-google-play" rel="noopener noreferrer"&gt;MAUI Android Google Play docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;You can test on an Android emulator before building a release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build &lt;span class="nt"&gt;-t&lt;/span&gt;:Run &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-android
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Platforms/Android/AndroidManifest.xml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;manifest&lt;/span&gt; &lt;span class="na"&gt;xmlns:android=&lt;/span&gt;&lt;span class="s"&gt;"http://schemas.android.com/apk/res/android"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;application&lt;/span&gt; &lt;span class="na"&gt;android:allowBackup=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="na"&gt;android:icon=&lt;/span&gt;&lt;span class="s"&gt;"@mipmap/appicon"&lt;/span&gt; &lt;span class="na"&gt;android:roundIcon=&lt;/span&gt;&lt;span class="s"&gt;"@mipmap/appicon_round"&lt;/span&gt; &lt;span class="na"&gt;android:supportsRtl=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/application&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.ACCESS_NETWORK_STATE"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;uses-permission&lt;/span&gt; &lt;span class="na"&gt;android:name=&lt;/span&gt;&lt;span class="s"&gt;"android.permission.INTERNET"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/manifest&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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. (&lt;a href="https://developer.android.com/reference/android/Manifest.permission" rel="noopener noreferrer"&gt;Android permission reference&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt; Bump &lt;code&gt;ApplicationDisplayVersion&lt;/code&gt; and &lt;code&gt;ApplicationVersion&lt;/code&gt; (the integer) in &lt;code&gt;.csproj&lt;/code&gt;, publish:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet publish &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-android ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload the &lt;code&gt;.aab&lt;/code&gt; to Play Console. The integer &lt;code&gt;ApplicationVersion&lt;/code&gt; must be higher than the previous release or the upload is rejected.&lt;/p&gt;




&lt;h2&gt;
  
  
  Apple App Store (MAUI iOS)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; 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. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/ios/deployment/publish-app-store" rel="noopener noreferrer"&gt;MAUI iOS App Store docs&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/ios/device-provisioning/manual-provisioning" rel="noopener noreferrer"&gt;manual provisioning guide&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Apple requires screenshots at exact pixel dimensions or the submission is rejected. Required sizes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iPhone 6.7": 1290x2796 or 2796x1290&lt;/li&gt;
&lt;li&gt;iPhone 6.5": 1242x2688 or 1284x2778&lt;/li&gt;
&lt;li&gt;iPhone 5.5": 1242x2208 or 2208x1242&lt;/li&gt;
&lt;li&gt;iPad 12.9" (2nd gen): 2048x2732 or 2732x2048&lt;/li&gt;
&lt;li&gt;iPad 13": 2064x2752 or 2048x2732&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can test on the simulator before building a release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build OpenHabitTracker.Blazor.Maui.csproj &lt;span class="nt"&gt;-t&lt;/span&gt;:Run &lt;span class="nt"&gt;-c&lt;/span&gt;:Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-ios
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Platforms/iOS/Info.plist&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;CFBundleIdentifier&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;net.openhabittracker&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;CFBundleDisplayName&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;OpenHT&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ITSAppUsesNonExemptEncryption&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;false/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;UIDeviceFamily&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;1&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- iPhone --&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;2&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;  &lt;span class="c"&gt;&amp;lt;!-- iPad --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/bundleresources/information-property-list/itsappusesnonexemptencryption" rel="noopener noreferrer"&gt;&lt;code&gt;ITSAppUsesNonExemptEncryption&lt;/code&gt;&lt;/a&gt; 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 &lt;code&gt;false&lt;/code&gt; if your app doesn't use encryption beyond standard HTTPS (which is exempt). (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/macios/info-plist" rel="noopener noreferrer"&gt;MAUI Info.plist docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The signing config lives in the &lt;code&gt;.csproj&lt;/code&gt; in a conditional PropertyGroup, not just in the publish command. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/ios/deployment/publish-cli" rel="noopener noreferrer"&gt;MAUI iOS publish CLI docs&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&lt;/span&gt; &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"$(TargetFramework.Contains('-ios')) and '$(Configuration)' == 'Release'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;ios-arm64&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CodesignKey&amp;gt;&lt;/span&gt;Apple Distribution: Your Name (53V66WG4KU)&lt;span class="nt"&gt;&amp;lt;/CodesignKey&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CodesignProvision&amp;gt;&lt;/span&gt;openhabittracker.ios&lt;span class="nt"&gt;&amp;lt;/CodesignProvision&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt; Publish, upload &lt;code&gt;.ipa&lt;/code&gt; via Transporter or Xcode.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet publish OpenHabitTracker.Blazor.Maui.csproj &lt;span class="nt"&gt;-c&lt;/span&gt;:Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-ios &lt;span class="nt"&gt;-p&lt;/span&gt;:ArchiveOnBuild&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:RuntimeIdentifier&lt;span class="o"&gt;=&lt;/span&gt;ios-arm64 &lt;span class="nt"&gt;-p&lt;/span&gt;:CodesignKey&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Apple Distribution: Your Name (53V66WG4KU)"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:CodesignProvision&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"openhabittracker.ios"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Mac App Store (MAUI macOS)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Same Apple Developer account, but separate Mac-specific provisioning profile and a second certificate type for the installer package. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/mac-catalyst/deployment/publish-app-store" rel="noopener noreferrer"&gt;MAUI macOS App Store docs&lt;/a&gt;, &lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/ios/device-provisioning/manual-provisioning" rel="noopener noreferrer"&gt;manual provisioning guide&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;Required screenshot sizes for Mac App Store: 1280x800, 1440x900, 2560x1600, 2880x1800.&lt;/p&gt;

&lt;p&gt;You can test locally before building a release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet build OpenHabitTracker.Blazor.Maui.csproj &lt;span class="nt"&gt;-t&lt;/span&gt;:Run &lt;span class="nt"&gt;-c&lt;/span&gt;:Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-maccatalyst
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Platforms/MacCatalyst/Info.plist&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;CFBundleIdentifier&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;net.openhabittracker&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LSApplicationCategoryType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;public.app-category.productivity&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;NSHumanReadableCopyright&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;© 2026 Jinjinov&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ITSAppUsesNonExemptEncryption&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;false/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;ITSAppUsesNonExemptEncryption&lt;/code&gt; caveat as iOS. Also &lt;a href="https://developer.apple.com/documentation/bundleresources/information-property-list/lsapplicationcategorytype" rel="noopener noreferrer"&gt;&lt;code&gt;LSApplicationCategoryType&lt;/code&gt;&lt;/a&gt; - the Mac App Store requires a category, the App Store will reject submission without it. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/macios/info-plist" rel="noopener noreferrer"&gt;MAUI Info.plist docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Platforms/MacCatalyst/Entitlements.plist&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.app-sandbox&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;com.apple.security.network.client&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://developer.apple.com/documentation/xcode/configuring-the-macos-app-sandbox" rel="noopener noreferrer"&gt;App Sandbox&lt;/a&gt; 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 &lt;code&gt;network.client&lt;/code&gt; for outgoing connections. Miss one and the feature fails silently inside the sandbox. (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/mac-catalyst/entitlements" rel="noopener noreferrer"&gt;MAUI macOS entitlements docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;The macOS signing config in &lt;code&gt;.csproj&lt;/code&gt; requires three separate keys (&lt;a href="https://learn.microsoft.com/en-us/dotnet/maui/mac-catalyst/deployment/publish-app-store" rel="noopener noreferrer"&gt;MAUI macOS publish CLI docs&lt;/a&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PropertyGroup&lt;/span&gt; &lt;span class="na"&gt;Condition=&lt;/span&gt;&lt;span class="s"&gt;"$(TargetFramework.Contains('-maccatalyst')) and '$(Configuration)' == 'Release'"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CodesignKey&amp;gt;&lt;/span&gt;Apple Distribution: Your Name (53V66WG4KU)&lt;span class="nt"&gt;&amp;lt;/CodesignKey&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CodesignProvision&amp;gt;&lt;/span&gt;openhabittracker.macos&lt;span class="nt"&gt;&amp;lt;/CodesignProvision&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CodesignEntitlements&amp;gt;&lt;/span&gt;Platforms\MacCatalyst\Entitlements.plist&lt;span class="nt"&gt;&amp;lt;/CodesignEntitlements&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;PackageSigningKey&amp;gt;&lt;/span&gt;3rd Party Mac Developer Installer: Your Name (53V66WG4KU)&lt;span class="nt"&gt;&amp;lt;/PackageSigningKey&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;EnableCodeSigning&amp;gt;&lt;/span&gt;True&lt;span class="nt"&gt;&amp;lt;/EnableCodeSigning&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;EnablePackageSigning&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/EnablePackageSigning&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;CreatePackage&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/CreatePackage&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;MtouchLink&amp;gt;&lt;/span&gt;SdkOnly&lt;span class="nt"&gt;&amp;lt;/MtouchLink&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/PropertyGroup&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different certificate types are involved: &lt;code&gt;Apple Distribution&lt;/code&gt; (signs the app bundle), &lt;code&gt;3rd Party Mac Developer Installer&lt;/code&gt; (signs the &lt;code&gt;.pkg&lt;/code&gt; installer). The certificate names include your team ID in parentheses - they come from Keychain after you install the certificates from Apple Developer portal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dotnet publish OpenHabitTracker.Blazor.Maui.csproj &lt;span class="nt"&gt;-c&lt;/span&gt;:Release &lt;span class="nt"&gt;-f&lt;/span&gt;:net9.0-maccatalyst &lt;span class="nt"&gt;-p&lt;/span&gt;:MtouchLink&lt;span class="o"&gt;=&lt;/span&gt;SdkOnly &lt;span class="nt"&gt;-p&lt;/span&gt;:CreatePackage&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:EnableCodeSigning&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:EnablePackageSigning&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:CodesignKey&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Apple Distribution: Your Name (53V66WG4KU)"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:CodesignProvision&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"openhabittracker.macos"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:CodesignEntitlements&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Platforms&lt;/span&gt;&lt;span class="se"&gt;\M&lt;/span&gt;&lt;span class="s2"&gt;acCatalyst&lt;/span&gt;&lt;span class="se"&gt;\E&lt;/span&gt;&lt;span class="s2"&gt;ntitlements.plist"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt;:PackageSigningKey&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"3rd Party Mac Developer Installer: Your Name (53V66WG4KU)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload &lt;code&gt;.pkg&lt;/code&gt; via Transporter.&lt;/p&gt;




&lt;h2&gt;
  
  
  Flatpak / Flathub (Photino, Linux)
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Photino depends on WebKit. On a fresh Linux machine you need this before the app will run at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install &lt;/span&gt;libwebkit2gtk-4.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Apply to Flathub, fork their template repo, set up the app manifest, pass the linter, get reviewed. (&lt;a href="https://docs.flathub.org/docs/for-app-authors/submission" rel="noopener noreferrer"&gt;Flathub submission guide&lt;/a&gt;) Flathub creates a separate GitHub repository for your app's manifest at &lt;code&gt;github.com/flathub/net.openhabittracker.OpenHabitTracker&lt;/code&gt;. You maintain a fork at &lt;code&gt;github.com/Jinjinov/net.openhabittracker.OpenHabitTracker&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;net.openhabittracker.OpenHabitTracker.yaml&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The Flatpak build manifest. It references your git repository by tag AND commit hash - both must match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
  &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/Jinjinov/OpenHabitTracker.git&lt;/span&gt;
  &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.1&lt;/span&gt;
  &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;233c4b8410756159e14f31dd7a4e3607efa53749&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also handles cross-architecture builds through environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;build-options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;arch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;aarch64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;RUNTIME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux-arm64&lt;/span&gt;
    &lt;span class="na"&gt;x86_64&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;RUNTIME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;linux-x64&lt;/span&gt;
&lt;span class="na"&gt;build-commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dotnet publish OpenHabitTracker.Blazor.Photino/... -r $RUNTIME ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;net.openhabittracker.OpenHabitTracker.metainfo.xml&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Flathub validates this file with a linter before merging the PR. It must pass &lt;code&gt;appstream-util validate&lt;/code&gt; and &lt;code&gt;flatpak-builder-lint&lt;/code&gt;. It contains the app description, release history, and screenshot URLs. A release entry must be added for every version. (&lt;a href="https://freedesktop.org/software/appstream/docs/" rel="noopener noreferrer"&gt;AppStream spec&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;net.openhabittracker.OpenHabitTracker.desktop&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[Desktop Entry]&lt;/span&gt;
&lt;span class="py"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OpenHabitTracker&lt;/span&gt;
&lt;span class="py"&gt;Comment&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Take notes, plan tasks, track habits&lt;/span&gt;
&lt;span class="py"&gt;Exec&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;OpenHT&lt;/span&gt;
&lt;span class="py"&gt;Icon&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;net.openhabittracker.OpenHabitTracker&lt;/span&gt;
&lt;span class="py"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Application&lt;/span&gt;
&lt;span class="py"&gt;Categories&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Office;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the Linux standard for app launchers - how your app appears in GNOME, KDE, etc. The &lt;code&gt;Icon&lt;/code&gt; value must match the SVG filename (without extension).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;net.openhabittracker.OpenHabitTracker.svg&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Flathub requires an SVG icon, not PNG. This must use the reverse-domain naming convention that matches your app ID.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;nuget-sources.json&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;flatpak-dotnet-generator.py&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 flatpak-dotnet-generator.py &lt;span class="nt"&gt;--dotnet&lt;/span&gt; 9 &lt;span class="nt"&gt;--freedesktop&lt;/span&gt; 25.08 nuget-sources.json OpenHabitTracker/OpenHabitTracker.Blazor.Photino/OpenHabitTracker.Blazor.Photino.csproj
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The yaml then references it as an offline source:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;sources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;git&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://github.com/Jinjinov/OpenHabitTracker.git&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.2.1&lt;/span&gt;
    &lt;span class="na"&gt;commit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;233c4b8410756159e14f31dd7a4e3607efa53749&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nuget-sources.json&lt;/span&gt;
&lt;span class="na"&gt;build-commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;dotnet publish ... --source ./nuget-sources --source /usr/lib/sdk/dotnet9/nuget/packages&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;nuget-sources.json&lt;/code&gt; doesn't need to be regenerated every release - only when NuGet packages change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before opening a PR, validate everything locally. The Flathub linter will catch these too, but it's faster to fix them locally:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;desktop-file-validate net.openhabittracker.OpenHabitTracker.desktop
appstream-util validate net.openhabittracker.OpenHabitTracker.metainfo.xml
flatpak run &lt;span class="nt"&gt;--command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;flatpak-builder-lint org.flatpak.Builder manifest net.openhabittracker.OpenHabitTracker.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do a full local build and run to confirm it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;flatpak-builder build-dir &lt;span class="nt"&gt;--user&lt;/span&gt; &lt;span class="nt"&gt;--force-clean&lt;/span&gt; &lt;span class="nt"&gt;--install&lt;/span&gt; &lt;span class="nt"&gt;--repo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;repo net.openhabittracker.OpenHabitTracker.yaml
flatpak run &lt;span class="nt"&gt;--command&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;flatpak-builder-lint org.flatpak.Builder repo repo
flatpak run net.openhabittracker.OpenHabitTracker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then submit:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a git tag&lt;/li&gt;
&lt;li&gt;Get the commit hash: &lt;code&gt;git ls-remote https://github.com/Jinjinov/OpenHabitTracker.git refs/tags/1.2.1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Update &lt;code&gt;tag&lt;/code&gt; and &lt;code&gt;commit&lt;/code&gt; in &lt;code&gt;net.openhabittracker.OpenHabitTracker.yaml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add a release entry to &lt;code&gt;net.openhabittracker.OpenHabitTracker.metainfo.xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Push to your fork (&lt;code&gt;Jinjinov/net.openhabittracker.OpenHabitTracker&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Open a PR to &lt;code&gt;flathub/net.openhabittracker.OpenHabitTracker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The Flathub bot builds and tests it - wait for &lt;code&gt;✅ Test build succeeded&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;If the test build fails: push a fix, update the tag and commit in the yaml, then comment in the PR: &lt;code&gt;bot, build net.openhabittracker.OpenHabitTracker&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Merge the PR&lt;/li&gt;
&lt;li&gt;Sync your fork back from the upstream flathub repo so it stays up to date&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Snap Store (Photino, Linux)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;snapcraft &lt;span class="nt"&gt;--classic&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;lxd
&lt;span class="nb"&gt;sudo &lt;/span&gt;lxd init &lt;span class="nt"&gt;--auto&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; lxd &lt;span class="nv"&gt;$USER&lt;/span&gt;
newgrp lxd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;snapcraft.yaml&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://snapcraft.io/docs/snapcraft-yaml-reference" rel="noopener noreferrer"&gt;snapcraft.yaml reference&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;openhabittracker&lt;/span&gt;
&lt;span class="na"&gt;base&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;core24&lt;/span&gt;
&lt;span class="na"&gt;confinement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;strict&lt;/span&gt;
&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;1.2.1'&lt;/span&gt;

&lt;span class="na"&gt;parts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openhabittracker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;plugin&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dotnet&lt;/span&gt;
    &lt;span class="na"&gt;dotnet-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9.0"&lt;/span&gt;
    &lt;span class="na"&gt;override-build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;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&lt;/span&gt;
      &lt;span class="s"&gt;chmod 0755 $SNAPCRAFT_PART_INSTALL/OpenHT&lt;/span&gt;

&lt;span class="na"&gt;apps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openhabittracker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;extensions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;gnome&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenHT&lt;/span&gt;
    &lt;span class="na"&gt;plugs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;hardware-observe&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;home&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;removable-media&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;network&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;plugs&lt;/code&gt; are the snap equivalent of Android permissions - they declare what the app can access. (&lt;a href="https://snapcraft.io/docs/reference/interfaces/" rel="noopener noreferrer"&gt;Snap interfaces reference&lt;/a&gt;) &lt;code&gt;extensions: [gnome]&lt;/code&gt; pulls in GNOME libraries and is required for GTK-based apps (Photino uses WebKit which is part of the GNOME stack).&lt;/p&gt;

&lt;p&gt;&lt;code&gt;confinement: strict&lt;/code&gt; means the snap is fully sandboxed. During development you use &lt;code&gt;confinement: devmode&lt;/code&gt; and then switch to &lt;code&gt;strict&lt;/code&gt; for release.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snapcraft pack &lt;span class="nt"&gt;--debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the pack fails, clean the build cache and retry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snapcraft clean openhabittracker
snapcraft pack &lt;span class="nt"&gt;--debug&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test locally before uploading:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;snap &lt;span class="nb"&gt;install &lt;/span&gt;openhabittracker_1.2.1_amd64.snap &lt;span class="nt"&gt;--dangerous&lt;/span&gt; &lt;span class="nt"&gt;--devmode&lt;/span&gt;
snap run openhabittracker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upload and verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;snapcraft login
snapcraft upload &lt;span class="nt"&gt;--release&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;stable openhabittracker_1.2.1_amd64.snap
snapcraft status openhabittracker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Docker Hub + GitHub Container Registry (Blazor Server)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Docker Hub account, GitHub account (for GHCR), set up the Dockerfile, test the image locally. Authenticate to both registries before pushing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker login

&lt;span class="nb"&gt;echo&lt;/span&gt; &amp;lt;GitHubToken&amp;gt; | docker login ghcr.io &lt;span class="nt"&gt;-u&lt;/span&gt; YourUsername &lt;span class="nt"&gt;--password-stdin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The GitHub token needs &lt;code&gt;write:packages&lt;/code&gt; scope. Generate it at GitHub → Settings → Developer settings → Personal access tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Dockerfile&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Multi-stage build - SDK image to compile, ASP.NET runtime image to run. (&lt;a href="https://docs.docker.com/build/building/multi-stage/" rel="noopener noreferrer"&gt;Docker multi-stage build docs&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/sdk:9.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /src&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ["OpenHabitTracker/OpenHabitTracker.csproj", "OpenHabitTracker/"]&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; ["OpenHabitTracker.Blazor.Web/OpenHabitTracker.Blazor.Web.csproj", "OpenHabitTracker.Blazor.Web/"]&lt;/span&gt;
&lt;span class="c"&gt;# ... other projects&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet restore &lt;span class="s2"&gt;"OpenHabitTracker.Blazor.Web/OpenHabitTracker.Blazor.Web.csproj"&lt;/span&gt;

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;dotnet publish &lt;span class="s2"&gt;"OpenHabitTracker.Blazor.Web.csproj"&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; Release &lt;span class="nt"&gt;-o&lt;/span&gt; /app/publish

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;mcr.microsoft.com/dotnet/aspnet:9.0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=build /app/publish .&lt;/span&gt;
&lt;span class="k"&gt;ENTRYPOINT&lt;/span&gt;&lt;span class="s"&gt; ["dotnet", "OpenHT.dll"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only copying &lt;code&gt;.csproj&lt;/code&gt; files first and running &lt;code&gt;dotnet restore&lt;/code&gt; before copying the rest is intentional - it lets Docker cache the NuGet restore layer so rebuilds are fast when only source files change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;docker-compose.yml&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This ships to end users, not just for building. Users run &lt;code&gt;docker compose up&lt;/code&gt; with this file. It maps environment variables to &lt;code&gt;appsettings.json&lt;/code&gt; values so users can set their credentials without modifying the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;openhabittracker&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;jinjinov/openhabittracker:latest&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5000:8080"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AppSettings__UserName=${APPSETTINGS_USERNAME}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AppSettings__Email=${APPSETTINGS_EMAIL}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AppSettings__Password=${APPSETTINGS_PASSWORD}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;AppSettings__JwtSecret=${APPSETTINGS_JWT_SECRET}&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./.OpenHabitTracker:/app/.OpenHabitTracker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;a href="https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry" rel="noopener noreferrer"&gt;GitHub Container Registry docs&lt;/a&gt;)&lt;/p&gt;




&lt;h2&gt;
  
  
  WPF + ClickOnce (Windows direct download)
&lt;/h2&gt;

&lt;p&gt;ClickOnce is for users who want a classical Windows installer experience without going through the Microsoft Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Configure publish settings in Visual Studio, set up the bootstrapper. (&lt;a href="https://learn.microsoft.com/en-us/visualstudio/deployment/clickonce-security-and-deployment" rel="noopener noreferrer"&gt;ClickOnce deployment docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Properties/PublishProfiles/ClickOnceProfile.pubxml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;ApplicationVersion&amp;gt;&lt;/span&gt;1.2.1.0&lt;span class="nt"&gt;&amp;lt;/ApplicationVersion&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;PublishProtocol&amp;gt;&lt;/span&gt;ClickOnce&lt;span class="nt"&gt;&amp;lt;/PublishProtocol&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;RuntimeIdentifier&amp;gt;&lt;/span&gt;win-x86&lt;span class="nt"&gt;&amp;lt;/RuntimeIdentifier&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;SelfContained&amp;gt;&lt;/span&gt;False&lt;span class="nt"&gt;&amp;lt;/SelfContained&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;BootstrapperPackage&lt;/span&gt; &lt;span class="na"&gt;Include=&lt;/span&gt;&lt;span class="s"&gt;"Microsoft.NetCore.DesktopRuntime.9.0.x86"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Install&amp;gt;&lt;/span&gt;true&lt;span class="nt"&gt;&amp;lt;/Install&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;ProductName&amp;gt;&lt;/span&gt;.NET Desktop Runtime 9.0.0 (x86)&lt;span class="nt"&gt;&amp;lt;/ProductName&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/BootstrapperPackage&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;SelfContained=False&lt;/code&gt; + the bootstrapper means the installer checks for .NET Desktop Runtime and downloads it if missing. This keeps the installer small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt; Bump &lt;code&gt;ApplicationVersion&lt;/code&gt;, publish via Visual Studio ClickOnce, zip the output, FTP upload to the download server.&lt;/p&gt;




&lt;h2&gt;
  
  
  WASM / PWA (Blazor WebAssembly)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First time:&lt;/strong&gt; Set up IIS with the URL Rewrite module (required for SPA routing - without it, any direct URL that isn't the root returns 404). (&lt;a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/host-and-deploy/webassembly/iis" rel="noopener noreferrer"&gt;Blazor WASM IIS hosting docs&lt;/a&gt;)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;wwwroot/manifest.json&lt;/code&gt;&lt;/strong&gt; (&lt;a href="https://www.w3.org/TR/appmanifest/" rel="noopener noreferrer"&gt;web app manifest spec&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenHabitTracker"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"short_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OpenHT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standalone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#808080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"theme_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#808080"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icons/icon-512.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512x512"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icons/icon-192.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192x192"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This makes the app installable as a PWA. &lt;code&gt;display: standalone&lt;/code&gt; hides the browser chrome. Without the 512px icon, Chrome won't offer the install prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;wwwroot/service-worker.published.js&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The dev version (&lt;code&gt;service-worker.js&lt;/code&gt;) is a stub that always fetches from the network. The published version caches all &lt;code&gt;.dll&lt;/code&gt;, &lt;code&gt;.wasm&lt;/code&gt;, &lt;code&gt;.js&lt;/code&gt;, &lt;code&gt;.css&lt;/code&gt;, and asset files on first install for offline support. (&lt;a href="https://learn.microsoft.com/en-us/aspnet/core/blazor/progressive-web-app/" rel="noopener noreferrer"&gt;Blazor PWA docs&lt;/a&gt;)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offlineAssetsInclude&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;dll$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;pdb$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;wasm/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;html/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;js$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;json$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;css$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;woff$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;png$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;jpe&lt;/span&gt;&lt;span class="se"&gt;?&lt;/span&gt;&lt;span class="sr"&gt;g$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;gif$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="sr"&gt;ico$/&lt;/span&gt; &lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Special file: &lt;code&gt;Properties/PublishProfiles/FolderProfile.pubxml&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;PublishUrl&amp;gt;&lt;/span&gt;C:\inetpub\wwwroot&lt;span class="nt"&gt;&amp;lt;/PublishUrl&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;DeleteExistingFiles&amp;gt;&lt;/span&gt;false&lt;span class="nt"&gt;&amp;lt;/DeleteExistingFiles&amp;gt;&lt;/span&gt; &lt;span class="c"&gt;&amp;lt;!-- NEVER SET IT TO true! IT WILL DELETE C:\inetpub\wwwroot FOLDER! --&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The danger comment is real. &lt;code&gt;DeleteExistingFiles=true&lt;/code&gt; in a publish profile pointed at &lt;code&gt;C:\inetpub\wwwroot&lt;/code&gt; will delete the entire folder and everything in it before copying the published output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every release:&lt;/strong&gt; Publish to folder (directly to &lt;code&gt;C:\inetpub\wwwroot&lt;/code&gt;), then FTP upload to the server.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Flatpak is the most technically interesting because of the offline build sandbox and the &lt;code&gt;nuget-sources.json&lt;/code&gt; workflow. Flatpak has good official documentation for .NET at &lt;a href="https://docs.flatpak.org/en/latest/dotnet.html" rel="noopener noreferrer"&gt;docs.flatpak.org/en/latest/dotnet.html&lt;/a&gt; - but it still took me a while to put all the pieces together for a real app with many dependencies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;OpenHabitTracker is open source&lt;/a&gt; - all the files shown here are in the repo.&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>maui</category>
    </item>
    <item>
      <title>How I use the same Blazor code for WASM, Windows, Linux, macOS, iOS, Android without a single #if</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Sun, 29 Mar 2026 08:33:00 +0000</pubDate>
      <link>https://dev.to/jinjinov/how-i-use-the-same-blazor-code-for-wasm-windows-linux-macos-ios-android-without-a-single-if-2bjk</link>
      <guid>https://dev.to/jinjinov/how-i-use-the-same-blazor-code-for-wasm-windows-linux-macos-ios-android-without-a-single-if-2bjk</guid>
      <description>&lt;p&gt;&lt;a href="https://openhabittracker.net/" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; is a free, open source app for taking Markdown notes, planning tasks, and tracking habits. It runs on Windows, Linux, macOS, iOS, Android, and as a web app - all from a single shared Razor component library. This article explains how.&lt;/p&gt;

&lt;p&gt;In a &lt;a href="https://dev.to/jinjinov/how-to-create-a-blazor-app-for-wasm-windows-linux-macos-ios-android-1fh5"&gt;previous article&lt;/a&gt; I covered the architecture but skipped the actual code. This is the code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why so many entry points?
&lt;/h2&gt;

&lt;p&gt;Each store or distribution method has its own requirements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;MAUI&lt;/strong&gt; -&amp;gt; Microsoft Store, Google Play, App Store, Mac App Store&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blazor WASM&lt;/strong&gt; -&amp;gt; web / PWA&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blazor Server&lt;/strong&gt; -&amp;gt; Docker self-hosting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Photino&lt;/strong&gt; -&amp;gt; Linux (Flatpak, Snap) - MAUI simply has no Linux target&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WPF + ClickOnce&lt;/strong&gt; -&amp;gt; Windows direct download, classical installer experience outside the Store&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entry points exist because the distribution channels demanded them.&lt;/p&gt;

&lt;p&gt;Once you accept that you need six entry points, the question becomes: how do you stop the shared component library from knowing about any of them?&lt;/p&gt;




&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Every platform difference is hidden behind an interface. The shared &lt;code&gt;OpenHabitTracker.Blazor&lt;/code&gt; library defines the interface and a default (usually no-op) implementation. Each entry point registers its own implementation via DI. The shared library consumes the interface and never knows which platform it's running on.&lt;/p&gt;

&lt;p&gt;There is not a single &lt;code&gt;#if&lt;/code&gt; in &lt;code&gt;OpenHabitTracker.Blazor&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Let's go through each interface.&lt;/p&gt;




&lt;h2&gt;
  
  
  IOpenFile / ISaveFile
&lt;/h2&gt;

&lt;p&gt;File picking is the most dramatic example. In a browser, you cannot open a native file dialog programmatically - the user must click a file input element. In every other platform, you call the OS dialog directly.&lt;/p&gt;

&lt;p&gt;The interface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IOpenFile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;OpenFileDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns a &lt;code&gt;RenderFragment&lt;/code&gt; - the implementation decides what HTML to render and how to wire the file picker. The component consuming it just renders whatever comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;WASM&lt;/strong&gt; - must use a hidden &lt;code&gt;&amp;lt;InputFile&amp;gt;&lt;/code&gt; wrapped in a &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenFile&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOpenFile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;const&lt;/span&gt; &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;_maxAllowedFileSize&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt; &lt;span class="p"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;OpenFileDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bi bi-box-arrow-in-right"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;InputFile&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"d-none"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"OnChange"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;InputFileChangeEventArgs&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;Stream&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenReadStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;maxAllowedSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_maxAllowedFileSize&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}));&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseComponent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;WinForms&lt;/strong&gt; - a &lt;code&gt;&amp;lt;button&amp;gt;&lt;/code&gt; that opens &lt;code&gt;System.Windows.Forms.OpenFileDialog&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenFile&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOpenFile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;OpenFileDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"onclick"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;OpenFileDialog&lt;/span&gt; &lt;span class="n"&gt;openFileDialog&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;Filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"JSON|*.json|TSV|*.tsv|YAML|*.yaml|Markdown|*.md|Google Keep Takeout ZIP|*.zip"&lt;/span&gt;
                &lt;span class="p"&gt;};&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openFileDialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShowDialog&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;DialogResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OK&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;openFileDialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;openFileDialog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenFile&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}));&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bi bi-box-arrow-in-right"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;MAUI&lt;/strong&gt; - &lt;code&gt;FilePicker.PickAsync()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenFile&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOpenFile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;OpenFileDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"onclick"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;FileResult&lt;/span&gt;&lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;FilePicker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;PickAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;Stream&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenReadAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;FileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}));&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bi bi-box-arrow-in-right"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Photino&lt;/strong&gt; - &lt;code&gt;mainWindow.ShowOpenFile()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PhotinoWindow&lt;/span&gt; &lt;span class="n"&gt;mainWindow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IOpenFile&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;Extensions&lt;/span&gt;&lt;span class="p"&gt;)[]&lt;/span&gt; &lt;span class="n"&gt;_filters&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"JSON"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".json"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"TSV"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".tsv"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"YAML"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".yaml"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Markdown"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".md"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Google Keep Takeout ZIP"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;".zip"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;OpenFileDialog&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Func&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"button"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;css&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"onclick"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mainWindow&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ShowOpenFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;filters&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;_filters&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Length&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="n"&gt;FileStream&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenRead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
                    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;onFileOpened&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="p"&gt;}));&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;OpenElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"i"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"class"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"bi bi-box-arrow-in-right"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;" "&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseElement&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four completely different implementations. One interface. The Backup page that renders the import button doesn't know which it gets.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ISaveFile&lt;/code&gt; follows the same pattern: WASM triggers a JS download via &lt;code&gt;SaveAsUTF8&lt;/code&gt;, WinForms/WPF use &lt;code&gt;SaveFileDialog&lt;/code&gt;, MAUI uses &lt;code&gt;CommunityToolkit.Maui&lt;/code&gt;'s &lt;code&gt;FileSaver&lt;/code&gt;, Photino uses &lt;code&gt;mainWindow.ShowSaveFile()&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  IAuthFragment - the null object pattern
&lt;/h2&gt;

&lt;p&gt;OpenHabitTracker supports optional sync with a self-hosted server. Native desktop and mobile apps (WinForms, WPF, Photino, MAUI) need a login UI to connect to it. WASM is already the web app. Blazor Server IS the server - it handles its own auth via ASP.NET Identity middleware, not through this interface.&lt;/p&gt;

&lt;p&gt;So the null object default covers WASM and Blazor Server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthFragment&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAuthFragment&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsAuthAvailable&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TryRefreshTokenLogin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;GetAuthFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;stateChanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateChangedChanged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The real implementation is registered on WinForms, WPF, Photino, and MAUI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AuthFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAuthService&lt;/span&gt; &lt;span class="n"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAuthFragment&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsAuthAvailable&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;TryRefreshTokenLogin&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;authService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryRefreshTokenLogin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;RenderFragment&lt;/span&gt; &lt;span class="nf"&gt;GetAuthFragment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;stateChanged&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCallback&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;stateChangedChanged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OpenComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;LoginComponent&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"StateChanged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stateChanged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"StateChangedChanged"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stateChangedChanged&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;CloseComponent&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The settings page checks &lt;code&gt;IsAuthAvailable&lt;/code&gt; and conditionally renders the sync section. No &lt;code&gt;#if&lt;/code&gt;, no platform checks - just a boolean on an injected interface.&lt;/p&gt;




&lt;h2&gt;
  
  
  IPreRenderService - one line that solves SSR
&lt;/h2&gt;

&lt;p&gt;Blazor Server pre-renders pages on the server before the WebSocket connection is established. During that phase, calling JS interop throws an exception. The fix:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IPreRenderService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsPreRendering&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Default (all non-server platforms):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PreRenderService&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IPreRenderService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsPreRendering&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blazor Server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PreRenderService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IHttpContextAccessor&lt;/span&gt; &lt;span class="n"&gt;httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IPreRenderService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;IsPreRendering&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
        &lt;span class="p"&gt;!(&lt;/span&gt;&lt;span class="n"&gt;httpContextAccessor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HttpContext&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HasStarted&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One property. One line. The rest of the shared library guards JS calls with &lt;code&gt;if (!preRenderService.IsPreRendering)&lt;/code&gt; and works correctly on all platforms.&lt;/p&gt;




&lt;h2&gt;
  
  
  ILinkAttributeService - a Photino-only problem
&lt;/h2&gt;

&lt;p&gt;Photino runs Blazor inside an embedded WebView. When a user clicks an external link in a Markdown note, the WebView tries to navigate itself instead of opening the system browser. The fix is a JS call that adds a custom &lt;code&gt;onclick&lt;/code&gt; handler to all external links.&lt;/p&gt;

&lt;p&gt;Default (everyone else - no-op):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LinkAttributeService&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ILinkAttributeService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;AddAttributesToLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ElementReference&lt;/span&gt; &lt;span class="n"&gt;elementReference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CompletedTask&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Photino only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LinkAttributeService&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IJSRuntime&lt;/span&gt; &lt;span class="n"&gt;jsRuntime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ILinkAttributeService&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="n"&gt;Task&lt;/span&gt; &lt;span class="nf"&gt;AddAttributesToLinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ElementReference&lt;/span&gt; &lt;span class="n"&gt;elementReference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;jsRuntime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;InvokeVoidAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"addAttributeToLinks"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;elementReference&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a user clicks an external link in a Markdown note, Photino's WebView would navigate itself instead of opening the system browser. &lt;code&gt;addAttributeToLinks&lt;/code&gt; finds all &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; elements with &lt;code&gt;http://&lt;/code&gt; or &lt;code&gt;https://&lt;/code&gt; URLs and replaces their click behavior with an &lt;code&gt;onclick&lt;/code&gt; that calls &lt;code&gt;DotNet.invokeMethodAsync('OpenHT', 'OpenLink', url)&lt;/code&gt;. That invokes the &lt;code&gt;[JSInvokable] OpenLink&lt;/code&gt; method in Photino's &lt;code&gt;Program.cs&lt;/code&gt;, which does &lt;code&gt;Process.Start(new ProcessStartInfo(url) { UseShellExecute = true })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The full chain: user clicks link → JS intercepts → .NET interop → system browser opens.&lt;/p&gt;

&lt;p&gt;The shared library calls &lt;code&gt;AddAttributesToLinks&lt;/code&gt; after every Markdown render. On every other platform, it does nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  IAssemblyProvider - router wiring
&lt;/h2&gt;

&lt;p&gt;Blazor's router needs to know which assemblies contain page components. This differs between entry points:&lt;/p&gt;

&lt;p&gt;Default (desktop - pages only in the shared library):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AssemblyProvider&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAssemblyProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Assembly&lt;/span&gt; &lt;span class="n"&gt;AppAssembly&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAssemblyProvider&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;AdditionalAssemblies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;WASM and Blazor Server (entry point assembly also contains pages):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AssemblyProvider&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;IAssemblyProvider&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Assembly&lt;/span&gt; &lt;span class="n"&gt;AppAssembly&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IAssemblyProvider&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="n"&gt;AdditionalAssemblies&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;AssemblyProvider&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;Assembly&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  IDataAccess - the deepest split
&lt;/h2&gt;

&lt;p&gt;The storage layer is where the platforms diverge most fundamentally. The interface covers the full CRUD surface for every entity type. Behind it are three completely different backends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IndexedDB&lt;/strong&gt; (WASM) - browser storage via &lt;code&gt;DnetIndexedDb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQLite via EF Core&lt;/strong&gt; (WinForms, WPF, Photino, MAUI, Blazor Server) - local database file&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP API client&lt;/strong&gt; (remote sync) - calls a self-hosted Blazor Server instance&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Blazor Server entry point also needs ASP.NET Identity for JWT authentication, which means its user table has a different schema than the plain SQLite &lt;code&gt;UserEntity&lt;/code&gt;. &lt;code&gt;IUserEntity&lt;/code&gt; bridges this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="nc"&gt;IUserEntity&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;Id&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;UserName&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;PasswordHash&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;DateTime&lt;/span&gt; &lt;span class="n"&gt;LastChangeAt&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;get&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;UserEntity&lt;/code&gt; implements it for SQLite. &lt;code&gt;ApplicationUser&lt;/code&gt; (ASP.NET Identity) implements it for Blazor Server. The service layer works with &lt;code&gt;IUserEntity&lt;/code&gt; and doesn't know which it gets.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the entry points look like
&lt;/h2&gt;

&lt;p&gt;Each &lt;code&gt;Program.cs&lt;/code&gt; calls the same four shared registrations, then adds a few lines of platform-specific DI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="c1"&gt;// shared - every platform calls these four&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddServices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddDataAccess&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddBackup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddBlazor&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// platform-specific - only these lines differ&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IOpenFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OpenFile&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ISaveFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SaveFile&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;INavBarFragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NavBarFragment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAssemblyProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AssemblyProvider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;ILinkAttributeService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;LinkAttributeService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IPreRenderService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;PreRenderService&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Services&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddScoped&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;IAuthFragment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AuthFragment&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every class name in the platform-specific block is a different type - same interface name, different namespace. That's the entire surface area of platform divergence.&lt;/p&gt;




&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The shared &lt;code&gt;OpenHabitTracker.Blazor&lt;/code&gt; project - the one that contains all Razor components, pages, and layouts - has zero &lt;code&gt;#if&lt;/code&gt; directives for platform differences. It consumes interfaces, renders &lt;code&gt;RenderFragment&lt;/code&gt; values it receives, and checks boolean properties on injected services. It has no knowledge of IndexedDB, &lt;code&gt;OpenFileDialog&lt;/code&gt;, &lt;code&gt;FilePicker&lt;/code&gt;, Photino, or ASP.NET Identity.&lt;/p&gt;

&lt;p&gt;This is my third rewrite of this app in Blazor. &lt;a href="https://dev.to/jinjinov/what-led-me-to-creating-openhabittracker-and-lessons-learned-3p85"&gt;The first two taught me what not to do.&lt;/a&gt; The third time, the architecture finally felt right.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;OpenHabitTracker is open source&lt;/a&gt; - all the code shown here is in production.&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>OpenHabitTracker vs Habitica, Loop Habit Tracker, Streaks, and Everyday</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Sun, 22 Mar 2026 09:00:18 +0000</pubDate>
      <link>https://dev.to/jinjinov/openhabittracker-vs-habitica-loop-habit-tracker-streaks-and-everyday-1fp7</link>
      <guid>https://dev.to/jinjinov/openhabittracker-vs-habitica-loop-habit-tracker-streaks-and-everyday-1fp7</guid>
      <description>&lt;p&gt;This article compares OpenHabitTracker with four popular alternatives: Habitica, Loop Habit Tracker, Streaks, and Everyday. Not to declare a winner, but to help you figure out which one fits how you actually work.&lt;/p&gt;




&lt;h2&gt;
  
  
  🖥️ Platforms
&lt;/h2&gt;

&lt;p&gt;The first question is always: does it run on my device?&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Windows&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🌐 browser only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;🌐 browser only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Linux&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🌐 browser only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;🌐 browser only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Android&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;macOS&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;🌐 browser only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web / PWA&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Loop is Android only - there is no official iOS version. Streaks is Apple only - no Android, no Windows, no Linux. Habitica works in the browser on any platform but has no native desktop app. Everyday comes close to full coverage but skips Windows and Linux.&lt;/p&gt;

&lt;p&gt;OpenHabitTracker is the only one in this list with a native app on Windows and Linux, alongside Android, iOS, macOS, and a PWA.&lt;/p&gt;




&lt;h2&gt;
  
  
  💰 Price and business model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;unlimited&lt;/td&gt;
&lt;td&gt;❌ no free tier&lt;/td&gt;
&lt;td&gt;3 habits only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid tier&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;$4.99/month&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;~$5 one-time&lt;/td&gt;
&lt;td&gt;~$30/year or $100 lifetime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ads&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few things worth noting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Habitica's subscription is cosmetic only - the core RPG features are completely free&lt;/li&gt;
&lt;li&gt;Loop has no in-app purchases whatsoever&lt;/li&gt;
&lt;li&gt;Streaks is a one-time purchase with no ongoing cost&lt;/li&gt;
&lt;li&gt;Everyday's free tier caps you at 3 habits, which is enough to try it but not enough to actually use it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenHabitTracker is free with no habit limit, no subscription, and no ads.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔒 Privacy and data storage
&lt;/h2&gt;

&lt;p&gt;This is where the differences get more significant.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Account required&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data stored&lt;/td&gt;
&lt;td&gt;on your device&lt;/td&gt;
&lt;td&gt;cloud&lt;/td&gt;
&lt;td&gt;on your device&lt;/td&gt;
&lt;td&gt;device + iCloud&lt;/td&gt;
&lt;td&gt;cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open source&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Optional self-hosting&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Habitica and Everyday require an account and store your data on their servers. If either service shuts down, your data goes with it. Loop and Streaks keep everything local, which is good for privacy but means no sync between devices. Streaks syncs via iCloud, so it stays within Apple's ecosystem.&lt;/p&gt;

&lt;p&gt;OpenHabitTracker stores data locally with no account required. For anyone who wants to sync across devices without relying on a third-party cloud service, there is a Docker image that runs a self-hosted server. Any native version of the app - desktop or mobile - can log in to your own instance and sync, so your data travels across devices through infrastructure you control.&lt;/p&gt;

&lt;p&gt;Habitica and Loop are both open source, which means anyone can read the code and verify what it does. Streaks and Everyday are closed source.&lt;/p&gt;




&lt;h2&gt;
  
  
  📅 Habit tracking features
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Streak tracking&lt;/td&gt;
&lt;td&gt;❌ by design&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interval-based urgency&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Habit strength algorithm&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flexible schedules&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Weekly stats&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Apple Health integration&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gamification&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reminders&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Habit limit&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;24&lt;/td&gt;
&lt;td&gt;3 (free)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The biggest philosophical difference is in the first two rows.&lt;/p&gt;

&lt;p&gt;Four out of five apps use streak counters. Miss a day, your streak resets to zero. OpenHabitTracker was designed around a different idea: instead of tracking streaks, it tracks time elapsed since your last completion and compares it to the habit's desired interval. A habit with a 10-day interval that's 2 days overdue shows at 120%. A habit with a 4-day interval that's also 2 days overdue shows at 150% - because relative to its schedule, it's more urgent. Missing one day doesn't reset anything, it just shifts the percentage a little.&lt;/p&gt;

&lt;p&gt;Loop takes a similarly forgiving approach with its habit strength algorithm - consistency builds up a score gradually, and missing days dips it gradually rather than wiping it out entirely.&lt;/p&gt;

&lt;p&gt;Streaks integrates with Apple Health, so habits tied to steps, exercise, or sleep can complete automatically based on what your iPhone or Apple Watch already tracks. Nothing else in this list does that.&lt;/p&gt;

&lt;p&gt;Habitica's gamification is either the main reason to use it or the main reason not to - completing habits earns XP, gold, and gear, and you can go on quests with other players.&lt;/p&gt;




&lt;h2&gt;
  
  
  📝 Notes and tasks
&lt;/h2&gt;

&lt;p&gt;This is a category most habit trackers don't have at all.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Notes&lt;/td&gt;
&lt;td&gt;✅ Markdown&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tasks&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ To-Dos&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Habitica has a to-do list as part of its task system, but it's tied to the RPG mechanics - completing a to-do gives your character XP. Loop, Streaks, and Everyday are habit-only apps.&lt;/p&gt;

&lt;p&gt;OpenHabitTracker combines Markdown notes, tasks, and habits in one place. If you want to keep your daily notes, your task list, and your habit tracker in the same app without switching between three different tools, it's the only option in this comparison that supports all three.&lt;/p&gt;




&lt;h2&gt;
  
  
  🌍 Languages and customization
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Languages&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;td&gt;~10&lt;/td&gt;
&lt;td&gt;~10&lt;/td&gt;
&lt;td&gt;~10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Themes&lt;/td&gt;
&lt;td&gt;26&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;td&gt;limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dark mode&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OpenHabitTracker is available in:&lt;/p&gt;

&lt;p&gt;English, German, Spanish, Slovenian, French, Portuguese, Italian, Japanese, Chinese, Korean, Dutch, Danish, Norwegian, Swedish, Finnish, Polish, Czech, Slovak, Croatian, and Serbian.&lt;/p&gt;

&lt;p&gt;It ships with 26 themes in both dark and light modes, which is more than any of the alternatives. Habitica has the edge on language count thanks to its large community of volunteer translators.&lt;/p&gt;




&lt;h2&gt;
  
  
  ⌨️ Accessibility and keyboard navigation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenHabitTracker&lt;/th&gt;
&lt;th&gt;Habitica&lt;/th&gt;
&lt;th&gt;Loop&lt;/th&gt;
&lt;th&gt;Streaks&lt;/th&gt;
&lt;th&gt;Everyday&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keyboard navigation&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅ partial&lt;/td&gt;
&lt;td&gt;➖ N/A&lt;/td&gt;
&lt;td&gt;✅ VoiceOver&lt;/td&gt;
&lt;td&gt;❓ unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screen reader support&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;⚠️ partial&lt;/td&gt;
&lt;td&gt;⚠️ partial&lt;/td&gt;
&lt;td&gt;✅ VoiceOver&lt;/td&gt;
&lt;td&gt;❓ unknown&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Streaks has the strongest accessibility track record in this list. VoiceOver is explicitly supported on iOS and macOS, the App Store accessibility declarations are fully filled out, and the developer has a reputation for being very responsive to blind users.&lt;/p&gt;

&lt;p&gt;Habitica has partial keyboard navigation - Tab key navigation is documented and works across the web client, and ARIA labels were added to habit and task controls in 2023.&lt;/p&gt;

&lt;p&gt;Loop has addressed accessibility issues when reported - color contrast, touch target sizes, missing content descriptions, and respecting the system reduce motion setting.&lt;/p&gt;

&lt;p&gt;OpenHabitTracker follows the ARIA menu pattern in the sidebar - arrow keys move between items. The habit calendar follows the ARIA grid pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;← → arrow keys to move between days&lt;/li&gt;
&lt;li&gt;↑ ↓ arrow keys to move between weeks&lt;/li&gt;
&lt;li&gt;Home and End for the start and end of a week&lt;/li&gt;
&lt;li&gt;Page Up and Page Down to switch months&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every button has a descriptive ARIA label. The layout uses semantic HTML landmarks. Focus moves into sidebars when you open them and returns to where you were when you close them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Which one is right for you
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Habitica&lt;/strong&gt; - if you want gamification and accountability through a community. The RPG mechanics are unlike anything else and the core features are free.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Loop Habit Tracker&lt;/strong&gt; - if you are on Android and privacy is the priority. Open source, completely free, stores everything locally, and the habit strength algorithm is one of the most thoughtful approaches to streak tracking available.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Streaks&lt;/strong&gt; - if you are deep in the Apple ecosystem and want seamless Health integration. The one-time price is fair and automatic completions via Apple Watch are genuinely useful.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Everyday&lt;/strong&gt; - if you want something visual and beginner-friendly across mobile and web. Just be aware of the 3-habit limit on the free tier.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OpenHabitTracker&lt;/strong&gt; - if you want a free, open source habit tracker that runs natively on every platform, skips the streak counter in favor of interval-based urgency, keeps your data on your device, and can optionally sync across all your devices through a self-hosted Docker instance.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;🌐 &lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;openhabittracker.net&lt;/a&gt;&lt;br&gt;
📱 PWA: &lt;a href="https://pwa.openhabittracker.net" rel="noopener noreferrer"&gt;pwa.openhabittracker.net&lt;/a&gt;&lt;br&gt;
💻 Source: &lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;github.com/Jinjinov/OpenHabitTracker&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>privacy</category>
      <category>habittracker</category>
    </item>
    <item>
      <title>OpenHabitTracker is more user friendly now with better UI</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Sat, 21 Mar 2026 21:37:15 +0000</pubDate>
      <link>https://dev.to/jinjinov/openhabittracker-is-more-user-friendly-now-with-better-ui-1alb</link>
      <guid>https://dev.to/jinjinov/openhabittracker-is-more-user-friendly-now-with-better-ui-1alb</guid>
      <description>&lt;p&gt;OpenHabitTracker is a free, open source app for taking Markdown notes, planning tasks, and tracking habits. No account required, no ads, no subscription - all your data stays on your device.&lt;/p&gt;

&lt;p&gt;It runs on Windows, Linux, Android, iOS, macOS, and in the browser as a PWA.&lt;/p&gt;




&lt;h3&gt;
  
  
  A cleaner, more configurable interface
&lt;/h3&gt;

&lt;p&gt;The app now lets you hide the fields you don't use. Don't care about priorities? Turn them off. Don't use categories? Hide them. The settings are simple toggles and they make a real difference to how much visual noise you're dealing with day to day.&lt;/p&gt;

&lt;p&gt;Notes, tasks, and habits each have their own background color, so you can tell at a glance what type of item you're looking at without reading the label.&lt;/p&gt;

&lt;p&gt;There's also a filter to hide completed tasks, which keeps the list focused on what's still pending.&lt;/p&gt;




&lt;h3&gt;
  
  
  Weekly summary
&lt;/h3&gt;

&lt;p&gt;Tasks and habits both have an optional stats panel showing your week at a glance - how many you completed, how much time you spent, and whether you're keeping up with your habits or falling behind.&lt;/p&gt;




&lt;h3&gt;
  
  
  Keyboard navigation
&lt;/h3&gt;

&lt;p&gt;The menu sidebar follows the ARIA menu pattern - arrow keys move between items, no mouse needed. The habit calendar supports the full ARIA grid pattern: arrow keys to move between days, Home and End for the start and end of a week, Page Up and Page Down to switch months.&lt;/p&gt;

&lt;p&gt;When you open a sidebar, focus moves into it automatically. When you close it, focus returns to where you were. It sounds like a small thing but it's the difference between an app that feels under control and one that constantly loses your place.&lt;/p&gt;




&lt;h3&gt;
  
  
  Accessibility
&lt;/h3&gt;

&lt;p&gt;Every interactive element has a proper ARIA label. The layout uses semantic HTML landmarks - &lt;code&gt;&amp;lt;nav&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; - so screen readers can navigate the page structure without hunting through the content. Decorative icons are hidden from assistive technology. Toggle buttons correctly reflect their state with &lt;code&gt;aria-expanded&lt;/code&gt;. The active page in the navigation is marked with &lt;code&gt;aria-current&lt;/code&gt;. The app is fully usable without a mouse.&lt;/p&gt;




&lt;h3&gt;
  
  
  20 languages, 26 themes
&lt;/h3&gt;

&lt;p&gt;Besides English, German, Spanish, and Slovenian, the app is now also available in French, Portuguese, Italian, Japanese, Chinese, Korean, Dutch, Danish, Norwegian, Swedish, Finnish, Polish, Czech, Slovak, Croatian, and Serbian.&lt;/p&gt;

&lt;p&gt;It ships with 26 themes in both dark and light modes.&lt;/p&gt;




&lt;p&gt;Data can be exported and imported as JSON, YAML, TSV, or Markdown. Google Keep notes can be imported from a Google Takeout ZIP.&lt;/p&gt;

&lt;p&gt;For the self-hosting community, there's a Docker image that runs a Blazor Server instance. Besides accessing it in the browser, any native version of the app - Windows, Linux, Android, iOS, macOS - can log in to your instance and sync, so your data follows you across devices without going through anyone else's servers.&lt;/p&gt;

&lt;p&gt;Web: &lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;openhabittracker.net&lt;/a&gt;&lt;br&gt;
PWA: &lt;a href="https://pwa.openhabittracker.net" rel="noopener noreferrer"&gt;pwa.openhabittracker.net&lt;/a&gt;&lt;br&gt;
Source: &lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;github.com/Jinjinov/OpenHabitTracker&lt;/a&gt;&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>privacy</category>
      <category>habittracking</category>
    </item>
    <item>
      <title>nas-sync-script-builder generates a bash script for one-way, no-deletion NAS sync using rsync and lsyncd</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Thu, 29 Jan 2026 16:08:38 +0000</pubDate>
      <link>https://dev.to/jinjinov/nas-sync-script-builder-generates-a-bash-script-for-one-way-no-deletion-nas-sync-using-rsync-and-3npm</link>
      <guid>https://dev.to/jinjinov/nas-sync-script-builder-generates-a-bash-script-for-one-way-no-deletion-nas-sync-using-rsync-and-3npm</guid>
      <description>&lt;h2&gt;
  
  
  nas-sync-script-builder: a small tool for a very specific NAS problem
&lt;/h2&gt;

&lt;p&gt;Most backup and sync tools assume one of two models:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mirroring&lt;/strong&gt;: source and destination must be identical
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backup systems&lt;/strong&gt;: versioned, snapshot-based, or cloud-oriented&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is a common use case that falls between these models and is poorly served by existing tools:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Keep a NAS continuously updated from a local machine, &lt;strong&gt;one-way&lt;/strong&gt;, while preserving existing files on the NAS.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the problem &lt;code&gt;nas-sync-script-builder&lt;/code&gt; exists to solve.&lt;/p&gt;




&lt;h2&gt;
  
  
  The niche it fills
&lt;/h2&gt;

&lt;p&gt;The target scenario:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Linux workstation or server
&lt;/li&gt;
&lt;li&gt;One or more local disks (often NTFS or mixed filesystems)
&lt;/li&gt;
&lt;li&gt;A NAS (Synology or any SMB-compatible device) as a sink
&lt;/li&gt;
&lt;li&gt;Files flow &lt;strong&gt;local → NAS only&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Existing NAS files remain untouched
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Many tools approximate this behavior, but none provide it directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rsync&lt;/code&gt; requires careful flag management
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;lsyncd&lt;/code&gt; is powerful but tricky to configure
&lt;/li&gt;
&lt;li&gt;Backup tools (Borg, Restic, Duplicati) are versioned or snapshot-based
&lt;/li&gt;
&lt;li&gt;NAS vendor tools enforce mirroring or proprietary agents
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What’s missing is a safe, repeatable way to set up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Incremental initial sync
&lt;/li&gt;
&lt;li&gt;Continuous real-time sync
&lt;/li&gt;
&lt;li&gt;Systemd-aware mounts
&lt;/li&gt;
&lt;li&gt;Persistent credentials
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;nas-sync-script-builder&lt;/code&gt; is a &lt;strong&gt;configuration generator&lt;/strong&gt;, not a daemon.  &lt;/p&gt;

&lt;p&gt;It provides:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Python GUI (and optional CLI) for configuration
&lt;/li&gt;
&lt;li&gt;Automatic detection of eligible local partitions via UDisks2
&lt;/li&gt;
&lt;li&gt;A generated Bash script that:&lt;/li&gt;
&lt;/ul&gt;

&lt;ol&gt;
&lt;li&gt;Installs required packages (&lt;code&gt;rsync&lt;/code&gt;, &lt;code&gt;lsyncd&lt;/code&gt;, &lt;code&gt;cifs-utils&lt;/code&gt;)
&lt;/li&gt;
&lt;li&gt;Creates local and NAS mount points
&lt;/li&gt;
&lt;li&gt;Configures CIFS mounts using systemd automounts
&lt;/li&gt;
&lt;li&gt;Writes a managed &lt;code&gt;/etc/fstab&lt;/code&gt; block
&lt;/li&gt;
&lt;li&gt;Performs an initial one-way sync with &lt;code&gt;rsync&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Configures &lt;code&gt;lsyncd&lt;/code&gt; for continuous updates
&lt;/li&gt;
&lt;li&gt;Handles systemd dependencies, logs, and inotify limits
&lt;/li&gt;
&lt;li&gt;Is safe to re-run at any time
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Python tool exists to make this setup &lt;strong&gt;explicit, reviewable, and reproducible&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who this is for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Linux users with a NAS needing continuous updates
&lt;/li&gt;
&lt;li&gt;Users migrating from Windows disks to a NAS
&lt;/li&gt;
&lt;li&gt;Anyone who wants controlled, append-only sync without snapshot or backup tools
&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Project links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/Jinjinov/nas-sync-script-builder" rel="noopener noreferrer"&gt;https://github.com/Jinjinov/nas-sync-script-builder&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PyPI: &lt;a href="https://pypi.org/project/nas-sync-script-builder" rel="noopener noreferrer"&gt;https://pypi.org/project/nas-sync-script-builder&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nas</category>
      <category>sync</category>
      <category>rsync</category>
      <category>lsyncd</category>
    </item>
    <item>
      <title>How to create a Blazor app for WASM, Windows, Linux, macOS, iOS, Android</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Thu, 19 Sep 2024 14:08:16 +0000</pubDate>
      <link>https://dev.to/jinjinov/how-to-create-a-blazor-app-for-wasm-windows-linux-macos-ios-android-1fh5</link>
      <guid>https://dev.to/jinjinov/how-to-create-a-blazor-app-for-wasm-windows-linux-macos-ios-android-1fh5</guid>
      <description>&lt;p&gt;Hey everyone!&lt;/p&gt;

&lt;p&gt;I got a few messages asking me how I created my free, open source habit tracker app &lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; so I decided to write a post about it.&lt;/p&gt;

&lt;p&gt;You can see the source code on &lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Blazor
&lt;/h2&gt;

&lt;p&gt;I wanted to create an app that would work on as many platforms as possible and share as much code as possible between all platforms.&lt;/p&gt;

&lt;p&gt;I compared all cross platform C# frameworks that were available:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;First Release&lt;/th&gt;
&lt;th&gt;UI Technology&lt;/th&gt;
&lt;th&gt;Windows&lt;/th&gt;
&lt;th&gt;macOS&lt;/th&gt;
&lt;th&gt;Linux&lt;/th&gt;
&lt;th&gt;Android&lt;/th&gt;
&lt;th&gt;iOS&lt;/th&gt;
&lt;th&gt;Web (WASM)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;.NET MAUI&lt;/td&gt;
&lt;td&gt;May 2022&lt;/td&gt;
&lt;td&gt;XAML&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blazor&lt;/td&gt;
&lt;td&gt;Sep 2019&lt;/td&gt;
&lt;td&gt;HTML + CSS&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Avalonia&lt;/td&gt;
&lt;td&gt;Feb 2015&lt;/td&gt;
&lt;td&gt;XAML&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Experimental&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Uno Platform&lt;/td&gt;
&lt;td&gt;Sep 2018&lt;/td&gt;
&lt;td&gt;XAML&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Xamarin.Forms&lt;/td&gt;
&lt;td&gt;May 2014&lt;/td&gt;
&lt;td&gt;XAML&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Xamarin was replaced by MAUI and MAUI doesn't support Web - that left Avalonia (with poor web support at that time), Uno and Blazor.&lt;/p&gt;

&lt;p&gt;I have used a few open sourced project managed by volunteers and it did not have the best experience, so I wanted to choose a project backed by a company.&lt;/p&gt;

&lt;p&gt;I was working with WPF for 5 years and in that time I found out that I really don't like XAML, so I was really happy when Blazor came out.&lt;/p&gt;

&lt;p&gt;My first Blazor project was only with WASM and Windows through WebView2 in WinForms / WPF. When Maui Blazor Hybrid was announced I knew that Blazor was a good choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I started
&lt;/h2&gt;

&lt;p&gt;I started by creating a project from every Visual Studio Blazor project template in a single solution and then I started to move all the files that were the same into a shared library.&lt;/p&gt;

&lt;p&gt;At first, I had only a single shared library, but later I split it into &lt;code&gt;OpenHabitTracker&lt;/code&gt; of type &lt;code&gt;&amp;lt;Project Sdk="Microsoft.NET.Sdk"&amp;gt;&lt;/code&gt; that holds the core classes and to &lt;code&gt;OpenHabitTracker.Blazor&lt;/code&gt; of type &lt;code&gt;&amp;lt;Project Sdk="Microsoft.NET.Sdk.Razor"&amp;gt;&lt;/code&gt; that holds the razor files. This way the logic is separated from the UI and the razor files contain few lines of C# code which is a good thing because Visual Studio editor for razor files can behave strangely at times, especially with &lt;code&gt;@code&lt;/code&gt; blocks. The editor behaves better if you have your C# code in &lt;code&gt;.razor.cs&lt;/code&gt; code behind files, but you can just go one step further and have the C# code in a separate library.&lt;/p&gt;

&lt;p&gt;At first, I had only my razor components and razor pages in the shared library, I didn't move &lt;code&gt;App.razor&lt;/code&gt;, &lt;code&gt;_Imports.razor&lt;/code&gt;, &lt;code&gt;MainLayout.razor&lt;/code&gt;, &lt;code&gt;JsInterop.cs&lt;/code&gt;, &lt;code&gt;jsInterop.js&lt;/code&gt;, &lt;code&gt;app.css&lt;/code&gt; into the shared library, but later I figured out that you can move all files except &lt;code&gt;Program.cs&lt;/code&gt; and &lt;code&gt;index.html&lt;/code&gt; into the shared library - if there is any platform specific behavior, you can solve it with C# interfaces - even if you need platform specific razor UI, you can still solve it with interfaces and methods that return a &lt;code&gt;RenderFragment&lt;/code&gt;. All &lt;code&gt;.css&lt;/code&gt; and &lt;code&gt;.js&lt;/code&gt; files can be in the shared library and then included to platform specific projects in &lt;code&gt;index.html&lt;/code&gt; with &lt;code&gt;_content/OpenHabitTracker.Blazor/...&lt;/code&gt; for example &lt;code&gt;&amp;lt;link rel="stylesheet" href="_content/OpenHabitTracker.Blazor/app.css" /&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I chose the frameworks
&lt;/h2&gt;

&lt;p&gt;I started comparing all the frameworks that support Blazor on desktop and mobile:&lt;/p&gt;

&lt;p&gt;Windows only:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WPF: works great with &lt;code&gt;Microsoft.AspNetCore.Components.WebView.Wpf&lt;/code&gt; NuGet&lt;/li&gt;
&lt;li&gt;WinForms: works great with &lt;code&gt;Microsoft.AspNetCore.Components.WebView.WindowsForms&lt;/code&gt; NuGet&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Windows, Linux, macOS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Photino Blazor: works great with &lt;code&gt;Photino.Blazor&lt;/code&gt; NuGet&lt;/li&gt;
&lt;li&gt;Electron.NET: works with &lt;code&gt;ElectronNET.API&lt;/code&gt; NuGet - long compile times, very large builds, slow startup, opens the GUI window AND a terminal&lt;/li&gt;
&lt;li&gt;Chromely: works with &lt;code&gt;Chromely&lt;/code&gt; NuGet - long compile times, slow startup, opens the GUI window AND a terminal - GitHub repo was archived by the owner on Jan 16, 2023&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Windows, macOS, iOS, Android:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;MAUI: works with several Maui NuGet packages and &lt;code&gt;dotnet workload&lt;/code&gt;s: &lt;code&gt;android&lt;/code&gt;, &lt;code&gt;ios&lt;/code&gt;, &lt;code&gt;maccatalyst&lt;/code&gt;, &lt;code&gt;maui-windows&lt;/code&gt;, see &lt;a href="https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-workload" rel="noopener noreferrer"&gt;https://learn.microsoft.com/en-us/dotnet/core/tools/dotnet-workload&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To support WASM, Windows, Linux, macOS, iOS, Android you need at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blazor WASM&lt;/li&gt;
&lt;li&gt;Maui&lt;/li&gt;
&lt;li&gt;Photino Blazor&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I develop on Windows with Visual Studio 2022 and my startup project is usually &lt;code&gt;OpenHabitTracker.Blazor.Photino&lt;/code&gt; because it compiles and starts much faster than &lt;code&gt;OpenHabitTracker.Blazor.Wasm&lt;/code&gt; or &lt;code&gt;OpenHabitTracker.Blazor.Maui&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Saving user data
&lt;/h2&gt;

&lt;p&gt;I took a look at the different options for storing user data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Technology&lt;/th&gt;
&lt;th&gt;Size Limit&lt;/th&gt;
&lt;th&gt;Data Lifetime&lt;/th&gt;
&lt;th&gt;Storage Format&lt;/th&gt;
&lt;th&gt;Security&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cookies&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~4 KB&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;HttpOnly, Secure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Session Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5-10 MB&lt;/td&gt;
&lt;td&gt;Session-based&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Isolated to tab&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5-10 MB&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;String&lt;/td&gt;
&lt;td&gt;Shared across tabs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;WebSQL&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;5 MB+&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;Relational DB&lt;/td&gt;
&lt;td&gt;Deprecated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IndexedDB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hundreds of MB&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;Key-Value (Objects)&lt;/td&gt;
&lt;td&gt;Same-origin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cache Storage (SW)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Large&lt;/td&gt;
&lt;td&gt;Configurable&lt;/td&gt;
&lt;td&gt;HTTP responses&lt;/td&gt;
&lt;td&gt;Origin-bound&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;File System Access API&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Device-dependent&lt;/td&gt;
&lt;td&gt;Persistent&lt;/td&gt;
&lt;td&gt;Files/Blobs&lt;/td&gt;
&lt;td&gt;Requires Permission&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Because I wanted a large, persistent storage that does not require prompting the user for permission and is not deprecated I chose IndexedDB:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/wtulloch/Blazor.IndexedDB" rel="noopener noreferrer"&gt;https://github.com/wtulloch/Blazor.IndexedDB&lt;/a&gt; - &lt;code&gt;TG.Blazor.IndexedDB&lt;/code&gt; NuGet - awkward, not user friendly, abandoned project&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/Reshiru/Blazor.IndexedDB.Framework" rel="noopener noreferrer"&gt;https://github.com/Reshiru/Blazor.IndexedDB.Framework&lt;/a&gt; - &lt;code&gt;Reshiru.Blazor.IndexedDB.Framework&lt;/code&gt; NuGet - wraps &lt;code&gt;TG.Blazor.IndexedDB&lt;/code&gt; NuGet, making it much nicer to work with - but it has a huge flaw: it always loads all data - you don't have the ability to load only some objects - repository has been archived by the owner on Jan 26, 2021&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/amuste/DnetIndexedDb" rel="noopener noreferrer"&gt;https://github.com/amuste/DnetIndexedDb&lt;/a&gt; - &lt;code&gt;DnetIndexedDb&lt;/code&gt; NuGet - works well enough&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I made two mistakes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I chose &lt;code&gt;Reshiru.Blazor.IndexedDB.Framework&lt;/code&gt; and I even fixed a few things and created &lt;a href="https://github.com/Jinjinov/IndexedDB.Blazor" rel="noopener noreferrer"&gt;https://github.com/Jinjinov/IndexedDB.Blazor&lt;/a&gt; (don't use it, it has the same problem)&lt;/li&gt;
&lt;li&gt;I used IndexedDB on desktop and mobile - it works, but I think SQLite is a better option.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I switched to &lt;code&gt;DnetIndexedDb&lt;/code&gt; for the browser and to EF Core + SQLite for desktop and mobile.&lt;/p&gt;

&lt;h2&gt;
  
  
  The user interface
&lt;/h2&gt;

&lt;p&gt;At first, I wanted to write all CSS on my own, but I soon realized that using Bootstrap is not that bad and saves you a lot of time.&lt;br&gt;
That proved to be a good decision when I decided to implement themes and discovered that Bootswatch offers 26 themes for Bootstrap.&lt;/p&gt;

&lt;p&gt;I decided to use a free UI library, so I took a look at what was available:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Library&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;Features&lt;/strong&gt;&lt;/th&gt;
&lt;th&gt;&lt;strong&gt;First Release&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://blazorise.com/" rel="noopener noreferrer"&gt;Blazorise&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Bootstrap, Bulma, AntDesign, Material&lt;/td&gt;
&lt;td&gt;June 2019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://mudblazor.com/" rel="noopener noreferrer"&gt;MudBlazor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Material Design components&lt;/td&gt;
&lt;td&gt;April 2020&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://antblazor.com/" rel="noopener noreferrer"&gt;AntDesign Blazor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Inspired by Ant Design&lt;/td&gt;
&lt;td&gt;March 2020&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://www.matblazor.com/" rel="noopener noreferrer"&gt;MatBlazor&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Material Design components&lt;/td&gt;
&lt;td&gt;February 2019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://blazorstrap.io/" rel="noopener noreferrer"&gt;BlazorStrap&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Based on Bootstrap 4/5&lt;/td&gt;
&lt;td&gt;April 2019&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;a href="https://docs.blazorbootstrap.com/" rel="noopener noreferrer"&gt;Blazor Bootstrap&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;Bootstrap 5 components&lt;/td&gt;
&lt;td&gt;June 2021&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In my previous Blazor project, I used the Blazorise UI library because it had the most controls at the time and because it abstracts the CSS to C# enums and classes - that way it was really easy to switch from Bootstrap 4 to Bootstrap 5. The owner of the project is really responsive to ideas and requests (if you are reasonable and show some initiative). Later I found out that I don't actually need any complex components in my project, so now I use only Bootstrap 5.&lt;/p&gt;

&lt;p&gt;In my previous Blazor project, I used Font Awesome icons and Google Fonts, but now I use embedded font files and &lt;a href="https://icons.getbootstrap.com/" rel="noopener noreferrer"&gt;Bootstrap Icons&lt;/a&gt; because I think they work better with Bootstrap 5.&lt;/p&gt;

&lt;p&gt;In my previous Blazor project, I used CDN to get the CSS, JS and fonts from the web, but I found out that this makes the app noticeably slower. Now I include all CSS, JS and fonts into the project and the app is much faster. The app is around 2 MB larger because of all the included resources, but at 20 MB for the whole app that is only 10% more - for a much faster UI.&lt;/p&gt;

&lt;p&gt;Feel free to ask any questions! :)&lt;/p&gt;

</description>
      <category>blazor</category>
      <category>webassembly</category>
      <category>desktop</category>
      <category>mobile</category>
    </item>
    <item>
      <title>What Led Me to Creating OpenHabitTracker and Lessons Learned</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Fri, 06 Sep 2024 14:06:48 +0000</pubDate>
      <link>https://dev.to/jinjinov/what-led-me-to-creating-openhabittracker-and-lessons-learned-3p85</link>
      <guid>https://dev.to/jinjinov/what-led-me-to-creating-openhabittracker-and-lessons-learned-3p85</guid>
      <description>&lt;p&gt;&lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; is a free, ad-free, open-source habit tracker app that works on Windows, Linux, Android, iOS, macOS, and as a web app. You can find the source code on &lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Journey to Creating OpenHabitTracker
&lt;/h2&gt;

&lt;p&gt;I used Google Keep for a long time, but the more I used it, the harder it became to organize my notes. I tried using labels a few times, but without much success. &lt;/p&gt;

&lt;p&gt;Then, I began using OneNote to keep track of tasks that weren’t done daily (like cleaning the windows in all rooms or taking the car to the car wash). In the note, I listed a dozen tasks along with the dates they were last completed, but soon I found myself wishing for a tool that could better manage this.&lt;/p&gt;

&lt;p&gt;Google Calendar is good for repeating tasks, but finding out when you last completed a particular task can be bothersome—you have to search through previous weeks or months. Most habit tracker apps are great for tracking streaks, but if you miss a task, they fall short. You know you didn’t do it, but you don’t know when you last did it, how much time has passed since, or what the average repeat interval is compared to your desired repeat interval.&lt;/p&gt;

&lt;p&gt;This is why I decided to create my own app.&lt;/p&gt;

&lt;h2&gt;
  
  
  My Programming Background
&lt;/h2&gt;

&lt;p&gt;I wrote my first program in September 1989, during primary school, using QBasic, and I've been programming ever since. For the first 15 years of my programming career, I worked with game engines—writing my own for my university thesis, working on an in-house engine at one company, and using Unity at another. I had almost no experience with UI, UX, HTML, CSS, JS, SQL, or any other web technologies or databases.&lt;/p&gt;

&lt;h2&gt;
  
  
  First Attempt: Daily Checklist and Tracker
&lt;/h2&gt;

&lt;p&gt;On July 24, 2016, I started working on &lt;a href="https://github.com/Jinjinov/daily-checklist-and-tracker" rel="noopener noreferrer"&gt;Daily Checklist and Tracker&lt;/a&gt; using PHP and MySQL. I managed to create a simple website with a task list and three types of tasks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tasks to be done ASAP, with measured duration&lt;/li&gt;
&lt;li&gt;Tasks to be done once on a specified date, saving the time when they were finished&lt;/li&gt;
&lt;li&gt;Tasks with a repeat interval&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It had all the features I missed in Google Keep, but nothing else. The design was awful—or rather, there was no design at all, not even "programmer's art"—making it unusable. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned&lt;/strong&gt;: User interface and user experience are as important as the features. You have to learn some design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Second Attempt: Priority Task List
&lt;/h2&gt;

&lt;p&gt;On December 24, 2017, I started working on &lt;a href="https://github.com/Jinjinov/priority-task-list" rel="noopener noreferrer"&gt;Priority Task List&lt;/a&gt; using Vue.js, CouchDB, and PouchDB. I consider it a vast improvement over my first attempt. It had a menu, help section, and allowed you to group tasks into categories. Each task displayed the time elapsed since it was last completed and the number of times it was completed. It also had an automatically computed priority that changed with time and a priority factor that determined how quickly the priority increased.&lt;/p&gt;

&lt;p&gt;This time, I paid more attention to UI, but I had no experience with responsive design. The website had a mobile-first design—perhaps mobile-only design—with just one layout for all screen sizes. It had every feature I wanted, presented on screen the way I wanted, but the user experience was still lacking. You can still try it out &lt;a href="https://jinjinov.github.io/priority-task-list" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned&lt;/strong&gt;: Even if you learn some design and create the UI the way you want, it might not create a good user experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Third Attempt: What Should I Be Doing Right Now
&lt;/h2&gt;

&lt;p&gt;On September 16, 2018, I started working on &lt;a href="https://github.com/Jinjinov/what-should-i-be-doing-right-now" rel="noopener noreferrer"&gt;What Should I Be Doing Right Now&lt;/a&gt; using Vue.js, CouchDB, and PouchDB. I had an exciting new idea: users could write JavaScript code to determine which task should be done next, based on which tasks were marked as done on a list. The website would take the JavaScript as a string and execute it in code, allowing users to determine what they should do next based on their own rules and a list of tasks with checkboxes.&lt;/p&gt;

&lt;p&gt;It was a fun experiment, but not very useful in day-to-day life. You can still try it out &lt;a href="https://jinjinov.github.io/what-should-i-be-doing-right-now" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned&lt;/strong&gt;: Sometimes, you have to test your ideas, even if they don’t prove to be the best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fourth Attempt: What Should I Be Doing
&lt;/h2&gt;

&lt;p&gt;On May 12, 2019, I started working on &lt;a href="https://github.com/Jinjinov/what-should-i-be-doing" rel="noopener noreferrer"&gt;What Should I Be Doing&lt;/a&gt; using PHP and the Google Reminders API. I had another exciting idea: using the existing Google Reminders API to work with Google Reminders and add missing features to a website. Since I couldn’t find a PHP wrapper for the Google Reminders API, I decided to write one myself (&lt;a href="https://github.com/Jinjinov/google-reminders-php" rel="noopener noreferrer"&gt;Google Reminders PHP&lt;/a&gt;) and also created one for JavaScript (&lt;a href="https://github.com/Jinjinov/google-reminders-js" rel="noopener noreferrer"&gt;Google Reminders JS&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;However, this took most of my time, and the website itself didn’t progress. I didn’t add any features beyond listing the reminders you have in Google Calendar and Google Keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned&lt;/strong&gt;: Don’t start projects where you’ll spend more time writing missing libraries than working on the project itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fifth Attempt: The Last Time
&lt;/h2&gt;

&lt;p&gt;I became interested in Microsoft Blazor with the release of .NET Core 3.0 and started learning it. On October 4, 2020, I began working on &lt;a href="https://github.com/Jinjinov/TheLastTime" rel="noopener noreferrer"&gt;TheLastTime&lt;/a&gt; using Blazor with .NET Core 3.1. With the release of .NET 5 on November 10, 2020, I upgraded the project to .NET 5. This was my first Blazor app, and I thoroughly enjoyed working on it—using C# on the client side gave me IntelliSense in Visual Studio, which wasn't available with JavaScript.&lt;/p&gt;

&lt;p&gt;The app had a good design, a friendly UI, and was a pleasure to use—this was the first time I found myself daily using a program I had created. Using the app helped me significantly reduce procrastination and develop a few habits that I had struggled with before. The app worked on the web with Blazor WASM and on Windows with WinForms and WPF, where I used WebView to host Blazor. Most of the Blazor code was shared between all three projects.&lt;/p&gt;

&lt;p&gt;The app included all the features of my Priority Task List project, such as habit tracking and task grouping by categories. Additionally, it introduced new features: custom categories, advanced Search, Filter, and Sort options for customizing your view, and 26 Bootswatch themes for Bootstrap. The app also supported user data export/import in JSON and YAML formats and allowed users to back up JSON to Google Drive.&lt;/p&gt;

&lt;p&gt;However, the NuGet library I used for IndexedDB reloaded everything on every change. At first, this wasn’t an issue, but after a year, the app became so slow that it was unusable. It was clear that I needed a better library for IndexedDB, but since it was so deeply integrated into my code, I realized that a complete rewrite would be faster. You can try it out &lt;a href="https://old.ididit.today/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons learned&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When using third-party libraries, it pays off to get to know them better, especially if they are not widely used or are developed by a single person.&lt;/li&gt;
&lt;li&gt;Using abstractions and interfaces is considered best practice in C# for a reason—don’t ignore them.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Sixth Attempt: Ididit!
&lt;/h2&gt;

&lt;p&gt;On April 15, 2022, I started working on &lt;a href="https://github.com/Jinjinov/Ididit" rel="noopener noreferrer"&gt;Ididit&lt;/a&gt; using Blazor with .NET 6. With the release of .NET 7 on November 8, 2022, I upgraded the project to .NET 7. I aimed to make the app truly cross-platform, so in addition to WASM, WinForms, and WPF, I also explored Microsoft MAUI, Chromely, Electron.NET, and Photino.&lt;/p&gt;

&lt;p&gt;Using Blazor with Chromely and Electron.NET proved to be difficult and slow, and both libraries were too bloated for my needs. Photino allowed me to create a Linux version of the app. With MAUI, I could develop Android and iOS versions and publish them to the Google and Apple stores. I also published the MAUI version to the Microsoft Store and didn't bother with WinForms and WPF versions.&lt;/p&gt;

&lt;p&gt;I decided to implement all the features from TheLastTime and more, all at once. However, this approach caused the design to suffer—the UI wasn’t polished, and the app became bloated with features. The app combined notes, tasks, and habits into one bloated list. I added nested categories and a category tree, which proved to be unnecessary as I never used the feature myself.&lt;/p&gt;

&lt;p&gt;The app also had a few good improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Markdown support for notes&lt;/li&gt;
&lt;li&gt;User data export/import in TSV and Markdown in addition to JSON and YAML&lt;/li&gt;
&lt;li&gt;Import Google Keep notes by uploading the ZIP file produced by Google Takeout&lt;/li&gt;
&lt;li&gt;Localization to English, German, Spanish, Slovenian, and Czech&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because I wanted to write as little platform-specific code as possible, I thought using IndexedDB on desktop through WebView was a good idea. However, it didn’t work as well as using the platform's native file access. I also programmed file open/save for user data export/import with JavaScript on all platforms, and while it worked on web and desktop, it didn’t perform well on mobile. Although I used abstractions and interfaces this time, I didn’t structure the code independently enough. You can try it out &lt;a href="https://app.ididit.today/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lessons learned&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If you’re rewriting a project from scratch, don’t add all the features and ideas you had before at once—first rewrite the proven features, then start adding new ones.&lt;/li&gt;
&lt;li&gt;Sometimes, it’s better to write platform-specific code rather than force a platform-independent solution, even if it’s possible.&lt;/li&gt;
&lt;li&gt;Writing interfaces for their own sake is as bad as not writing them—if you suspect you might need a different implementation, plan for it when writing the interface.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Seventh Attempt: OpenHabitTracker
&lt;/h2&gt;

&lt;p&gt;On November 14, 2023, I started working on &lt;a href="https://github.com/Jinjinov/OpenHabitTracker" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; using Blazor and .NET 8, which was released on that day. This time, I decided to take all the good parts of my first Blazor app, TheLastTime, and combine them with the good aspects of Ididit while fixing its problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The app has a good design and polished UI.&lt;/li&gt;
&lt;li&gt;IndexedDB is used only for WASM, while SQLite with EF Core is used for desktop and mobile.&lt;/li&gt;
&lt;li&gt;User data export/import is done with native file open/save dialogs on each platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also introduced new improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All HTML, CSS, and JS files are now embedded, so the app doesn’t depend on an internet connection.&lt;/li&gt;
&lt;li&gt;Categories have been simplified—they are no longer nested, so there’s no need for a tree.&lt;/li&gt;
&lt;li&gt;Notes, tasks, and habits are now in separate lists, which can also be sorted by priority.&lt;/li&gt;
&lt;li&gt;Search, Filter, and Sort features have been improved.&lt;/li&gt;
&lt;li&gt;Added a Trash feature so deleted items can now be recovered.&lt;/li&gt;
&lt;li&gt;All 26 themes now also work in dark mode.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>blazor</category>
      <category>opensource</category>
      <category>productivity</category>
      <category>crossplatform</category>
    </item>
    <item>
      <title>Introducing OpenHabitTracker: A Free, Open Source, and Privacy-Focused Habit Tracker</title>
      <dc:creator>Urban</dc:creator>
      <pubDate>Sat, 31 Aug 2024 07:54:02 +0000</pubDate>
      <link>https://dev.to/jinjinov/introducing-openhabittracker-a-free-open-source-and-privacy-focused-habit-tracker-316j</link>
      <guid>https://dev.to/jinjinov/introducing-openhabittracker-a-free-open-source-and-privacy-focused-habit-tracker-316j</guid>
      <description>&lt;p&gt;I’m excited to announce the launch of &lt;strong&gt;OpenHabitTracker&lt;/strong&gt;, a powerful, free, and ad-free habit tracking tool designed with privacy and flexibility at its core. Whether you're managing daily tasks, building new habits, or organizing notes, OpenHabitTracker offers a robust solution that respects your privacy and adapts to your needs.&lt;/p&gt;

&lt;p&gt;You can start using it today by visiting &lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why OpenHabitTracker?
&lt;/h2&gt;

&lt;p&gt;In a world where most productivity apps are either paid, ad-supported, or intrusive on your privacy, OpenHabitTracker stands out as a true alternative. Here’s why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Free and Ad-Free&lt;/strong&gt;: Enjoy the full range of features without being interrupted by ads or paying for premium upgrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Source&lt;/strong&gt;: The entire project is open source, meaning the code is transparent, and contributions from the community are welcome.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy Focused&lt;/strong&gt;: Your data stays with you. All user data is stored locally on your device, ensuring that your personal information remains private and secure.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cross-Platform Availability
&lt;/h2&gt;

&lt;p&gt;OpenHabitTracker is accessible across multiple platforms, making it convenient to use whether you're at your desk or on the go. It’s available on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Windows&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Linux&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Android&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;iOS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;macOS&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Web App&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No matter what device you use, your habits and tasks are always within reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Features
&lt;/h2&gt;

&lt;p&gt;OpenHabitTracker comes packed with a range of features designed to help you stay organized and productive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Markdown Support&lt;/strong&gt;: Write detailed notes with full Markdown support, making it easy to format your content.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Organize with Categories and Priorities&lt;/strong&gt;: Structure your tasks, notes, and habits using customizable categories and priority levels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Advanced Search, Filter, and Sort&lt;/strong&gt;: Quickly find what you’re looking for with powerful search, filtering, and sorting options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Export/Import&lt;/strong&gt;: Easily export your data in various formats including JSON, YAML, TSV, and Markdown. You can also import notes directly from Google Keep, making it seamless to switch over.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization with Themes&lt;/strong&gt;: Choose from 26 different themes, and switch between Dark and Light modes to suit your environment and preference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localized Experience&lt;/strong&gt;: OpenHabitTracker is localized in English, German, Spanish, and Slovenian, making it accessible to a broader audience.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A Tool for Everyone
&lt;/h2&gt;

&lt;p&gt;Whether you're a student, professional, or anyone looking to improve productivity, OpenHabitTracker is designed to be a versatile tool that adapts to your needs. Its flexibility, privacy-first approach, and wide range of features make it a powerful choice for anyone serious about habit tracking and task management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Get Started Today
&lt;/h2&gt;

&lt;p&gt;Ready to take control of your habits and tasks? Visit &lt;a href="https://openhabittracker.net" rel="noopener noreferrer"&gt;OpenHabitTracker&lt;/a&gt; and download the app on your preferred platform. With OpenHabitTracker, you can build better habits, organize your life, and do it all with the confidence that your data is yours and yours alone.&lt;/p&gt;

&lt;p&gt;If you have any feedback, suggestions, or would like to contribute to the project, feel free to get involved through our open source community. Let's build something great together!&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>productivity</category>
      <category>privacy</category>
      <category>habittracking</category>
    </item>
  </channel>
</rss>
