Davide De Rosa

Cross-platform Swift: Build system (pt. 2)

Cross-platform Swift: Build system (pt. 2)

part of a series: < prev | next >

Table of contents (ongoing)

  1. Introduction
  2. Combine
  3. Core libraries
  4. Platform specifics
  5. C interop (pt. 1)
  6. C interop (pt. 2)
  7. Build system (pt. 1)
  8. Build system (pt. 2)

Our goals

Before heading back to the Partout code, let’s focus on our ultimate goals:

  1. Build as Swift module on Apple platforms
  2. Build as dynamic library on other platforms (.so on Linux/Android, .dll on Windows)

Adding that:

  • Step 1 is natural with SwiftPM, except for how to manage dependencies
  • Step 2 involves OpenSSL (C), WireGuard (Go), and Wintun (Windows DLL) at the time of writing, plus the proper Swift runtime for the OS

Step 1: Building with SwiftPM

We know that SwiftPM can handle Swift, C, C++, and ObjC (Apple only) sources with the help of module maps. How do we include dependencies that don’t use neither of these languages, or depend on a custom build system? We bundle them as binary libraries.

We need to take a short break and spot a major annoyance of this: contrary to source files, binary libraries are tied to a CPU architecture. That’s why XCFramework was introduced, because it’s the best way to bundle binary libraries for multiple architectures. With a XCFramework, SwiftPM will pick the right binaries for the target architecture. This is exactly how I integrated OpenSSL and WireGuard into Partout, with the openssl-apple and wg-go-apple repositories respectively.

One may argue that OpenSSL, for example, is written in C and SwiftPM supports C, but a build system may go miles further than a programming language. Building OpenSSL is a very complex task, and distributing prebuilt binaries is a standard way to decouple from such complexity. In exchange, we accept the complexity of handling multiple architectures with an XCFramework.

The problem with XCFrameworks, though, is that they do not work on non-Apple platforms. Artifact bundles will solve this limitation, unless Swift 6.2 has already introduced them (I remember this was half-done in July 2025).

Step 2: Building with CMake

Needless to say, SwiftPM is not mature enough on non-Apple. In order to have consistent builds of Swift code with binary dependencies, we’ll have resort to one of the least loved build tools around: CMake.

You know the say “love and hate”. Well, the thought of CMake generally leans towards “hate and hate”, because I haven’t heard a single developer that enjoys using it. In all fairness, the low popularity of CMake might stem from being the typical build system of C++ projects. I mean, there could be a bias, but we don’t care here, because CMake is somewhat the only way to accomplish what we need.

Why CMake?

CMake is not a replacement for Make, it’s rather a “generator of makefiles”, with a makefile being not necessarily the Makefile file, but the configuration file of a build system. The main feature of CMake is the ability of coordinating multiple projects written in different languages and/or built with different build systems. Since we are assembling a Frankenstein project made of Swift, C (OpenSSL), Go (WireGuard), and other prebuilt binary libraries (Wintun), CMake comes to our rescue.

For the record, CMake supports Swift code only through the ninja generator.

The layout of our superproject

The first, comprehensive approach is to build our Swift library entirely with CMake. This is the most solid approach.

Partout is built in four steps:

  1. The vendors are built as binary libraries. OpenSSL produces libcrypto and libssl, WireGuard produces libwg-go.
  2. The C/C++ code of our package is compiled as a monolith. Manual module maps must be exposed in the headers search paths for Swift interop with C/C++ code (SwiftPM does this automatically).
  3. The Swift code of our package is compiled altogether, thus losing any notion of SwiftPM sub-targets. C modules are available to Swift through step 2.
  4. All the outputs are linked together into the final dynamic library.

Without delving into the very details of this complex task, we want to use a single CMake superproject to build our Swift project, Partout, plus all its dependencies. The superproject will treat both our Swift/C code and the vendors as opaque dependencies, i.e. subprojects. This way, CMake can ignore the internals of how any dependency is built.

Assume that each vendored third party comes with its own build system, and we orchestrate them all in a root CMakeLists.txt. The root configuration includes one CMake file (*.cmake) for each vendor, as you can see in the vendors directory of Partout. The .cmake files instruct the root configuration how to build each library as a subproject, and where to find the outputs, typically in the form of static or dynamic libraries.

Our package is also treated as a subproject, only as a monolith. By monolith, I mean that we give up on the granularity of SwiftPM targets and compile the Swift sources altogether. By doing this, the internal target imports will have to omitted, and we do that with the PARTOUT_MONOLITH symbol in the CMake project and in the Swift code.

You find the entry point of this long process in scripts/build.sh.

Hybrid SwiftPM/CMake

The full CMake approach works on all platforms except one: Android.

Why is that? Because, unless you want to rebuild the entire Swift toolchain for Android, the common way to target the Android platform is through a Swift SDK, and Swift SDKs only work with SwiftPM. On the other hand, building Swift for Android with CMake is still flaky and painful, so I’ll describe what we’re left with.

