feat(apple): VoiceCatCore Swift package + XCFramework build for macOS/iOS clients
Lays the groundwork for the macOS (AppKit) and iOS (SwiftUI) clients with a shared Swift core wrapping the C ABI, mirroring the proven Windows VoiceCat.Interop layer. Architecture decision: macOS UI = AppKit (not SwiftUI) for the most mature VoiceOver accessibility story — same rationale as the Windows client's WinForms-over-WinUI-3 decision. iOS stays SwiftUI. Recorded in docs/roadmap.md §2. Build infrastructure (Phase 0): - clients/apple/scripts/build-xcframework.sh: runs cmake --preset apple-dev, merges libvoicecat.a + 107 vcpkg static deps into a single ~30 MB fat static library (libvoicecat-fat.a) via libtool -static (SPM binary targets link one .a per slice), stages voicecat.h + a generated module.modulemap (module VoiceCatC) into the headers, runs xcodebuild -create-xcframework -> clients/apple/VoiceCatCore.xcframework. VoiceCatCore Swift Package (Phase 1): - Package.swift: binary target (VoiceCatCoreXCF) + library (VoiceCatCore) + test target. - Sources/VoiceCatCore/: 7 files mirroring the C# VoiceCat.Interop patterns adapted to Swift native C interop — Enums (9 Swift mirrors of C enums, UInt32-backed), Config, Event (copies ev.text to String inside the callback — the #1 lifetime rule), Models (10 Swift value types), Marshaling (C arrays -> Swift + immediate vc_free_*), Callbacks (@convention(c) + Unmanaged.passUnretained, the Swift analog of C#'s [UnmanagedCallersOnly] + GCHandle), VoiceCatClient (owns vc_client* as OpaquePointer, all 38 C ABI functions, deinit -> vc_client_destroy then frees config CStrings, event delivery on main queue via coalesced DispatchQueue.main drain). Tests — 6/6 green (swift test against a real voicecat-server): - testConnectTofuAuthListChannelsRoundTrips, testAdminChannelCrudAccountCrudRoundTrips, testScreenAudioStreamStartsAndStops, testPerStreamRecvControlsRoundTrip, plus two static smoke tests. Catches Swift-specific interop bugs (@convention(c) callback lifetime, Unmanaged pointer resolution, CString memory management, enum raw-value bridging, struct layout) that C++ ctest cannot. C++ suite still 21/21 green. Docs updated (house rule): tech-stack.md §2, architecture.md §4, roadmap.md M4 + §2, clients/apple/README.md (full rewrite), PROGRESS.md, .gitignore.
This commit is contained in:
155
clients/apple/scripts/build-xcframework.sh
Executable file
155
clients/apple/scripts/build-xcframework.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/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"
|
||||
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/<triplet>/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.
|
||||
local vcpkg_libs_dir="$REPO_ROOT/build/$preset/vcpkg_installed"
|
||||
local fat_lib="$REPO_ROOT/build/$preset/lib/libvoicecat-fat.a"
|
||||
echo "[build-xcframework] $slice_name: merging vcpkg deps into fat static lib"
|
||||
# Collect all .a files (libvoicecat.a + every vcpkg .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" )
|
||||
# Only release libs (arm64-osx/lib/*.a), NOT debug libs (arm64-osx/debug/lib/*.a) —
|
||||
# vcpkg installs both; the debug libs are ~10x larger and not needed for a Release build.
|
||||
while IFS= read -r f; do all_libs+=( "$f" ); done < <(find "$vcpkg_libs_dir" -name '*.a' -not -name 'libvoicecat*' -not -path '*/debug/*' | 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)"; 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)"; 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)"; 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
|
||||
Reference in New Issue
Block a user