Files
chanora/apps/chanora_flutter/ios/chanora_bridge.podspec
T
Edison Jwa a589ac953f fix(ios,macos): preserve Silero @_cdecl exports across Xcode Archive
The Apple CoreML Silero VAD backend resolves six @_cdecl Swift symbols
via dlsym(RTLD_DEFAULT) at runtime in the Rust audio crate. Local
flutter build paths preserved those symbols, but Xcode Archive (the
path used for TestFlight and App Store uploads) silently stripped them
through two independent mechanisms, causing Rust to fall back to
WebRTC VAD on every shipped build.

Both stripping mechanisms are now neutralised:

* ld dead-strip: OTHER_LDFLAGS now whitelists each of the six
  chanora_silero_vad_* symbols via repeated `-Xlinker -exported_symbol`
  pairs in ios/Flutter/{Release,Debug}.xcconfig and
  macos/Flutter/Flutter-{Release,Debug}.xcconfig.
* install-time strip: STRIP_STYLE is set to `non-global` in the same
  four xcconfigs so the post-link strip phase no longer drops exported
  global text symbols from the Archive product. Cost: ~264 bytes per
  binary; verified `xcrun strip` vs `xcrun strip -x` behaviour.

Self-test wired into both AppDelegates: at launch on a utility queue,
ChanoraSileroSelfTest resolves all six symbols through dlsym (the same
path the Rust runtime uses, not a direct call that would mask the bug
class) and exercises create → reset → process → destroy. Result is
logged via NSLog and surfaces in Console.app / idevicesyslog.

A post-link verify_silero_exports.sh build phase runs nm -gU on the
final Archive binary and fails the build if any of the six symbols are
missing. Empirically caught the original Archive regression that
flutter build --no-codesign did not.

CocoaPods bridge podspecs now emit a proper .dSYM via dsymutil so
TestFlight crash reports are symbolicated; Cargo.toml release profile
sets `debug = true` because dsymutil needs DWARF in the input dylib.

macOS chanora_bridge.podspec PATH inserts /opt/homebrew/opt/rustup/bin
ahead of /opt/homebrew/bin so rustup's cargo (which has the
x86_64-apple-darwin target installed) wins over the homebrew rust
formula that is aarch64-only.

iOS Podfile target renamed from `Runner` to `Chanora` to match the
Xcode target name shipped in the project (the workspace and scheme
already referenced Chanora; the Podfile mismatch produced lint
warnings during `pod install`).

ITSAppUsesNonExemptEncryption=false declared in both Info.plist files
so TestFlight and App Store Connect uploads skip the export-compliance
prompt; Chanora uses only platform-provided TLS.

.gitignore now covers Xcode archive bundles, IPA exports, dSYM
directories, the local macOS release zip, and agent/tooling state
directories so generated TestFlight artifacts no longer appear in
git status.

End-to-end verified by headless archive:
  xcodebuild -workspace Runner.xcworkspace -scheme Runner \
    -configuration Release -destination 'generic/platform=iOS' \
    -archivePath /tmp/chanora.xcarchive archive CODE_SIGNING_ALLOWED=NO
nm -gU on the resulting .app/Chanora binary shows all six
chanora_silero_vad_* symbols present.
2026-06-07 23:12:07 +09:00

303 lines
12 KiB
Ruby

