Linking Swift code into a Rust app

A guide to statically linking Swift library into a Rust application.

Why would you want to do that?

I initially wrote my “KVM replacement” display-switch application twice, in Rust and Swift. The primary reason was that there is no MacOS-compatible DDC/CI control library for Rust. However, as the app became somewhat popular, and I wanted to add new features and improvements, I realized that this is not sustainable: I would have to implement every new feature twice.

So I thought: what if I extract only the MacOS-compatible DDC control Swift code as a separate library, and statically link it into the main Rust application?

There is plenty of information online how to achieve the opposite: link Rust into Swift app, but not vice versa. This blog post fills this gap.

Compiling the Swift code

For simplicity, I put the Swift portion of the code into the same repo as the main Rust app, in a subdirectory.

I recommend using Swift Package Manager manifest file to manage the Swift code build process: we don’t need to deal with XCode’s unsightly project files. For my project, the manifest was pretty simple. This is my mac_ddc/Package.swift:

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "mac_ddc",
    platforms: [
        .macOS(.v10_15),
    ],
    products: [
        .library(name: "mac_ddc", type: .static, targets: ["mac_ddc"]),
    ],
    dependencies: [
        .package(url: "https://github.com/reitermarkus/DDC.swift", .branch("master")),
    ],
    targets: [
        .target(name: "mac_ddc", dependencies: [.product(name: "DDC", package: "DDC.swift")], path: "src"),
    ]
)

Note the product definition above, this is what specifies that this is a static library project. From now on, I could just do swift build in the same directory and my library would be built.

Making the Swift code callable from Rust

Since Rust does not support Swift ABI, whatever I export from Swift must be using “C” calling conventions. I ended up writing shim functions to act as an intermediate between Swift and Rust code:

/// Use DDC to write inputSelect command
@_cdecl("ddcWriteInputSelect")
public func ddcWriteInputSelect(screenIdx: Int, input: UInt16) -> Bool {
    guard let ddc = ddc(for: screenIdx) else { return false }
    return ddc.write(command: .inputSelect, value: input)
}

From the Rust side, it’s as simple as:

/// These are exported by the Swift code in mac_ddc/src/mac_ddc.swift
extern "C" {
    fn ddcWriteInputSelect(display_idx: isize, input: u16) -> bool;
    ...
}

Building and linking the Swift code automatically

I wanted to use Cargo build scripts to automatically build and link the Swift code when I do cargo build to reduce the friction as much as possible.

My first, naive, build.rs was:

use std::env;
use std::process::Command;

/// Builds mac_ddc library Swift project
fn build_mac_ddc() {
    let profile = env::var("PROFILE").unwrap();
    let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();

    if !Command::new("swift")
        .args(&["build", "-c", &profile])
        .current_dir("./mac_ddc")
        .status()
        .unwrap()
        .success()
    {
        panic!("Swift library mac_ddc compilation failed")
    }

    println!("cargo:rustc-link-search=native=./mac_ddc/.build/{}-apple-macosx/{}", arch, profile);
    println!("cargo:rustc-link-lib=static=mac_ddc");
    println!("cargo:rerun-if-changed=mac_ddc/src/*.swift");
}

fn main() {
    let target = env::var("CARGO_CFG_TARGET_OS").unwrap();
    if target == "macos" {
        build_mac_ddc();
    }
}

This script achieves quite a bit:

  • Builds the Swift library on MacOS only.
  • Builds a Debug or Release version of Swift code automatically.
  • Fails the application build process build if Swift library build fails.
  • Re-builds automatically if the Swift source code changed.

Missing dependencies

Sadly, the linking stage fails: since version 5.0, Swift no longer supports static linking of its standard library on Apple platforms. So we need to link against Swift run-time library, dynamically.

How do we find where the run-time library is?

Swift provides a nice way to get all the information about where its run-time is. swift -print-target-info prints:

{
  "target": {
    "triple": "x86_64-apple-macosx10.15",
    "unversionedTriple": "x86_64-apple-macosx",
    "moduleTriple": "x86_64-apple-macos",
    "swiftRuntimeCompatibilityVersion": "5.1",
    "librariesRequireRPath": false
  },
  "paths": {
    "runtimeLibraryPaths": [
      "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx",
      "/usr/lib/swift"
    ],
    "runtimeLibraryImportPaths": [
      "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/macosx"
    ],
    "runtimeResourcePath": "/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift"
  }
}

The runtimeLibraryPaths key in the above JSON contains the list of paths that we need to add to Rust native library search path to make the linker happy and find all the dependencies. So let’s:

  • Use serde and serde-json to parse the above JSON and deserialize it into a Rust structure.
  • Add all the paths in the runtimeLibraryPaths array to rustc linker search path
use serde::Deserialize;
use std::env;
use std::process::Command;

const MACOS_TARGET_VERSION: &str = "10.15";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SwiftPaths {
    runtime_library_paths: Vec<String>,
}

#[derive(Debug, Deserialize)]
struct SwiftTarget {
    paths: SwiftPaths,
}

/// Builds mac_ddc library Swift project, sets the library search options right so we link
/// against Swift run-time correctly.
fn build_mac_ddc() {
    let profile = env::var("PROFILE").unwrap();
    let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
    let target = format!("{}-apple-macosx{}", arch, MACOS_TARGET_VERSION);

    let swift_target_info_str = Command::new("swift")
        .args(&["-target", &target, "-print-target-info"])
        .output()
        .unwrap()
        .stdout;
    let swift_target_info: SwiftTarget = serde_json::from_slice(&swift_target_info_str).unwrap();
    swift_target_info.paths.runtime_library_paths.iter().for_each(|path| {
        println!("cargo:rustc-link-search=native={}", path);
    });

    // Thr rest of the build process goes here...

And that’s it! Rust is able to find everything it needs, the build succeeds and the executable runs correctly. You can see the full build.rs source in the “display-switch” repository.

comments powered by Disqus