From a WinForms Hack to a Cross-Platform Desktop App

In 2022 I built a small battery notifier for myself. In 2026 I finally shipped it as a proper cross-platform product — with Avalonia UI, Duolingo-inspired notifications, and a real design system.

Battery Notifier v2 — now on macOS, Windows, and Linux
March 21, 2026 7 min read

The Origin Story

Back in 2022 I had a simple problem. My ageing laptop would die without warning — the battery had degraded so badly that the default Windows alert at 20% was useless. The machine would shut off at 50% or higher, sometimes right in the middle of work.

So I did what any developer does — I built something. A small WinForms utility that polls the battery status and fires a toast notification when it crosses a threshold I set. Nothing fancy. It ran from the system tray, did its job, and I used it every day.

I wrote about that original version a while back, and at the end I said I wanted to rebuild it with Avalonia UI so it could run on macOS and Linux too. That line sat there for a long time. This post is about what happened when I finally followed through.

The app went through three distinct phases — the bare-bones WinForms release in 2022, a major UI redesign in 2024, and the 2026 cross-platform rewrite in Avalonia.

Battery Notifier evolution: 2022 first release, 2024 redesign, 2026 Avalonia cross-platformFrom left to right: the original WinForms release (2022), the redesigned UI (2024), and the current Avalonia cross-platform version (2026).

Why It Took So Long

It was not about waiting for a framework to mature. It was about me not being ready.

When I first built Battery Notifier I was a web developer. I knew C# and .NET from building backends, and WinForms from a POS app at work, but my understanding of what happens below the application layer was shallow. Cross-platform desktop development scared me — not because of the UI toolkit, but because I did not trust myself to handle the things that go wrong when your code runs close to the OS.

That changed when I joined IT University of Copenhagen for my master's in Computer Science. For the first time I studied concurrency properly — why race conditions appear, why deadlocks happen, how locks actually work under the hood. I got into memory management, hardware architecture, how the OS schedules processes. Things I had been hand-waving over for years.

That foundation gave me the confidence to actually attempt this. When you are building a system tray app that polls hardware, manages background threads, and talks to platform-specific APIs, you need to understand what is happening at a lower level than most web work requires. ITU gave me that.

The Platform Reality

Confidence is one thing. Each operating system fighting you is another.

Getting battery information sounds simple until you try it on three platforms. On Windows I call GetSystemPowerStatus through P/Invoke — I initially tried WMI but it was slow and its status values were unreliable. On macOS there is no managed API at all, so I spawn pmset -g batt and parse text output with regex. Same data, completely different paths to get there.

Detecting charger plug/unplug events was trickier. Windows has WMI power management events. macOS required registering for Darwin kernel notifications and blocking on a file descriptor in a background thread — with a careful cleanup path to avoid hanging on shutdown.

Do Not Disturb detection was the worst. Windows uses an undocumented notification facility API. macOS changed their DND implementation across Monterey, Ventura, and Tahoe — I ended up with a three-tier fallback that, on the newest versions, literally uses AppleScript to click the Control Center and read the Focus state. Not my proudest code, but it works.

Not Just a Port — a Rethink

I did not want to just rewrite the same app with a different UI toolkit. I wanted to raise the standard. If I was going to ship this, it had to feel like a product someone would actually want to install and keep.

The biggest influence on my thinking was Apple's design philosophy. Say what you will about their ecosystem, but Apple raised the bar for what a utility app should look and feel like. I wanted Battery Notifier to feel considered — not like a developer's side project, but like something designed with intention.

So I opened Figma and started from scratch. Multiple design iterations. Revised the layout, the colour palette, the iconography, the settings flow. I went through more versions than I want to admit before landing on something I was happy with.

Battery Notifier Figma workspace — dashboard, settings, alerts, and sound picker screensThe Figma file with all the screens — dashboard, settings, notification preferences, and the sound picker.

The Notification Problem

Here is the thing about battery notifications — if they are annoying, people turn them off. And once someone turns off notifications for your app, you have lost them permanently.

The original version had a naive approach: check the battery every N minutes, send a notification if it crosses the threshold. Simple, but it meant you could get the same alert every few minutes if you did not act on it. That gets old fast.

I had been reading about how Duolingo approaches push notifications. Their research paper lays out the core insight clearly — every notification you send has a cost, which is the risk of the user opting out entirely. So instead of sending more, they send smarter. If a user ignores a notification, they back off. They treat the notification channel as a scarce resource to be protected, not a firehose to be maximised.

I took that same principle and built an escalating backoff system. The intervals ramp up progressively, and after seven ignored notifications the app goes silent entirely — then auto-recovers after two hours, borrowing Duolingo's "recovering arm" concept:

C#
private static readonly TimeSpan[] BackoffIntervals =
[
    TimeSpan.Zero,
    TimeSpan.FromMinutes(2),
    TimeSpan.FromMinutes(5),
    TimeSpan.FromMinutes(10),
    TimeSpan.FromMinutes(15),
    TimeSpan.FromMinutes(30),
    TimeSpan.FromMinutes(45)
];

private const int MaxNotificationsBeforeSilence = 7;

// After two hours of silence the tracker resets,
// giving the user a fresh reminder cycle.
private static readonly TimeSpan RecoveryInterval = TimeSpan.FromHours(2);

A small detail, but the difference between an app people tolerate and one they actually keep running.

What Actually Shipped

The scope grew beyond what I originally planned, but each feature earned its place:

  • Customisable thresholds — set your own high and low battery triggers
  • Battery health dashboard — capacity, cycle count, temperature, power draw, charge history sparkline, and wear trend
  • Battery drainer detection — shows which apps are consuming the most power, with estimated time impact and tips
  • Sound library — built-in synthesized tones, curated Editor's Choice sounds, or import your own audio files
  • Encrypted settings — DPAPI on Windows, AES-256-GCM on macOS and Linux
  • System tray — runs quietly in the background, click to show or hide
  • Launch at startup — auto-start with your OS
  • Themes — System, Light, and Dark mode

The app runs on Windows (x64 and ARM64) and macOS (Apple Silicon), with Linux support in progress. You can grab it from GitHub Releases or install via the command line.

Battery Notifier running on Windows and macOS in light and dark themesThe current version running on Windows and macOS — light and dark themes.

AI and Shipping

AI helped me move faster. Platform-specific battery APIs, encrypted settings, the notification backoff logic — I could iterate on these in hours instead of days. That part is real and I would be lying if I said otherwise.

But AI did not decide what this app should be. It did not open Figma. It did not throw away three design iterations because they felt cheap. It did not care whether the notification timing felt respectful or annoying. That stuff — the product instinct, the taste — is still yours to bring.

From Repo to Product

I have plenty of side projects that live as repos and nothing else. This time I wanted to go further. I designed in Figma first. I set up batterynotifier.com. I built proper installers for each platform. I wrote release notes.

None of that is hard. But it is the gap between something you built and something someone else would actually use. I have always cared more about building products than building code — Battery Notifier was my chance to do that from start to finish.

What Stuck With Me

My first Figma mockup looked nothing like the final app. I went through more iterations than I expected, but each one got closer to something that felt right. The lesson is obvious in retrospect — the first version is never the good one.

Cross-platform is still hard. Avalonia handles the UI well, but each OS has its own system tray behaviour, notification quirks, and security model. You cannot abstract that away entirely.

And honestly — the biggest thing was just committing to shipping. This project sat as a "someday" for years. Once I decided it was happening, everything else followed.

The source is on GitHub and the app is at batterynotifier.com.