Table of Contents

  1. AppleClang vs Upstream Clang
  2. Homebrew Path Configuration
  3. CMAKE_OSX_DEPLOYMENT_TARGET
  4. @rpath / @loader_path / @executable_path
  5. Universal Binaries
  6. Framework Search Paths
  7. macOS SDK Selection
  8. Notarization Preparation
  9. System Integrity Protection
Back to CMake Mastery Series

macOS with Clang

June 4, 2026 Wasil Zafar 10 min read

Platform-specific guidance for building C++ on macOS — AppleClang quirks, Homebrew integration, universal binaries, deployment targets, frameworks, and preparing builds for notarization.

AppleClang vs Upstream Clang

macOS ships with AppleClang — Apple's fork of LLVM/Clang that uses its own versioning scheme and has platform-specific differences. CMake reports it as CMAKE_CXX_COMPILER_ID = "AppleClang", distinct from upstream "Clang". This distinction matters for feature detection and flag compatibility.

cmake_minimum_required(VERSION 3.21)
project(MacOSDetection LANGUAGES CXX)

# AppleClang has a separate compiler ID
if(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang")
    message(STATUS "AppleClang ${CMAKE_CXX_COMPILER_VERSION}")
    # AppleClang 15.x ~ LLVM 16, AppleClang 16.x ~ LLVM 17
    # Version mapping is not 1:1 with upstream!
elseif(CMAKE_CXX_COMPILER_ID STREQUAL "Clang")
    message(STATUS "Upstream Clang ${CMAKE_CXX_COMPILER_VERSION}")
endif()

add_library(mylib src/core.cpp)

# Use generator expression to handle both
target_compile_options(mylib PRIVATE
    $<$<CXX_COMPILER_ID:AppleClang,Clang>:
        -Wall -Wextra -Wpedantic
        -Wno-unknown-warning-option
    >
)

# Feature that AppleClang may lack vs upstream
# (e.g., some sanitizer features, modules support)
if(CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang" AND
   CMAKE_CXX_COMPILER_VERSION VERSION_LESS "15.0")
    message(WARNING "AppleClang < 15 has limited C++20 modules support")
endif()
macOS Gotcha: Don't use if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang") to catch AppleClang — they are distinct IDs! Use $<CXX_COMPILER_ID:AppleClang,Clang> generator expressions or explicit if() checks for both when you want the same flags for both compilers.

Homebrew Path Configuration

Homebrew installs libraries outside the default system paths, especially on Apple Silicon where the prefix is /opt/homebrew instead of /usr/local. CMake needs help finding these packages.

cmake_minimum_required(VERSION 3.21)
project(HomebrewDemo LANGUAGES CXX)

# Detect Homebrew prefix (works on both Intel and Apple Silicon)
execute_process(
    COMMAND brew --prefix
    OUTPUT_VARIABLE HOMEBREW_PREFIX
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
)

if(HOMEBREW_PREFIX)
    message(STATUS "Homebrew prefix: ${HOMEBREW_PREFIX}")
    # /opt/homebrew on Apple Silicon, /usr/local on Intel

    # Add Homebrew paths for find_package and find_library
    list(APPEND CMAKE_PREFIX_PATH "${HOMEBREW_PREFIX}")

    # For specific packages (e.g., OpenSSL which is keg-only)
    set(OPENSSL_ROOT_DIR "${HOMEBREW_PREFIX}/opt/openssl@3")

    # pkg-config path for Homebrew packages
    set(ENV{PKG_CONFIG_PATH}
        "${HOMEBREW_PREFIX}/lib/pkgconfig:$ENV{PKG_CONFIG_PATH}")
endif()

find_package(OpenSSL REQUIRED)
find_package(ZLIB REQUIRED)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE OpenSSL::SSL ZLIB::ZLIB)
# Common pattern: pass Homebrew paths at configure time
cmake -G Ninja \
    -DCMAKE_PREFIX_PATH="$(brew --prefix)" \
    -DOPENSSL_ROOT_DIR="$(brew --prefix openssl@3)" \
    -S . -B build

CMAKE_OSX_DEPLOYMENT_TARGET

The deployment target defines the minimum macOS version your binary supports. It affects which system APIs are available and determines backward compatibility. Set this before any project() call or targets for it to take full effect.

# Set BEFORE project() for maximum effect
set(CMAKE_OSX_DEPLOYMENT_TARGET "13.0" CACHE STRING "Minimum macOS version")

cmake_minimum_required(VERSION 3.21)
project(DeploymentDemo LANGUAGES CXX)

# Verify the setting propagated
message(STATUS "Deployment target: ${CMAKE_OSX_DEPLOYMENT_TARGET}")

add_executable(app src/main.cpp)

