Integrating Rust with SwiftUI using UniFFI

Introduction

This article is about integrating Rust through UniFFI with SwiftUI and some problems I ran into. It’s mostly focused on the troubleshooting aspect because UniFFI already has some documentation on how to integrate with SwiftUI and Xcode. However, when I tried to follow them I ran into build issues with Xcode. I did use an LLM to work through the problem since I’m unfamiliar with Xcode and most things iOS development. I used UniFFI v0.31, I’m on Swift 6 and compiled for iOS 18.6 using Xcode 26. I assume some familiarity with Swift, SwiftUI and Rust.

TLDR troubleshooting issues

This will only make sense if you’ve gone through trying to integrate a Rust project with Xcode with UniFFI. This is basically an LLM summary of the issues I ran into as they happened.

Error: Permission denied

Script-XXXXX.sh: line 2: .../xc-universal-binary.sh: Permission denied

Fix: Make the script executable:

chmod +x xc-universal-binary.sh

Error: Sandbox deny file-read-data

Sandbox: bash(XXXXX) deny(1) file-read-data .../xc-universal-binary.sh

Fix: Disable User Script Sandboxing in Build Settings.

Error: Could not find Cargo.toml

could not find Cargo.toml in /Users/you/Projects/MyApp\ or any parent directory

Fix: Add cd “$SRC_ROOT” to the script after setting variables (see complete script above).

Error: Linking with cc failed (iOS SDK architecture mismatch)

ld: warning: ignoring file '.../iPhoneOS.sdk/usr/lib/libSystem.tbd': 
    missing required architecture x86_64

Fix: The script must unset Xcode’s environment variables that pollute the build:

unset SDKROOT
unset LIBRARY_PATH
unset SDK_DIR
unset CC CXX LD AR
unset LDFLAGS CFLAGS CPPFLAGS CXXFLAGS
export SDKROOT=$(xcrun --sdk macosx --show-sdk-path)

Error: Undefined symbols / Symbol not found

Undefined symbols for architecture arm64:
  "_your_function_name", referenced from: ...

Fix: Ensure the static library is added to Link Binary With Libraries in Build Phases.

Error: Xcode copying Rust build artifacts

Target 'App' has copy command from '.../target/debug/incremental/...'

Fix: The target/ directory was accidentally added to the Xcode project.

In Project Navigator, find the target folder Right-click → Delete → Remove Reference (not Move to Trash) Clean Build Folder (Cmd + Shift + K)

Important: Never add the target/ directory to your Xcode project. Only add:

  • The generated Swift bindings (out/your_library.swift)
  • The bridging header
  • Optionally, the build script

Motivation

My main motivation for using Rust and UniFFI was needing URL validation. In the app I’m developing the user has the option to provide a link for a fabric they’re using. I needed to ensure that the URL is valid before saving it to the database. I thought I could use the URL type provided by Apple’s Foundation library but it didn’t do what I expected. They use URLs to represent both web URLs and files. The code below is an example of a url that was valid. This initialization should return nil if the URL can’t be parsed, however it succeeds.

let url = URL(string: "hello")

I did find out, eventually, that there is an option to parse urls in the docs, but I found it too late. Somehow all my searches, every stackoverflow post, forum post, or article made no mention of this API even though it was released in 2022. If you’re lazy like me, read the docs carefully. At this point, I had already gone into using Rust with SwiftUI and knew I wanted to use UniFFI for other integrations like database and filesystem functionality. I also wanted to use UniFFI in the future for things like a search engine with tantivy and fuzzy matching with Nucleo. I eventually want to make an Android port of my app and keeping as much logic in a shared Rust core should hopefully make that easier. This was an opportunity to try out Rust and UniFFI on something small.

I’m not going to go too much into how to use UniFFI with Rust. I followed the tutorial first and then looked at the type model docs on interfaces and objects and remote types. Since the Url type provided by the Url crate was an external type I needed to let UniFFI know that it was a remote type. I opted to use the procedural macros because it seemed more powerful and I wouldn’t need to learn UDL, which is the interface definition language used. Be sure follow the tutorial first before continuing with the other steps here.

This is the rust code I wrote to use the Url crate.

use std::fmt::Display;

use url::Url;

uniffi::setup_scaffolding!();

#[derive(Debug, uniffi::Error)]
pub enum UrlError {
    Err(String),
}

