DEV Community

Neha Chauhan
Neha Chauhan

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

and my application was built as:

x86_64 + arm64
Enter fullscreen mode Exit fullscreen mode

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     ❌
Enter fullscreen mode Exit fullscreen mode

I therefore needed to rebuild the dependencies.

The target became:

Library
├── x86_64
└── arm64
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now the resulting library contains both architectures.

You can verify it using:

lipo -info libMyLibrary.dylib
Enter fullscreen mode Exit fullscreen mode

A successful result should indicate both:

Architectures in the fat file:
x86_64
arm64
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

or:

lipo -info MyLibrary.dylib
Enter fullscreen mode Exit fullscreen mode

For frameworks:

file MyFramework.framework/MyFramework
Enter fullscreen mode Exit fullscreen mode

This immediately tells you whether the binary contains:

x86_64
arm64
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

versus:

Universal application
        │
        ├── x86_64 → Intel Mac
        │
        └── arm64  → Apple Silicon
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Then inspect important binaries:

file MyApplication.app/Contents/MacOS/MyApplication
Enter fullscreen mode Exit fullscreen mode

and:

lipo -info MyApplication.app/Contents/MacOS/MyApplication
Enter fullscreen mode Exit fullscreen mode

I also recommend checking embedded frameworks and dynamic libraries individually.

For example:

find MyApplication.app/Contents/Frameworks -type f
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

and combine them only when required.

3. Verify every important binary

Use:

file
Enter fullscreen mode Exit fullscreen mode

and:

lipo -info
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Test the actual:

Archive → Export → Install → Run
Enter fullscreen mode Exit fullscreen mode

application.


Final Thoughts

Moving from an Intel Mac development environment to Apple Silicon is not simply a matter of changing:

x86_64
Enter fullscreen mode Exit fullscreen mode

to:

arm64
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Those two commands can tell you a surprising amount about what's really inside your application.

Top comments (0)