# Conditionally use newer APIs with availability checks
target_compile_definitions(app PRIVATE
    MACOS_MIN_VERSION=130000  # 13.0.0 encoded
)
# Override at configure time
cmake -G Ninja \
    -DCMAKE_OSX_DEPLOYMENT_TARGET=12.0 \
    -DCMAKE_BUILD_TYPE=Release \
    -S . -B build

@rpath / @loader_path / @executable_path

macOS uses install names and @rpath tokens (instead of Linux's ELF RPATH) to locate dynamic libraries at runtime. Understanding the three special tokens is essential for creating relocatable applications.

cmake_minimum_required(VERSION 3.21)
project(RpathMacOS LANGUAGES CXX)

add_library(mylib SHARED src/lib.cpp)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)

# --- Install name configuration ---
# @rpath/libmylib.dylib — library says "find me via rpath"
set_target_properties(mylib PROPERTIES
    INSTALL_NAME_DIR "@rpath"
    BUILD_WITH_INSTALL_RPATH TRUE
)

# --- Application embeds rpath search paths ---
set_target_properties(app PROPERTIES
    INSTALL_RPATH "@executable_path/../lib"
    BUILD_WITH_INSTALL_RPATH TRUE
)

# For bundle/framework layouts:
# @executable_path/../Frameworks  — for .app bundles
# @loader_path/../Frameworks     — relative to the binary loading it

install(TARGETS mylib LIBRARY DESTINATION lib)
install(TARGETS app RUNTIME DESTINATION bin)
Token meanings: @executable_path = directory of the main executable. @loader_path = directory of the binary doing the loading (useful for plugins). @rpath = searched from the rpath list embedded in the loading binary. Use @rpath for libraries and set rpath in executables for maximum flexibility.

Universal Binaries

Universal binaries (fat binaries) contain code for multiple architectures — typically arm64 (Apple Silicon) and x86_64 (Intel). CMake 3.19+ supports this natively via CMAKE_OSX_ARCHITECTURES.

cmake_minimum_required(VERSION 3.21)
project(UniversalDemo LANGUAGES CXX)

# Build for both architectures
set(CMAKE_OSX_ARCHITECTURES "arm64;x86_64" CACHE STRING "Target architectures")

add_library(mylib SHARED src/core.cpp)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)

# Architecture-conditional code
target_compile_definitions(app PRIVATE
    $<$<STREQUAL:${CMAKE_OSX_ARCHITECTURES},arm64>:TARGET_ARM64>
)
# Build universal binary
cmake -G Ninja \
    -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \
    -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \
    -DCMAKE_BUILD_TYPE=Release \
    -S . -B build-universal

cmake --build build-universal

# Verify the result
lipo -info build-universal/app
# Output: Architectures in the fat file: app are: x86_64 arm64

# Extract single architecture (for debugging)
lipo build-universal/app -thin arm64 -output app-arm64
macOS Gotcha — Universal + Homebrew: Homebrew on Apple Silicon installs arm64-only libraries in /opt/homebrew. If you're building a universal binary and link against Homebrew libraries, the link will fail for the x86_64 slice. Solutions: use vcpkg with universal triplets, build dependencies from source for both architectures, or use lipo to merge separately-built binaries.

macOS Frameworks are bundles containing headers, libraries, and resources. CMake can find and link against both system frameworks and third-party frameworks distributed outside the SDK.

cmake_minimum_required(VERSION 3.21)
project(FrameworkDemo LANGUAGES CXX)

# System frameworks are found automatically
find_library(COCOA_FRAMEWORK Cocoa)
find_library(IOKIT_FRAMEWORK IOKit)
find_library(METAL_FRAMEWORK Metal)
find_library(QUARTZCORE_FRAMEWORK QuartzCore)

add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE
    ${COCOA_FRAMEWORK}
    ${IOKIT_FRAMEWORK}
    ${METAL_FRAMEWORK}
    ${QUARTZCORE_FRAMEWORK}
)

# Alternative: link framework by name (CMake resolves -framework)
target_link_libraries(app PRIVATE
    "-framework CoreFoundation"
    "-framework Security"
)

# Custom framework search paths
set(CMAKE_FRAMEWORK_PATH
    "/Library/Frameworks"
    "$ENV{HOME}/Library/Frameworks"
    "${CMAKE_SOURCE_DIR}/frameworks"
)

# Create your own framework
add_library(myframework SHARED src/framework.cpp)
set_target_properties(myframework PROPERTIES
    FRAMEWORK TRUE
    FRAMEWORK_VERSION A
    MACOSX_FRAMEWORK_IDENTIFIER "com.example.myframework"
    MACOSX_FRAMEWORK_BUNDLE_VERSION "${PROJECT_VERSION}"
    MACOSX_FRAMEWORK_SHORT_VERSION_STRING "${PROJECT_VERSION}"
    PUBLIC_HEADER "include/myframework/api.h;include/myframework/types.h"
)

macOS SDK Selection

The macOS SDK determines which APIs are available at compile time. You typically want the latest SDK combined with a lower deployment target to get new API availability attributes while supporting older macOS versions.