impl Display for UrlError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            UrlError::Err(err) => write!(f, "URL error: {}", err),
        }
    }
}

#[uniffi::export]
fn parse_url(url: String) -> Result<Url, UrlError> {
    Url::parse(&url).map_err(|_| UrlError::Err("Not a valid URL".to_string()))
}

type AppUrl = Url;

// copied from Url struct definition
#[uniffi::remote(Object)]
struct AppUrl {
    serialization: String,

    // Components
    scheme_end: u32,  // Before ':'
    username_end: u32,  // Before ':' (if a password is given) or '@' (if not)
    host_start: u32,
    host_end: u32,
    host: HostInternal,
    port: Option<u16>,
    path_start: u32,  // Before initial '/', if any
    query_start: Option<u32>,  // Before '?', unlike Position::QueryStart
    fragment_start: Option<u32>,  // Before '#', unlike Position::FragmentStart
}

Up to this point everything was straight forward. Now I needed to integrate this code with my app. I looked at the UniFFI docs on integrating with Xcode. I followed the steps in the readme here for the example project, but I ran into several issues after taking the steps I listed below. If you’re also trying to integrate with Xcode go through those steps first; if you’re successful then great, but if you have any confusions or issues then continue reading and hopefully it helps.

Steps I took

I copied the xc-universal-binary.sh into the root of my Rust project, named url_validation.

I then generated the swift bindings with this

cargo build
cargo run --bin uniffi-bindgen generate --library target/debug/lib<your_library>.dylib --language swift --out-dir out

So since my rust library was named url_validation the flag value for the flag --library would be target/debug/liburl_validation.dylib. After running these commands you should have an out directory in the root of your rust project which contains your_libraryFFI.h and your_library.swift. It should be like this

├── rust_library/
│   ├── Cargo.toml
│   ├── src/
│   │   └── lib.rs
│   ├── out/
│   │   ├── your_libraryFFI.h      # Generated C header
│   │   └── your_library.swift      # Generated Swift bindings
│   ├── Bridging_Header.h
│   └── xc-universal-binary.sh      # Build script

The Bridging_Header.h should have been generated according to the UniFFI docs but it didn’t for me. In that case create the file yourself.

//
//  Use this file to import your target's public headers that you would like to expose to Swift.

//

#ifndef Bridging_Header_h
#define Bridging_Header_h

// this is the relative path(to your library's root) to the generated C headers
// if you have other generated libraries they would go here as well
#import "out/your_libraryFFI.h"

#endif // !Bridging_Header_h

Issues I ran into

Don’t worry about UDL files if you’re using the proc-macro

The example docs use UDL files, but since I’m using the proc-macro I don’t need to let Xcode know how to process any UDL files. I was confused on what I should do since I don’t use UDL files, but I didn’t have to do anything.

Make sure you add the libyour_library.a file

In the docs under the title Compiling the Rust crate. I did not make sure to include the resulting libyour_library.a file in the “Link Binary with Libraries” build phase. The file can be found in your rust crate under /target/aarch64-apple-ios/debug.

Run script

The next part that I was a bit confused with was adding the build script to compile Rust. The example docs said to add the build script to build the universal binary. I didn’t initially realize that they had their xc-universal-binary.sh in the root of the iOS project. I initially placed the script within my rust project. So ensure that, when you paste this command

xc-universal-binary.sh <FFI_TARGET> <WORKSPACE_PATH> <BUILD_CONFIGURATION>"

for the run script, you correctly specify the location of the xc-universal-binary.sh. For example, with the steps I’ve taken so far, the command looks like

"$SRCROOT/url_validation/xc-universal-binary.sh" url_validation "$SRCROOT/url_validation" $CONFIGURATION

Don’t do what I did. Ideally, place the xc-universal-binary.sh into your iOS project folder, like the example docs. Moving it out of your Rust library makes more sense because the binary isn’t specific to any project. If you have multiple rust libraries the script will be used by Xcode to build all of them, so having it in a single location would be better than per library.

Also you might want to, at least initially, uncheck Based on dependency analysis which will force Xcode to run the build script even during incremental builds. This option is found under the script textbox in the run script block. This is nice just to get going, but you will want to recheck to save on build times of course. When you do that you will need to let Xcode know which input files it needs to track in order to know when to rebuild. So for a rust crate you would need to add all the files in src and Cargo.toml.

