A 200KB Windows tray app for passive activity check-ins
The macOS companion for a personal safety app I run does one small thing: every five minutes, if the user was active in the last minute, it POSTs a check-in. I wanted the same thing on Windows, with the same privacy posture (no keystroke capture, no mouse content) and the same low resource footprint. The shipping binary is a 200KB .NET 8 WinForms app that lives in the system tray.
What was happening
The macOS version uses NSEvent monitoring. The Windows
equivalent is the much older Win32 GetLastInputInfo API, which
reports how many milliseconds since the last keyboard or mouse
input system-wide. It doesn't tell you what was pressed, just
that something was. Perfect for "did the user touch the
computer in the last 60 seconds" without ever touching the
content.
What I found
The whole thing fit in one file per concern:
CheckOnMineTray.csproj .NET 8 WinForms, win-x64
app.manifest asInvoker (no UAC), per-monitor DPI
Program.cs entry, single-instance mutex
TrayAppContext.cs NotifyIcon, 30s timer, 60s cooldown
ActivityMonitor.cs GetLastInputInfo() wrapper
ApiService.cs login/refresh/mfa/logout/record_activity
SecureStorage.cs DPAPI CurrentUser scope, tokens.dat
AutoStart.cs HKCU Run key entry
SignInForm.cs login + MFA dialogs
The activity check is one P/Invoke:
[StructLayout(LayoutKind.Sequential)]
struct LASTINPUTINFO {
public uint cbSize;
public uint dwTime;
}
[DllImport("user32.dll")]
static extern bool GetLastInputInfo(ref LASTINPUTINFO plii);
public static uint IdleSeconds() {
var info = new LASTINPUTINFO { cbSize = (uint)Marshal.SizeOf<LASTINPUTINFO>() };
GetLastInputInfo(ref info);
return (Environment.TickCount - (uint)info.dwTime) / 1000U;
}
The timer ticks every 30s. On each tick: if IdleSeconds() < 60
and the last successful check-in was more than 60s ago, POST. Net
effect: at most one check-in per minute, and only when the user
was actually present in the last 60 seconds.
Credentials are stored under
%APPDATA%\CheckOnMine\tokens.dat and encrypted with DPAPI under
the CurrentUser scope. That ties decryption to the user's logon
session — another user on the same machine can't read it.
The fix (well, the build)
Two build flavors:
# Framework-dependent: ~200KB, requires .NET 8 Desktop Runtime
dotnet publish -c Release -r win-x64 \
--self-contained false -o publish
# Self-contained: ~72MB, no runtime needed
dotnet publish -c Release -r win-x64 \
--self-contained true -o publish-sc
Working set after launch is ~43MB. That's not as tight as a C++ tray app would be but it's perfectly fine for a thing that lives in the corner forever.
One quirk worth flagging: Windows 11 hides tray icons in the
overflow flyout by default. The icon launches fine but is
invisible until the user clicks the ^ arrow. I added a
6-second balloon-tip toast on every launch that says "Check on
Mine is running. Look for the heart icon in the system tray (you
may need to click the ^ next to the clock)." It's annoying once
per session and lets the user know the app is alive even if the
icon is hidden.
What I'd do differently
I'd reach for WiX and a signed installer earlier. The unsigned self-contained binary triggers SmartScreen on first run, which trains the user to click through warnings. A code-signing cert is ~$100/year and removes a class of friction permanently.
Also: the single-instance mutex name should include a version suffix. If you ever ship an upgrade that runs concurrently with the old version (during a botched uninstall), you want them to be able to coexist for a few seconds rather than the new one silently failing to launch.