# List available SDKs
xcodebuild -showsdks

# Find SDK path
xcrun --show-sdk-path
# /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.5.sdk

# Configure with specific SDK
cmake -G Ninja \
    -DCMAKE_OSX_SYSROOT=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX14.5.sdk \
    -DCMAKE_OSX_DEPLOYMENT_TARGET=13.0 \
    -S . -B build
cmake_minimum_required(VERSION 3.21)
project(SdkDemo LANGUAGES CXX)

# CMake detects SDK automatically, but you can override
# set(CMAKE_OSX_SYSROOT "/path/to/SDK")

# Report SDK in use
message(STATUS "macOS SDK: ${CMAKE_OSX_SYSROOT}")

# Use availability attributes for newer APIs
add_executable(app src/main.cpp)
target_compile_options(app PRIVATE
    -Wunguarded-availability  # Warn on using newer APIs without checks
)

Notarization Preparation

Apple requires notarization for distributed macOS software. This involves code signing with a hardened runtime. CMake can configure the necessary settings for a notarization-ready build.

cmake_minimum_required(VERSION 3.21)
project(NotarizationDemo LANGUAGES CXX)

add_executable(app src/main.cpp)

# Enable hardened runtime (required for notarization)
set_target_properties(app PROPERTIES
    XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME YES
    XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Developer ID Application"
    XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "YOUR_TEAM_ID"
)

# For Ninja/Makefiles builds, add entitlements manually
if(NOT CMAKE_GENERATOR MATCHES "Xcode")
    # Sign after build with hardened runtime
    add_custom_command(TARGET app POST_BUILD
        COMMAND codesign --force --sign "Developer ID Application: Your Name (TEAM_ID)"
            --options runtime
            --entitlements "${CMAKE_SOURCE_DIR}/resources/app.entitlements"
            "$<TARGET_FILE:app>"
        COMMENT "Code signing with hardened runtime"
    )
endif()
# Complete notarization workflow
# 1. Build
cmake --build build --config Release

# 2. Create ZIP for notarization
ditto -c -k --keepParent build/app.app app.zip

# 3. Submit for notarization
xcrun notarytool submit app.zip \
    --apple-id "dev@example.com" \
    --team-id "TEAM_ID" \
    --password "@keychain:AC_PASSWORD" \
    --wait

# 4. Staple the ticket
xcrun stapler staple build/app.app

System Integrity Protection

System Integrity Protection (SIP) restricts access to system directories and strips DYLD_LIBRARY_PATH from child processes. This affects development workflows and requires careful RPATH configuration.

cmake_minimum_required(VERSION 3.21)
project(SipDemo LANGUAGES CXX)

add_library(mylib SHARED src/lib.cpp)
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mylib)

# SIP strips DYLD_* variables — don't rely on them!
# Instead, use proper @rpath configuration:
set_target_properties(app PROPERTIES
    # Build tree: find libs in their build location
    BUILD_RPATH_USE_ORIGIN ON
    # Install tree: find libs relative to executable
    INSTALL_RPATH "@executable_path/../lib"
)

# For development/testing without install:
# CMake automatically sets build RPATH so tests work
# This is stripped on install (replaced by INSTALL_RPATH)
set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE)
set(CMAKE_INSTALL_RPATH_USE_LINK_PATH TRUE)
macOS Production Pattern
macOS Application Bundle with CMake

A complete .app bundle layout that handles frameworks, resources, and code signing for distribution:

cmake_minimum_required(VERSION 3.21)
project(BundleApp VERSION 1.0.0 LANGUAGES CXX)

add_executable(MyApp MACOSX_BUNDLE
    src/main.cpp
    src/app.cpp
    resources/icon.icns
)

set_target_properties(MyApp PROPERTIES
    MACOSX_BUNDLE TRUE
    MACOSX_BUNDLE_BUNDLE_NAME "My Application"
    MACOSX_BUNDLE_BUNDLE_VERSION "${PROJECT_VERSION}"
    MACOSX_BUNDLE_SHORT_VERSION_STRING "${PROJECT_VERSION}"
    MACOSX_BUNDLE_GUI_IDENTIFIER "com.example.myapp"
    MACOSX_BUNDLE_ICON_FILE "icon.icns"
    MACOSX_BUNDLE_INFO_PLIST "${CMAKE_SOURCE_DIR}/resources/Info.plist.in"
    INSTALL_RPATH "@executable_path/../Frameworks"
)

# Ensure icon ends up in Resources/
set_source_files_properties(resources/icon.icns PROPERTIES
    MACOSX_PACKAGE_LOCATION "Resources"
)

# Copy frameworks into bundle
add_custom_command(TARGET MyApp POST_BUILD
    COMMAND ${CMAKE_COMMAND} -E make_directory
        "$<TARGET_BUNDLE_DIR:MyApp>/Contents/Frameworks"
)