skills/jcurbelo/skills/wpf-best-practices

wpf-best-practices

SKILL.md

WPF Best Practices

You are an expert in C#, .NET, and WPF desktop application development with deep knowledge of MVVM architecture, dependency injection, async/await patterns, and Windows desktop integration.

C# Code Style

Basic Principles

  • Use English for all code and documentation
  • Always declare types for variables and functions (parameters and return values)
  • Enable nullable reference types project-wide
  • Use file-scoped namespaces — no braces, no nesting
  • Write concise, maintainable code — avoid over-engineering

File-Scoped Namespaces

Always use file-scoped namespaces:

namespace MyApp.Services;

public class SettingsService
{
    // No extra indentation level
}

Nullable Reference Types

Always enabled in csproj (<Nullable>enable</Nullable>):

public class AppNotification
{
    public string Title { get; set; } = "";       // Non-nullable with default
    public string Message { get; set; } = "";
    public string? Channel { get; set; }           // Explicitly nullable
    public string[]? Tags { get; set; }
}

Naming Conventions

  • PascalCase for types, interfaces, properties, methods, events, constants
  • camelCase for local variables and parameters
  • _camelCase for private fields
  • IPascalCase for interfaces (prefix with I)
  • UPPERCASE for environment variables only
  • Prefix boolean properties/fields with Is, Has, Can, Should
public class ConnectionService
{
    private readonly ILogger _logger;
    private readonly string _serverUrl;
    private bool _isConnected;

    public bool IsConnected => _isConnected;
    public bool CanReconnect { get; private set; }
}

Modern C# Features

Use switch expressions:

public string StatusIcon => Status switch
{
    ConnectionStatus.Connected    => "🟢",
    ConnectionStatus.Connecting   => "🟡",
    ConnectionStatus.Disconnected => "🔴",
    _                             => "⚪"
};

Use range/slice operators:

private static string Capitalize(string s) =>
    string.IsNullOrEmpty(s) ? s : char.ToUpper(s[0]) + s[1..];

Avoid redundant default initializers:

// Good
private bool _disposed;
private int _retryCount;

// Bad — redundant
private bool _disposed = false;
private int _retryCount = 0;

Project Structure

MyApp/
├── MyApp.sln                    # Solution file
├── src/
│   ├── MyApp/                   # Main WPF application
│   │   ├── App.xaml             # Application entry, resource dictionaries
│   │   ├── App.xaml.cs          # DI setup, app lifecycle, crash handlers
│   │   ├── Views/               # XAML windows and user controls
│   │   ├── ViewModels/          # MVVM presentation logic
│   │   ├── Models/              # Data classes and enums
│   │   ├── Services/            # Business logic (DI singletons)
│   │   │   └── Interfaces/      # Service contracts
│   │   ├── Utilities/           # Helpers (encryption, screen, JSON)
│   │   └── Constants/           # App-wide constants
│   └── MyApp.Shared/            # Cross-platform shared library
│       └── MyApp.Shared.csproj
├── tests/
│   └── MyApp.Tests/             # xUnit test project
│       └── MyApp.Tests.csproj
└── .github/
    └── workflows/               # CI/CD

Project File Configuration

WPF app csproj:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net10.0-windows</TargetFramework>
    <UseWPF>true</UseWPF>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project>

Shared library csproj (cross-platform):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <InternalsVisibleTo Include="MyApp.Tests" />
  </ItemGroup>
</Project>

MVVM Pattern

ViewModelBase

Every ViewModel should inherit from a shared base:

namespace MyApp.ViewModels;

public abstract class ViewModelBase : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    protected void OnPropertyChanged([CallerMemberName] string? name = null) =>
        PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));

    protected bool SetProperty<T>(ref T field, T value,
        [CallerMemberName] string? name = null)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return false;
        field = value;
        OnPropertyChanged(name);
        return true;
    }
}

Usage:

public class MainViewModel : ViewModelBase
{
    private string _title = "";
    public string Title
    {
        get => _title;
        set => SetProperty(ref _title, value);
    }

