Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion .github/workflows/build_osx.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ jobs:
-B "${{ github.workspace }}/cmake-build-cxx-api" \
-DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}" \
-DCMAKE_PREFIX_PATH="${{ github.workspace }}/install"

cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Debug"
cmake --build "${{ github.workspace }}/cmake-build-cxx-api" --config "Release"

Expand All @@ -98,3 +98,73 @@ jobs:
with:
name: projectm-osx-${{ matrix.libs }}-${{ matrix.fslib }}-${{ matrix.arch }}-${{ matrix.runs-on }}
path: install/*

build-framework:
name: "Framework: ${{ matrix.cxx_interface && 'C + C++' || 'C only' }}, Arch: ${{ matrix.arch }}, Build OS: ${{ matrix.runs-on }}"
runs-on: ${{ matrix.runs-on }}
strategy:
fail-fast: false
matrix:
arch: ['arm64', 'x86_64']
cxx_interface: [true, false]
runs-on: ['macos-15', 'macos-15-intel']
exclude:
- arch: arm64
runs-on: macos-15-intel
- arch: x86_64
runs-on: macos-15

steps:
- name: Install Packages
run: brew install ninja

- uses: actions/checkout@v4
with:
submodules: 'recursive'

- name: Configure Framework Build
run: |
if [ "${{ matrix.cxx_interface }}" == "true" ]; then
cxx_iface=ON
else
cxx_iface=OFF
fi
cmake -G "Ninja Multi-Config" \
-S "${{ github.workspace }}" \
-B "${{ github.workspace }}/cmake-build" \
-DCMAKE_INSTALL_PREFIX="${{ github.workspace }}/install" \
-DCMAKE_VERBOSE_MAKEFILE=YES \
-DBUILD_SHARED_LIBS=ON \
-DENABLE_MACOS_FRAMEWORK=ON \
-DENABLE_CXX_INTERFACE="${cxx_iface}" \
-DCMAKE_OSX_ARCHITECTURES="${{ matrix.arch }}"

- name: Build Release
run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --parallel

- name: Validate Frameworks
run: |
STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \
"${{ github.workspace }}/cmake-build"

- name: Install
run: cmake --build "${{ github.workspace }}/cmake-build" --config "Release" --target install

- name: Verify Installed Frameworks
run: |
echo "--- Checking installed framework structure ---"
ls -la "${{ github.workspace }}/install/lib/"
# Verify frameworks were installed (not bare dylibs)
test -d "${{ github.workspace }}/install/lib/projectM-4.framework" \
|| { echo "FAIL: projectM-4.framework not installed"; exit 1; }
test -d "${{ github.workspace }}/install/lib/projectM-4-playlist.framework" \
|| { echo "FAIL: projectM-4-playlist.framework not installed"; exit 1; }
# Run the same validation on installed frameworks
STRICT=1 bash "${{ github.workspace }}/scripts/test-macos-framework.sh" \
"${{ github.workspace }}/install"

- name: Upload Framework Artifact
uses: actions/upload-artifact@v4
with:
name: projectm-osx-framework-${{ matrix.cxx_interface && 'cxx' || 'c-only' }}-${{ matrix.arch }}-${{ matrix.runs-on }}
path: install/*
223 changes: 223 additions & 0 deletions cmake/MacOSFramework.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
# MacOSFramework.cmake
# Build macOS framework bundles from scratch, with proper header hierarchy support.
#
# This module provides functions to create macOS framework bundles manually,
# bypassing CMake's built-in FRAMEWORK support which doesn't preserve header
# directory structures.

# Create a macOS framework bundle from a shared library target.
#
# Usage:
# create_macos_framework(
# TARGET <target>
# FRAMEWORK_NAME <name>
# IDENTIFIER <bundle-identifier>
# VERSION <version>
# C_HEADERS <list of C header files>
# [CXX_HEADERS <list of C++ header files with subdirs>]
# [HEADER_BASE_DIR <base directory to strip from header paths>]
# [HEADER_SUBDIR <subdirectory name under Headers, defaults to FRAMEWORK_NAME>]
# )
#
# The framework will be created in CMAKE_CURRENT_BINARY_DIR.
# C_HEADERS are installed flat under Headers/<header-subdir>/
# CXX_HEADERS preserve their directory structure relative to HEADER_BASE_DIR.
#
function(create_macos_framework)
set(options "")
set(oneValueArgs TARGET FRAMEWORK_NAME IDENTIFIER VERSION HEADER_BASE_DIR HEADER_SUBDIR)
set(multiValueArgs C_HEADERS CXX_HEADERS)
cmake_parse_arguments(FW "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})

if(NOT FW_TARGET)
message(FATAL_ERROR "create_macos_framework: TARGET is required")
endif()
if(NOT FW_FRAMEWORK_NAME)
message(FATAL_ERROR "create_macos_framework: FRAMEWORK_NAME is required")
endif()
if(NOT FW_IDENTIFIER)
message(FATAL_ERROR "create_macos_framework: IDENTIFIER is required")
endif()
if(NOT FW_VERSION)
message(FATAL_ERROR "create_macos_framework: VERSION is required")
endif()

# Use a concrete output directory (CMAKE_CURRENT_BINARY_DIR) for the framework
# This avoids issues with generator expressions in OUTPUT paths
set(_framework_dir "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}.framework")
set(_versions_dir "${_framework_dir}/Versions")
set(_version_a_dir "${_versions_dir}/A")

# Headers are placed in Headers/<header-subdir>/ to support existing include patterns.
# By default, header-subdir is the framework name, but can be overridden with HEADER_SUBDIR.
# Users should add -I <framework>/Headers to their include path.
if(FW_HEADER_SUBDIR)
set(_header_subdir "${FW_HEADER_SUBDIR}")
else()
set(_header_subdir "${FW_FRAMEWORK_NAME}")
endif()
set(_headers_dir "${_version_a_dir}/Headers/${_header_subdir}")
set(_resources_dir "${_version_a_dir}/Resources")

# Marker file to track framework creation
set(_framework_marker "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}.framework.marker")

# Generate Info.plist content
set(_info_plist_content "<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">
<plist version=\"1.0\">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>${FW_FRAMEWORK_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${FW_IDENTIFIER}</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>${FW_FRAMEWORK_NAME}</string>
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>${FW_VERSION}</string>
<key>CFBundleVersion</key>
<string>${FW_VERSION}</string>
</dict>
</plist>")

# Write Info.plist to build directory (will be copied by custom command)
set(_info_plist_file "${CMAKE_CURRENT_BINARY_DIR}/${FW_FRAMEWORK_NAME}_Info.plist")
file(WRITE "${_info_plist_file}" "${_info_plist_content}")

# Build list of commands to copy headers
set(_header_copy_commands "")
set(_header_dependencies "")

# Process C headers (flat structure)
foreach(_header ${FW_C_HEADERS})
get_filename_component(_header_name "${_header}" NAME)
list(APPEND _header_copy_commands
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_header_name}"
)
list(APPEND _header_dependencies "${_header}")
endforeach()

# Process C++ headers (preserve directory structure)
if(FW_CXX_HEADERS AND FW_HEADER_BASE_DIR)
foreach(_header ${FW_CXX_HEADERS})
# Get relative path from base directory
file(RELATIVE_PATH _rel_path "${FW_HEADER_BASE_DIR}" "${_header}")
get_filename_component(_rel_dir "${_rel_path}" DIRECTORY)

if(_rel_dir)
list(APPEND _header_copy_commands
COMMAND ${CMAKE_COMMAND} -E make_directory "${_headers_dir}/${_rel_dir}"
)
endif()
list(APPEND _header_copy_commands
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_rel_path}"
)
list(APPEND _header_dependencies "${_header}")
endforeach()
elseif(FW_CXX_HEADERS)
# No base dir specified, install flat
foreach(_header ${FW_CXX_HEADERS})
get_filename_component(_header_name "${_header}" NAME)
list(APPEND _header_copy_commands
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_header}" "${_headers_dir}/${_header_name}"
)
list(APPEND _header_dependencies "${_header}")
endforeach()
endif()

# Create custom command to build the framework
# Note: We use a marker file as OUTPUT since the actual output path depends on the target
add_custom_command(
OUTPUT "${_framework_marker}"
# Clean up any existing framework directory to ensure clean symlinks
COMMAND ${CMAKE_COMMAND} -E rm -rf "${_framework_dir}"

# Create directory structure
COMMAND ${CMAKE_COMMAND} -E make_directory "${_version_a_dir}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${_headers_dir}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${_resources_dir}"

# Copy the dylib
COMMAND ${CMAKE_COMMAND} -E copy_if_different "$<TARGET_FILE:${FW_TARGET}>" "${_version_a_dir}/${FW_FRAMEWORK_NAME}"

# Copy Info.plist
COMMAND ${CMAKE_COMMAND} -E copy_if_different "${_info_plist_file}" "${_resources_dir}/Info.plist"

# Copy headers
${_header_copy_commands}

# Create symlinks (Current -> A)
COMMAND ${CMAKE_COMMAND} -E create_symlink "A" "${_versions_dir}/Current"

# Create top-level symlinks
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/${FW_FRAMEWORK_NAME}" "${_framework_dir}/${FW_FRAMEWORK_NAME}"
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/Headers" "${_framework_dir}/Headers"
COMMAND ${CMAKE_COMMAND} -E create_symlink "Versions/Current/Resources" "${_framework_dir}/Resources"

# Create marker file
COMMAND ${CMAKE_COMMAND} -E touch "${_framework_marker}"

DEPENDS ${FW_TARGET} ${_header_dependencies}
COMMENT "Building ${FW_FRAMEWORK_NAME}.framework"
VERBATIM
)

# Create a target that depends on the framework being built
add_custom_target(${FW_TARGET}_framework ALL
DEPENDS "${_framework_marker}"
)

# Set properties on the framework target for use by install commands
set_target_properties(${FW_TARGET} PROPERTIES
MACOS_FRAMEWORK_OUTPUT_DIR "${_framework_dir}"
MACOS_FRAMEWORK_NAME "${FW_FRAMEWORK_NAME}"
)
endfunction()

# Install a framework created by create_macos_framework
#
# Usage:
# install_macos_framework(
# TARGET <target>
# DESTINATION <install-dir>
# [COMPONENT <component>]
# )
#
function(install_macos_framework)
set(options "")
set(oneValueArgs TARGET DESTINATION COMPONENT)
set(multiValueArgs "")
cmake_parse_arguments(FW "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN})

if(NOT FW_TARGET)
message(FATAL_ERROR "install_macos_framework: TARGET is required")
endif()
if(NOT FW_DESTINATION)
message(FATAL_ERROR "install_macos_framework: DESTINATION is required")
endif()

get_target_property(_framework_name ${FW_TARGET} MACOS_FRAMEWORK_NAME)
if(NOT _framework_name)
message(FATAL_ERROR "install_macos_framework: Target ${FW_TARGET} does not have MACOS_FRAMEWORK_NAME property. Did you call create_macos_framework first?")
endif()

# Install the entire framework directory
set(_component_arg "")
if(FW_COMPONENT)
set(_component_arg COMPONENT ${FW_COMPONENT})
endif()

get_target_property(_framework_dir ${FW_TARGET} MACOS_FRAMEWORK_OUTPUT_DIR)
install(
DIRECTORY "${_framework_dir}"
DESTINATION "${FW_DESTINATION}"
${_component_arg}
USE_SOURCE_PERMISSIONS
)
endfunction()
Loading
Loading