A practical guide for migrating legacy WPF applications to modern .NET
This document chronicles the complete migration of BabySmash!, an 18-year-old WPF application, from .NET Framework 3.5 to .NET 10. It serves as both a historical record and an educational resource for developers facing similar migrations.
- Executive Summary
- Before You Start
- Phase 1: Project File Migration
- Phase 2: XAML Compatibility
- Phase 3: C# Code Modernization
- Phase 4: Resource Handling
- Phase 5: Build & Test
- Phase 6: Auto-Updates with Updatum
- Phase 7: Code Signing
- Phase 8: CI/CD with GitHub Actions
- Lessons Learned
| Metric | Before | After |
|---|---|---|
| Framework | .NET Framework 3.5 | .NET 10 |
| Project Format | Legacy csproj (500+ lines) | SDK-style (50 lines) |
| Deployment | ClickOnce | Inno Setup + Updatum auto-update |
| Code Signing | Self-signed PFX | Azure Trusted Signing |
| CI/CD | Manual builds | GitHub Actions |
| Executable Size | ~2MB + .NET Framework | ~68MB self-contained |
| .NET Required | Yes (.NET 3.5) | No (self-contained) |
Time to complete: ~2 days of focused work
Before migrating, understand what you're working with:
# Check for ClickOnce dependencies (must be replaced)
Select-String -Path "*.cs" -Pattern "System.Deployment"
# Check for deprecated WPF features
Select-String -Path "*.xaml" -Pattern "BitmapEffect|Luna"
# Check for Newtonsoft.Json (consider System.Text.Json)
Select-String -Path "*.cs" -Pattern "Newtonsoft"| Component | Status | Action Needed |
|---|---|---|
| WPF UI | ✅ Works | Minor XAML fixes |
| P/Invoke (user32, winmm) | ✅ Works | No changes |
| System.Speech | ✅ Works | No changes |
| ClickOnce | ❌ Removed in .NET Core | Replace with Updatum |
| BitmapEffect | Keep or replace with Effect | |
| Newtonsoft.Json | Replaced with System.Text.Json |
Legacy .csproj files are verbose XML nightmares that mix project configuration with build logic. SDK-style projects are clean and convention-based.
Delete your old .csproj and create a new one:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<ApplicationIcon>App.ico</ApplicationIcon>
<!-- Single-file deployment -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Speech" Version="9.0.0" />
<PackageReference Include="Updatum" Version="1.2.1" />
</ItemGroup>
</Project>- Single-file: Users get one .exe, no scattered DLLs
- Self-contained: No .NET runtime required on user's machine
- x64 only: ARM64 Windows has excellent x64 emulation - one binary is enough for a simple app like this
Delete it. Package references go directly in .csproj now:
<!-- Old way (packages.config) -->
<package id="Newtonsoft.Json" version="9.0.1" targetFramework="net35" />
<!-- New way (in .csproj) -->
<PackageReference Include="System.Text.Json" Version="9.0.0" />.NET Framework had BitmapEffect (software-rendered) and Effect (GPU-accelerated). BitmapEffect was deprecated but still compiles and runs in .NET 10 WPF.
<!-- This still works in .NET 10! (but uses CPU) -->
<TextBlock.BitmapEffect>
<OuterGlowBitmapEffect GlowColor="Yellow" GlowSize="3"/>
</TextBlock.BitmapEffect>
<!-- Modern replacement (uses GPU) -->
<TextBlock.Effect>
<DropShadowEffect Color="Yellow" BlurRadius="6" ShadowDepth="0"/>
</TextBlock.Effect>Our decision: Keep BitmapEffect for visual continuity, but use modern Effect class for new features. Added GPU tier detection to auto-enable effects only on capable hardware.
Windows XP themes don't exist anymore:
<!-- DELETE THIS LINE from your XAML -->
xmlns:theme="clr-namespace:Microsoft.Windows.Themes;assembly=PresentationFramework.Luna"ClickOnce (System.Deployment.Application) doesn't exist in .NET Core/.NET 5+. Search and destroy:
// DELETE all of this:
using System.Deployment.Application;
if (ApplicationDeployment.IsNetworkDeployed)
{
deployment = ApplicationDeployment.CurrentDeployment;
deployment.CheckForUpdateAsync();
}Replacement: Updatum - covered in Phase 6.
// Old (Newtonsoft.Json)
var obj = JsonConvert.DeserializeObject<MyType>(json);
// New (System.Text.Json) - built into .NET
var obj = JsonSerializer.Deserialize<MyType>(json);System.Text.Json is faster and has no external dependency.
When you add <UseWPF>true</UseWPF>, you get both WPF and WinForms types. Add explicit aliases:
using Application = System.Windows.Application;
using MessageBox = System.Windows.MessageBox;
using Point = System.Windows.Point;
using WinForms = System.Windows.Forms;| Type | Use Case | Configuration |
|---|---|---|
| Embedded Resource | WAV sounds, icons | <EmbeddedResource Include="..." /> |
| Content | JSON config, text files | <Content Include="..." CopyToOutputDirectory="PreserveNewest" /> |
With PublishSingleFile, embedded resources work fine, but Content files need special handling. We auto-extract Words.txt on first run:
private string GetWordsFilePath()
{
// Check next to executable first
string exeDir = AppContext.BaseDirectory;
string localPath = Path.Combine(exeDir, _wordsFileName);
if (File.Exists(localPath)) return localPath;
// For single-file publish, extract from embedded resource
string appDataPath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"BabySmash", _wordsFileName);
if (!File.Exists(appDataPath))
{
Directory.CreateDirectory(Path.GetDirectoryName(appDataPath)!);
using var stream = Assembly.GetExecutingAssembly()
.GetManifestResourceStream("BabySmash.Words.txt");
using var file = File.Create(appDataPath);
stream!.CopyTo(file);
}
return appDataPath;
}# Debug build
dotnet build
# Release build
dotnet build -c Release
# Publish single-file executable
dotnet publish -c Release -r win-x64 --self-contained
# Output location
# bin/Release/net10.0-windows/win-x64/publish/BabySmash.exe- App launches without crash
- Keyboard input shows shapes
- Audio plays (WAV sounds)
- Speech synthesis works
- Options dialog opens (Ctrl+Shift+Alt+O)
- Multi-monitor support works
- Settings persist between runs
Updatum is a lightweight library that uses GitHub Releases for auto-updates. No server infrastructure needed.
// App.xaml.cs
internal static readonly UpdatumManager AppUpdater = new("owner", "repo")
{
FetchOnlyLatestRelease = true,
InstallUpdateSingleFileExecutableName = "BabySmash",
};
private async void Application_Startup(object sender, StartupEventArgs e)
{
var shouldLaunch = await CheckForUpdatesBeforeLaunchAsync();
if (shouldLaunch)
{
Controller.Instance.Launch();
}
}
private async Task<bool> CheckForUpdatesBeforeLaunchAsync()
{
try
{
var updateFound = await AppUpdater.CheckForUpdatesAsync();
if (!updateFound) return true;
var dialog = new UpdateDialog(
AppUpdater.LatestRelease!.TagName,
AppUpdater.GetChangelog(true));
dialog.ShowDialog();
if (dialog.Result == UpdateDialogResult.Download)
{
await DownloadAndInstallUpdateAsync();
return false; // App will restart
}
return true;
}
catch
{
return true; // Don't block app on update failure
}
}Updatum's default AssetRegexPattern looks for the platform identifier (e.g., win-x64) in the asset name:
✅ BabySmash-win-x64.zip <- Updatum finds this
❌ BabySmash-Portable.zip <- Updatum can't find this!
Include at least 2 files in the ZIP for Updatum to handle it correctly:
BabySmash-win-x64.zip
├── BabySmash.exe
└── README.md
Without code signing, Windows SmartScreen shows scary warnings that make users think your app is malware.
Microsoft's cloud-based code signing service (~$10/month):
- Create Azure Trusted Signing resource
- Create Certificate Profile (Public Trust)
- Create Service Principal with signing permissions
- Store credentials as GitHub Secrets
- uses: azure/trusted-signing-action@v0
with:
azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }}
azure-client-id: ${{ secrets.AZURE_CLIENT_ID }}
azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }}
endpoint: https://wus2.codesigning.azure.net/
trusted-signing-account-name: your-account
certificate-profile-name: your-profile
files-folder: ${{ github.workspace }}/publish
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256Our workflow:
- Builds on every push
- Creates releases only on tags (
v*) - Uses GitVersion for semantic versioning
- Signs both EXE and installer
- Creates Inno Setup installer with Start Menu shortcuts
For a proper Windows experience, we added an installer:
[Setup]
AppName=BabySmash!
AppVersion={#MyAppVersion}
DefaultDirName={localappdata}\BabySmash
PrivilegesRequired=lowest
[Files]
Source: "publish\BabySmash.exe"; DestDir: "{app}"
[Icons]
Name: "{userprograms}\BabySmash!"; Filename: "{app}\BabySmash.exe"
Name: "{userstartup}\BabySmash!"; Filename: "{app}\BabySmash.exe"; Tasks: startupicongit tag v3.9.9
git push origin v3.9.9
# GitHub Actions handles the rest!Don't panic about deprecated APIs. Test them first - they might still work fine.
The default regex pattern looks for win-x64 in asset names. Name your ZIPs accordingly or set a custom pattern.
For a baby-smashing app, show the update dialog before the baby starts smashing keys!
WPF's RenderCapability.Tier tells you if effects are hardware-accelerated:
- Tier 0: Software rendering (disable effects)
- Tier 1: Partial hardware
- Tier 2: Full GPU acceleration (enable effects)
Yes, the EXE is 68MB. But users don't need to install .NET, and you don't get "which .NET version?" support tickets.
- Installer handles first-time setup (Start Menu, shortcuts)
- Updatum handles all future updates (in-place, no reinstall)
- Updatum - GitHub-based auto-update library
- Azure Trusted Signing - Code signing service
- WindowsEdgeLight - Reference implementation with same patterns
- Inno Setup - Windows installer creator
All phases complete! ✅
| Success Criteria | Status |
|---|---|
| Application compiles | ✅ |
| Single executable | ✅ 68MB self-contained |
| All shapes display | ✅ |
| Audio plays | ✅ |
| Speech synthesis | ✅ |
| Options dialog | ✅ |
| Multi-monitor | ✅ |
| Auto-updates | ✅ Updatum working |
| CI/CD | ✅ GitHub Actions |
| Code signed | ✅ No SmartScreen warnings |
Current release: v3.9.9
This migration was completed in January 2026. The app was originally written in 2008 for .NET Framework 3.5.