    private bool _isLoading;
    public bool IsLoading
    {
        get => _isLoading;
        set => SetProperty(ref _isLoading, value);
    }
}

RelayCommand

Use a simple RelayCommand for XAML command bindings:

namespace MyApp.ViewModels;

public class RelayCommand : ICommand
{
    private readonly Action<object?> _execute;
    private readonly Func<object?, bool>? _canExecute;

    public RelayCommand(Action<object?> execute, Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true;
    public void Execute(object? parameter) => _execute(parameter);
}

public class RelayCommand<T> : ICommand
{
    private readonly Action<T?> _execute;
    private readonly Func<T?, bool>? _canExecute;

    public RelayCommand(Action<T?> execute, Func<T?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) =>
        _canExecute?.Invoke(parameter is T t ? t : default) ?? true;
    public void Execute(object? parameter) =>
        _execute(parameter is T t ? t : default);
}

AsyncRelayCommand

For async operations bound to UI:

namespace MyApp.ViewModels;

public class AsyncRelayCommand : ICommand
{
    private readonly Func<object?, Task> _execute;
    private readonly Func<object?, bool>? _canExecute;
    private bool _isExecuting;

    public AsyncRelayCommand(Func<object?, Task> execute,
        Func<object?, bool>? canExecute = null)
    {
        _execute = execute;
        _canExecute = canExecute;
    }

    public event EventHandler? CanExecuteChanged
    {
        add => CommandManager.RequerySuggested += value;
        remove => CommandManager.RequerySuggested -= value;
    }

    public bool CanExecute(object? parameter) =>
        !_isExecuting && (_canExecute?.Invoke(parameter) ?? true);

    public async void Execute(object? parameter)
    {
        if (_isExecuting) return;
        _isExecuting = true;
        CommandManager.InvalidateRequerySuggested();

        try
        {
            await _execute(parameter);
        }
        finally
        {
            _isExecuting = false;
            CommandManager.InvalidateRequerySuggested();
        }
    }
}

Data Binding in XAML

<Window x:Class="MyApp.Views.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        Title="{Binding Title}">
    <Grid>
        <TextBlock Text="{Binding StatusText}"
                   Visibility="{Binding IsLoading,
                     Converter={StaticResource BoolToVisibilityConverter}}" />
        <Button Content="Save"
                Command="{Binding SaveCommand}"
                IsEnabled="{Binding CanSave}" />
    </Grid>
</Window>

Code-behind should only set the DataContext:

public partial class MainWindow : Window
{
    public MainWindow(MainViewModel viewModel)
    {
        InitializeComponent();
        DataContext = viewModel;
    }
}

Dependency Injection

Use Microsoft.Extensions.DependencyInjection for all service wiring.

Setup in App.xaml.cs

namespace MyApp;

public partial class App : Application
{
    private ServiceProvider? _serviceProvider;

    protected override void OnStartup(StartupEventArgs e)
    {
        var services = new ServiceCollection();
        ConfigureServices(services);
        _serviceProvider = services.BuildServiceProvider();

        var mainWindow = _serviceProvider.GetRequiredService<MainWindow>();
        mainWindow.Show();

        base.OnStartup(e);
    }

    private static void ConfigureServices(IServiceCollection services)
    {
        // Services — singletons for app-wide state
        services.AddSingleton<ISettingsService, SettingsService>();
        services.AddSingleton<IConnectionService, ConnectionService>();

        // HTTP clients
        services.AddHttpClient("AppClient", client =>
        {
            client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
            client.Timeout = TimeSpan.FromSeconds(10);
        });

        // ViewModels — transient, created per window
        services.AddTransient<MainViewModel>();

        // Windows
        services.AddTransient<MainWindow>();
    }

    protected override void OnExit(ExitEventArgs e)
    {
        (_serviceProvider as IDisposable)?.Dispose();
        base.OnExit(e);
    }
}

Service Pattern

Always define an interface. Inject via constructor. Never use a service locator.

// Interface
namespace MyApp.Services.Interfaces;

public interface ISettingsService
{
    string GetValue(string key, string defaultValue = "");
    void SetValue(string key, string value);
    void Save();
}