I chose to go with a hybrid build system where:

  • The Partout code (Swift/C) is built with SwiftPM, targeting the Swift Android SDK and the Android NDK.
  • The vendored libraries are still built with CMake, this time for Android (PP_BUILD_FOR_ANDROID in CMake).
  • SwiftPM depends on the CMake-built binaries via .unsafeFlags and generates a .dynamic library (libpartout.so).

The way I accomplish this is with a highly flexible Package.swift. A dynamic manifest helps a lot when you need to overcome the occasional limitations of SwiftPM, and the Package.swift of Partout handles, among other things, OS conditionals and environment variables as the build input.

For example:

  • PP_BUILD_OS works around the limitation of #if os(Android), because the condition fails unless a full Android toolchain is used.
  • PP_BUILD_CMAKE_OUTPUT tells the SwiftPM build where to find the arch-specific outputs of CMake for use with .unsafeFlags (the well-known -I, -L, and -l flags of the compiler/linker).

You can learn more about the whole process in scripts/build-android.sh.

Distribution

Will your Swift project work with libpartout.so|.dylib|.dll alone? Of course not. One reason is obvious, and it’s because you need to bundle the OpenSSL/WireGuard binaries too. The other reason is that your end-user will lack the Swift runtime, and this is still kind of a big deal.

As I mentioned in earlier posts, the Swift runtime is big, or huge if you use Foundation like nearly every Swift programmer does. It’s not even the worst part, as the one I dislike the most is that distributing the Swift runtime is a heavily manual process, and even the standard lib is made of dozens of files. Use otool (macOS) or ldd (Linux) on your output to learn what libraries you need, and beware that your app will crash on launch if it lacks any of them. In my experience, bundling more dependencies than necessary may also lead to runtime crashes.

The -static-swift-stdlib flag exists to embed the runtime and mitigate the issue, but it seems it doesn’t work properly for dynamic libraries. It doesn’t help with Foundation either because it’s not part of the standard Swift library.

Long story short, libpartout.so must be distributed side by side with the CMake binaries and the whole Swift runtime for the target architecture. You should find the runtime libraries for your current architecture at:

$SWIFT_HOME/toolchains/<swift_version>/usr/lib/swift/<platform_name>

With an additional dependency on libc++_shared.so for Android.

Anyway, this Linux output should explain better what I’m talking about:

ldd src/passepartout/submodules/partout/.build/debug/libpartout.so 
	linux-vdso.so.1 (0x0000ec74cd2dc000)
	libswiftSwiftOnoneSupport.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswiftSwiftOnoneSupport.so (0x0000ec74ccc40000)
	libswiftCore.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswiftCore.so (0x0000ec74cc5a0000)
	libswift_Concurrency.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswift_Concurrency.so (0x0000ec74cc4f0000)
	libswift_StringProcessing.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswift_StringProcessing.so (0x0000ec74cc430000)
	libswift_RegexParser.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswift_RegexParser.so (0x0000ec74cc310000)
	libBlocksRuntime.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libBlocksRuntime.so (0x0000ec74cc2e0000)
	libdispatch.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libdispatch.so (0x0000ec74cc260000)
	libswiftDispatch.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswiftDispatch.so (0x0000ec74cc210000)
	libssl.so.3 => /lib/aarch64-linux-gnu/libssl.so.3 (0x0000ec74cc0f0000)
	libcrypto.so.3 => /lib/aarch64-linux-gnu/libcrypto.so.3 (0x0000ec74cbb60000)
	libc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ec74cb990000)
	/lib/ld-linux-aarch64.so.1 (0x0000ec74cd2a0000)
	libstdc++.so.6 => /lib/aarch64-linux-gnu/libstdc++.so.6 (0x0000ec74cb6f0000)
	libm.so.6 => /lib/aarch64-linux-gnu/libm.so.6 (0x0000ec74cb630000)
	libgcc_s.so.1 => /lib/aarch64-linux-gnu/libgcc_s.so.1 (0x0000ec74cb5f0000)
	libswiftGlibc.so => /home/keeshux/.local/share/swiftly/toolchains/6.2.0/usr/lib/swift/linux/libswiftGlibc.so (0x0000ec74cb5c0000)
	libz.so.1 => /lib/aarch64-linux-gnu/libz.so.1 (0x0000ec74cb580000)
	libzstd.so.1 => /lib/aarch64-linux-gnu/libzstd.so.1 (0x0000ec74cb4c0000)

Bottom line

I assumed the readers to be familiar with build systems and CMake in particular, as the subject is vast and goes way beyond the scope of my article. I rather wanted to describe the design that worked for me to build a polyglot Swift library not only for non-Apple platforms, but also with multi-language, real-world dependencies.

This is the real novelty, the one I’m so excited about, and the one that you would have a very hard time finding examples about. I’m bringing up proof that all this stuff works in Swift as Partout, the subject of this series, is not a toy project for the sake of a tutorial, but software in use by hundreds of thousands of users every day for streaming, privacy, remote work, and VPNs in general. Think about it for the bright future of the Swift language.

In the next article, we’ll go through the integration of Partout in both Swift and non-Swift applications, with my Passepartout app being the living example of it.