Skip to content

Latest commit

 

History

History

dotnet

README.md

QuickProtect for Windows & Linux (.NET / Avalonia port)

A cross-platform port of the macOS QuickProtect menu-bar app — a UniFi Protect camera viewer — built on .NET 10 + Avalonia + FFmpeg so a single codebase runs on Windows and Linux (and macOS, if ever wanted).

The original macOS app is a Swift/SwiftUI/AppKit project in macos/. This dotnet/ folder is the independent .NET reimplementation. Nothing here is shared with the Swift sources at the source level — only the behavior and the UniFi Protect protocol logic are ported.

The Windows build ships on the Microsoft Store (signed, auto-updating) and as a free installer (not code-signed) on GitHub Releases; the Linux build ships (since 1.3.1) as self-contained tarballs on the same releases — x64, plus arm64 from 1.4 (see PARITY.md → Distribution for the channel details).

Why this stack

Concern macOS (Swift) This port
Language / runtime Swift C# / .NET 10 (cross-platform, supersedes Mono)
UI SwiftUI + AppKit Avalonia 12 (Windows + Linux + macOS)
Tray / menu-bar NSStatusItem Avalonia TrayIcon
Video (RTSP→decode→render) hand-written RTSP/RTP + VideoToolbox + AVSampleBufferDisplayLayer FFmpeg (via FFmpeg.AutoGen) demux + decode, frames composited by Avalonia VideoSurface
RTSPS (RTSP-over-TLS) own TLS socket with TOFU trust loopback RtspTlsTunnel with the same TOFU trust (FFmpeg's TLS can't do the app's TOFU pinning)
Secrets Keychain DPAPI (Windows) / libsecret via secret-tool, 0600-file fallback (Linux)
Preferences UserDefaults JSON file in the user config dir
Start at login SMAppService Run registry key (Windows) / XDG autostart (Linux)
Cert trust TOFU pin via URLSession delegate TOFU pin via HttpClientHandler callback + SslStream callback (video)

The video engine is a custom pipeline on FFmpeg (FfmpegEngine + VideoStreamClient/VideoStreamCoordinator/VideoSurface): FFmpeg demuxes RTSP and decodes H.264/H.265, and the frames are composited inside Avalonia — no native child windows, so video is clickable, gesture-capable, and overlayable. (An earlier iteration used libVLC; it was replaced because a native VideoView child window can't participate in Avalonia hit-testing or overlays.) TLS is bridged by RtspTlsTunnel: a 127.0.0.1 listener that pipes bytes over TLS to the controller with the same TOFU pinning as the API client, so FFmpeg plays plain rtsp://127.0.0.1:… while the wire stays encrypted and pinned.

Project layout

dotnet/
  QuickProtect.sln
  Directory.Build.props            # shared TFM/version
  src/
    QuickProtect.Core/             # portable domain layer (no UI, unit-tested)
      Models/      Camera, StreamQuality, PtzMapping, PtzBurstTimer,
                   SnapshotNaming, PinnedWindowGeometry
      Services/    ProtectService (dual UniFi API client), RtspTlsTunnel,
                   CertificateTrust (TOFU), ControllerErrors, ControllerRequestPolicy,
                   StreamAllocationLedger, AppSettings, IPreferences,
                   ISecretStore, ILaunchAtLogin, UpdateChecker, AppPaths, Log
    QuickProtect.App/              # Avalonia desktop app
      Program.cs, App.axaml(.cs)   # tray agent shell (≈ AppDelegate)
      ApertureIcon.cs              # the tray mark, drawn at runtime
      Platform/                    # global hotkey, launch-at-login, image clipboard
      Services/    PinnedWindowManager, SnapshotService
      Video/       FfmpegEngine, VideoStreamClient, VideoStreamCoordinator,
                   VideoSurface (the custom FFmpeg video pipeline)
      ViewModels/  Main, CameraTile, Settings, Onboarding, Layout
      Views/       MainWindow (grid/focus), SettingsWindow, OnboardingWindow,
                   PinnedCameraWindow
      Localization/                # 7 languages, same keys as the macOS String
                                   # Catalog (kept in sync by hand; a parity test
                                   # fails on drift between the .resx files)
  tests/QuickProtect.Core.Tests/   # unit tests for the Core layer
  tests/QuickProtect.App.Tests/    # video engine, view/layout and platform tests
  installer/QuickProtect.iss       # Inno Setup script (free GitHub build)
  installer/msix/                  # MSIX manifest + tile assets (Store build)
  installer/aur/PKGBUILD           # AUR package consuming the Linux tarball
  THIRD-PARTY-NOTICES.txt          # bundled component licenses (shipped in every build)
  scripts/get-ffmpeg.ps1           # fetch pinned FFmpeg natives, SHA-256 verified (Windows RIDs by default)
  scripts/get-ffmpeg.sh            # same, for Linux (host-arch RID by default)
  scripts/get-sdk-buildtools.ps1   # makeappx/signtool from NuGet for package-msix.ps1 (no full SDK)
  scripts/package-windows.ps1      # publish + build the Windows installer
  scripts/package-msix.ps1         # publish + build the Store (MSIX) package
  scripts/package-linux.sh         # publish + build the Linux tarball (--rid linux-arm64 for arm64)
  scripts/generate-msix-assets.swift  # regenerate the MSIX tile PNGs (macOS)

Build & run

Prerequisites: the .NET 10 SDK, plus the FFmpeg 9.0 shared libraries for video. Fetch them once per checkout into native/ffmpeg/<rid>/ (gitignored); the build bundles them next to the app automatically:

cd dotnet
./scripts/get-ffmpeg.sh                            # Linux (host arch; or pass linux-x64 linux-arm64)
powershell -File scripts/get-ffmpeg.ps1            # Windows (win-x64 + win-arm64)

If the bundle is absent, the engine falls back to system-installed FFmpeg libraries — that only works when the system major version matches the FFmpeg.AutoGen binding (9.x, i.e. libavcodec.so.63); otherwise the app still runs, just with video disabled.

dotnet build QuickProtect.sln
dotnet test QuickProtect.sln                       # both test projects
dotnet run --project src/QuickProtect.App          # host-arch build
dotnet publish src/QuickProtect.App -c Release -r win-x64 --self-contained

Or use the repo scripts, which build, replace any running instance, and launch: scripts\windows\run.ps1 (Windows) / scripts/linux/run.sh (Linux), from the repo root.

The app launches as a tray agent (no main window). Left-click the tray icon to open the camera grid; right-click for the menu. Open Settings to enter the controller IP and Integration API key. --open-panel opens the grid immediately (useful for testing).

Diagnostics: fatal errors land in %APPDATA%\QuickProtect\crash.log (~/.config/QuickProtect/ on Linux), FFmpeg warnings/errors in video.log next to it.

Display scaling on Wayland: Avalonia (12.x here) has no Wayland backend, so the app runs through XWayland on an unscaled surface and takes its scale from the desktop's GDK_SCALE (see Platform/LinuxDisplayScaling.cs) — the same signal GTK and Electron apps follow. Set AVALONIA_GLOBAL_SCALE_FACTOR to override it, e.g. to get the fractional value behind an integer GDK_SCALE. The scale is read once at startup, so restart the agent after changing the desktop's scaling.

Packaging (Windows)

Two artifacts, matching the two distribution channels (see PARITY.md).

Free build — installer for GitHub releases. It is not code-signed, so Windows shows a SmartScreen warning on first run.

winget install JRSoftware.InnoSetup   # once
powershell -File scripts/package-windows.ps1
# → dist/QuickProtect-Setup-<version>-win-x64.exe

Paid build — MSIX for the Microsoft Store. Upload the package unsigned; Partner Center re-signs it with a Microsoft certificate, so no code-signing certificate is needed.

powershell -File scripts/package-msix.ps1
# → dist/QuickProtect-<version>-win-x64.msix

This needs makeappx.exe. If no Windows SDK is installed the script fetches just the tools from NuGet (~30 MB, no admin, cached in native/). To use a full SDK instead, install it once — note the ID is versioned, plain Microsoft.WindowsSDK no longer resolves:

winget install Microsoft.WindowsSDK.10.0.26100

-IdentityName and -Publisher default to the reserved app's real values from Partner Center → QuickProtect → Product identity, so the plain command above is uploadable as-is. They must match exactly or the upload is rejected twice over: once on the publisher string, and once on the package family name, whose _g3ygdvw278mtr suffix is a hash of that publisher.

To sideload for local testing instead, run with -SelfSign and import the generated certificate into Trusted People. This writes a separate -sideload.msix file — it carries the test certificate's identity and Partner Center will reject it, so never upload that one:

powershell -File scripts/package-msix.ps1 -SelfSign
# → dist/QuickProtect-<version>-win-x64-sideload.msix

The manifest declares only the Windows.Desktop device family, so in Partner Center leave Windows 10/11 Desktop checked and uncheck everything else — a checked family with no matching package blocks submission.

Partner Center always flags runFullTrust as a restricted capability needing approval. That warning is expected, not a defect: it is required for any Win32 app in an MSIX container and is reviewed during certification. Justify it in Notes for certification — full-trust desktop app bundling a native FFmpeg decoder, talking to the user's own UniFi Protect console on the LAN.

The tile PNGs under installer/msix/Assets/ are committed and drawn from the same Aurora design as the macOS icon. Regenerate them only if the icon changes (requires macOS):

swift scripts/generate-msix-assets.swift installer/msix/Assets

Two behaviours differ inside the MSIX container, both handled in code: settings still land in %APPDATA%\QuickProtect (Windows redirects it into the package's private store), and launch-at-login switches from the Run registry key — which a packaged app cannot use — to the manifest's windows.startupTask, managed by Windows under Settings → Apps → Startup.

Feature parity status

The port is at feature parity with the macOS app for day-to-day use: tray shell, live camera grid, focus view with PTZ (d-pad + keyboard, quick-tap min-burst), pinned always-on-top windows, snapshots (folder or clipboard), secondary-lens PiP, layout profiles, onboarding, global hotkey, notify-only update checks, TOFU certificate pinning end-to-end (API + video), theming with accent colors, audio playback (WASAPI/ALSA), and 7-language localization.

See PARITY.md for the detailed feature-by-feature status and the remaining polish items.