// Implementation
namespace MyApp.Services;

public class SettingsService : ISettingsService
{
    private readonly string _settingsPath;
    private Dictionary<string, string> _settings = new();

    public SettingsService()
    {
        _settingsPath = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
            "MyApp", "settings.json");
        Load();
    }

    public string GetValue(string key, string defaultValue = "") =>
        _settings.TryGetValue(key, out var value) ? value : defaultValue;

    public void SetValue(string key, string value) =>
        _settings[key] = value;

    public void Save()
    {
        var dir = Path.GetDirectoryName(_settingsPath)!;
        Directory.CreateDirectory(dir);
        var json = JsonSerializer.Serialize(_settings,
            new JsonSerializerOptions { WriteIndented = true });
        File.WriteAllText(_settingsPath, json);
    }

    private void Load()
    {
        try
        {
            if (!File.Exists(_settingsPath)) return;
            var json = File.ReadAllText(_settingsPath);
            _settings = JsonSerializer.Deserialize<Dictionary<string, string>>(json) ?? new();
        }
        catch { _settings = new(); }
    }
}

Anti-Pattern: Service Locator

Never resolve services from a static provider. Always inject through the constructor:

// BAD — service locator
public class MyViewModel
{
    public void DoSomething()
    {
        var service = App.ServiceProvider.GetService<IMyService>();
        service?.Execute();
    }
}

// GOOD — constructor injection
public class MyViewModel
{
    private readonly IMyService _myService;

    public MyViewModel(IMyService myService)
    {
        _myService = myService;
    }

    public void DoSomething() => _myService.Execute();
}

If circular dependencies arise, use Lazy<T>:

private readonly Lazy<IMyService> _myService;

public MyViewModel(Lazy<IMyService> myService)
{
    _myService = myService;
}

Async/Await Patterns

Rules

  • Never use .Result, .Wait(), or .GetAwaiter().GetResult() — they deadlock WPF's single-threaded STA dispatcher
  • Always propagate CancellationToken through async methods
  • Use ConfigureAwait(false) in library/service code (not in ViewModels or code-behind where you need the UI context)
  • Never fire-and-forget without error handling

UI Thread Marshaling

Use Dispatcher to update UI from background threads:

// From any thread — safe UI update
private void OnDataReceived(object? sender, DataEventArgs e)
{
    Application.Current.Dispatcher.InvokeAsync(() =>
    {
        StatusText = e.Message;
        Items.Add(e.Item);
    });
}

For ViewModels that update from background threads:

public class DashboardViewModel : ViewModelBase
{
    private readonly Dispatcher _dispatcher;

    public DashboardViewModel()
    {
        _dispatcher = Application.Current.Dispatcher;
    }

    public async Task RefreshAsync(CancellationToken ct = default)
    {
        var data = await _dataService.FetchAsync(ct).ConfigureAwait(false);

        _dispatcher.InvokeAsync(() =>
        {
            Items.Clear();
            foreach (var item in data)
                Items.Add(item);
        });
    }
}

DispatcherTimer for Periodic Work

private DispatcherTimer? _statusTimer;

private void StartStatusPolling()
{
    _statusTimer = new DispatcherTimer
    {
        Interval = TimeSpan.FromSeconds(5)
    };
    _statusTimer.Tick += async (s, e) =>
    {
        try
        {
            await RefreshStatusAsync();
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Status poll failed: {ex.Message}");
        }
    };
    _statusTimer.Start();
}

Safe Fire-and-Forget

Never use bare _ = Task.Run(...). Always wrap with error handling:

namespace MyApp.Utilities;

internal static class AsyncHelper
{
    public static async void FireAndForget(Func<Task> operation, string context)
    {
        try
        {
            await operation();
        }
        catch (OperationCanceledException) { }
        catch (Exception ex)
        {
            Debug.WriteLine($"[{context}] Background task failed: {ex.Message}");
        }
    }
}

// Usage
AsyncHelper.FireAndForget(
    () => LoadDataAsync(),
    "MainViewModel.LoadData");

