You are looking at historical revision 45331 of this page. It may differ significantly from its current revision.
Building a macOS App Bundle with CHICKEN Scheme
Overview
The architecture for my case is:
- Native code (C++/Objective-C) compiled to .o object files
- Scheme modules compiled to .so shared libraries using csc -s -J
- Main app compiled with -deployed -private-repository for bundle deployment
- Dependencies (eggs + system dylibs) bundled for distribution
Native Code Compilation
C++/Objective-C components are compiled normally:
video_player.o: video_player.cpp c++ $(CXXFLAGS) $(FFMPEG_CFLAGS) -c $< -o $@ file_dialog.o: file_dialog.mm c++ $(CXXFLAGS) -x objective-c++ -c $< -o $@
Scheme Module Compilation
Each Scheme module is compiled as a shared library:
CSC_MODULE_FLAGS = -d3 -O0 -c++ -s -J -L "-Wl,-headerpad_max_install_names" # Example module with native dependencies video.so: video.scm av_player.o csc $(CSC_MODULE_FLAGS) -C "$(CXXFLAGS)" -L "$(LDFLAGS)" $< av_player.o -o video.so
Key flags:
- -s
- compile to shared library (.so)
- -J
- generate import library (.import.scm) - needed for compilation only
- -headerpad_max_install_names
- reserve space for install_name_tool to rewrite paths later
Note on import libraries: The -J flag generates .import.scm files which can be compiled to .import.so. These are only needed at compile time for modules that depend on other modules. They are not needed at runtime and should be excluded from the final bundle to reduce size.
Main Application
The main app.scm dynamically loads modules at runtime:
app: $(SCHEME_SOS) $(SCHEME_IMPORT_SOS) app.scm csc $(CSC_FLAGS) -deployed -private-repository \ -C "$(CXXFLAGS)" \ -L "-Wl,-headerpad_max_install_names" \ app.scm -o $@
Key flags:
- -deployed
- sets rpath for bundled libraries
- -private-repository
- load extensions from executable's directory
Bundle Creation
The bundle follows the standard macOS structure:
MyApp.app/
Contents/
MacOS/
MyApp # launcher (see below)
MyApp.bin # real CHICKEN binary
*.so # Scheme modules (runtime only, NOT .import.so)
Resources/
fonts/
...
Frameworks/
*.dylib # bundled dynamic libraries (for distribution)
Creating the bundle:
mkdir -p MyApp.app/Contents/MacOS MyApp.app/Contents/Resources cp app MyApp.app/Contents/MacOS/MyApp.bin cp *.so MyApp.app/Contents/MacOS/ # Do NOT copy *.import.so - they are not needed at runtime
Installing eggs directly into the bundle:
CHICKEN_REPOSITORY_PATH=MyApp.app/Contents/MacOS \ CHICKEN_INSTALL_REPOSITORY=MyApp.app/Contents/MacOS \ chicken-install medea http-client ... # Clean up compile-time only files rm -f MyApp.app/Contents/MacOS/*.import.so rm -f MyApp.app/Contents/MacOS/*.link MyApp.app/Contents/MacOS/*.o rm -f MyApp.app/Contents/MacOS/*.egg-info MyApp.app/Contents/MacOS/*.types rm -f MyApp.app/Contents/MacOS/types.db
Self-Contained Distribution
For distribution to machines without Homebrew, dylibs must be embedded.
First, identify and copy all Homebrew dylibs (recursively for dependencies):
mkdir -p MyApp.app/Contents/Frameworks
# Copy direct dependencies
for lib in $(otool -L MyApp.app/Contents/MacOS/MyApp.bin MyApp.app/Contents/MacOS/*.so 2>/dev/null \
| grep '/opt/homebrew\|/usr/local' | awk '{print $1}' | sort -u); do
cp -n "$lib" MyApp.app/Contents/Frameworks/ 2>/dev/null || true
done
# Recursively copy dependencies of copied dylibs (repeat a few times)
for i in 1 2 3 4 5; do
for lib in MyApp.app/Contents/Frameworks/*.dylib; do
for dep in $(otool -L "$lib" 2>/dev/null | grep '/opt/homebrew\|/usr/local' | awk '{print $1}'); do
cp -n "$dep" MyApp.app/Contents/Frameworks/ 2>/dev/null || true
done
done
done
Then rewrite the load paths using install_name_tool:
# For dylibs in Frameworks - use -change, add @rpath
for lib in MyApp.app/Contents/Frameworks/*.dylib; do
for dep in $(otool -L "$lib" | grep '/opt/homebrew\|/usr/local' | awk '{print $1}'); do
depname=$(basename "$dep")
install_name_tool -change "$dep" "@rpath/$depname" "$lib" 2>/dev/null || true
done
install_name_tool -add_rpath "@loader_path" "$lib" 2>/dev/null || true
done
# For .so modules
for so in MyApp.app/Contents/MacOS/*.so; do
for dep in $(otool -L "$so" 2>/dev/null | grep '/opt/homebrew\|/usr/local' | awk '{print $1}'); do
depname=$(basename "$dep")
install_name_tool -change "$dep" "@rpath/$depname" "$so" 2>/dev/null || true
done
install_name_tool -add_rpath "@executable_path/../Frameworks" "$so" 2>/dev/null || true
done
# For main binary
for dep in $(otool -L MyApp.app/Contents/MacOS/MyApp.bin | grep '/opt/homebrew\|/usr/local' | awk '{print $1}'); do
depname=$(basename "$dep")
install_name_tool -change "$dep" "@rpath/$depname" MyApp.app/Contents/MacOS/MyApp.bin 2>/dev/null || true
done
install_name_tool -add_rpath "@executable_path/../Frameworks" MyApp.app/Contents/MacOS/MyApp.bin 2>/dev/null || true
Critical: Never use install_name_tool -id on any binary. On x86_64 Macs, this corrupts CHICKEN-compiled binaries and dylibs. Only use -change to rewrite paths and -add_rpath to add search paths.
Launcher Binary
A small C launcher is needed to set CHICKEN_REPOSITORY_PATH so CHICKEN can find the .so modules:
// launcher.c #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <string.h> #include <libgen.h> #include <mach-o/dyld.h> int main(int argc, char *argv[]) { char exec_path[4096]; uint32_t size = sizeof(exec_path); if (_NSGetExecutablePath(exec_path, &size) != 0) return 1; char *dir = dirname(exec_path); char real_binary[4096]; snprintf(real_binary, sizeof(real_binary), "%s/MyApp.bin", dir); // CHICKEN needs this to find .so modules setenv("CHICKEN_REPOSITORY_PATH", dir, 1); // Note: DYLD_LIBRARY_PATH is NOT needed when using @rpath properly argv[0] = real_binary; execv(real_binary, argv); return 1; }
Compile and install the launcher:
clang -mmacosx-version-min=13.0 -o launcher launcher.c mv MyApp.app/Contents/MacOS/MyApp MyApp.app/Contents/MacOS/MyApp.bin cp launcher MyApp.app/Contents/MacOS/MyApp
Key Takeaways
- Module separation: Each .scm file becomes a .so loaded at runtime
- -deployed -private-repository: Critical for the main binary to find modules
- -headerpad_max_install_names: Essential for install_name_tool to work
- Eggs installed into bundle: Using CHICKEN_REPOSITORY_PATH redirection
- No .import.so files needed: Import libraries are compile-time only, exclude from bundle
- Never use install_name_tool -id: Corrupts binaries on x86_64; use -change and -add_rpath only
- @rpath over DYLD_LIBRARY_PATH: Properly configured rpaths work without environment variables
- Launcher for CHICKEN_REPOSITORY_PATH: Small C wrapper to set the module search path
Hope this helps anyone else trying to build distributable macOS apps with CHICKEN Scheme!