When I started developing my macOS application, my development machine was an Intel-based Mac. At that time, most of my third-party libraries were built specifically for the x86_64 architecture.
Everything worked perfectly.
Then came the transition to Apple Silicon (ARM64).
My requirement was simple:
Build one macOS application that can run natively on both Intel and Apple Silicon Macs.
In practice, however, this turned into a much bigger build and dependency-management problem.
This is the story of how I approached that migration and eventually rebuilt my third-party dependencies as Universal 2 libraries.
The Original Setup
My original development environment looked roughly like this:
Intel Mac
│
├── Qt libraries
├── Third-party libraries
│ ├── Library A → x86_64
│ ├── Library B → x86_64
│ ├── Library C → x86_64
│ └── Library D → x86_64
│
└── Application → x86_64
Because everything was built for Intel, there was no architecture mismatch.
The application compiled, linked and ran normally.
The problem appeared when I wanted the same application to run natively on Apple Silicon.
The Goal: One Application for Both Architectures
The desired architecture was:
Universal Application
│
┌────────────┴────────────┐
│ │
x86_64 arm64
│ │
Intel Mac Apple Silicon Mac
The application itself needed to contain both architectures.
But there was an important dependency:
Every native library used by the application also needed to support the architectures required by the final application.
Simply making the application universal wasn't enough.
Why My Existing Libraries Were Not Enough
Suppose a third-party library contained only:
x86_64
and my application was built as:
x86_64 + arm64
The linker could use that library when building the Intel portion of the application.
But it couldn't use the same binary to produce the ARM64 portion.
Conceptually:
Application
├── x86_64 ──────> Library x86_64 ✅
└── arm64 ──────> Library x86_64 ❌
I therefore needed to rebuild the dependencies.
The target became:
Library
├── x86_64
└── arm64
This is what is commonly referred to as a Universal 2 binary on macOS.
Building the Libraries for Both Architectures
The approach I eventually adopted was to build the third-party libraries separately for each architecture.
For example:
Library A
│
├── Build x86_64
│
└── Build arm64
Then combine the resulting binaries into a universal binary.
For libraries that produce Mach-O binaries, Apple's lipo tool is useful for this.
For example:
lipo -create \
build-x86_64/libMyLibrary.dylib \
build-arm64/libMyLibrary.dylib \
-output libMyLibrary.dylib
Now the resulting library contains both architectures.
You can verify it using:
lipo -info libMyLibrary.dylib
A successful result should indicate both:
Architectures in the fat file:
x86_64
arm64
The Important Part: Don't Just Build the Final Library
One mistake that is easy to make during this migration is thinking only about the final .dylib or .framework.
A dependency can itself depend on other libraries.
For example:
MyApplication
│
▼
Library A
│
├── Library B
│ │
│ └── Library C
│
└── Library D
If Library A is universal but Library B is only x86_64, the application can still fail to build or run for ARM64.
Therefore, I had to look at the entire dependency chain.
The real target became:
Application
│
├── Qt
│
├── Library A
│ ├── Library B
│ └── Library C
│
└── Library D
Every native component in that chain needed to support the required architectures.
The Qt Complication
Since my application was based on Qt, the situation became more interesting.
It wasn't enough to make only my own third-party libraries universal.
Qt itself had to be built correctly for the target architectures.
The Qt configuration and build process therefore became an important part of the migration.
I also had to deal with components such as:
- Qt WebEngine
- Qt WebView
- Qt PDF
- WebRTC
- proprietary codecs
- Chromium dependencies
- macOS frameworks
This significantly increased the complexity compared with a simple C++ application.
Debugging Architecture Problems
One of the most useful things I learned during this process was:
Always check the architecture of the actual binary you are linking.
For example:
file MyLibrary.dylib
or:
lipo -info MyLibrary.dylib
For frameworks:
file MyFramework.framework/MyFramework
This immediately tells you whether the binary contains:
x86_64
arm64
or only one of them.
This simple check can save a lot of debugging time.
What About Rosetta?
During the migration, I also came across Rosetta 2.
Rosetta allows Intel (x86_64) applications to run on Apple Silicon Macs by translating Intel instructions for the ARM processor.
This is extremely useful for running older applications.
However, Rosetta doesn't magically convert an Intel development environment into a native ARM development environment.
There is an important distinction:
Intel application
│
▼
Rosetta
│
▼
Apple Silicon
versus:
Universal application
│
├── x86_64 → Intel Mac
│
└── arm64 → Apple Silicon
For a professional application, I wanted the second approach.
Archive and Release Builds Are Another Story
One of the biggest lessons from this migration was:
A successful Debug build does not necessarily mean that the final distributable application is correct.
Development builds, Archive builds and packaged applications can involve different paths, signing, frameworks and dependency handling.
This became particularly important when I started using Xcode's:
- Archive
- Analyze
- Release configuration
Some problems appeared only after the application was archived.
This forced me to inspect the final application bundle rather than trusting the build result alone.
Always Inspect the Final .app
For example:
find MyApplication.app -type f
Then inspect important binaries:
file MyApplication.app/Contents/MacOS/MyApplication
and:
lipo -info MyApplication.app/Contents/MacOS/MyApplication
I also recommend checking embedded frameworks and dynamic libraries individually.
For example:
find MyApplication.app/Contents/Frameworks -type f
Then inspect suspicious binaries.
The goal is to ensure that the final application actually contains what you think it contains.
Universal Does Not Mean Everything Is Automatically Fixed
Another important lesson:
Universal binaries solve architecture compatibility, not every macOS compatibility problem.
You can have:
arm64
x86_64
in every library and still have problems involving:
- incorrect install names
@rpath- missing frameworks
- code signing
- hardened runtime
- embedded dependencies
- incompatible deployment targets
- incorrect framework packaging
- plugins
- architecture-specific resources
Therefore, architecture verification should be treated as one stage of the release process, not the entire solution.
My Final Build Strategy
The approach that worked best for me was essentially:
Source Code
│
┌──────────┴──────────┐
│ │
x86_64 arm64
│ │
Build dependencies Build dependencies
│ │
└──────────┬──────────┘
│
Combine
│
Universal
│
▼
Qt Application
│
▼
Final .app
│
┌───────┴────────┐
│ │
Intel Mac Apple Silicon
The important thing is to make the entire dependency chain consistent.
Lessons I Learned
1. Don't wait until the end to test ARM64
Build and test ARM64 early.
The earlier you discover an architecture-specific dependency, the easier it is to fix.
2. Keep separate build directories
I found it much cleaner to maintain separate builds:
build-x86_64/
build-arm64/
and combine them only when required.
3. Verify every important binary
Use:
file
and:
lipo -info
Don't assume a library is universal just because the application is universal.
4. Check transitive dependencies
Your library can be universal while one of its dependencies isn't.
Always inspect the dependency tree.
5. Test the packaged application
Don't stop at:
Build succeeded
Test the actual:
Archive → Export → Install → Run
application.
Final Thoughts
Moving from an Intel Mac development environment to Apple Silicon is not simply a matter of changing:
x86_64
to:
arm64
For applications with many native dependencies—especially Qt-based desktop applications—the migration can involve rebuilding a significant portion of the dependency stack.
In my case, rebuilding the third-party libraries as Universal 2 libraries was an important step toward producing a single application capable of running on both Intel and Apple Silicon Macs.
The biggest lesson was simple:
Don't think of Universal 2 as an application setting. Think of it as a dependency-chain strategy.
Once you understand that, debugging architecture problems becomes much more systematic.
And when something fails, the first questions I now ask are:
file <binary>
lipo -info <binary>
Those two commands can tell you a surprising amount about what's really inside your application.
Top comments (0)