System Tray Integration

WPF does not have built-in system tray support. Use System.Windows.Forms.NotifyIcon:

namespace MyApp.Services;

public class SystemTrayService : IDisposable
{
    private readonly System.Windows.Forms.NotifyIcon _notifyIcon;

    public SystemTrayService()
    {
        _notifyIcon = new System.Windows.Forms.NotifyIcon
        {
            Text = "My App",
            Visible = true
        };

        // Load icon from resources
        var iconStream = Application.GetResourceStream(
            new Uri("pack://application:,,,/Resources/app.ico"))?.Stream;
        if (iconStream != null)
            _notifyIcon.Icon = new System.Drawing.Icon(iconStream);

        _notifyIcon.DoubleClick += (s, e) => ShowMainWindow();
        BuildContextMenu();
    }

    private void BuildContextMenu()
    {
        var menu = new System.Windows.Forms.ContextMenuStrip();
        menu.Items.Add("Show", null, (s, e) => ShowMainWindow());
        menu.Items.Add(new System.Windows.Forms.ToolStripSeparator());
        menu.Items.Add("Exit", null, (s, e) => Application.Current.Shutdown());
        _notifyIcon.ContextMenuStrip = menu;
    }

    private static void ShowMainWindow()
    {
        var window = Application.Current.MainWindow;
        if (window == null) return;

        window.Show();
        window.WindowState = WindowState.Normal;
        window.Activate();
    }

    public void Dispose()
    {
        _notifyIcon.Visible = false;
        _notifyIcon.Dispose();
    }
}

GDI Handle Management

Always destroy GDI icon handles to prevent leaks:

[DllImport("user32.dll", CharSet = CharSet.Auto)]
private static extern bool DestroyIcon(IntPtr handle);

public static System.Drawing.Icon CreateStatusIcon(System.Drawing.Color color)
{
    using var bitmap = new System.Drawing.Bitmap(16, 16);
    using var g = System.Drawing.Graphics.FromImage(bitmap);
    using var brush = new System.Drawing.SolidBrush(color);

    g.Clear(System.Drawing.Color.Transparent);
    g.FillEllipse(brush, 2, 2, 12, 12);

    var hIcon = bitmap.GetHicon();
    var icon = System.Drawing.Icon.FromHandle(hIcon);
    var result = (System.Drawing.Icon)icon.Clone();  // Clone to own the data

    DestroyIcon(hIcon);  // CRITICAL: release GDI handle
    return result;
}

Cache icons — don't recreate them on every state change:

private static System.Drawing.Icon? _connectedIcon;
private static System.Drawing.Icon? _disconnectedIcon;

public void UpdateStatus(bool connected)
{
    _connectedIcon ??= CreateStatusIcon(System.Drawing.Color.Green);
    _disconnectedIcon ??= CreateStatusIcon(System.Drawing.Color.Red);
    _notifyIcon.Icon = connected ? _connectedIcon : _disconnectedIcon;
}

Window Management

Minimize to Tray

public partial class MainWindow : Window
{
    protected override void OnStateChanged(EventArgs e)
    {
        if (WindowState == WindowState.Minimized)
            Hide();
        base.OnStateChanged(e);
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        e.Cancel = true;  // Don't close, minimize to tray
        Hide();
    }
}

Transparent Overlay Windows

For transparent floating windows (widgets, overlays):

<Window AllowsTransparency="True"
        WindowStyle="None"
        Background="Transparent"
        Topmost="True"
        ShowInTaskbar="False">
    <!-- Content renders over desktop -->
</Window>

Single Instance with Mutex

private static Mutex? _mutex;

protected override void OnStartup(StartupEventArgs e)
{
    const string mutexName = "Global\\MyAppSingleInstance";
    _mutex = new Mutex(true, mutexName, out bool isNewInstance);

    if (!isNewInstance)
    {
        MessageBox.Show("Application is already running.");
        Shutdown();
        return;
    }

    // Continue startup...
}

Global Hotkey Registration

namespace MyApp.Services;

