# Rust shared core, part 4: delivering it to iOS as an XCFramework

> Passing Rust tests does not mean an iOS app links. Bundle device and simulator static libraries into an XCFramework, ship it as a Swift package, and align CI.

- Canonical: https://jaemyeong.com/en/blog/rust-shared-core-04-apple-xcframework/
- Published: 2026.07.01
- Updated: 2026.10.04
- Category: IT/기술
- Tags: #Rust, #iOS, #Xcode, #SwiftPM, #FFI

Using a shared Rust core in an iOS app takes more than passing tests on the Rust side. That is not a guarantee that the app links. Results can differ between the simulator and a device, or between a development machine and CI, and the binary checksum of a Swift package can fail to match.

Part 4 goes through delivering the Rust binary and the Swift calling layer to an Apple app, based on the Apple Developer, Rust Reference, rustc, and UniFFI documentation. I reopened and checked these documents on October 4, 2026.

## Bundle the static libraries into an XCFramework

The crate types for Rust output that another language links against include `staticlib` and `cdylib` ([linkage in the Rust Reference](https://doc.rust-lang.org/reference/linkage.html)). On iOS, the approach is to package a static library as an XCFramework.

The device build and the simulator build are made separately, the headers and the module map are prepared, and `xcodebuild -create-xcframework` combines them. Written out conceptually, the flow is this.

```text
cargo build --target aarch64-apple-ios
cargo build --target aarch64-apple-ios-sim

libdomain_core.a
headers/
module.modulemap

-> DomainCore.xcframework
```

The supported targets are listed in [rustc's Apple iOS platform notes](https://doc.rust-lang.org/rustc/platform-support/apple-ios.html). The block above is not commands that were run with their output. It is a picture of how the inputs relate to the artifact.

An [XCFramework](https://developer.apple.com/documentation/xcode/creating-a-multi-platform-binary-framework-bundle) can hold a variant per platform and architecture in one bundle. iOS devices, the simulator, macOS, and visionOS are examples. When it is used as a binary dependency of an Xcode project or a Swift package, Xcode can choose the slice that matches the build destination.

Reproducing the build needs matching conditions. I recommend making the installed targets, the toolchain version, the profile, the output location, and the place where headers are generated the same on development machines and in CI. I also recommend putting target installation and packaging into a script. The values of the environment in which I built the example are in the section "An XCFramework bundled from an example."

The headers, the module map, and the generated Swift bindings have to match each other. If the code UniFFI produced and the Rust library are from different versions, linking can fail or symbol problems can appear at run time. So I suggest managing the two generation steps as one release unit.

## An XCFramework bundled from an example

I ran the flow above for real with a small example. The example is deliberately small. Its job is to check the structure, and it is not a real app. The run was on October 4, 2026, on macOS 27.0.1 with Xcode 27.0, cargo 1.96.0, and uniffi 0.29.5. The Rust targets `aarch64-apple-ios` and `aarch64-apple-ios-sim` were already installed. The `crate-type` of the binding crate includes `staticlib`.

For the header and the module map, I used what UniFFI generated together with the Swift bindings. The generated `sudoku_uniffiFFI.modulemap` was copied under the name `module.modulemap`. Below are the lines of the script that was run which belong to this step. The `sed` at the end shortens the absolute path printed in the output, and `out/host` is the directory used for the Swift call in part 3.

```bash
mkdir -p out/headers out/host
cp out/swift/sudoku_uniffiFFI.h out/headers/
cp out/swift/sudoku_uniffiFFI.modulemap out/headers/module.modulemap
# …
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios
cargo build -q -p sudoku-uniffi --release --target aarch64-apple-ios-sim
xcodebuild -create-xcframework \
  -library target/aarch64-apple-ios/release/libsudoku_uniffi.a -headers out/headers \
  -library target/aarch64-apple-ios-sim/release/libsudoku_uniffi.a -headers out/headers \
  -output out/SudokuCore.xcframework | sed 's#: .*/out/#: out/#'
```

This is the output and the directory that was produced.

```text
xcframework successfully written out to: out/SudokuCore.xcframework
SudokuCore.xcframework
SudokuCore.xcframework/Info.plist
SudokuCore.xcframework/ios-arm64
SudokuCore.xcframework/ios-arm64-simulator
SudokuCore.xcframework/ios-arm64-simulator/Headers
SudokuCore.xcframework/ios-arm64-simulator/libsudoku_uniffi.a
SudokuCore.xcframework/ios-arm64/Headers
SudokuCore.xcframework/ios-arm64/libsudoku_uniffi.a
```

`Info.plist` held two entries, `ios-arm64` and `ios-arm64-simulator`. Each static library was about 31 MB. That is the size of a release build that was not stripped.

That is as far as the check went. I went one step further to see whether this XCFramework can really be consumed. I made a Swift package that points at the XCFramework with a `binaryTarget` and puts the generated Swift bindings in a target, and built it with `xcodebuild build` for the iOS simulator and for a device. Both succeeded. The module map generated by UniFFI was used unchanged. That is the result on Xcode 27.0, and other Xcode versions need their own check. Running an app in the simulator and `xcodebuild test` were not tried. Calling Rust through the generated Swift bindings was confirmed on macOS only, and that record is in part 3.

## Delivering through a Swift package

What an XCFramework holds, how `xcodebuild -create-xcframework` is used, and Rust's crate types and targets are documented. Preparing a module map, Xcode choosing a slice, and the symptoms of a version mismatch were not found in the documents checked this time. The scripting and the single release unit recommended in the previous section, and the package layout and CI practice from this section on, are not decided by the documentation. They are my own design choices.

With a single app, the XCFramework can be added to the project directly. With several apps, or with samples and test targets, I suggest using a [Swift package](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages). The binary target and the target for the Swift adapter can be separated.

```text
DomainEnginePackage/
  Package.swift
  Sources/
    DomainEngine/
      DomainEngineClient.swift
      UniFFIAdapter.swift
      GeneratedBindings.swift
  Artifacts/
    DomainCore.xcframework
```

The point of contact exposed to the app is `DomainEngineClient`. The generated code and the binary are treated as internal implementation. That reduces what the app has to know about the Rust connection, and the dependencies are clearer than piling up scripts in the project's build phases.

Distribution has decisions of its own: where to keep the artifact, how to manage the checksum of a remote ZIP, and how to match the version of the source package to the version of the binary. For internal use only, one way is to start with the artifact inside the repository and move it to release files once things are stable.

## Making local builds and CI follow the same order

For integration on the Apple side, I think managing the Swift layer, the package, the build phases, and CI takes more of the work than calling Rust functions does.

A local machine can still have targets that were installed earlier and artifacts that were built before. CI is assumed to start from a fresh clone. Different paths cause problems as well. So I recommend managing the inputs and the order in one place.

```text
install/check rust targets
cargo build for ios device
cargo build for ios simulator
generate UniFFI Swift bindings
generate headers/module map
create XCFramework
run xcodebuild test
```

This list names the steps to put into automation, not a shell script that runs as written.

Compiling Rust in a build phase every time can lengthen the edit-and-run cycle during development. I recommend setting inputs, outputs, and a cache locally, and running CI mainly as clean builds. A measured time for how much longer it gets is not in this post.

A setup that does not commit generated files adds one more condition. The required artifacts have to be generated automatically in the process of cloning fresh, opening the Xcode project, and building. Otherwise the first build of a newcomer fails.

## Put the UniFFI implementation behind a Swift protocol

Using the API generated by UniFFI directly in the app is an easy start. Over time, the range the app depends on can widen. So the UniFFI implementation goes behind a Swift protocol. This is the shape of the protocol I propose.

```swift
protocol SudokuEngineClient {
    func startGame(request: StartGameRequest) throws -> GameSnapshot
    func apply(_ action: GameAction, to snapshot: GameSnapshot) throws -> Transition
}
```

The ViewModel depends on this protocol, and the generated API is used only inside the adapter. Heavy Rust work can be wrapped in an async API in the adapter, with UI updates handled on the MainActor. With a fake client, the ViewModel can be tested by itself. Delivering the same structure to Android as a `.so` (ABI, Gradle, the NDK, and the 16 KB page size) is in part 5.

Three things are worth confirming. Build for both a device and the simulator. See that local builds and CI use the same commands. See that an Xcode build works from a fresh clone. Of these, the example covered building for a device and for the simulator. I also copied only the sources to a fresh directory and ran the same script from the start, and it passed to the end. Running in CI and building an Xcode project were not checked.
