iOS
By the end of this guide your own C program runs on your iPhone, launched from the home screen.
Read this first. iOS is the hardest platform Natiny supports, and most of the difficulty is Apple's, not the engine's. Every app on a device must be signed by a registered developer, so a large part of this guide is about certificates, provisioning profiles and Xcode. The guide assumes you already know what code signing is and how apps reach a device. If you have never signed anything, expect to spend an hour here.
Everything below happens on a Mac.
How an iOS build is split in two
The engine is cross-compiled for you and shipped as a static archive. Your program is compiled and linked on your Mac, and the result is wrapped in a signed app bundle there too:
we cross-compile → libnatiny_ios_arm64.a (in the SDK bundle)
you compile and link → mygame (a Mach-O, not yet an app)
you pack and sign → MyGame.app (installable)
Check that your Mac can build for iOS
You need Xcode - the application, not only the command-line tools. The tools carry the macOS SDK and no other.
xcrun --sdk iphoneos --show-sdk-path
A path means you are ready. If it says SDK "iphoneos" cannot be located,
install Xcode from the App Store, open it once, then:
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license accept
Unzip the SDK and put your program beside it
Download the SDK bundle for ios-arm64:
my_game/
├── include/
│ └── natiny.h
├── lib/
│ └── libnatiny_ios_arm64.a
├── ios/
│ ├── make_app.py
│ └── Info.plist.in
└── main.c
Understand what happens to your main()
An iOS process starts on the main thread, and that thread has to be handed to
UIApplicationMain, which runs the event loop and never returns. Something
must therefore own main, and Natiny does: natiny.h renames your main to
natiny_main, provides one of its own, gives the main thread to UIKit and
calls yours on a separate thread.
You do not write anything iOS-specific because of this. The same main.c
compiles for Windows, Linux, macOS and iOS. If you ever need the real entry
point back, #undef main after including the header.
Write a program you will recognise on screen
The window always covers the screen, so the size you ask for is ignored - use
natiny_window_get_width and natiny_window_get_height for the real one. A
green square on a dark background is enough to prove the whole chain worked.
#include "natiny.h"
#include <stddef.h>
static NatinyWindow window;
static void frame(float dt, void* userdata)
{
(void)dt;
(void)userdata;
natiny_window_bind(window);
natiny_render_clear(0.07f, 0.09f, 0.13f, 1.0f);
natiny_render_set_color(0.66f, 0.76f, 0.22f, 1.0f);
natiny_render_rectangle(360.0f, 260.0f, 80.0f, 80.0f);
natiny_window_unbind();
}
int main(void)
{
if (!natiny_backend_init(NATINY_BACKEND_AUTO)) {
return 1;
}
window = natiny_window_create("Hello, Natiny", 800, 600);
if (!window) {
natiny_backend_shutdown();
return 1;
}
natiny_backend_loop(frame, NULL);
natiny_backend_shutdown();
return 0;
}
NATINY_BACKEND_AUTO selects Metal, the only backend iOS has.
Compile and link for the phone
xcrun reaches Xcode's clang and its iOS SDK in one word. The -target
triple carries both the architecture and the deployment minimum, and it is
what makes the linker mark the binary as iOS rather than macOS:
xcrun clang -O2 -target arm64-apple-ios13.0 \
-c main.c -Iinclude -o main.o
xcrun clang -O2 -target arm64-apple-ios13.0 \
main.o lib/libnatiny_ios_arm64.a -o mygame \
-framework UIKit -framework Foundation -framework CoreGraphics \
-framework Metal -framework QuartzCore -framework AudioToolbox \
-framework AVFoundation
Keep the minimum at 13.0 - the objects inside the archive were compiled for it, and asking the linker for an older one will not make them run on an older phone.
Check what you produced:
otool -l mygame | grep -A4 LC_BUILD_VERSION
platform 2 is iOS and minos 13.0 is the deployment target. If you see
platform 1, the -target flag is missing from one of the two commands and
you have built for macOS.
mygame is a valid Mach-O that no phone will run yet: it is not in a bundle
and it is not signed. Both are the next steps.
Register an app identifier with Apple
An app on a device is identified by its bundle identifier, and Apple keeps one global namespace of them.
Warning:
com.example.*is taken, and Xcode answers "cannot be registered to your development team because it is not available". Derive your own from a domain or account you control -ru.yourname.mygame.
The same string has to appear in the Xcode project below, in the provisioning
profile and in --bundle-id.
Get a certificate and a profile through Xcode
Two things must exist before a phone runs your app: a certificate, proving a registered developer signed it, and a provisioning profile, saying this identifier may run on this device. A free Apple ID gets both, and Xcode issues them.
- Xcode → Settings → Accounts, add your Apple ID. This creates an Apple Development certificate in your keychain.
- Connect the iPhone, unlock it, tap Trust This Computer.
- On the phone: Settings → Privacy & Security → Developer Mode, on, and let it restart. Without it nothing development-signed will install.
- Xcode → File → New → Project → iOS → App, with Bundle Identifier set to yours and Team set to your personal team.
- Select the iPhone as the destination and press Run (⌘R).
Step 5 is the one that issues the profile - building to a connected device is what registers the device with Apple. Afterwards the throwaway project can be deleted.
Find the two names the packer asks for
security find-identity -v -p codesigning
1) 4738DCEAD9968002836BCAC7DFD05A619052CCAD "Apple Development: you@mail.com (77UNP8U994)"
Either half works; the hash has no spaces and is harder to mistype.
Profiles live in one of two folders, depending on the Xcode version. Print which identifier each one covers:
for d in ~/Library/Developer/Xcode/UserData/Provisioning\ Profiles \
~/Library/MobileDevice/Provisioning\ Profiles; do
[ -d "$d" ] || continue
for p in "$d"/*.mobileprovision; do
echo "$p -> $(security cms -D -i "$p" | plutil -extract Entitlements.application-identifier raw -)"
done
done
Copy the one ending in your identifier next to your program as
mygame.mobileprovision. If both folders are empty, step 5 did not happen:
adding an Apple ID creates the certificate, but only a device build creates
the profile.
Wrap the executable in a signed app
ios/make_app.py builds the bundle: Info.plist, your data folder, the icon,
the embedded profile and the signature. Create a folder for your data even if
the program reads nothing yet:
mkdir -p assets
python3 ios/make_app.py --project ./assets --executable mygame \
--name "My Game" --bundle-id ru.yourname.mygame \
--identity 4738DCEAD9968002836BCAC7DFD05A619052CCAD \
--profile mygame.mobileprovision
--executable is what tells the packer to wrap your Mach-O. Without it, the
packer would link the Lua wrapper's archive instead - that is the other half
of the release, and not what you want here.
Install it and run it
xcrun devicectl list devices
xcrun devicectl device install app --device <UDID> "My Game.app"
On Xcode older than 15, use Window → Devices and Simulators and drag the
.app onto Installed Apps.
An app signed with a free account is refused on first launch until you trust the certificate: Settings → General → VPN & Device Management → Developer App. Then tap the icon. A dark screen with a green square means your C, the cross-compiled engine, Metal and your signature all worked.
To see why something failed, launch it from the Mac:
xcrun devicectl device process launch --console --device <UDID> ru.yourname.mygame
--console prints the program's output in your terminal.
The Simulator is not an option. Your binary is arm64-apple-ios, the
Simulator runs arm64-apple-ios-simulator, and those are different platforms
built against different SDKs. On Apple silicon the Simulator may install the
app and then crash it on launch, which looks like a bug in your code and is
not.
Read and write files from the app
Whatever you pass to --project becomes data/ inside the bundle, beside the
executable, and that is where natiny_resource_load reads from - the working
directory of an iOS process is / and holds nothing:
assets/
└── player.png
NatinyResource player = natiny_resource_load("data/player.png");
That is the same string a desktop build uses. A bundle is read-only, so a
relative path given to natiny_resource_save goes to Documents/ inside the
app's container, and natiny_resource_load looks there too.
Typical problems
SDK "iphoneos" cannot be located- Xcode is missing or not selected.- Undefined symbols mentioning UIKit or Metal classes - a framework is missing from the link line; all seven are required.
building for iOS but linking object file built for macOS- the-targettriple is missing from the compile step, the link step, or both.cannot be registered to your development team- the bundle identifier is taken; choose your own.no signing identity matches ...- the packer prints the identities you do have; use one of those.- The install fails - Developer Mode is off, or the profile does not cover this identifier or this device.
- The app stops launching after about a week - a free account's profile expired. Rebuild and reinstall.
What you have now
Your C program runs on an iPhone, and only one command in the whole project is iOS-shaped: the link line.
Two things about the platform are worth knowing next. A touch arrives as the left mouse button, plus a callback that reports every finger. And when iOS sends your app to the background it takes away the right to draw: Natiny reports that as window iconification, and iOS kills an app that keeps rendering anyway. Stop drawing inside that callback.