From Intel Mac to Apple Silicon: How I Built Universal Third-Party Libraries for a Qt macOS Application
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 lipo -info Those two commands can tell you a surprising amount about what's really inside your application. Top comments (0)
Comments
No comments yet. Start the discussion.