Managing binary dependencies for swift
The problem
One of the core dependencies for my employer is the PjSIP Project. As many others libraries it is written in C for maximum compatibility. Modernizing parts of our stack I wanted a single swift package PjSIP
that I can rely on with no further fiddeling.
To be useable it needed to support at least the following environments: macOS (Intel and Apple Silicon), iPhoneOS and iPhone Simulator running on Apple Silicon. PjSIP project is a complex library to begin with and the above are four builds, already. So we want to build them in a controlled fashion and use as binary dependency.
XCFramework
XCFrameworks handle the above well. They can contain libraries for multiple platforms (and variants!) and the libraries can even be fat-libs so we get everything we need.
So the first step was to create an XCFramework. You can see how it's done here pjproject-apple-platforms beginning at around line 150 cat << 'END' > pjproject/build_apple_platforms.sh
. Basically we build the object files and pack them together with libtool
.
XCFrameworks are simple: The build system sees them, reads their Info.plist
and copies the appropriate library to your build folder before the build. So speaking in C-lingo -lpjproject
will be happy.
Headers vs Modules
If we would start a multiplatform app now and drop the XCFramwork into the app the linker would be able to link against our libpjproject
and see the _pj_init
symbol, for example. But Swift still can't see any of the symbols from PjSIP project. In Xcode you could create a Bridging-Header and configure the include paths.
Since our goal is a neat Swift Package we create one for PjSIP project including the following:
.systemLibrary(name: "Cpjproject", pkgConfig: "pjproject-apple-platforms")
Pkg-config is a simple yet super-useful mechanism to configure the C/C++ compilers. If you have installed my final brew package for pjproject brew install oliverepper/made/pjproject-apple-platforms
you can try it out by typing:
pkg-config --cflags --libs pjproject-apple-platforms
What you receive as output can be passed to a C/C++ compiler on the command-line. Let's make an example.
Example program in ObjC
Create the program
cat << EOF > pjsip-test.m
#define PJ_AUTOCONF 1
#include <pjsua.h>
int main()
{
pj_init();
return 0;
}
EOF
compile it for macOS
clang -isysroot $(xcode-select -p)/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk `pkg-config --libs --cflags pjproject-apple-platforms` -o pjsip-test pjsip-test.m
It should give you the following output:
08:40:15.580 os_core_unix.c !pjlib 2.12 for POSIX initialized
You can compile it for the iPhone simulator running on Apple Silicon like this:
clang -isysroot /Applications/Xcode-13.3.1.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk `pkg-config --libs --cflags pjproject-apple-platforms-iPhoneSimulator` -o pjsip-test pjsip-test.m
SPM
Back to the systemLibrary
-target we're still missing the translation between C-style header files and Swift modules. This can be achieved via the following module.modulemap
:
module Cpjproject [system] {
header "shim.h"
}
and the shim header:
#define PJ_AUTOCONF 1
#include <pjsua.h>
Wrapper or test target
Now we can create other targets in our swift package that can depend on Cpjproject
and the will "see" all of pjproject from within Swift. All is great as long as we only build for the Mac!
Other target
Once we try to build the swift package for other platforms (iPhoneOS, iPhoneSimulator) the pkg-config file configures the linker to load the version of libpjproject.a
that was build for macOS which will then fail.
The trick
I made another pkg-config file called pjproject-apple-platforms-SPM
that intentionally gives no path to the libraries so using -lpjproject
would fail.
.systemLibrary(name: "Cpjproject", pkgConfig: "pjproject-apple-platforms-SPM")
Swift package has another target type that can do the rescue, here:
.binaryTarget(name: "libpjproject", path: "libpjproject.xcframework")
A binary target understands XCFrameworks and copies the right libraries into place just before the build. This enables the linker to find the appropriate library for -lpjproject
.
Final
Finally create a third target:
.target(name: "PJSIP", dependencies: ["Cpjproject", "libpjproject"])
that you can use to give PjSIP project a nice swift interface. Something like func pjInit() throws
and so on.
What we have achieved now ist that you can work on the swift package in isolation and have executable targets for integration tests, test targets for unit tests and once you use the swift package in a multiplatform app everything is automatically configured for you. Pretty neat :-D