public class GlobalHotkeyService : IDisposable
{
    private const int HOTKEY_ID = 9001;

    [DllImport("user32.dll")]
    private static extern bool RegisterHotKey(IntPtr hWnd, int id, uint mods, uint vk);

    [DllImport("user32.dll")]
    private static extern bool UnregisterHotKey(IntPtr hWnd, int id);

    private readonly IntPtr _hwnd;

    public void Register(IntPtr windowHandle, uint modifiers, uint key)
    {
        RegisterHotKey(windowHandle, HOTKEY_ID, modifiers, key);
    }

    public void Dispose()
    {
        UnregisterHotKey(_hwnd, HOTKEY_ID);
    }
}

Settings Patterns

JSON Settings with Safe Load/Save

namespace MyApp.Services;

public class SettingsManager
{
    private static readonly string SettingsDir = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
        "MyApp");
    private static readonly string SettingsFile =
        Path.Combine(SettingsDir, "settings.json");

    // Properties with defaults
    public string ServerUrl { get; set; } = "http://localhost:8080";
    public bool AutoStart { get; set; }
    public bool ShowNotifications { get; set; } = true;

    public SettingsManager() => Load();

    public void Load()
    {
        try
        {
            if (!File.Exists(SettingsFile)) return;
            var json = File.ReadAllText(SettingsFile);
            var loaded = JsonSerializer.Deserialize<SettingsData>(json);
            if (loaded != null)
            {
                ServerUrl = loaded.ServerUrl ?? ServerUrl;
                AutoStart = loaded.AutoStart;
                ShowNotifications = loaded.ShowNotifications;
            }
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Failed to load settings: {ex.Message}");
        }
    }

    public void Save()
    {
        try
        {
            Directory.CreateDirectory(SettingsDir);
            var data = new SettingsData
            {
                ServerUrl = ServerUrl,
                AutoStart = AutoStart,
                ShowNotifications = ShowNotifications
            };
            var json = JsonSerializer.Serialize(data,
                new JsonSerializerOptions { WriteIndented = true });
            File.WriteAllText(SettingsFile, json);
        }
        catch (Exception ex)
        {
            Debug.WriteLine($"Failed to save settings: {ex.Message}");
        }
    }

    // Keep serialization shape separate from public API
    private class SettingsData
    {
        public string? ServerUrl { get; set; }
        public bool AutoStart { get; set; }
        public bool ShowNotifications { get; set; } = true;
    }
}

Registry for Auto-Start

public static void SetAutoStart(bool enable, string appName)
{
    using var key = Registry.CurrentUser.OpenSubKey(
        @"SOFTWARE\Microsoft\Windows\CurrentVersion\Run", true);
    if (key == null) return;

    if (enable)
    {
        var exe = Environment.ProcessPath;
        key.SetValue(appName, $"\"{exe}\"");
    }
    else
    {
        key.DeleteValue(appName, throwOnMissingValue: false);
    }
}

DPAPI Encryption for Sensitive Settings

using System.Security.Cryptography;

public static string EncryptForCurrentUser(string plainText)
{
    var data = Encoding.UTF8.GetBytes(plainText);
    var encrypted = ProtectedData.Protect(data, null,
        DataProtectionScope.CurrentUser);
    return Convert.ToBase64String(encrypted);
}

public static string DecryptForCurrentUser(string encrypted)
{
    var data = Convert.FromBase64String(encrypted);
    var decrypted = ProtectedData.Unprotect(data, null,
        DataProtectionScope.CurrentUser);
    return Encoding.UTF8.GetString(decrypted);
}

Error Handling

Crash Handlers

Register all three crash handler layers in App.xaml.cs:

public App()
{
    InitializeComponent();

    // WPF UI thread exceptions
    DispatcherUnhandledException += (s, e) =>
    {
        LogCrash("DispatcherUnhandled", e.Exception);
        e.Handled = true;
    };

    // CLR unhandled exceptions (all threads)
    AppDomain.CurrentDomain.UnhandledException += (s, e) =>
        LogCrash("DomainUnhandled", e.ExceptionObject as Exception);

    // Unobserved async Task exceptions
    TaskScheduler.UnobservedTaskException += (s, e) =>
    {
        LogCrash("UnobservedTask", e.Exception);
        e.SetObserved();
    };
}

