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.
This commit is contained in:
Edison Jwa
2026-06-07 23:12:07 +09:00
parent ad8b996376
commit a589ac953f
22 changed files with 582 additions and 101 deletions
@@ -1,2 +1,6 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
// Mirror Flutter-Release.xcconfig (see explanation there).
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
STRIP_STYLE = non-global
@@ -1,2 +1,11 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "ephemeral/Flutter-Generated.xcconfig"
// macOS Release defaults to DEAD_CODE_STRIPPING = YES. Without these flags the
// Swift @_cdecl symbols below would be stripped because no Swift caller exists;
// the chanora_bridge Rust framework resolves them at runtime via
// dlsym(RTLD_DEFAULT) and would silently fall back to WebRTC VAD.
OTHER_LDFLAGS = $(inherited) -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_create -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_destroy -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_reset -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_process -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_last_error -Xlinker -exported_symbol -Xlinker _chanora_silero_vad_free_string
// See ios/Flutter/Release.xcconfig for the STRIP_STYLE rationale.
STRIP_STYLE = non-global
+1 -1
View File
@@ -13,7 +13,7 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral
SPEC CHECKSUMS:
chanora_bridge: 4105993843b5421ee4ce72220a74c63f6fd99103
chanora_bridge: 9d1469952801a1caa3bb56d5d3bce91df8dca4ad
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
PODFILE CHECKSUM: 99f0d126cab50f07c488b8550ebf033d2e8bcaeb
@@ -248,6 +248,7 @@
4287874B577AE59BBE39386D /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
CA110002000000000000A200 /* Verify Silero Exports */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
@@ -441,6 +442,21 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
CA110002000000000000A200 /* Verify Silero Exports */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Verify Silero Exports";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${SRCROOT}/../scripts/verify_silero_exports.sh\"\n";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -7,6 +7,10 @@ class AppDelegate: FlutterAppDelegate {
override func applicationDidFinishLaunching(_ notification: Notification) {
super.applicationDidFinishLaunching(notification)
DispatchQueue.global(qos: .utility).async {
ChanoraSileroSelfTest.run()
}
// Ask for microphone access on launch rather than on first
// voice-channel join. Matches user expectations for a voice
// chat client and saves the user from a surprising prompt
@@ -22,6 +22,8 @@
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
@@ -1,4 +1,5 @@
import CoreML
import Darwin
import Foundation
import SileroCoreML
@@ -96,3 +97,97 @@ public func chanoraSileroVadFreeString(_ string: UnsafeMutablePointer<CChar>?) {
guard let string else { return }
free(string)
}
@objc public final class ChanoraSileroSelfTest: NSObject {
// Validates the same code path the Rust framework uses: dlsym(RTLD_DEFAULT) for all
// six @_cdecl symbols, then exercises create -> reset -> process -> destroy. Catches
// the dead-strip / linker-export class of bug that broke TestFlight; calling the Swift
// functions directly would mask it because direct calls bypass the dynamic symbol table.
@objc public static func run() {
let started = DispatchTime.now()
typealias CreateFn = @convention(c) () -> UnsafeMutableRawPointer?
typealias DestroyFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
typealias ResetFn = @convention(c) (UnsafeMutableRawPointer?) -> Int32
typealias ProcessFn = @convention(c) (
UnsafeMutableRawPointer?, UnsafePointer<Float>?, Int, UnsafeMutablePointer<Float>?
) -> Int32
typealias LastErrorFn = @convention(c) () -> UnsafeMutablePointer<CChar>?
typealias FreeStringFn = @convention(c) (UnsafeMutablePointer<CChar>?) -> Void
func resolve<T>(_ name: String, as type: T.Type) -> T? {
guard let raw = dlsym(UnsafeMutableRawPointer(bitPattern: -2), name) else {
return nil
}
return unsafeBitCast(raw, to: type)
}
let names = [
"chanora_silero_vad_create",
"chanora_silero_vad_destroy",
"chanora_silero_vad_reset",
"chanora_silero_vad_process",
"chanora_silero_vad_last_error",
"chanora_silero_vad_free_string",
]
let missing = names.filter { dlsym(UnsafeMutableRawPointer(bitPattern: -2), $0) == nil }
if !missing.isEmpty {
NSLog("chanora_flutter: SileroCoreML self-test FAILED dlsym missing=\(missing.joined(separator: ","))")
return
}
guard
let create = resolve("chanora_silero_vad_create", as: CreateFn.self),
let destroy = resolve("chanora_silero_vad_destroy", as: DestroyFn.self),
let reset = resolve("chanora_silero_vad_reset", as: ResetFn.self),
let process = resolve("chanora_silero_vad_process", as: ProcessFn.self),
let lastError = resolve("chanora_silero_vad_last_error", as: LastErrorFn.self),
let freeString = resolve("chanora_silero_vad_free_string", as: FreeStringFn.self)
else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED unsafeBitCast resolution")
return
}
func readError() -> String {
guard let ptr = lastError() else { return "unknown" }
let msg = String(cString: ptr)
freeString(ptr)
return msg
}
guard let handle = create() else {
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at create err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let resetRc = reset(handle)
if resetRc != 0 {
destroy(handle)
let elapsedMs = elapsedMs(since: started)
NSLog("chanora_flutter: SileroCoreML self-test FAILED at reset rc=\(resetRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
return
}
let chunkSize = SileroVADRunner.chunkSize
var probability: Float = 0
let samples = [Float](repeating: 0, count: chunkSize)
let processRc = samples.withUnsafeBufferPointer { buf -> Int32 in
process(handle, buf.baseAddress, chunkSize, &probability)
}
destroy(handle)
let elapsedMs = elapsedMs(since: started)
if processRc == 0 {
NSLog("chanora_flutter: SileroCoreML self-test OK probability=\(probability) elapsed_ms=\(elapsedMs)")
} else {
NSLog("chanora_flutter: SileroCoreML self-test FAILED at process rc=\(processRc) err=\(readError()) elapsed_ms=\(elapsedMs)")
}
}
private static func elapsedMs(since start: DispatchTime) -> String {
let ns = DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds
return String(format: "%.1f", Double(ns) / 1_000_000.0)
}
}
@@ -52,7 +52,7 @@ Pod::Spec.new do |s|
echo "[chanora_bridge.podspec] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:$PATH" \\
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\
@@ -60,7 +60,7 @@ Pod::Spec.new do |s|
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge.podspec] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:$PATH" \\
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \\
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \\
CMAKE_POLICY_VERSION_MINIMUM=3.5 \\
LIBOPUS_STATIC=1 \\
@@ -112,7 +112,19 @@ PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \\
"$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge.podspec] framework ready at $FW"
# 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). dsymutil reads
# the DWARF that cargo emitted (enabled by [profile.release]
# debug = true at the workspace root) and writes the bundle next
# to the framework. We then strip the in-framework binary so the
# shipped app stays slim; symbols live in the dSYM bundle, which
# is the layout xcodebuild -exportArchive and notarisation expect.
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge.podspec] framework + dSYM ready at $FW"
SCRIPT
# Pod CocoaPods picks this up; the framework gets embedded into
@@ -141,7 +153,7 @@ PLIST
echo "[chanora_bridge script_phase] cargo build aarch64-apple-darwin"
cd "$REPO_ROOT"
PATH="$HOME/.cargo/bin:$PATH" \
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \
@@ -149,7 +161,7 @@ PLIST
cargo build --release --target aarch64-apple-darwin -p chanora_bridge
echo "[chanora_bridge script_phase] cargo build x86_64-apple-darwin"
PATH="$HOME/.cargo/bin:$PATH" \
PATH="$HOME/.cargo/bin:/opt/homebrew/opt/rustup/bin:/opt/homebrew/bin:$PATH" \
MACOSX_DEPLOYMENT_TARGET=#{MACOS_BRIDGE_DEPLOYMENT_TARGET} \
CMAKE_POLICY_VERSION_MINIMUM=3.5 \
LIBOPUS_STATIC=1 \
@@ -158,27 +170,31 @@ PLIST
cd "$REPO_ROOT/apps/chanora_flutter/macos"
FW=Frameworks/chanora_bridge.framework
FW_UP_TO_DATE=0
# 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).
# 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.
if [ -f "$FW/Versions/A/chanora_bridge" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_ARM64" ] && [ "$FW/Versions/A/chanora_bridge" -nt "$BRIDGE_X86_64" ]; then
echo "[chanora_bridge script_phase] framework already up-to-date"
exit 0
FW_UP_TO_DATE=1
fi
# Create universal binary with lipo.
mkdir -p "$(dirname "$UNIVERSAL")"
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
if [ "$FW_UP_TO_DATE" = 0 ]; then
# Create universal binary with lipo.
mkdir -p "$(dirname "$UNIVERSAL")"
lipo -create "$BRIDGE_ARM64" "$BRIDGE_X86_64" -output "$UNIVERSAL"
rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources"
ln -sfh A "$FW/Versions/Current"
ln -sfh Versions/Current/Resources "$FW/Resources"
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
rm -rf "$FW"
mkdir -p "$FW/Versions/A/Resources"
ln -sfh A "$FW/Versions/Current"
ln -sfh Versions/Current/Resources "$FW/Resources"
cp "$UNIVERSAL" "$FW/Versions/A/chanora_bridge"
ln -sfh Versions/Current/chanora_bridge "$FW/chanora_bridge"
cat > "$FW/Versions/A/Resources/Info.plist" <<PLIST
cat > "$FW/Versions/A/Resources/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">
@@ -195,9 +211,24 @@ PLIST
</plist>
PLIST
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
"$FW/Versions/A/chanora_bridge"
echo "[chanora_bridge script_phase] framework refreshed"
install_name_tool -id "@rpath/chanora_bridge.framework/Versions/A/chanora_bridge" \
"$FW/Versions/A/chanora_bridge"
rm -rf "$FW.dSYM"
xcrun dsymutil "$FW/Versions/A/chanora_bridge" -o "$FW.dSYM"
xcrun strip -S -x "$FW/Versions/A/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). See the iOS podspec for the full
# rationale — same constraint applies to macOS notarisation
# and archive-based distribution.
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,
}