Audio Unit v3 (iOS / iPadOS)
iOS only hosts AU v3 plugins, delivered as an .appex App
Extension inside a container .app. Logic Pro for iPad,
GarageBand, AUM, Cubasis, BeatMaker, Loopy Pro all consume this
shape. Every other format truce supports — CLAP, VST3, VST2, LV2,
AAX, standalone host — is unviable on iOS by platform contract.
cargo truce install --ios drives the same Rust plugin code your
macOS / Windows / Linux build produces — same DSP, same params,
same editor — through the iOS Simulator. Add --ios-device for a
tethered iPhone / iPad. The build flow is identical apart from
signing identity.
#Enable
[features]
au = ["dep:truce-au"]
default = ["clap", "vst3", "au"]
Same au feature flag as macOS. The framework's truce-au crate
gates iOS-specific entry points via #[cfg(target_os = "ios")] —
no extra opt-in.
#Requirements
| Path | Toolchain | Signing |
|---|---|---|
| Simulator | Xcode CLI tools + a booted iOS Simulator | Ad-hoc (-) works |
| Device | Full Xcode + paired & trusted iPhone / iPad | Apple Development identity + .mobileprovision |
.ipa |
Full Xcode | Apple Distribution identity + .mobileprovision (App Store / TestFlight) |
xcode-select -p must point at a real Xcode.app. The CLI tools
alone don't carry the iPhoneOS / iPhoneSimulator SDKs.
#Per-developer credentials
Put these in .cargo/config.toml [env] (gitignored — never in
truce.toml):
[env]
# Required for device + .ipa paths. Apple Developer team ID.
TRUCE_IOS_TEAM_ID = "ABCD1234EF"
# Required for device + .ipa paths. Path to a .mobileprovision
# downloaded from developer.apple.com — match the bundle ID + team
# you're signing with.
TRUCE_IOS_PROVISIONING_PROFILE = "/abs/path/to/MyPlugin.mobileprovision"
# Optional. Defaults to TRUCE_SIGNING_IDENTITY. For App Store
# distribution, set this to "Apple Distribution: …".
TRUCE_IOS_SIGNING_IDENTITY = "Apple Development: Your Name (TEAMID)"
Simulator installs ignore all three and ad-hoc sign with -.
#Per-plugin truce.toml fields
[ios]
# Workspace-wide default. Per-plugin `ios_minimum_os_version`
# overrides. Floor: 16.0 (the lowest officially supported by the
# Swift + AUv3 toolchain we drive).
minimum_os_version = "16.0"
[[plugin]]
# When set, both the container and the appex get the
# com.apple.security.application-groups entitlement so fullState
# blobs and preset files round-trip across the sandbox boundary.
# Convention: group.{vendor.id}.{bundle_id}
ios_app_group = "group.com.acme.myplugin"
# Path (relative to workspace root) to an Xcode-format
# `.appiconset` directory. When `xcrun actool` is on `$PATH` it's
# compiled into an `Assets.car` + matching `CFBundleIcons` plist
# additions (App-Store-compatible). When `actool` is missing or
# the directory isn't a real .appiconset, the build falls back to
# copying raw PNGs into the bundle root and emits a minimal
# `CFBundleIconFiles` array — good enough for simulator + ad-hoc,
# but App Store ingestion will reject. Absent → system stub icon.
ios_icon_set = "assets/MyPlugin.appiconset"
# Per-plugin override of the workspace floor.
ios_minimum_os_version = "17.0"
#Container app
cargo truce install --ios wraps the .appex in a container .app
built from a Swift template truce ships at install time
(crates/cargo-truce/templates/au_ios/AppMain.swift). The
container handles AU discovery, hosts the embedded editor, shows a
Play button that drives cb.process from mic input (effects) or
a test note (instruments / MIDI processors), and switches between
portrait chrome and a landscape hamburger sidebar.
Custom container apps are not yet supported. Plug-ins that
need a bespoke shell (custom branding, multi-screen flows, App
Store-grade marketing UI) currently hand-author one against the
framework's C ABI, build it outside cargo truce, and load the
same .appex artifact. Wiring the install pipeline to accept a
user-supplied container template is on the roadmap.
#Install
#Simulator (most iteration happens here)
xcrun simctl boot 'iPhone 17 Pro' # boot once
cargo truce install --ios -p truce-example-gain # build + install
xcrun simctl launch booted audio.truce.gain # launch container
The container app discovers the AU through
AVAudioUnitComponentManager, instantiates it, and hosts its
editor in-process. process() runs end-to-end (100 ms silent
buffer by default — pass --mic-input at launch for live audio).
#Device
cargo truce install --ios-device -p truce-example-gain
Needs ios-deploy on $PATH (brew install ios-deploy) or
Xcode 15+'s xcrun devicectl. Pair + trust the device first
(xcrun devicectl list devices to verify).
#Package
cargo truce package --ios -p truce-example-gain
Produces target/ios/ipa/MyPlugin.ipa, signed with the
TRUCE_IOS_SIGNING_IDENTITY identity and the embedded
.mobileprovision. Upload through xcrun altool /
Transporter
to TestFlight or the App Store.
#xcframework redistribution
cargo truce package --ios --xcframework -p truce-example-gain
Produces target/ios/xcframework/<Plugin>AU.xcframework/
containing both device + simulator slices. Useful if you're
shipping the framework as a building block for someone else's
Xcode project; the .ipa path embeds the framework directly and
doesn't need this artifact.
#Identifiers
iOS uses the same AU manufacturer / subtype codes as macOS — they're in the AU spec, not the platform:
[vendor]
au_manufacturer = "AcMe" # 4-char code, shared with macOS
[[plugin]]
fourcc = "MyFx"
au3_subtype = "MyF3" # optional; iOS reuses au3_subtype
#Hosts
| Host | Status |
|---|---|
| GarageBand for iPad | ✅ |
| Logic Pro for iPad | ✅ |
| AUM | ✅ |
| Cubasis | ✅ |
| BeatMaker 3 | ✅ |
| Loopy Pro | ✅ |
#Gotchas
- No hot reload on iOS.
--shellmode relies ondlopen-ing freshly-built logic dylibs at runtime; iOS App Extensions rejectdlopenof anything outside the signed bundle'sFrameworks/. Shell mode stays macOS-only. - Simulator + remote-VC quirks. Under ad-hoc signing, the iOS
Simulator's remote view-controller bridge doesn't always fire
loadViewon the appex'sAUViewController. The container app links the framework directly and callsg_callbacks.gui_open(ctx, container_view)itself — same rendering pipeline as a real AU host, just without the remote-VC indirection. Production hosts use the registered factory; this workaround is simulator-only. - Universal
.ipaisn't a thing. iOS device (platform 2) + simulator (platform 7) slices have distinct Mach-O platform IDs and aren't lipo-able. Usexcframeworkif you need both; the install / package paths build a single slice each. - MIDI 2.0 forwarding. iOS 17+ / macOS 14+ AU hosts deliver
MIDI via
AURenderEvent.MIDIEventList(UMPs) rather than the legacy 3-byteAURenderEvent.MIDI. Truce decodes both. MIDI 2.0 channel-voice messages (per-note expression, 32-bit resolution) land as the appropriateEventBodyvariant; MIDI 1.0 UMPs are downconverted to the legacyEventBodyshapes. - Single-pointer touch routing. Multi-finger touches don't
hijack an in-progress drag — the editor pins the gesture to the
UITouchthat started it and ignores other simultaneous touches. True multi-widget multi-touch (two knobs grabbed at once) is a framework-wide change queued for follow-up. - App Store review. The container app must present a
meaningful UI; Apple rejects "stub" container apps. Truce's
default container shows the plugin name + a description label.
Customize via
truce.tomlios_icon_setfor branding. - Alt-GUI backends:
truce-eguiruns egui-wgpu on iOS through a CAMetalLayer-backed UIView with a CADisplayLink-driven repaint pump.truce-slintruns Slint'sMinimalSoftwareWindowsoftware renderer into an RGBA buffer that's blitted to theUIView'slayer.contentsviaCGImage(the same CPU path the built-in iOS editor uses). Both translateUITouchevents into their backend's pointer-event shape.truce-icedis the lone hold-out (see the iced gotcha below). truce-icedis desktop-only on iOS today, upstream blocker. iced'sicedumbrella crate has a non-optional dependency oniced_winit, and iced_winit callswinit::platform::modifier_supplement::KeyEventExtModifierSupplementmethods inside acfg(not(target_arch = "wasm32"))branch. winit only ships that trait on desktop, so the branch fires on iOS and fails to compile — and there's no feature flag to opt out of iced_winit. Plugins built ontruce-icedskip the iOS build with a clear cfg gate until the upstream issue lands. Use the built-in editor,truce-egui, ortruce-slintfor iOS coverage in the meantime.
#iOS screenshot regression
cargo truce screenshot --ios --out screenshots/gain_default_ios.png -p truce-example-gain # bake baseline
cargo truce screenshot --ios --check --out screenshots/gain_default_ios.png -p truce-example-gain # regress against it
Builds + installs the plugin on the booted simulator, launches it,
and captures the simulator's rendered output via xcrun simctl io screenshot. This is the only path that sees iOS-specific
compositing (the BuiltinEditor's CGImage blit, UIView layer
contents swap, scale-factor application). The desktop dlopen-based
screenshot path can't reach into the iOS render pipeline.
--check compares byte-for-byte against the committed baseline
and fails non-zero on diff with the exact cp command to accept
the new render. Sim resolution depends on the booted device, so
bake the baseline on whichever simulator your CI matrix uses.
#Programmatic regression test
cargo truce install --ios -p truce-example-gain && xcrun simctl launch booted gain --probe-touch synthesises a synthetic
down-drag on the editor's first knob via a hidden
_truce_probe_touch: selector, reads param[0] before and after,
and logs PASS — touch dispatch routed through to set_param when
the value changed. Useful as a smoke check after editor-pipeline
edits.