Issues with xc-universal-binary.sh and Xcode

Permission denied

One issue is that Xcode didn’t have permission to run the script. So if you get a permission denied build error in Xcode then run chmod +x on the xc-universal-binary.sh, like chmod +x /path/to/xc-universal-binary.sh in your terminal.

Xcode sandboxing blocking

After I fixed the permission denied issue I ran into this one

Got a new build error `
Showing Recent Errors Only
Sandbox: bash(10864) deny(1) file-read-data /path/to/url_validation/xc-universal-binary.sh
`

I found some information on this here. Essentially Xcode is blocking the script from modifying files for security reasons and general safety. Make sure you trust the scripts you’re running if you disable this protection. You can either disable it entirely or add the files that the script has permission to modify. I opted to disable it for now, which I won’t recommend. You can find the setting in the GUI under Build Settings and searching for User Script Sandboxing.

Cargo.toml not found

If you run into this error could not find Cargo.toml in /path/to/source_root. This means your script isn’t running from the root of the rust crate. You need to cd into it. Add this line

cd "$SRC_ROOT"

to line 32 in the script right after the RELFLAG block

RELFLAG=
if [[ "${BUILDVARIANT}" != "debug" ]]; then
    RELFLAG=--release
fi

# the new line
cd "$SRC_ROOT"
Environment variable issues

At this point the script executed cargo build and compilation was finally happening. I then ran into linking issues with errors like

clang: error: linker command failed with exit code 1 (use -v to see invocation)
          

error: could not compile `getrandom` (build script) due to 1 previous error
warning: build failed, waiting for other jobs to finish...
error: linking with `cc` failed: exit status: 1

The problem boils down to, from the LLM, “Xcode sets a lot of environment variables that can interfere with cross-compilation” such as the ones below:

I went through a few iterations of suggestions from the LLM and eventually settled on what it called the nuclear option. This resulted in these modifications to the script

Here's the the entire modified script to refer back to
#!/usr/bin/env bash
set -eEuvx

function error_help()
{
    ERROR_MSG="It looks like something went wrong building the Example App Universal Binary."
    echo "error: ${ERROR_MSG}"
}
trap error_help ERR

# Save the variables we need from Xcode
SAVED_ARCHS="${ARCHS}"
SAVED_LLVM_TARGET_TRIPLE_SUFFIX="${LLVM_TARGET_TRIPLE_SUFFIX:-}"

# Completely clear all Xcode environment pollution
unset SDKROOT
unset IPHONEOS_DEPLOYMENT_TARGET
unset TVOS_DEPLOYMENT_TARGET
unset WATCHOS_DEPLOYMENT_TARGET
unset CC CXX LD AR
unset LDFLAGS CFLAGS CPPFLAGS CXXFLAGS
unset OTHER_LDFLAGS
unset LIBRARY_PATH
unset SDK_DIR

# Reset PATH to get cargo
PATH="$(bash -l -c 'echo $PATH')"

# Force macOS SDK for host compilation
export SDKROOT=$(xcrun --sdk macosx --show-sdk-path)

# Restore what we need
ARCHS="${SAVED_ARCHS}"
LLVM_TARGET_TRIPLE_SUFFIX="${SAVED_LLVM_TARGET_TRIPLE_SUFFIX}"

if [[ "${#}" -ne 3 ]]; then
    echo "Usage (note: only call inside xcode!):"
    echo "path/to/build-scripts/xc-universal-binary.sh <FFI_TARGET> <SRC_ROOT_PATH> <buildvariant>"
    exit 1
fi

FFI_TARGET=${1}
SRC_ROOT=${2}
BUILDVARIANT=$(echo "${3}" | tr '[:upper:]' '[:lower:]')

RELFLAG=
if [[ "${BUILDVARIANT}" != "debug" ]]; then
    RELFLAG=--release
fi

cd "$SRC_ROOT"

IS_SIMULATOR=0
if [ "${LLVM_TARGET_TRIPLE_SUFFIX-}" = "-simulator" ]; then
  IS_SIMULATOR=1
fi