#
# chanora_bridge — Rust bridge library as a CocoaPods-vended dynamic framework.
#
# What this file does:
#
# 1. `prepare_command` runs once per `pod install` and once per archive
# / device build. It:
# a) Invokes `cargo build --release --target <iOS arch>` for both
# aarch64-apple-ios (device) and aarch64-apple-ios-sim
# (simulator on Apple Silicon) so the resulting framework
# works for both targets.
# b) Wraps the built dylib in a proper `chanora_bridge.framework`
# bundle (Versions/A layout with the right Info.plist).
# c) Rewrites LC_ID_DYLIB to
# @rpath/chanora_bridge.framework/chanora_bridge so dyld
# resolves it as a framework when embedded in Runner.app.
#
# 2. `vendored_frameworks` exposes the result to the Pods workspace.
# CocoaPods then integrates the framework into Runner.xcodeproj
# with the appropriate embed-and-sign build phase.
#
# Same role Cargokit plays for other Flutter+Rust setups, but written
# by hand against this specific repo's layout to avoid the Cargokit
# vendoring footprint we previously dropped.
#
# Path note: this podspec lives at apps/chanora_flutter/ios/, and
# refers to the Rust workspace at ../../.. (repo root). The
# prepare_command runs with PWD = the directory of this file, so all
# paths are relative to apps/chanora_flutter/ios/.
Pod::Spec.new do |s|
s.name = 'chanora_bridge'
s.version = '1.0.0'
s.summary = 'Chanora Rust bridge (libchanora_bridge) as an iOS framework.'
s.description = <<-DESC
Vendors the Rust-built libchanora_bridge as chanora_bridge.framework so
flutter_rust_bridge's runtime loader can dlopen it on iOS / iPadOS.
DESC
s.homepage = 'https://github.com/EdisonJwa/chanora'
s.license = { :type => 'Apache-2.0 OR MIT', :text => 'See LICENSE-APACHE / LICENSE-MIT at the repo root' }
s.author = { 'EdisonJwa' => 'me@edison.network' }
s.source = { :path => '.' }
s.platform = :ios, '16.0'
# Build the Rust bridge on `pod install`. The script runs under
# bash; we use `set -e` so any failure (cargo missing, target not
# installed, link error) aborts the install loudly. The
# CMAKE_POLICY_VERSION_MINIMUM + IPHONEOS_DEPLOYMENT_TARGET
# env vars satisfy audiopus_sys's cmake invocation on modern
# CMake 4.x and Apple iOS SDK; both are required.
s.prepare_command = <<-SCRIPT
set -e
REPO_ROOT="$(cd ../../.. && pwd)"
USER_NAME="$(id -un)"
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
if [ -z "$USER_HOME" ]; then
USER_HOME="$(cd ~ && pwd)"
fi
BRIDGE="$REPO_ROOT/target/aarch64-apple-ios/release/libchanora_bridge.dylib"
find_cargo() {
for candidate in \
"$USER_HOME/.cargo/bin/cargo" \
"/opt/homebrew/opt/rustup/bin/cargo" \
"/usr/local/opt/rustup/bin/cargo"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v cargo
}
find_rustc() {
for candidate in \
"$USER_HOME/.cargo/bin/rustc" \
"/opt/homebrew/opt/rustup/bin/rustc" \
"/usr/local/opt/rustup/bin/rustc"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v rustc
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
echo "[chanora_bridge.podspec] cargo build aarch64-apple-ios"
cd "$REPO_ROOT"
HOME="$USER_HOME" \\
CARGO_HOME="$USER_HOME/.cargo" \\
RUSTUP_HOME="$USER_HOME/.rustup" \\
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
RUSTC="$RUSTC_BIN" \\
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
"$CARGO_BIN" build --release --target aarch64-apple-ios -p chanora_bridge
if [ ! -f "$BRIDGE" ]; then
echo "ERROR: bridge dylib not found at $BRIDGE" >&2
exit 1
fi
cd "$REPO_ROOT/apps/chanora_flutter/ios"
# Build the chanora_bridge.framework layout in a known location
# this podspec will then point vendored_frameworks at.
FW=Frameworks/chanora_bridge.framework
rm -rf "$FW"
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?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>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>iPhoneOS</string></array>
<key>MinimumOSVersion</key><string>16.0</string>
</dict>
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
# Generate the framework's dSYM bundle. Apple's archive validator
# rejects uploads when an embedded framework has no matching dSYM
# (UUID lookup miss in the archive's dSYMs/ folder), which is the
# failure mode that produced this prepare_command in the first
# place. dsymutil reads the DWARF that cargo emitted (enabled by
# [profile.release] debug = true at the workspace root) and writes
# chanora_bridge.framework.dSYM next to the framework. We then
# strip the in-framework binary so the shipped app stays slim —
# the symbols live exclusively in the dSYM bundle, which is the
# layout xcodebuild -exportArchive and App Store Connect expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into
# Runner.app/Frameworks/ with Embed & Sign automatically.
s.vendored_frameworks = 'Frameworks/chanora_bridge.framework'
# Re-run the cargo build + framework wrap on every Xcode build,
# not only on `pod install`. The `prepare_command` above runs
# once per `pod install` which is too sticky — Rust source
# changes were getting silently ignored because Xcode happily
# re-bundled the stale framework. This script_phase shells out to
# cargo on every Xcode "Build" so the framework is always in sync
# with the current Rust workspace.
#
# `:execution_position => :before_compile` runs the script before
# Xcode's CompileSources phase, so by the time the linker / embed
# step sees `Frameworks/chanora_bridge.framework`, it is fresh.
s.script_phase = {
:name => 'Rebuild chanora_bridge.framework from Rust',
:script => <<-SCRIPT,
set -e
REPO_ROOT="$(cd "${PODS_TARGET_SRCROOT}/../../.." && pwd)"
USER_NAME="$(id -un)"
USER_HOME="$(dscl . -read "/Users/$USER_NAME" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
if [ -z "$USER_HOME" ]; then
USER_HOME="$(cd ~ && pwd)"
fi
find_cargo() {
for candidate in \
"$USER_HOME/.cargo/bin/cargo" \
"/opt/homebrew/opt/rustup/bin/cargo" \
"/usr/local/opt/rustup/bin/cargo"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v cargo
}
find_rustc() {
for candidate in \
"$USER_HOME/.cargo/bin/rustc" \
"/opt/homebrew/opt/rustup/bin/rustc" \
"/usr/local/opt/rustup/bin/rustc"
do
if [ -x "$candidate" ]; then
echo "$candidate"
return 0
fi
done
command -v rustc
}
CARGO_BIN="$(find_cargo)"
RUSTC_BIN="$(find_rustc)"
# Prepend Homebrew's bin dir to PATH so `cmake` (used by
# audiopus_sys's libopus source build) is found. Xcode's
# script_phase PATH sanitisation strips /opt/homebrew/bin,
# which on Apple Silicon hosts is where Homebrew tools live.
export PATH="/opt/homebrew/bin:$PATH"
if [ "${PLATFORM_NAME:-iphoneos}" = "iphonesimulator" ]; then
RUST_TARGET="aarch64-apple-ios-sim"
SUPPORTED_PLATFORM="iPhoneSimulator"
else
RUST_TARGET="aarch64-apple-ios"
SUPPORTED_PLATFORM="iPhoneOS"
fi
BRIDGE="$REPO_ROOT/target/$RUST_TARGET/release/libchanora_bridge.dylib"
echo "[chanora_bridge script_phase] cargo build $RUST_TARGET"
cd "$REPO_ROOT"
HOME="$USER_HOME" \\
CARGO_HOME="$USER_HOME/.cargo" \\
RUSTUP_HOME="$USER_HOME/.rustup" \\
RUSTUP_TOOLCHAIN="stable-aarch64-apple-darwin" \\
RUSTC="$RUSTC_BIN" \\
IPHONEOS_DEPLOYMENT_TARGET=16.0 \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
CMAKE_OSX_DEPLOYMENT_TARGET=16.0 \\
"$CARGO_BIN" build --release --target "$RUST_TARGET" -p chanora_bridge
cd "$REPO_ROOT/apps/chanora_flutter/ios"
FW=Frameworks/chanora_bridge.framework
# Skip the wrap step if the framework's binary is already
# up-to-date with the cargo output (fast no-op on incremental
# builds where Rust didn't change). We still publish the dSYM
# into DWARF_DSYM_FOLDER_PATH below so archive builds always
# have the symbols, even when the framework itself is cached.
FW_UP_TO_DATE=0
if [ -f "$FW/chanora_bridge" ] && [ "$FW/chanora_bridge" -nt "$BRIDGE" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date"
FW_UP_TO_DATE=1
fi
if [ "$FW_UP_TO_DATE" = 0 ]; then
mkdir -p "$FW"
cp "$BRIDGE" "$FW/chanora_bridge"
cat > "$FW/Info.plist" <<PLIST
<?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>CFBundleExecutable</key><string>chanora_bridge</string>
<key>CFBundleIdentifier</key><string>app.chanora.bridge</string>
<key>CFBundleName</key><string>chanora_bridge</string>
<key>CFBundlePackageType</key><string>FMWK</string>
<key>CFBundleShortVersionString</key><string>1.0.0</string>
<key>CFBundleVersion</key><string>1</string>
<key>CFBundleSupportedPlatforms</key><array><string>$SUPPORTED_PLATFORM</string></array>
<key>MinimumOSVersion</key><string>16.0</string>
</dict>
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/chanora_bridge" \\
"$FW/chanora_bridge"
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed (with dSYM)"
fi
# Publish the dSYM into Xcode's archive dSYM folder on every
# build (cached or not). Without this the archive validator
# fails with "archive did not include a dSYM for the
# chanora_bridge.framework with the UUIDs [<uuid>]" and the
# IPA cannot be uploaded to App Store Connect / TestFlight.
# ${DWARF_DSYM_FOLDER_PATH} resolves to <ARCHIVE>/dSYMs for
# archive builds and <BUILT_PRODUCTS_DIR> otherwise; both paths
# are the ones xcodebuild scans when collecting symbols.
if [ -n "${DWARF_DSYM_FOLDER_PATH:-}" ] && [ -d "$FW.dSYM" ]; then
mkdir -p "$DWARF_DSYM_FOLDER_PATH"
rm -rf "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
cp -R "$FW.dSYM" "$DWARF_DSYM_FOLDER_PATH/chanora_bridge.framework.dSYM"
echo "[chanora_bridge script_phase] dSYM published to $DWARF_DSYM_FOLDER_PATH"
fi
SCRIPT
:execution_position => :before_compile,
}
# The pod has no Objective-C sources; it's purely a framework
# carrier. Suppress CocoaPods's source-file warning.
s.source_files = 'Frameworks/chanora_bridge.framework/Headers/*.h'
# No public Swift / ObjC API to expose — the Flutter app talks to
# the framework via FRB's FFI symbol lookup at runtime, not via
# imports.
end