DEV Community

Cover image for Offline-First, Encrypted, Audit-Ready: Building CalibKeep with .NET 8 + Avalonia
r emrah gökkaya
r emrah gökkaya

Posted on

Offline-First, Encrypted, Audit-Ready: Building CalibKeep with .NET 8 + Avalonia

Walk into any small factory and ask how calibration is tracked. Nine times out of ten, someone points at a spreadsheet. It lives on one person's desktop, gets updated when somebody remembers, and when the ISO 9001 auditor asks for proof, that's what gets waved at them. It doesn't hold up.

CalibKeep is my attempt to fix that: a fully offline, encrypted desktop app that manages calibration schedules, depreciation, and audit trails. I learned a few painful lessons building it. This post is the stack, the traps, and the fixes — in the order I wish I'd hit them.

The Stack at a Glance

  • .NET 8 + Avalonia UI 11.2 (MVVM, CommunityToolkit.Mvvm)
  • EF Core 8 + SQLCipher for an AES-256 encrypted local database
  • QuestPDF for calibration/asset/depreciation reports
  • xUnit + Moq, currently 105 tests
  • DPAPI for local credential storage (auto-unlock hint only — never the master key)

Rule #0: No DbContext Singletons

// ❌ Never this
services.AddSingleton<AppDbContext>();

// ✅ Always this
using var ctx = _contextFactory.CreateDbContext();
Enter fullscreen mode Exit fullscreen mode

Every repository and service opens its own short-lived context via IDbContextFactory. This keeps shared state out of the picture. No cross-thread context leaks, no weird collisions when the UI thread and a background export hit the database at the same time.

The SQLCipher Trap That Cost Me a Day

The scariest bug in this project: opening a SQLCipher database with the wrong passphrase permanently poisons the connection handle for the process lifetime. Not Close(). Not ClearAllPools(). Not even GC. The handle stays broken.

The fix was to never open the file to validate the password:

// metadata.json holds an HMAC marker computed from the passphrase.
// We verify the HMAC before ever touching the SQLite file.
Enter fullscreen mode Exit fullscreen mode

Restore went through the same kind of ordeal. Instead of swapping files around, we moved to SQLite's online backup API (BackupDatabase) and write into the live DB without locking:

temp file  ──►  BackupDatabase  ──►  live SQLCipher DB
Enter fullscreen mode Exit fullscreen mode

That eliminated both "file is not a database" and "file is being used" errors for good.

The Audit Trail: Write It in the Same Transaction

ISO 9001 wants proof of every change. So every service that does Create/Update/Delete writes an AuditLog row in the same transaction — no exceptions.

Table            FieldName      OldValue        NewValue
Calibration      NextDueDate    2026-08-01      2026-11-01
Enter fullscreen mode Exit fullscreen mode

It's boring code, but it's the difference between "a nice app" and "a compliance tool."

Calibration Impact: The Feature That Adds Real Value

Changing a calibration interval isn't a single field update — it can invalidate dependent equipment. So when a calibration-critical field changes (manufacturer, model, measurement range, accuracy, reference standard...), a small rule engine flags the impact as HIGH, counts the open calibration tasks affected, and writes the reasoning into the audit trail. Interval changes on a calibration task get the same treatment. It's a hard-coded table of critical fields and two careful checks — no AI, no magic — but it's what makes the app feel like it understands the domain instead of just storing dates.

MSIX Packaging: Three Traps, One Script

Shipping to the Microsoft Store surfaced three gotchas:

  1. makeappx demands AppxManifest.xml — not Package.appxmanifest — and the schema requires a TargetDeviceFamily.
  2. Signing fails with 0x8007000B when the manifest Publisher (CN=RuffStack) doesn't match the signing certificate subject. Event log ID 150 told us exactly this.
  3. Store licenses are real, local trials are fake. We now read the actual StoreContext license in packaged builds and fall back to a local 15-day trial in dev. One interface:
public interface IStoreLicenseContext
{
    Task<LicenseInfo?> GetLicenseAsync(); // null = not a Store build
}
Enter fullscreen mode Exit fullscreen mode

Everything is now one command:

powershell -File CalibKeep.Packaging\build-msix.ps1 -Sign
Enter fullscreen mode Exit fullscreen mode

Takeaways

  • Encrypted local storage in .NET is doable, but budget time for SQLCipher handle poisoning and backup/restore edge cases.
  • If you need compliance, bake the audit trail into the transaction boundary, not as a side effect.
  • Test the packaging/licensing story early — it's the "works on my machine" trap with extra steps.

If you're building an offline-first desktop tool with .NET 8 + Avalonia, I hope this saves you a couple of rabbit holes.

RuffStack | Software Developer | SaaS & AI Tools

Independent developer shipping EU compliance SaaS and AI-powered desktop tools.

favicon ruffstack.dev

Top comments (0)