for arch in $ARCHS; do
  case "$arch" in
    x86_64)
      if [ $IS_SIMULATOR -eq 0 ]; then
        echo "Building for x86_64, but not a simulator build. What's going on?" >&2
        exit 2
      fi
      export CFLAGS_x86_64_apple_ios="-target x86_64-apple-ios"
      $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target x86_64-apple-ios
      ;;
    arm64)
      if [ $IS_SIMULATOR -eq 0 ]; then
        $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios
      else
        $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios-sim
      fi
      ;;
  esac
done

I’ll summarize(irony not lost on me) the choices the LLM made but I’ll also provide its explanation of everything. Basically I had to clear our the environment variables that Xcode injects because they clashed with the environment Cargo and some rust crates depend on to compile correctly. A lot of the issues boiled down to, the environment variables injected by Xcode caused the crates to build for iOS. Cargo needed to have the right SDK environment for compilation, which was macOS, and from there it would cross compile correctly for iOS.

LLM explanation of the script modifications.
1. Save Xcode Variables Before Clearing Environment
# Save the variables we need from Xcode
SAVED_ARCHS="${ARCHS}"
SAVED_LLVM_TARGET_TRIPLE_SUFFIX="${LLVM_TARGET_TRIPLE_SUFFIX:-}"

The script needs ARCHS and LLVM_TARGET_TRIPLE_SUFFIX from Xcode to decide which Rust target to build (device vs simulator, arm64 vs x86_64). Since we’re about to wipe all Xcode environment variables, we save these two first.

The :- syntax in ${LLVM_TARGET_TRIPLE_SUFFIX:-} means “use an empty string if the variable is unset” — this prevents an error when set -eEuvx is active and the variable doesn’t exist (which happens on device builds where there’s no suffix).


2. Unset Xcode Environment Variables
unset SDKROOT
unset IPHONEOS_DEPLOYMENT_TARGET
unset TVOS_DEPLOYMENT_TARGET
unset WATCHOS_DEPLOYMENT_TARGET
unset CC CXX LD AR
unset LDFLAGS CFLAGS CPPFLAGS CXXFLAGS
unset OTHER_LDFLAGS
unset LIBRARY_PATH
unset SDK_DIR

When Xcode runs a build script, it injects dozens of environment variables into the shell. These are meant for Apple’s own toolchain but wreak havoc on Cargo because:

  • SDKROOT — Xcode sets this to the iOS SDK path (e.g., iPhoneOS26.0.sdk). Rust’s build scripts and proc-macros need to compile for macOS (the host), not iOS. With this set, the linker tries to find libSystem, libm, etc. in the iOS SDK, which doesn’t have x86_64/macOS libraries.

  • IPHONEOS_DEPLOYMENT_TARGET, TVOS_DEPLOYMENT_TARGET, WATCHOS_DEPLOYMENT_TARGET — These tell compilers the minimum OS version to target. They can confuse the C compilers that Rust invokes for build scripts.

  • CC, CXX, LD, AR — Xcode overrides these to point to its own toolchain configured for iOS. Rust build scripts that compile C code (like getrandom, libc) use these variables and fail because they end up cross-compiling for iOS when they should compile for macOS.

  • LDFLAGS, CFLAGS, CPPFLAGS, CXXFLAGS, OTHER_LDFLAGS — Xcode injects iOS-specific flags (architectures, SDK paths, deployment targets) into these. Any C compilation triggered by Cargo inherits them and breaks.

  • LIBRARY_PATH — This tells the linker where to search for libraries. Xcode sets it to the iOS SDK’s usr/lib/. This is what caused the specific error: the linker found iPhoneOS26.0.sdk/usr/lib/libSystem.tbd which doesn’t contain x86_64, instead of the macOS version.

  • SDK_DIR — The original script used this to intentionally add iOS SDK paths to LIBRARY_PATH. Removing it ensures we don’t reintroduce the problem.


3. Reset PATH
# Reset PATH to get cargo
PATH="$(bash -l -c 'echo $PATH')"

Xcode sanitizes PATH to a minimal set of system directories, removing custom additions like ~/.cargo/bin. This line spawns a login shell (bash -l) to get your full PATH back, which includes the Cargo binary.

This was in the original script too — it’s unchanged, just repositioned after the unsets.


4. Force macOS SDK for Host Compilation
# Force macOS SDK for host compilation
export SDKROOT=$(xcrun --sdk macosx --show-sdk-path)