private static readonly string CrashLogPath = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "MyApp", "crash.log");

private static void LogCrash(string source, Exception? ex)
{
    try
    {
        Directory.CreateDirectory(Path.GetDirectoryName(CrashLogPath)!);
        var msg = $"\n[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] {source}\n{ex}\n";
        File.AppendAllText(CrashLogPath, msg);
    }
    catch { }
}

Run Marker for Unclean Exit Detection

private static readonly string RunMarkerPath = Path.Combine(
    Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
    "MyApp", "running.marker");

protected override void OnStartup(StartupEventArgs e)
{
    if (File.Exists(RunMarkerPath))
    {
        Debug.WriteLine("Previous session did not exit cleanly");
        File.Delete(RunMarkerPath);
    }
    File.WriteAllText(RunMarkerPath, DateTime.Now.ToString("O"));

    // ... rest of startup

    base.OnStartup(e);
}

protected override void OnExit(ExitEventArgs e)
{
    try { File.Delete(RunMarkerPath); } catch { }
    base.OnExit(e);
}

Empty Catch Blocks

Never use empty catch blocks. At minimum, log in DEBUG:

// BAD
catch { }

// GOOD — use a safe execution wrapper
namespace MyApp.Utilities;

internal static class SafeExec
{
    public static void Try(Action action,
        [CallerMemberName] string? caller = null)
    {
        try { action(); }
        catch (Exception ex)
        {
            Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
        }
    }

    public static async Task TryAsync(Func<Task> action,
        [CallerMemberName] string? caller = null)
    {
        try { await action(); }
        catch (Exception ex)
        {
            Debug.WriteLine($"[SafeExec:{caller}] {ex.Message}");
        }
    }
}

Logging

Thread-Safe File Logger

namespace MyApp.Services;

public static class Logger
{
    private static readonly object _lock = new();
    private static readonly string _logFilePath;

    static Logger()
    {
        var dir = Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
            "MyApp");
        Directory.CreateDirectory(dir);
        _logFilePath = Path.Combine(dir, "app.log");

        // Rotate if > 5MB
        try
        {
            var fi = new FileInfo(_logFilePath);
            if (fi.Exists && fi.Length > 5 * 1024 * 1024)
            {
                var backup = Path.Combine(dir, "app.log.old");
                if (File.Exists(backup)) File.Delete(backup);
                File.Move(_logFilePath, backup);
            }
        }
        catch { }
    }

    public static void Info(string message)  => Log("INFO", message);
    public static void Warn(string message)  => Log("WARN", message);
    public static void Error(string message) => Log("ERROR", message);

    private static void Log(string level, string message)
    {
        var line = $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}] [{level}] {message}";
        lock (_lock)
        {
            try { File.AppendAllText(_logFilePath, line + Environment.NewLine); }
            catch { }
        }
#if DEBUG
        System.Diagnostics.Debug.WriteLine(line);
#endif
    }
}

Logger Interface for DI

public interface IAppLogger
{
    void Info(string message);
    void Debug(string message);
    void Warn(string message);
    void Error(string message, Exception? ex = null);
}

Testing

xUnit Project Setup

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <IsPackable>false</IsPackable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
    <PackageReference Include="xunit" Version="2.*" />
    <PackageReference Include="xunit.runner.visualstudio" Version="3.*" />
    <PackageReference Include="coverlet.collector" Version="6.*" />
  </ItemGroup>

  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\..\src\MyApp.Shared\MyApp.Shared.csproj" />
  </ItemGroup>
</Project>

Test Patterns

public class SettingsTests : IDisposable
{
    private readonly string _tempDir;

    public SettingsTests()
    {
        _tempDir = Path.Combine(Path.GetTempPath(),
            $"myapp-test-{Guid.NewGuid():N}");
        Directory.CreateDirectory(_tempDir);
    }

