The Strong ARM of .NET: Building Desktop Apps for x64 and Arm64 Deployment
In this detailed post, Rick Strahl outlines the key challenges and solutions for deploying .NET Windows Desktop apps that run natively on both x64 and Arm64 platforms, with specific insights from developments on Markdown Monster.
The Strong ARM of .NET: Building Desktop Apps for x64 and Arm64 Deployment
By Rick Strahl
Building cross-platform and cross-architecture desktop applications with .NET allows developers to target both x64 and Arm64 systems. However, distributing an application that “just works” out-of-the-box on both platforms involves more than simply compiling for AnyCPU
. In this article, Rick Strahl shares real-world lessons learned from enabling the Markdown Monster application to run natively on both x64 and Arm64 Windows devices.
Introduction
Developers expect .NET’s cross-platform promise to minimize friction when running their desktop apps on both x64 and ARM devices. While .NET IL code is theoretically universal (with AnyCPU
), packaging, launching, and providing native performance for end users proves more involved than anticipated. Rick details both the promises and pitfalls encountered during this process.
The Good News: Seamless Cross-Compilation
- Compiling a .NET application for
AnyCPU
generally produces IL code that can run on any platform with the appropriate runtime. - JIT (Just-In-Time) compilation ensures that the app’s managed code adjusts to the underlying architecture (x64, Arm64, others) without the need for separate codebases.
- Running under the shared runtime (via
dotnet <dllName>
) works across x64 and Arm64 (and even Linux/Mac) provided the application doesn’t use platform-specific dependencies. - The
dotnet tool
ecosystem further simplifies distribution for CLI and developer tools.
The Bad News: Platform-Specific Launchers
Launcher Challenges
- Desktop applications commonly require platform-specific launchers (native
.exe
files) for integration with the OS, security (signing), and user workflow. - The default EXE generated by .NET during build is a native binary. Its architecture matches the build machine unless explicitly overriden.
- x64 EXEs run on Arm64 in emulation mode (resulting in performance loss), while Arm64 EXEs fail outright on x64 systems.
Example
- x64 launcher on Arm64: Runs, but only through slower emulation.
- Arm64 launcher on x64: Fails to run, resulting in error dialogs.
- Using
dotnet <yourApp.dll>
to launch works natively, but is not typical/ideal for end-user desktop apps.
Solution: Two Native Launchers with Smart Swapping
Building For Multiple Architectures
- Build the application twice: once targeting
AnyCPU
(for x64) and once forarm64
. - Produce two launchers: e.g.,
MarkdownMonster.exe
(x64) andMarkdownMonsterArm64.exe
(Arm64).
Project File Approach
Leverage MSBuild to produce both EXEs:
<PropertyGroup>
<PlatformTarget>AnyCPU</PlatformTarget>
...
</PropertyGroup>
<PropertyGroup Condition="'$(PlatformTarget)' == 'arm64'">
...
</PropertyGroup>
<Target Name="PostBuildArm" ...>
<Exec Command="copy "$(TargetDir)MarkdownMonster.exe" "$(ProjectDir)MarkdownMonsterArm64.exe"" />
</Target>
Build Process
- Build for
arm64
, rename/copy the launcher. - Build again for
AnyCPU
to ensure the main EXE will work on all platforms if swapping fails. - Optionally, only rebuild launchers for minor/major releases, since the launcher is only tied to the assembly name, not version.
Installer Integration
To ensure users always run natively, Rick uses an installer helper to swap in the Arm64 EXE on Arm systems during installation:
- An installer helper, written as a .NET Framework console app, determines the system architecture with native calls (e.g.,
IsWow64Process2
) and swaps the executables accordingly. - The process involves renaming the main EXE, copying the correct version for the detected architecture, and updating shortcuts.
- Example Inno Setup script:
[Run]
Filename: "{app}\mm.exe"; Parameters: "-runtimeinstall -silent"; Description: "Checking for and installing the .NET Desktop Runtime..."
Installer Helper Code (Snippet)
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool IsWow64Process2(IntPtr hProcess, out ushort processMachine, out ushort nativeMachine);
... // Logic to swap .exe's based on detected architecture
Performance Considerations
- x64 executables on Arm64 systems are noticeably slower due to emulation (sluggish UI, longer startup).
- Native Arm64 launchers provide a much snappier experience and faster startup, justifying the additional effort.
Lessons and Recommendations
- Developers should be aware that
AnyCPU
does not guarantee native execution on Arm64 when distributing desktop apps. - Always provide native launchers for each target architecture (automated via build scripts and installers).
- The dotnet team’s approach of having the
dotnet
launcher select the right runtime cannot be replicated in a stand-alone EXE, so per-architecture launchers are necessary. - Make the process transparent for users by automating EXE selection and swapping at install time.
Resources
- .NET Desktop Runtime Installer (GitHub)
- Related articles on OWIN authentication, ARM with SQL Server, and ASP.NET utilities are recommended for further reading.
Conclusion
While the process of supporting both x64 and Arm64 in .NET is more complicated than expected, the steps outlined help ensure your desktop apps take full advantage of each platform’s capabilities. End users will benefit from optimized performance, especially Arm64 users. Proactive architecture management during build and installation is key to a seamless cross-architecture experience.
Author: Rick Strahl
Is this content useful to you? Consider making a small donation to support future articles.
This post appeared first on “Rick Strahl’s Blog”. Read the entire article here