After unsetting Xcode’s iOS SDKROOT, we explicitly set it to the macOS SDK. This is what fixed the linker error:

ld: warning: ignoring file '.../iPhoneOS26.0.sdk/usr/lib/libSystem.tbd': 
    tapi error: missing required architecture x86_64

xcrun --sdk macosx --show-sdk-path returns something like /Applications/Xcode.app/.../MacOSX.sdk. Now when Cargo compiles build scripts for the host machine, the linker finds the correct macOS libraries.

The actual iOS cross-compilation still works because cargo rustc --target aarch64-apple-ios uses Rust’s own target specification to find the right iOS SDK — it doesn’t rely on the SDKROOT environment variable.


5. Restore Saved Variables
# Restore what we need
ARCHS="${SAVED_ARCHS}"
LLVM_TARGET_TRIPLE_SUFFIX="${SAVED_LLVM_TARGET_TRIPLE_SUFFIX}"

We put back the two variables that the script’s own logic depends on:

  • ARCHS — Used in the for arch in $ARCHS loop to know which architectures to build (e.g., arm64, x86_64)
  • LLVM_TARGET_TRIPLE_SUFFIX — Checked to determine if this is a simulator build (-simulator) or a real device build (empty)

6. Removed the LIBRARY_PATH / SDK_DIR Block

Original code that was removed:

if [[ -n "${SDK_DIR:-}" ]]; then
# Assume we're in Xcode, which means we're probably cross-compiling.
# In this case, we need to add an extra library search path for build scripts and proc-macros,
# which run on the host instead of the target.
# (macOS Big Sur does not have linkable libraries in /usr/lib/.)
export LIBRARY_PATH="${SDK_DIR}/usr/lib:${LIBRARY_PATH:-}"
fi

This block was the root cause of the linker errors. Despite its comment saying it helps build scripts, it actually does the opposite — it adds the iOS SDK’s library path to LIBRARY_PATH, so when Cargo compiles host build scripts (macOS x86_64), the linker finds iOS ARM64 libraries and fails.

The comment about macOS Big Sur was correct historically (Apple removed linkable libraries from /usr/lib/), but the fix is to point at the macOS SDK, not the iOS SDK. Our export SDKROOT=$(xcrun --sdk macosx --show-sdk-path) handles this correctly.


7. Added cd "$SRC_ROOT"
cd "$SRC_ROOT"

The original script accepted SRC_ROOT as a parameter but never changed to that directory. When Xcode runs the script, the working directory is the Xcode project root (e.g., /Users/art/Projects/Sewing Planner), not the Rust project directory.

Cargo looks for Cargo.toml in the current working directory (or a parent). Without this cd, it searched /Users/art/Projects/Sewing Planner and all parents, never finding it:

could not find `Cargo.toml` in `/Users/art/Projects/Sewing Planner` or any parent directory

8. Added Missing ;; on arm64 Case
  arm64)
  if [ $IS_SIMULATOR -eq 0 ]; then
      $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios
  else
      $HOME/.cargo/bin/cargo rustc -p "${FFI_TARGET}" --lib --crate-type staticlib $RELFLAG --target aarch64-apple-ios-sim
  fi
  ;;

The original script was missing the ;; terminator on the arm64) case branch. In bash case statements, each branch should end with ;;. While bash can sometimes handle this when it’s the last case before esac, adding it is correct and prevents subtle bugs if new cases are added later.

After working through the issues that cropped up I was finally able to call my rust code from swift like

do {
    let validUrl = try parseUrl(url: "https://example.com/")
    print("URL is valid!")
} catch let error as UrlError {
    print("Invalid URL: \(error.localizedDescription)")
}

There are improvements I can make to the library. I probably don’t need to return an error because I don’t need to act upon the potential parsing errors. I would just let the user know that they didn’t enter a valid URL and maybe provide a template what a correct URL should look like.

Conclusion

Someone could have easily worked through these issues with an LLM of course or someone more knowledgeable about scripting and Xcode. I wanted to write this in case someone runs into issues similar to my own when trying to integrate Rust with SwiftUI. I’m also planning on using UniFFI to put my database and filesystem code in Rust land.

If you find any inaccuracies or betters way to do things then I’m happy to make changes. I didn’t fully investigate every issue myself to see if there is a better way to do things so I’m sure there is a lot to improve. Thanks for reading.