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.

The Strong Arm Of The Law Banner

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 for arm64.
  • Produce two launchers: e.g., MarkdownMonster.exe (x64) and MarkdownMonsterArm64.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 &quot;$(TargetDir)MarkdownMonster.exe&quot; &quot;$(ProjectDir)MarkdownMonsterArm64.exe&quot;" />
</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

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