    public void Dispose()
    {
        try { Directory.Delete(_tempDir, true); } catch { }
    }

    [Fact]
    public void GetValue_ReturnsDefault_WhenKeyMissing()
    {
        var settings = CreateSettings();
        var result = settings.GetValue("nonexistent", "fallback");
        Assert.Equal("fallback", result);
    }

    [Theory]
    [InlineData("key1", "value1")]
    [InlineData("key2", "value2")]
    public void SetValue_StoresAndRetrieves(string key, string value)
    {
        var settings = CreateSettings();
        settings.SetValue(key, value);
        Assert.Equal(value, settings.GetValue(key));
    }
}

Run tests:

dotnet test
dotnet test --filter "FullyQualifiedName~SettingsTests"

Networking

Exponential Backoff Reconnection

private static readonly int[] BackoffMs = { 1000, 2000, 4000, 8000, 15000, 30000, 60000 };
private int _reconnectAttempts;

private async Task ReconnectWithBackoffAsync(CancellationToken ct)
{
    var index = Math.Min(_reconnectAttempts, BackoffMs.Length - 1);
    var delay = BackoffMs[index];

    Debug.WriteLine($"Reconnecting in {delay}ms (attempt {_reconnectAttempts + 1})");

    await Task.Delay(delay, ct);
    await ConnectAsync(ct);
    _reconnectAttempts++;
}

// Reset on successful connect
private void OnConnected()
{
    _reconnectAttempts = 0;
}

Named HTTP Clients

Register once in DI, use everywhere:

// Registration
services.AddHttpClient("AppApi", client =>
{
    client.DefaultRequestHeaders.Add("User-Agent", "MyApp/1.0");
    client.Timeout = TimeSpan.FromSeconds(10);
});

// Usage in services
public class ApiService
{
    private readonly IHttpClientFactory _httpClientFactory;

    public ApiService(IHttpClientFactory httpClientFactory)
    {
        _httpClientFactory = httpClientFactory;
    }

    public async Task<string> GetDataAsync(CancellationToken ct = default)
    {
        using var client = _httpClientFactory.CreateClient("AppApi");
        var response = await client.GetAsync("/api/data", ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync(ct);
    }
}

Deep Links & IPC

URI Scheme Registration

public static void RegisterUriScheme(string scheme, string friendlyName)
{
    var exePath = Environment.ProcessPath ?? "";

    using var key = Registry.CurrentUser.CreateSubKey(
        $@"Software\Classes\{scheme}");
    key.SetValue("", $"URL:{friendlyName}");
    key.SetValue("URL Protocol", "");

    using var iconKey = key.CreateSubKey("DefaultIcon");
    iconKey?.SetValue("", $"\"{exePath}\",1");

    using var commandKey = key.CreateSubKey(@"shell\open\command");
    commandKey?.SetValue("", $"\"{exePath}\" \"%1\"");
}

Named Pipe IPC (Single Instance)

private const string PipeName = "MyApp-IPC";

// Sender (second instance)
private static void SendToRunningInstance(string message)
{
    using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
    client.Connect(1000);
    using var writer = new StreamWriter(client);
    writer.WriteLine(message);
    writer.Flush();
}

// Receiver (primary instance)
private void StartIpcServer()
{
    _ = Task.Run(async () =>
    {
        while (!_disposed)
        {
            try
            {
                using var server = new NamedPipeServerStream(PipeName, PipeDirection.In);
                await server.WaitForConnectionAsync();
                using var reader = new StreamReader(server);
                var message = await reader.ReadLineAsync();
                if (!string.IsNullOrEmpty(message))
                {
                    Application.Current.Dispatcher.InvokeAsync(
                        () => HandleIpcMessage(message));
                }
            }
            catch
            {
                await Task.Delay(1000);
            }
        }
    });
}

Build & Packaging

Build Commands

# Debug build
dotnet build

# Release build
dotnet build -c Release

# Self-contained single-file EXE
dotnet publish -c Release -r win-x64 --self-contained -p:PublishSingleFile=true

# ARM64 build
dotnet publish -c Release -r win-arm64 --self-contained -p:PublishSingleFile=true

MSIX Packaging

Add conditional MSIX support in csproj:

<!-- Unpackaged (traditional EXE) — default -->
<PropertyGroup Condition="'$(PackageMsix)' != 'true'">
  <WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>

<!-- MSIX packaged (store/sideload) -->
<PropertyGroup Condition="'$(PackageMsix)' == 'true'">
  <WindowsPackageType>MSIX</WindowsPackageType>
  <AppxPackageSigningEnabled>false</AppxPackageSigningEnabled>
  <GenerateAppxPackageOnBuild>true</GenerateAppxPackageOnBuild>
</PropertyGroup>

Build MSIX:

dotnet publish -c Release -p:PackageMsix=true

CI/CD with GitHub Actions

name: Build and Test

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

jobs:
  test:
    runs-on: windows-latest
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: 10.0.x }
    - run: dotnet restore
    - run: dotnet build -c Debug
    - run: dotnet test --no-build -c Debug

  build:
    needs: test
    runs-on: windows-latest
    strategy:
      matrix:
        rid: [win-x64, win-arm64]
    steps:
    - uses: actions/checkout@v4
    - uses: actions/setup-dotnet@v4
      with: { dotnet-version: 10.0.x }
    - run: >
        dotnet publish src/MyApp/MyApp.csproj
        -c Release -r ${{ matrix.rid }}
        --self-contained
        -p:PublishSingleFile=true
        -o publish
    - uses: actions/upload-artifact@v4
      with:
        name: myapp-${{ matrix.rid }}
        path: publish/

