Building a macOS App Bundle with CHICKEN Scheme

  1. Building a macOS App Bundle with CHICKEN Scheme
    1. Overview
    2. Native Code Compilation
    3. Scheme Module Compilation
    4. Main Application
    5. Bundle Creation
    6. Self-Contained Distribution
      1. Launcher Binary
    7. Key Takeaways

Overview

The architecture for my case is:

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

  1. Module separation: Each .scm file becomes a .so loaded at runtime
  2. -deployed -private-repository: Critical for the main binary to find modules
  3. -headerpad_max_install_names: Essential for install_name_tool to work
  4. Eggs installed into bundle: Using CHICKEN_REPOSITORY_PATH redirection
  5. No .import.so files needed: Import libraries are compile-time only, exclude from bundle
  6. Never use install_name_tool -id: Corrupts binaries on x86_64; use -change and -add_rpath only
  7. @rpath over DYLD_LIBRARY_PATH: Properly configured rpaths work without environment variables
  8. 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!