The woes of native development (A.K.A why does Apple suck)
What am I complaining of now?
Unfortunately new developments have forced me to put my cloud series on hold so today I will take this time to rant about the miserable experience that is mobile cross platform development.
As usual my setup is very conventional. I wanted to make a very simple app so I chose the easiest and most proven path to do it. The core application logic is in zig using the totally non controversial 0.16 release - Rust was giving me too much NPM cringe after pulling only 3 dependencies and they raining down the world on me. The UI in Flutter (cause this will save me time sure). Well no matter all the unjustified (and justified) hate Dart gets. The FFI crossing is way way better than the JNI so I'm counting on it saving me some headaches down the lane.
Hello world
Flutter has been going on for a while now and, while I've never been much of a fan of the idea of using it, when you have to deal with multiple languages you appreciate having one less split point.
Android
I haven't used Android in many years and so when my Pixel arrived I was a little surprised how much it had changed (still ugly though).
Anyway I suspected building for Android would be the easiest path so I set out to do that first.
I stole a flake from somewhere and configured flutter. flutter create and PUM! the counter is working.
Luckily zig is capable of compiling for virtually every arch out there so compiling for Android was as simple as
zig build -Dtarget=aarch64-linux-android
Taking my lib and copying it to jnilibs and in code just doing
typedef NativeAdd = Int32 Function(Int32, Int32);
typedef DartAdd = int Function(int, int);
class ZigBridge {
static final ZigBridge _instance = ZigBridge._internal();
factory ZigBridge() => _instance;
late final DartAdd addNumbers;
late final DynamicLibrary nativeLib;
ZigBridge._internal() {
final DynamicLibrary nativeLib = _loadLibrary();
addNumbers = nativeLib
.lookup<NativeFunction<NativeAdd>>('add_numbers')
.asFunction<DartAdd>();
}
DynamicLibrary _loadLibrary() {
if (Platform.isAndroid) {
return DynamicLibrary.open("liblib.so");
}
throw UnsupportedError("Unsupported platform");
}
}
A simple binding and a dlopen and my app is running. Whether you call it flexibility or a huge attack vector, there's no denying that loading a dynamic library on Android is a breeze.
Why do you complain so much then?
Apple
Last time I tried iOS development I played around a little with SwiftUI and it just felt like a worse Jetpack Compose. Anyway getting an app running when you play by Apple's rules is a easy as it gets. I was very unfamiliar with Apple's development landscape outside of the blissful silo of Golang so I ran into every possible roadblock to get this app going.
Obviously Apple being Apple you can't just drop your dylib on the app's assets and call it a day - Oh no! First is cocoapods. A dependency manager for MacOS. It's some weird Ruby thing and somehow it has been holding together package management for Swift all this time. Obviously that was always a dumb idea so now Apple is pushing the Swift Package Manager (SPM).
I don't know why but Apple's developer docs suck ass and I could not either make any sense of this package manager. And nothing works well without using that Xcode crap. Figures.
Since even I know when to stop trying to swim against the current I looked up the flutter way to do it. Turns out now flutter is also deprecating their ways and now the recommend "simplifying" by using package_ffi.
Supposedly you get a super sleek hook to build all your assets and link your libs( dynamically). And a nice generator for turning your C headers into flutter bindings. To be honest I did not find it all that useful since I already have to make the headers by hand when exporting from zig using the ffigen didn't seem super useful to me but anyway I took their advice. I spawned a new project with the ffi template. Hit run, it worked(at least that).
Now comes my project. Since I can't feed zig code for the hook to process I gotta build the libs elsewhere. After a few lessons on why aarch64-iphones is different from aarch64-iphonesimulator I got the package to build and my app to launch. I click on the counter button and get a colorful exception.
code signature invalid
And madness began
One thing special about iPhones is that - not only do they require you to sign your app even when testing on your own goddamn phone - they require every piece of code that runs to be signed before hand with same account and you can't actually load random libs on runtime - cause that's a big attack vector blah blah blah... . Anyway my code was supposed to be signed so I did not find out where the problem was. I verified my signature again. Nuked the project and started over. Tried signing manually myself instead of letting the dart hook do it. After burning a few thousand tokens of LLM brain power I got ... nothing.
36 hours later
After sacrificing my sanity to Xcode I gave up on trying to bundle my dynamic library and looked to the dark side.
Apparently static compilation solves your problems. If your code is already embedded in the binary there's no suspicious agent to sign. So I went I tried to the change the linking to static in the flutter hook. Yeah nice try. Flutter does not support any other preference in their linking other than dynamic - kinda makes me wonder why the preference is there at all. I did check their code and it's totally hardcoded. You cannot configure it in anyway. And they have no branches for handling a static lib at all so I'm guessing is a work in progress. Anyway flutter was out.
I went ahead and tried to recreate the flutter project and guess what flutter no longer uses cocopods by default. Now is SPM. So I don't have to deal with a framework I don't know but very well documented. Now I have to deal with a brand new framework that nobody knows how to use yet.
I just want to load my library!
I was already getting a headache looking all this packaging documentation and it was starting to look like crazy town to me so I went back to the basics: bash
xcodebuild -create-xcframework \
-library ../../core/zig-out/lib/aarch64-ios-iphoneos/libvidulu_core.a \
-headers ../../core/zig-out/include \
-library ../../core/zig-out/lib/aarch64-ios-iphonesimulator/libvidulu_core.a \
-headers ../../core/zig-out/include \
-library ../../core/zig-out/lib/aarch64-macos/libvidulu_core.a \
-headers ../../core/zig-out/include \
-output ./Frameworks/ViduluCore.xcframework
Once you strip away all the fluff there really isn't all that much going on. Build the lib. Put them in a dumb folder layout and call it a universal framework. I loaded the framework in Xcode the manual way like a caveman and hit build.
Error (Xcode): 64-bit mach-o member 'libvidulu_core_zcu.o' not 8-byte aligned
You have got to be kidding me
The error is pretty clear. Apparently the static libs zig is producing is not aligned the way iOS wants it, figures. This weekend I did hit a lucky finding. I have been using Ghostty for a few months now. This is one of the few popular projects written in zig and I was very lucky to find that, being a multiplatform app and whatnot, they had already dealt with most of the issues I was hitting. So as a good developer I stole some of their build scripts for my projects.
...
if (target.result.os.tag.isDarwin() and target.result.cpu.arch == .aarch64) {
const obj = b.addObject(.{
.name = library_name,
.root_module = root_module,
});
const darwin_combine = RunStep.create(b, b.fmt("libtool {s}", .{library_name}));
darwin_combine.addArgs(&.{ "libtool", "-static", "-o" });
const output = darwin_combine.addOutputFileArg(lib_path);
darwin_combine.addFileArg(obj.getEmittedBin());
darwin_combine.step.dependOn(&lib.step);
install.dependOn(&darwin_combine.step);
install.dependOn(&b.addInstallLibFile(output, lib_path).step);
continue;
}
...
I can count!
Conclusion
FU Apple: You better hope this platform is as secure as the hoops to get foreign code going would have you believe.
Getting zig running on my iPhone was a lot harder than I had hoped for. But well the past is the past and now it works. I'm still a little wary of choosing the super stable and not footgunny zig for an app that's all about security but, my Rust POC had only 3 crates and was already pulling dozens of dependencies. Call me a misanthrope, but I don't feel like trusting hundreds of random maintainers at the core of my system. At least now I only have one big heavily audited C library to keep up with.
PS: It would've saved me a lot of headaches had I read Mitchell Hashimoto's blog1 before trying literally every solution on Earth.
PS2: Andrew please stop breaking zig.