Theme Detection

public static class ThemeHelper
{
    public static bool IsDarkMode()
    {
        try
        {
            using var key = Registry.CurrentUser.OpenSubKey(
                @"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize");
            var value = key?.GetValue("AppsUseLightTheme");
            return value is int i && i == 0;
        }
        catch { return false; }
    }
}

XAML Resources

App-Level Resource Dictionary

<Application.Resources>
    <ResourceDictionary>
        <ResourceDictionary.MergedDictionaries>
            <ResourceDictionary Source="Themes/Colors.xaml" />
            <ResourceDictionary Source="Themes/Styles.xaml" />
        </ResourceDictionary.MergedDictionaries>

        <BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
    </ResourceDictionary>
</Application.Resources>

Custom Styles

<!-- Themes/Styles.xaml -->
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">

    <Style x:Key="PrimaryButton" TargetType="Button">
        <Setter Property="Background" Value="{StaticResource PrimaryBrush}" />
        <Setter Property="Foreground" Value="White" />
        <Setter Property="Padding" Value="16,8" />
        <Setter Property="BorderThickness" Value="0" />
        <Setter Property="Cursor" Value="Hand" />
        <Style.Triggers>
            <Trigger Property="IsMouseOver" Value="True">
                <Setter Property="Opacity" Value="0.9" />
            </Trigger>
            <Trigger Property="IsEnabled" Value="False">
                <Setter Property="Opacity" Value="0.5" />
            </Trigger>
        </Style.Triggers>
    </Style>
</ResourceDictionary>

What NOT to Do

  • Don't use Dispatcher.Invoke (synchronous) — use Dispatcher.InvokeAsync
  • Don't call .Result or .Wait() on Tasks — deadlocks the UI thread
  • Don't use App.ServiceProvider directly — inject through constructors
  • Don't fire-and-forget with _ = Task.Run(...) — use AsyncHelper.FireAndForget
  • Don't leave empty catch { } blocks — use SafeExec.Try() or log the error
  • Don't use System.Timers.Timer for UI work — use DispatcherTimer
  • Don't create GDI objects without disposing them — always call DestroyIcon
  • Don't add NuGet packages without confirming with the user
  • Don't modify XAML styles without visual verification
  • Don't refactor more than what was asked — scope creep is the enemy
Weekly Installs
16
Repository
jcurbelo/skills
GitHub Stars
2
First Seen
Feb 27, 2026
Installed on
kimi-cli16
gemini-cli16
codex16
amp16
cline16
github-copilot16