#!/usr/bin/env bash # # build-xcframework.sh — build libvoicecat as a static .a slice (or slices) and stitch them # into a VoiceCatCore.xcframework that the Swift Package at clients/apple/Package.swift # consumes as a binary target. # # The XCFramework's Headers directory carries a generated module.modulemap alongside # voicecat.h, so Swift gets a clean `import VoiceCatC` module (see docs/architecture.md §4 — # "Swift / Apple. Import the C ABI via a module map"). core/include/ itself stays pure C; # the module map is an Apple-packaging concern that lives only in the staged headers. # # Usage: # scripts/build-xcframework.sh # macOS slice only (default, validated) # scripts/build-xcframework.sh --all # macOS + iOS device + iOS sim (iOS still scaffolding) # scripts/build-xcframework.sh --preset apple-dev # scripts/build-xcframework.sh --no-configure # skip cmake configure, just rebuild + stitch # # Requires: VCPKG_ROOT set (or detectable from an existing build/apple-dev/CMakeCache.txt), # Xcode + macOS SDK. iOS slices additionally require the iOS SDK. Install Homebrew # autoconf-archive for vcpkg's libsodium port (see clients/apple/README.md). # # Mirrors the Windows client's convention: the native binary is a local build artifact, NOT # committed — the C# project references build/windows-client/bin/voicecat.dll the same way # this script's output at clients/apple/VoiceCatCore.xcframework is referenced by Package.swift. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # scripts/ is 3 levels below repo root: voice-cat/clients/apple/scripts/ REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" APPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" BUILD_MACOS=true BUILD_IOS_DEVICE=false BUILD_IOS_SIM=false DO_CONFIGURE=true while [[ $# -gt 0 ]]; do case "$1" in --all) BUILD_MACOS=true; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=true; shift ;; --preset) case "$2" in apple-dev) BUILD_MACOS=true; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=false ;; apple-ios) BUILD_MACOS=false; BUILD_IOS_DEVICE=true; BUILD_IOS_SIM=false ;; apple-ios-sim) BUILD_MACOS=false; BUILD_IOS_DEVICE=false; BUILD_IOS_SIM=true ;; *) echo "unknown preset: $2" >&2; exit 2 ;; esac; shift 2 ;; --no-configure) DO_CONFIGURE=false; shift ;; -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; *) echo "unknown arg: $1" >&2; exit 2 ;; esac done # ── Resolve VCPKG_ROOT ─────────────────────────────────────────────────────────── # The apple-dev CMake cache records the vcpkg root it was configured with (Z_VCPKG_ROOT_DIR); # reuse that so a developer who already configured `cmake --preset dev` doesn't need VCPKG_ROOT # in their shell env to run this script. if [[ -z "${VCPKG_ROOT:-}" ]]; then cache="$REPO_ROOT/build/apple-dev/CMakeCache.txt" if [[ -f "$cache" ]]; then detected="$(grep -m1 '^Z_VCPKG_ROOT_DIR:INTERNAL=' "$cache" | cut -d= -f2-)" if [[ -n "$detected" && -d "$detected" ]]; then export VCPKG_ROOT="$detected" fi fi fi if [[ -z "${VCPKG_ROOT:-}" || ! -d "$VCPKG_ROOT" ]]; then echo "error: VCPKG_ROOT is not set or does not exist." >&2 echo " bootstrap vcpkg (https://vcpkg.io) then: export VCPKG_ROOT=/path/to/vcpkg" >&2 exit 1 fi echo "[build-xcframework] VCPKG_ROOT=$VCPKG_ROOT" # ── Build each requested slice ──────────────────────────────────────────────────── build_slice() { local preset="$1" slice_name="$2" vcpkg_triplet="$3" echo "[build-xcframework] === $slice_name: configure + build ($preset) ===" if $DO_CONFIGURE; then cmake --preset "$preset" fi cmake --build --preset "$preset" local lib="$REPO_ROOT/build/$preset/lib/libvoicecat.a" if [[ ! -f "$lib" ]]; then echo "error: expected output not found: $lib" >&2 exit 1 fi echo "[build-xcframework] $slice_name -> $lib ($(stat -f%z "$lib") bytes)" # The static libvoicecat.a only contains voicecat's own object files — vcpkg's static # deps (protobuf, mbedtls, libsodium, opus, sqlite3, spdlog, asio, abseil, …) are # separate .a files under vcpkg_installed//lib/. A Swift Package binary target # can only link ONE .a per XCFramework slice, so we merge them all into a single # self-contained "fat" static library using libtool -static. This is the Apple equivalent # of how the Windows client ships a single voicecat.dll with all deps statically linked # (via MinGW's -static flags in core/CMakeLists.txt). # # Without this, the final executable (test runner / app) would get undefined-symbol # linker errors for protobuf/mbedtls/sodium/opus/… symbols that libvoicecat.a references # but doesn't contain. See clients/apple/README.md "Fat static library" section. # # IMPORTANT: search only the TARGET triplet's lib dir, not all of vcpkg_installed/. # Cross-compile builds install a HOST triplet directory alongside the target (e.g. # arm64-osx/ next to arm64-ios/) — that host directory contains macOS binaries needed # to build protoc/etc at configure time. Merging those macOS .a files into the iOS # fat library triggers xcodebuild's "binaries with multiple platforms" rejection. local vcpkg_target_lib_dir="$REPO_ROOT/build/$preset/vcpkg_installed/$vcpkg_triplet/lib" local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a" echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib (triplet: $vcpkg_triplet)" # Collect all .a files (libvoicecat.a + every vcpkg target .a). libtool -static concatenates # object files from all input archives; duplicate-object warnings are benign (the linker # resolves duplicates at final link time). "no symbols" warnings are for empty AVX2/AVX512 # objects on arm64 — also benign. local all_libs=( "$lib" ) while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_target_lib_dir" -name '*.a' -not -name 'libvoicecat*' | sort) libtool -static -o "$fat_lib" "${all_libs[@]}" 2>&1 | grep -v 'has no symbols' || true echo "[build-xcframework] $slice_name -> $fat_lib ($(stat -f%z "$fat_lib") bytes, fat)" } args=() if $BUILD_MACOS; then build_slice apple-dev "macOS (arm64-osx)" "arm64-osx"; args+=( -library "$REPO_ROOT/build/apple-dev/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/macos" ); fi if $BUILD_IOS_DEVICE; then build_slice apple-ios "iOS device (arm64-ios)" "arm64-ios"; args+=( -library "$REPO_ROOT/build/apple-ios/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios" ); fi if $BUILD_IOS_SIM; then build_slice apple-ios-sim "iOS sim (arm64-ios-sim)" "arm64-ios-simulator"; args+=( -library "$REPO_ROOT/build/apple-ios-sim/lib/libvoicecat-fat.a" -headers "$APPLE_DIR/.staged-headers/ios-sim" ); fi # ── Stage headers + module map ─────────────────────────────────────────────────── # Each slice gets its own headers dir (xcodebuild -create-xcframework requires a -headers # per -library). The module map wraps voicecat.h as `module VoiceCatC` so Swift imports it # as a clean named module rather than a Clang module inferred from the header path. stage_headers() { local dest="$1" mkdir -p "$dest" cp "$REPO_ROOT/core/include/voicecat.h" "$dest/voicecat.h" cat > "$dest/module.modulemap" <<'MODULEMAP' module VoiceCatC { header "voicecat.h" export * } MODULEMAP # voicecat.h is the single public C ABI header (core/include/ has nothing else); exposing # it as `module VoiceCatC` gives Swift a clean named import rather than a path-inferred # Clang module. The export * re-exports all C symbols for Swift access. } STAGED_ROOT="$APPLE_DIR/.staged-headers" rm -rf "$STAGED_ROOT" if $BUILD_MACOS; then stage_headers "$STAGED_ROOT/macos"; fi if $BUILD_IOS_DEVICE; then stage_headers "$STAGED_ROOT/ios"; fi if $BUILD_IOS_SIM; then stage_headers "$STAGED_ROOT/ios-sim"; fi # ── Stitch the XCFramework ─────────────────────────────────────────────────────── OUTPUT="$APPLE_DIR/VoiceCatCore.xcframework" echo "[build-xcframework] === stitching $OUTPUT ===" rm -rf "$OUTPUT" xcodebuild -create-xcframework "${args[@]}" -output "$OUTPUT" # Clean up staged headers (the xcframework has its own copy now). rm -rf "$STAGED_ROOT" echo "[build-xcframework] done -> $OUTPUT" xcodebuild -list -xcframework "$OUTPUT" 2>/dev/null || true