OpenHabitTracker 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.
In a previous article I covered the architecture but skipped the actual code. This is the code.
Why so many entry points?
Each store or distribution method has its own requirements:
- MAUI -> Microsoft Store, Google Play, App Store, Mac App Store
- Blazor WASM -> web / PWA
- Blazor Server -> Docker self-hosting
- Photino -> Linux (Flatpak, Snap) - MAUI simply has no Linux target
- WPF + ClickOnce -> Windows direct download, classical installer experience outside the Store
The entry points exist because the distribution channels demanded them.
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?
The pattern
Every platform difference is hidden behind an interface. The shared OpenHabitTracker.Blazor 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.
There is not a single #if in OpenHabitTracker.Blazor.
Let's go through each interface.
IOpenFile / ISaveFile
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.
The interface:
public interface IOpenFile
{
RenderFragment OpenFileDialog(string css, string content, Func<string, Stream, Task> onFileOpened);
}
It returns a RenderFragment - the implementation decides what HTML to render and how to wire the file picker. The component consuming it just renders whatever comes back.
WASM - must use a hidden <InputFile> wrapped in a <label>:
public class OpenFile : IOpenFile
{
const long _maxAllowedFileSize = 50 * 1024 * 1024;
public RenderFragment OpenFileDialog(string css, string content, Func<string, Stream, Task> onFileOpened)
{
return builder =>
{
builder.OpenElement(0, "label");
builder.AddAttribute(1, "class", css);
builder.OpenElement(2, "i");
builder.AddAttribute(3, "class", "bi bi-box-arrow-in-right");
builder.CloseElement();
builder.AddContent(4, " ");
builder.AddContent(5, content);
builder.OpenComponent<InputFile>(6);
builder.AddAttribute(7, "class", "d-none");
builder.AddAttribute(8, "OnChange", EventCallback.Factory.Create(this, async (InputFileChangeEventArgs args) =>
{
Stream stream = args.File.OpenReadStream(maxAllowedSize: _maxAllowedFileSize);
await onFileOpened(args.File.Name, stream);
}));
builder.CloseComponent();
builder.CloseElement();
};
}
}
WinForms - a <button> that opens System.Windows.Forms.OpenFileDialog:
public class OpenFile : IOpenFile
{
public RenderFragment OpenFileDialog(string css, string content, Func<string, Stream, Task> onFileOpened)
{
return builder =>
{
builder.OpenElement(0, "button");
builder.AddAttribute(1, "class", css);
builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, async () =>
{
OpenFileDialog openFileDialog = new()
{
Filter = "JSON|*.json|TSV|*.tsv|YAML|*.yaml|Markdown|*.md|Google Keep Takeout ZIP|*.zip"
};
if (openFileDialog.ShowDialog() == DialogResult.OK)
{
await onFileOpened(openFileDialog.FileName, openFileDialog.OpenFile());
}
}));
builder.OpenElement(3, "i");
builder.AddAttribute(4, "class", "bi bi-box-arrow-in-right");
builder.CloseElement();
builder.AddContent(5, " ");
builder.AddContent(6, content);
builder.CloseElement();
};
}
}
MAUI - FilePicker.PickAsync():
public class OpenFile : IOpenFile
{
public RenderFragment OpenFileDialog(string css, string content, Func<string, Stream, Task> onFileOpened)
{
return builder =>
{
builder.OpenElement(0, "button");
builder.AddAttribute(1, "class", css);
builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, async () =>
{
FileResult? result = await FilePicker.PickAsync();
if (result != null)
{
Stream stream = await result.OpenReadAsync();
await onFileOpened(result.FileName, stream);
}
}));
builder.OpenElement(3, "i");
builder.AddAttribute(4, "class", "bi bi-box-arrow-in-right");
builder.CloseElement();
builder.AddContent(5, " ");
builder.AddContent(6, content);
builder.CloseElement();
};
}
}
Photino - mainWindow.ShowOpenFile():
public class OpenFile(PhotinoWindow mainWindow) : IOpenFile
{
private readonly (string Name, string[] Extensions)[] _filters =
[
("JSON", [".json"]),
("TSV", [".tsv"]),
("YAML", [".yaml"]),
("Markdown", [".md"]),
("Google Keep Takeout ZIP", [".zip"])
];
public RenderFragment OpenFileDialog(string css, string content, Func<string, Stream, Task> onFileOpened)
{
return builder =>
{
builder.OpenElement(0, "button");
builder.AddAttribute(1, "class", css);
builder.AddAttribute(2, "onclick", EventCallback.Factory.Create(this, async () =>
{
string[] paths = mainWindow.ShowOpenFile(filters: _filters);
if (paths.Length == 1)
{
FileStream stream = File.OpenRead(paths[0]);
await onFileOpened(paths[0], stream);
}
}));
builder.OpenElement(3, "i");
builder.AddAttribute(4, "class", "bi bi-box-arrow-in-right");
builder.CloseElement();
builder.AddContent(5, " ");
builder.AddContent(6, content);
builder.CloseElement();
};
}
}
Four completely different implementations. One interface. The Backup page that renders the import button doesn't know which it gets.
ISaveFile follows the same pattern: WASM triggers a JS download via SaveAsUTF8, WinForms/WPF use SaveFileDialog, MAUI uses CommunityToolkit.Maui's FileSaver, Photino uses mainWindow.ShowSaveFile().
IAuthFragment - the null object pattern
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.
So the null object default covers WASM and Blazor Server:
public class AuthFragment : IAuthFragment
{
public bool IsAuthAvailable => false;
public Task<bool> TryRefreshTokenLogin() => Task.FromResult(false);
public RenderFragment GetAuthFragment(bool stateChanged, EventCallback<bool> stateChangedChanged)
=> builder => { };
}
The real implementation is registered on WinForms, WPF, Photino, and MAUI:
public class AuthFragment(IAuthService authService) : IAuthFragment
{
public bool IsAuthAvailable => true;
public Task<bool> TryRefreshTokenLogin() => authService.TryRefreshTokenLogin();
public RenderFragment GetAuthFragment(bool stateChanged, EventCallback<bool> stateChangedChanged)
{
return builder =>
{
builder.OpenComponent<LoginComponent>(0);
builder.AddAttribute(1, "StateChanged", stateChanged);
builder.AddAttribute(2, "StateChangedChanged", stateChangedChanged);
builder.CloseComponent();
};
}
}
The settings page checks IsAuthAvailable and conditionally renders the sync section. No #if, no platform checks - just a boolean on an injected interface.
IPreRenderService - one line that solves SSR
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:
public interface IPreRenderService
{
bool IsPreRendering { get; }
}
Default (all non-server platforms):
public class PreRenderService : IPreRenderService
{
public bool IsPreRendering => false;
}
Blazor Server:
public class PreRenderService(IHttpContextAccessor httpContextAccessor) : IPreRenderService
{
public bool IsPreRendering { get; } =
!(httpContextAccessor.HttpContext?.Response.HasStarted == true);
}
One property. One line. The rest of the shared library guards JS calls with if (!preRenderService.IsPreRendering) and works correctly on all platforms.
ILinkAttributeService - a Photino-only problem
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 onclick handler to all external links.
Default (everyone else - no-op):
public class LinkAttributeService : ILinkAttributeService
{
public Task AddAttributesToLinks(ElementReference elementReference)
=> Task.CompletedTask;
}
Photino only:
public class LinkAttributeService(IJSRuntime jsRuntime) : ILinkAttributeService
{
public async Task AddAttributesToLinks(ElementReference elementReference)
{
await jsRuntime.InvokeVoidAsync("addAttributeToLinks", elementReference);
}
}
When a user clicks an external link in a Markdown note, Photino's WebView would navigate itself instead of opening the system browser. addAttributeToLinks finds all <a href> elements with http:// or https:// URLs and replaces their click behavior with an onclick that calls DotNet.invokeMethodAsync('OpenHT', 'OpenLink', url). That invokes the [JSInvokable] OpenLink method in Photino's Program.cs, which does Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }).
The full chain: user clicks link → JS intercepts → .NET interop → system browser opens.
The shared library calls AddAttributesToLinks after every Markdown render. On every other platform, it does nothing.
IAssemblyProvider - router wiring
Blazor's router needs to know which assemblies contain page components. This differs between entry points:
Default (desktop - pages only in the shared library):
public class AssemblyProvider : IAssemblyProvider
{
public Assembly AppAssembly { get; } = typeof(IAssemblyProvider).Assembly;
public Assembly[] AdditionalAssemblies { get; } = [];
}
WASM and Blazor Server (entry point assembly also contains pages):
public class AssemblyProvider : IAssemblyProvider
{
public Assembly AppAssembly { get; } = typeof(IAssemblyProvider).Assembly;
public Assembly[] AdditionalAssemblies { get; } = [typeof(AssemblyProvider).Assembly];
}
IDataAccess - the deepest split
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:
-
IndexedDB (WASM) - browser storage via
DnetIndexedDb - SQLite via EF Core (WinForms, WPF, Photino, MAUI, Blazor Server) - local database file
- HTTP API client (remote sync) - calls a self-hosted Blazor Server instance
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 UserEntity. IUserEntity bridges this:
public interface IUserEntity
{
long Id { get; set; }
string UserName { get; set; }
string Email { get; set; }
string PasswordHash { get; set; }
DateTime LastChangeAt { get; set; }
}
UserEntity implements it for SQLite. ApplicationUser (ASP.NET Identity) implements it for Blazor Server. The service layer works with IUserEntity and doesn't know which it gets.
What the entry points look like
Each Program.cs calls the same four shared registrations, then adds a few lines of platform-specific DI:
// shared - every platform calls these four
builder.Services.AddServices();
builder.Services.AddDataAccess();
builder.Services.AddBackup();
builder.Services.AddBlazor();
// platform-specific - only these lines differ
builder.Services.AddScoped<IOpenFile, OpenFile>();
builder.Services.AddScoped<ISaveFile, SaveFile>();
builder.Services.AddScoped<INavBarFragment, NavBarFragment>();
builder.Services.AddScoped<IAssemblyProvider, AssemblyProvider>();
builder.Services.AddScoped<ILinkAttributeService, LinkAttributeService>();
builder.Services.AddScoped<IPreRenderService, PreRenderService>();
builder.Services.AddScoped<IAuthFragment, AuthFragment>();
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.
The result
The shared OpenHabitTracker.Blazor project - the one that contains all Razor components, pages, and layouts - has zero #if directives for platform differences. It consumes interfaces, renders RenderFragment values it receives, and checks boolean properties on injected services. It has no knowledge of IndexedDB, OpenFileDialog, FilePicker, Photino, or ASP.NET Identity.
This is my third rewrite of this app in Blazor. The first two taught me what not to do. The third time, the architecture finally felt right.
OpenHabitTracker is open source - all the code shown here is in production.
Top comments (0)