Mobile (iOS & Android) in Rust
Your Azul app is the mobile app. The same App::create(...).run(...) you
wrote for desktop ships as an .apk or .ipa — there is no Java/Kotlin or
Swift/Objective-C app layer to write, and you need neither Xcode nor Android
Studio. An .apk and an .ipa/.app are just ZIP archives with a known
layout, so you cross-compile the native library with the Rust compiler and pack
it with a few command-line tools. You can build for both platforms from Linux;
only the final iOS code-signing touches an Apple-specific tool, and even that has
a cross-platform option (see iOS signing).
The two ready-made scripts —
scripts/build-android.sh
and scripts/build-ios.sh
— do the whole thing (cross-compile → bundle → sign → optionally deploy) with no
IDE; CI runs exactly these to produce the release artifacts. The sections below
explain what they do so you can reproduce or adapt them. Every example in the
repo (AzulMaps, azul-paint, azul-meet, …) is packaged this way.
Supported targets
| Target | Use |
|---|---|
aarch64-apple-ios |
iOS device (arm64) |
aarch64-apple-ios-sim |
iOS simulator on Apple silicon |
x86_64-apple-ios |
iOS simulator on Intel |
aarch64-linux-android |
Android device (arm64-v8a) |
x86_64-linux-android |
Android emulator |
rustup target add <triple> installs each. bash scripts/mobile-check-all.sh
runs cargo check across all five — the gate kept green as the port lands.
How an Azul app maps onto each platform
Everything — layout, rendering (CPU), callbacks, the realtime-media / sensor / gamepad / geolocation device APIs — is identical to desktop. The one structural difference is the entry point:
- iOS keeps a normal
fn main(). YourmaincallsApp::run(...), which on iOS hands control toUIApplicationMainand drives the UIKit run loop. So an iOS app is just your example compiled as a binary for an iOS target — the exact same source as desktop. - Android has no
main(): the OS loads your.soand callsANativeActivity_onCreate(provided by the bundledandroid-activityglue), which invokesandroid_maininsidelibazul. So an Android app is your example compiled as acdylibwith a tiny load-time shim. See Android entry point.
Two ways to ship libazul
Just like desktop, you don't rebuild the framework — you decide how your app links against it:
- Dynamic (drop-in prebuilt). Ship the prebuilt
libazulin the bundle and link your small app against it. On Android, putlibazul.soin the APK underlib/<abi>/(e.g.lib/arm64-v8a/); on iOS, embedlibazul.dylibinMyApp.app/Frameworks/and set the app binary's rpath to@executable_path/Frameworks. CI publishes alibazulper mobile target on the release page for download. Your app can be C, Rust, or any binding — a Chello-world.clinkslibazuland callsAzApp_create/AzApp_runexactly as on desktop. On iOS the app binary and the embeddedlibazul.dylibmust both be code-signed. - Static (single artifact). Build with the
link-staticfeature so your app and azul compile into one.so(Android) or binary (iOS). This is what the build scripts and every repo example use, and what the rest of this page shows.
A mobile libazul is per-ABI/arch (aarch64 for devices, x86_64 for the
emulator/simulator) — you bundle the slice(s) you target, not a single file.
Minimal toolchain (only the stubs you need)
You do not need the full NDK or the iOS SDK. Both are mostly link stubs
(empty .so API stubs / .tbd text stubs) plus headers, and Rust already ships
its own linker (rust-lld). Azul renders on the CPU on mobile
(gl_context_ptr = None), so there are no OpenGL ES / Metal libraries to link
either. The entire system-library surface is:
| Platform | Links against | Notes |
|---|---|---|
| Android | libandroid (NativeActivity, ANativeWindow, ALooper), liblog (__android_log_print), and libc / libm / libdl |
Tiny NDK stub .sos — extract just those five, no full NDK needed at link time. (Set via cargo:rustc-link-lib=android,log in dll/build.rs.) |
| iOS | Foundation, UIKit, CoreGraphics, libSystem — plus one framework per device API you use (AVFoundation, CoreMotion, CoreLocation, GameController) |
.tbd text stubs in the iOS SDK; copy only the ones you reference. |
So a from-scratch minimal setup is:
rustup target add <triple>— brings the Ruststdfor the target.- A small stub sysroot: the five Android stub
.sos (from the NDK'splatforms/android-<api>/.../usr/lib/) or the iOS framework.tbdstubs (from the SDK'sSystem/Library/Frameworks/). A few hundred KB, not the multi-GB toolchain. rust-lldas the linker — no externalld,clang, orxcrun.
For packaging you then need only small CLI tools — aapt2 / zipalign /
apksigner for Android (head-less sdkmanager install, no Studio), nothing for
iOS beyond zip and a signer. A pure-NativeActivity Android app needs no
Java (so no JDK / d8) — the custom AzulActivity.java is only for the
optional gesture bridge.
The ready-made
build-android.sh/build-ios.shcurrently lean on a normal NDK / iOS-SDK install for convenience; the table above is the irreducible set if you want to assemble a minimal cross-toolchain (e.g. to build iOS apps on Linux). Extracting a minimal stub sysroot is a one-time step.
Building the native library
Mobile builds use link-static with no default features (the desktop
windowing/renderer defaults pull in things mobile doesn't want):
# iOS device — produces the binary (its main() runs UIApplicationMain via App::run)
cargo build --release --target aarch64-apple-ios -p my-app \
--no-default-features --features "std,logging,link-static,a11y"
# Android arm64 — produces the cdylib the APK ships
cargo build --release --target aarch64-linux-android -p my-app \
--no-default-features --features "std,logging,link-static,a11y,android-activity"
From Rust, depend on azul-dll directly with those features; from C, use the
generated azul.h (cargo run -r -p azul-doc -- codegen c) and the same
AzApp_create / AzApp_run entry points every binding uses.
Android
Android entry point
Because there is no main(), run your setup from a load-time constructor.
libazul already provides android_main (via the android-activity glue);
App::run on Android just stashes the window options for it to read — and it
must run before ANativeActivity_onCreate, which is exactly what a
ctor gives you. Factor the setup into one
function and wire both entry points:
use azul::prelude::*;
pub fn start() {
let data = RefAny::new(DataModel { counter: 0 });
let app = App::create(data, AppConfig::create());
// Android: run() stashes the window options + returns; desktop/iOS: blocks.
app.run(WindowCreateOptions::create(my_layout));
}
// Desktop / iOS — main() runs and App::run drives UIApplicationMain on iOS.
#[cfg(not(target_os = "android"))]
fn main() { start(); }
// Android — fires at dlopen, before libazul's android_main reads the options.
#[cfg(target_os = "android")]
#[ctor::ctor]
fn azul_android_init() { start(); }
(azul-maps / azul-paint in the repo are set up exactly like this.) Your crate
must build as a cdylib for Android, and pulls the android-activity glue + ctor
only on Android:
[lib]
crate-type = ["cdylib", "rlib"]
[target.'cfg(target_os = "android")'.dependencies]
azul = { package = "azul-dll", version = "0.2", default-features = false, features = ["link-static", "android-activity"] }
ctor = "0.2"
That is the entire difference from a desktop app.
Package the APK (no Android Studio)
You need NDK 27, build-tools 34 (aapt2, zipalign, apksigner) and a
JDK 17 — all installable head-less via sdkmanager, no IDE. An .apk is a
ZIP, assembled like this (what build-android.sh does):
# 1. cross-compile the cdylib (cargo-ndk sets the NDK linker for you).
# NB: the API level is --platform (cargo-ndk forwards a bare -p to cargo as
# --package), and it goes BEFORE the `build` subcommand.
cargo ndk -t arm64-v8a --platform 24 -o ./jniLibs build --release \
-p my-app --no-default-features --features "std,logging,link-static,a11y,android-activity"
# 2. lay out the APK tree, compile the manifest, add the lib + Java glue
aapt2 link --manifest AndroidManifest.xml -I "$ANDROID_HOME/platforms/android-34/android.jar" -o base.apk
zip -r base.apk lib/arm64-v8a/libmy_app.so classes.dex # classes.dex = dexed scripts/android/*.java
# 3. align + sign with a (debug) keystore — apksigner is a CLI tool
zipalign -f 4 base.apk aligned.apk
apksigner sign --ks debug.keystore --ks-pass pass:android aligned.apk
Files to copy into your project (Java glue + manifest template) live in
scripts/android/:
AndroidManifest.xml (sets android.app.lib_name to your lib), AzulActivity.java
(the NativeActivity subclass), NativeGestureBridge.java, AzulFilePicker.java.
The manifest's lib_name must match your cdylib name. Simplest path:
bash scripts/build-android.sh aarch64-linux-android <AppName> <com.pkg>.
Declare permissions in AndroidManifest.xml (CAMERA, RECORD_AUDIO,
ACCESS_FINE_LOCATION, INTERNET, …) and request the dangerous ones at runtime.
Minimum Android 7.0 (API 24) — the camera backend links the NDK Camera2 stubs (API 24); AAudio (API 26) is loaded at runtime, so on 7.0/7.1 the app runs and audio just reports unavailable.
iOS
Build + bundle the .app/.ipa (no Xcode project)
A .app is a directory; an .ipa is Payload/<App>.app zipped. You do not
need an Xcode project — just the iOS SDK (for the linker sysroot) and the
cross-linker. build-ios.sh does:
# 1. build your example as an iOS binary (main() runs UIApplicationMain via App::run)
cargo build --release --target aarch64-apple-ios -p my-app \
--no-default-features --features "std,logging,link-static,a11y"
# 2. assemble the bundle: the executable + an Info.plist
mkdir -p MyApp.app
cp target/aarch64-apple-ios/release/my-app MyApp.app/MyApp
cp scripts/ios/Info.plist MyApp.app/Info.plist # template to copy/edit
# 3. (device) sign, then zip into an .ipa
mkdir -p Payload && cp -r MyApp.app Payload/ && zip -r MyApp.ipa Payload
The Info.plist template + entitlements are in
scripts/ios/.
Add the usage strings for the device APIs you use: NSCameraUsageDescription,
NSMicrophoneUsageDescription, NSLocationWhenInUseUsageDescription,
NSMotionUsageDescription.
Cross-compiling iOS from Linux: install the iOS SDK sysroot (extractable from the Xcode toolchain, no GUI) and point Rust's linker at it. The simulator needs no signing and runs unsigned
.apps directly.
iOS code signing (no Xcode)
Code signing is the only Apple-specific step, and it does not require Xcode or even a Mac:
rcodesign(the Rustapple-codesigncrate) signs.app/.ipabundles on Linux or Windows and can submit to Apple's notarization web service (rcodesign notary-submit), a REST API — noxcrun/notarytoolneeded.- You still need an Apple Developer ID certificate + provisioning profile (the paid program), but those are files, not tools.
- Simulator builds and personal-team device installs over a debug bridge need no signing at all.
Installing & debugging the built app
You don't have to build anything to try the demos — every example is published
per-OS on the release page (Demos section): a .apk for Android, a
device .app and a Simulator .app for iOS. To install a build (yours or a
downloaded one):
Android (.apk)
The APKs are debug-signed, so they sideload directly.
# Over USB (enable Settings → Developer options → USB debugging first):
adb install azul-maps-android.apk
# Replace an existing install: adb install -r …; uninstall: adb uninstall com.azul.azul_maps
Or copy the .apk to the phone and tap it (allow „install unknown apps“ for the
browser/file manager).
Debug logs: azul's platform layer logs through the log facade to logcat
(via liblog):
adb logcat -s azul:V '*:S' # azul lines only
adb logcat | grep -E '\[camera\]|\[udp\]|\[gamepad\]|\[sensors\]|\[cap\]'
A native crash prints a tombstone — adb logcat shows the backtrace: with the
faulting library; pull /data/tombstones/ for the full dump.
iOS Simulator (.app, no signing)
The Simulator slice (<demo>-ios-sim.app.zip) runs unsigned — easiest to try
on a Mac:
unzip azul-maps-ios-sim.app.zip
open -a Simulator # boot a simulator
xcrun simctl install booted azul-maps.app
xcrun simctl launch --console booted <bundle-id> # --console streams stdout/stderr
iOS device (.app → signed)
A physical iPhone needs the binary code-signed (a free Apple ID / personal team works for a 7-day sideload). On a Mac:
codesign --force --sign "Apple Development: you@example.com (TEAMID)" \
--entitlements scripts/ios/entitlements.plist azul-maps.app
xcrun devicectl device install app --device <udid> azul-maps.app
No Mac? rcodesign signs an .app/.ipa from Linux/Windows with a Developer ID
.p12. Device logs: xcrun devicectl device console --device <udid>, or
Console.app filtered by the app name, or idevicesyslog (libimobiledevice).
From Rust to a final .apk / .ipa — cross-platform
The whole pipeline is cargo + small CLI tools, no IDE, and the same on Linux or
macOS (only the final iOS signing prefers a Mac, and even that has the
rcodesign escape hatch):
| Step | Android | iOS |
|---|---|---|
| 1. Compile | cargo ndk -t arm64-v8a --platform 24 build (the cdylib NativeActivity loads) |
cargo build --target aarch64-apple-ios[-sim] (the binary; main() runs UIApplicationMain) |
| 2. Bundle | aapt2 link + zip the .so + classes.dex → .apk |
lay out MyApp.app/ (binary + Info.plist); .ipa = Payload/MyApp.app zipped |
| 3. Sign | zipalign + apksigner (debug keystore is fine for sideloading) |
codesign / rcodesign (device only; Simulator needs none) |
| 4. Install | adb install |
xcrun simctl install (sim) / devicectl (device) |
build-android.sh and build-ios.sh run steps 1–4 end to end.
Testing without a device
scripts/mobile-check-all.sh proves all five targets cargo check clean;
event/runtime paths are covered by the synthetic-event harness (no hardware) —
see e2e-testing. The iOS simulator runs unsigned .apps.
See also
- Realtime Media and Devices — camera/mic/sensors/gamepad/ geolocation on mobile, and the permissions you declare.
- Web Deployment — the WASM target, by comparison.
- hello-world — the app you're shipping.