Skip to content

Commit f67dac9

Browse files
revmischaMischa
andauthored
Fix macOS framework build to properly include headers (#964)
* Fix macOS framework build to properly include headers The previous implementation using CMake's built-in FRAMEWORK property had two issues: 1. Headers were not copied into the framework at all 2. PUBLIC_HEADER flattens directory structure, breaking C++ interface This replaces the CMake FRAMEWORK support with a custom MacOSFramework cmake module that: - Builds proper framework bundles from scratch - Preserves header directory hierarchy (Audio/, Renderer/ subdirs) - Creates correct symlink structure (Versions/A, Current, etc.) - Generates Info.plist with bundle metadata Also adds CI test script (scripts/test-macos-framework.sh) that validates: - Framework directory structure - Header completeness - Linkability (compile and link test program) Fixes the empty framework issue reported after ef00cfc. * Fix review issues in macOS framework build - Add missing Renderer/TextureTypes.hpp to C++ framework headers - Skip pkg-config generation for playlist in framework mode - Use stored framework path property for install instead of TARGET_FILE_DIR - Show compiler errors on linkability test failure instead of suppressing - Fix comment about framework output location * Add framework CI jobs and harden test script New CI job matrix (build-framework): - Tests framework builds on both arm64 and x86_64 - Tests with and without C++ interface - Runs strict validation after build AND after install - Verifies installed frameworks match build output Test script improvements: - Exhaustive header lists (all C API + all C++ headers) - Exact header count validation (catches stale/unexpected files) - Strict mode (STRICT=1) where SKIPs become FAILs - Info.plist CFBundleExecutable validation - Symlink target verification (Current, Headers, Resources) - Mach-O dylib binary type check - dylib install name validation - Flexible framework search across build tree and install prefix - Test pass counter in summary --------- Co-authored-by: Mischa <[email protected]>
1 parent 56a2220 commit f67dac9

File tree

6 files changed

+995
-131
lines changed

6 files changed

+995
-131
lines changed

.github/workflows/build_osx.yml

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ jobs:
8989
-B "${{ github.workspace }}/cmake-build-cxx-api" \
9090
-DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}" \
9191
-DCMAKE_PREFIX_PATH="${{ github.workspace }}/install"
92-
92+
9393
cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Debug"
9494
cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Release"
9595
@@ -98,3 +98,73 @@ jobs:
9898
with:
9999
name: projectm-osx-${{ matrix.libs }}-${{ matrix.fslib }}-${{ matrix.arch }}-${{ matrix.runs-on }}
100100
path: install/*
101+
102+
build-framework:
103+
name: "Framework: ${{ matrix.cxx_interface && 'C + C++' || 'C only' }}, Arch: ${{ matrix.arch }}, Build OS: ${{ matrix.runs-on }}"
104+
runs-on: ${{ matrix.runs-on }}
105+
strategy:
106+
fail-fast: false
107+
matrix:
108+
arch: ['arm64', 'x86_64']
109+
cxx_interface: [true, false]
110+
runs-on: ['macos-15', 'macos-15-intel']
111+
exclude:
112+
- arch: arm64
113+
runs-on: macos-15-intel
114+
- arch: x86_64
115+
runs-on: macos-15
116+
117+
steps:
118+
- name: Install Packages
119+
run: brew install ninja
120+
121+
- uses: actions/checkout@v4
122+
with:
123+
submodules: 'recursive'
124+
125+
- name: Configure Framework Build
126+
run: |
127+
if [ "${{ matrix.cxx_interface }}" == "true" ]; then
128+
cxx_iface=ON
129+
else
130+
cxx_iface=OFF
131+
fi
132+
cmake -G "Ninja Multi-Config" \
133+
-S "${{ github.workspace }}" \
134+
-B "${{ github.workspace }}/cmake-build" \
135+
-DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" \
136+
-DCMAKE_VERBOSE_MAKEFILE=YES \
137+
-DBUILD_SHARED_LIBS=ON \
138+
-DENABLE_MACOS_FRAMEWORK=ON \
139+
-DENABLE_CXX_INTERFACE="${cxx_iface}" \
140+
-DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}"
141+
142+
- name: Build Release
143+
run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --parallel
144+
145+
- name: Validate Frameworks
146+
run: |
147+
STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \
148+
"${{ github.workspace }}/cmake-build"
149+
150+
- name: Install
151+
run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --target install
152+
153+
- name: Verify Installed Frameworks
154+
run: |
155+
echo "--- Checking installed framework structure ---"
156+
ls -la "${{ github.workspace }}/install/lib/"
157+
# Verify frameworks were installed (not bare dylibs)
158+
test -d "${{ github.workspace }}/install/lib/projectM-4.framework" \
159+
|| { echo "FAIL: projectM-4.framework not installed"; exit 1; }
160+
test -d "${{ github.workspace }}/install/lib/projectM-4-playlist.framework" \
161+
|| { echo "FAIL: projectM-4-playlist.framework not installed"; exit 1; }
162+
# Run the same validation on installed frameworks
163+
STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \
164+
"${{ github.workspace }}/install"
165+
166+
- name: Upload Framework Artifact
167+
uses: actions/upload-artifact@v4
168+
with:
169+
name: projectm-osx-framework-${{ matrix.cxx_interface && 'cxx' || 'c-only' }}-${{ matrix.arch }}-${{ matrix.runs-on }}
170+
path: install/*

cmake/MacOSFramework.cmake

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
# MacOSFramework.cmake
2+
# Build macOS framework bundles from scratch, with proper header hierarchy support.
3+
#
4+
# This module provides functions to create macOS framework bundles manually,
5+
# bypassing CMake's built-in FRAMEWORK support which doesn't preserve header
6+
# directory structures.
7+
8+
# Create a macOS framework bundle from a shared library target.
9+
#
10+
# Usage:
11+
# create_macos_framework(
12+
# TARGET <target>
13+
# FRAMEWORK_NAME <name>
14+
# IDENTIFIER <bundle-identifier>
15+
# VERSION <version>
16+
# C_HEADERS <list of C header files>
17+
# [CXX_HEADERS <list of C++ header files with subdirs>]
18+
# [HEADER_BASE_DIR <base directory to strip from header paths>]
19+
# [HEADER_SUBDIR <subdirectory name under Headers, defaults to FRAMEWORK_NAME>]
20+
# )
21+
#
22+
# The framework will be created in CMAKE_CURRENT_BINARY_DIR.
23+
# C_HEADERS are installed flat under Headers/<header-subdir>/
24+
# CXX_HEADERS preserve their directory structure relative to HEADER_BASE_DIR.
25+
#
26+
function(create_macos_framework)
27+
set(options "")
28+
set(oneValueArgs TARGET FRAMEWORK_NAME IDENTIFIER VERSION HEADER_BASE_DIR HEADER_SUBDIR)
29+
set(multiValueArgs C_HEADERS CXX_HEADERS)
30+
cmake_parse_arguments(FW "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
31+
32+
if(NOT FW_TARGET)
33+
message(FATAL_ERROR "create_macos_framework: TARGET is required")
34+
endif()
35+
if(NOT FW_FRAMEWORK_NAME)
36+
message(FATAL_ERROR "create_macos_framework: FRAMEWORK_NAME is required")
37+
endif()
38+
if(NOT FW_IDENTIFIER)
39+
message(FATAL_ERROR "create_macos_framework: IDENTIFIER is required")
40+
endif()
41+
if(NOT FW_VERSION)
42+
message(FATAL_ERROR "create_macos_framework: VERSION is required")
43+
endif()
44+
45+
# Use a concrete output directory (CMAKE_CURRENT_BINARY_DIR) for the framework
46+
# This avoids issues with generator expressions in OUTPUT paths
47+
set(_framework_dir "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}.framework")
48+
set(_versions_dir "${_framework_dir}/Versions")
49+
set(_version_a_dir "${_versions_dir}/A")
50+
51+
# Headers are placed in Headers/<header-subdir>/ to support existing include patterns.
52+
# By default, header-subdir is the framework name, but can be overridden with HEADER_SUBDIR.
53+
# Users should add -I <framework>/Headers to their include path.
54+
if(FW_HEADER_SUBDIR)
55+
set(_header_subdir "${FW_HEADER_SUBDIR}")
56+
else()
57+
set(_header_subdir "${FW_FRAMEWORK_NAME}")
58+
endif()
59+
set(_headers_dir "${_version_a_dir}/Headers/${_header_subdir}")
60+
set(_resources_dir "${_version_a_dir}/Resources")
61+
62+
# Marker file to track framework creation
63+
set(_framework_marker "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}.framework.marker")
64+
65+
# Generate Info.plist content
66+
set(_info_plist_content "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
67+
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
68+
<plist version=\"1.0\">
69+
<dict>
70+
<key>CFBundleDevelopmentRegion</key>
71+
<string>en</string>
72+
<key>CFBundleExecutable</key>
73+
<string>${FW_FRAMEWORK_NAME}</string>
74+
<key>CFBundleIdentifier</key>
75+
<string>${FW_IDENTIFIER}</string>
76+
<key>CFBundleInfoDictionaryVersion</key>
77+
<string>6.0</string>
78+
<key>CFBundleName</key>
79+
<string>${FW_FRAMEWORK_NAME}</string>
80+
<key>CFBundlePackageType</key>
81+
<string>FMWK</string>
82+
<key>CFBundleShortVersionString</key>
83+
<string>${FW_VERSION}</string>
84+
<key>CFBundleVersion</key>
85+
<string>${FW_VERSION}</string>
86+
</dict>
87+
</plist>")
88+
89+
# Write Info.plist to build directory (will be copied by custom command)
90+
set(_info_plist_file "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}_Info.plist")
91+
file(WRITE "${_info_plist_file}" "${_info_plist_content}")
92+
93+
# Build list of commands to copy headers
94+
set(_header_copy_commands "")
95+
set(_header_dependencies "")
96+
97+
# Process C headers (flat structure)
98+
foreach(_header ${FW_C_HEADERS})
99+
get_filename_component(_header_name "${_header}" NAME)
100+
list(APPEND _header_copy_commands
101+
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_header_name}"
102+
)
103+
list(APPEND _header_dependencies "${_header}")
104+
endforeach()
105+
106+
# Process C++ headers (preserve directory structure)
107+
if(FW_CXX_HEADERS AND FW_HEADER_BASE_DIR)
108+
foreach(_header ${FW_CXX_HEADERS})
109+
# Get relative path from base directory
110+
file(RELATIVE_PATH _rel_path "${FW_HEADER_BASE_DIR}" "${_header}")
111+
get_filename_component(_rel_dir "${_rel_path}" DIRECTORY)
112+
113+
if(_rel_dir)
114+
list(APPEND _header_copy_commands
115+
COMMAND ${CMAKE_COMMAND} -E make_directory "${_headers_dir}/${_rel_dir}"
116+
)
117+
endif()
118+
list(APPEND _header_copy_commands
119+
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_rel_path}"
120+
)
121+
list(APPEND _header_dependencies "${_header}")
122+
endforeach()
123+
elseif(FW_CXX_HEADERS)
124+
# No base dir specified, install flat
125+
foreach(_header ${FW_CXX_HEADERS})
126+
get_filename_component(_header_name "${_header}" NAME)
127+
list(APPEND _header_copy_commands
128+
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_header_name}"
129+
)
130+
list(APPEND _header_dependencies "${_header}")
131+
endforeach()
132+
endif()
133+
134+
# Create custom command to build the framework
135+
# Note: We use a marker file as OUTPUT since the actual output path depends on the target
136+
add_custom_command(
137+
OUTPUT "${_framework_marker}"
138+
# Clean up any existing framework directory to ensure clean symlinks
139+
COMMAND ${CMAKE_COMMAND} -E rm -rf "${_framework_dir}"
140+
141+
# Create directory structure
142+
COMMAND ${CMAKE_COMMAND} -E make_directory "${_version_a_dir}"
143+
COMMAND ${CMAKE_COMMAND} -E make_directory "${_headers_dir}"
144+
COMMAND ${CMAKE_COMMAND} -E make_directory "${_resources_dir}"
145+
146+
# Copy the dylib
147+
COMMAND ${CMAKE_COMMAND} -E copy_if_different "$<TARGET_FILE:${FW_TARGET}>" "${_version_a_dir}/${FW_FRAMEWORK_NAME}"
148+
149+
# Copy Info.plist
150+
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_info_plist_file}" "${_resources_dir}/Info.plist"
151+
152+
# Copy headers
153+
${_header_copy_commands}
154+
155+
# Create symlinks (Current -> A)
156+
COMMAND ${CMAKE_COMMAND} -E create_symlink "A" "${_versions_dir}/Current"
157+
158+
# Create top-level symlinks
159+
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/${FW_FRAMEWORK_NAME}" "${_framework_dir}/${FW_FRAMEWORK_NAME}"
160+
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/Headers" "${_framework_dir}/Headers"
161+
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/Resources" "${_framework_dir}/Resources"
162+
163+
# Create marker file
164+
COMMAND ${CMAKE_COMMAND} -E touch "${_framework_marker}"
165+
166+
DEPENDS ${FW_TARGET} ${_header_dependencies}
167+
COMMENT "Building ${FW_FRAMEWORK_NAME}.framework"
168+
VERBATIM
169+
)
170+
171+
# Create a target that depends on the framework being built
172+
add_custom_target(${FW_TARGET}_framework ALL
173+
DEPENDS "${_framework_marker}"
174+
)
175+
176+
# Set properties on the framework target for use by install commands
177+
set_target_properties(${FW_TARGET} PROPERTIES
178+
MACOS_FRAMEWORK_OUTPUT_DIR "${_framework_dir}"
179+
MACOS_FRAMEWORK_NAME "${FW_FRAMEWORK_NAME}"
180+
)
181+
endfunction()
182+
183+
# Install a framework created by create_macos_framework
184+
#
185+
# Usage:
186+
# install_macos_framework(
187+
# TARGET <target>
188+
# DESTINATION <install-dir>
189+
# [COMPONENT <component>]
190+
# )
191+
#
192+
function(install_macos_framework)
193+
set(options "")
194+
set(oneValueArgs TARGET DESTINATION COMPONENT)
195+
set(multiValueArgs "")
196+
cmake_parse_arguments(FW "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})
197+
198+
if(NOT FW_TARGET)
199+
message(FATAL_ERROR "install_macos_framework: TARGET is required")
200+
endif()
201+
if(NOT FW_DESTINATION)
202+
message(FATAL_ERROR "install_macos_framework: DESTINATION is required")
203+
endif()
204+
205+
get_target_property(_framework_name ${FW_TARGET} MACOS_FRAMEWORK_NAME)
206+
if(NOT _framework_name)
207+
message(FATAL_ERROR "install_macos_framework: Target ${FW_TARGET} does not have MACOS_FRAMEWORK_NAME property. Did you call create_macos_framework first?")
208+
endif()
209+
210+
# Install the entire framework directory
211+
set(_component_arg "")
212+
if(FW_COMPONENT)
213+
set(_component_arg COMPONENT ${FW_COMPONENT})
214+
endif()
215+
216+
get_target_property(_framework_dir ${FW_TARGET} MACOS_FRAMEWORK_OUTPUT_DIR)
217+
install(
218+
DIRECTORY "${_framework_dir}"
219+
DESTINATION "${FW_DESTINATION}"
220+
${_component_arg}
221+
USE_SOURCE_PERMISSIONS
222+
)
223+
endfunction()

0 commit comments

Comments
 (0)