Appearance
iOS build & device testing — step by step
An end-to-end walkthrough: from a clean Mac to the QR Setu app running on a physical iPhone.
Machine setup (Xcode, Node, CocoaPods) lives in macOS · iOS Build Environment — this page assumes it is done and covers the build and test loop itself. What to actually check once it runs is in Parity Verification.
Read this first — your iPhone X must be on iOS 16.4 or newer
Verified against this repo's lockfile, not assumed:
- React Native 0.86 requires iOS 15.1+ and Xcode 16.1+.
- But
expo-routerdepends on@expo/ui,expo-glass-effectandexpo-symbols, and the first two declare:ios => '16.4'in their podspecs. They are direct dependencies of the router, not optional extras we chose, so they cannot be dropped. The effective floor is therefore iOS 16.4. - An iPhone X tops out at iOS 16.7.x — its final release. So it works, but with only ~0.3 of headroom.
Before you start: Settings → General → About → check the iOS version. If it is below 16.4, update it (Settings → General → Software Update). On an iPhone X you cannot go higher than 16.7.x, and that is fine.
Disk space — the real constraint on a 256 GB Mac mini
This matters more than the M4's speed. Budget honestly before you begin:
| Item | Size |
|---|---|
| Xcode (app + one iOS platform) | ~20–25 GB |
| Additional iOS Simulator runtime, if you add one | ~7–10 GB each |
This repo — node_modules alone is 5.2 GB | ~6.5 GB |
ios/ after prebuild — Pods + build artefacts | ~2–4 GB |
~/Library/Developer/Xcode/DerivedData — grows every build | 2–10 GB, unbounded |
CocoaPods spec repo + cache (~/.cocoapods, ~/Library/Caches/CocoaPods) | ~1–3 GB |
Plan for ~40 GB before you write a line of code. Two habits that keep a 256 GB machine healthy:
bash
# DerivedData is pure cache — safe to delete any time, Xcode rebuilds it.
rm -rf ~/Library/Developer/Xcode/DerivedData/*
# List and remove Simulator runtimes you are not using (each is several GB).
xcrun simctl runtime list
xcodebuild -downloadPlatform iOS -exportPath /dev/null # only if a runtime is missingYou do not need extra Simulator runtimes for this project — Xcode ships with one, and your real target is the iPhone X anyway.
Cleanup is allowlist-only
Reclaim from the table below and nothing else. Never sort ~/Library/Developer by size and delete the big folders. The rule that matters: everything that frees meaningful space is regenerable cache, and everything that breaks your device is tiny. There is no path that is both large and dangerous, so a size-sorted cleanup has no upside and a real downside. Delete from the ✅ rows and stop.
| Path | Frees | Verdict |
|---|---|---|
~/Library/Developer/Xcode/DerivedData/* | 2–10 GB | ✅ Pure build cache |
Unused Simulator runtimes (xcrun simctl runtime list) | 7–10 GB each | ✅ Not needed here |
~/Library/Caches/CocoaPods + ~/.cocoapods | 1–3 GB | ✅ Re-downloads on next pod install |
~/Library/Developer/CoreSimulator/Caches | 1–5 GB | ✅ Regenerates |
apps/mobile/ios/ | 2–4 GB | ✅ Generated — but redo signing 6c + 6d after the next prebuild |
~/Library/Developer/Xcode/iOS DeviceSupport/* | 3–8 GB | ⚠️ Regenerates, but costs a 5–20 min "Preparing debugger support" pass during which the phone is not a usable build destination. Only delete under real pressure |
~/Library/MobileDevice/Provisioning Profiles/ | KB | ❌ No space win. Deleting breaks signing until Xcode re-mints (6d) |
Keychain → Apple Development: … certificate | 0 bytes | ❌ Never. Must be recreated by hand (6b) |
/var/db/lockdown/ | KB | ❌ No space win. Destroys the trust pairing, and the phone vanishes from Xcode entirely |
This has already bitten us once
A 15 GB cleanup on the Mac mini (2026-07-28) removed enough of the above that the iPhone X stopped appearing in Xcode as a build destination. Nothing was broken on the phone and nothing needed reinstalling — but the recovery order is not guessable, which is why it is now written down in The iPhone disappeared from Xcode below. Tracked as QRS-232.
The M4 needs no special handling
Apple Silicon is the native, default path for Expo/RN today. No Rosetta, no arch -x86_64 prefixes, no excluded_architectures Podfile hacks. Ignore any Stack Overflow answer telling you otherwise — those are Intel-era workarounds and they will actively break an M4 build. 16 GB of RAM is comfortable for this project.
Why local builds, and when EAS earns its place
A fair question, since the EAS free tier looks like it should remove the need for any of this. It does not — and the reason is Apple's, not Expo's.
EAS Build cannot install an app on your iPhone without a paid Apple Developer account. Expo's own documentation is explicit that internal/ad-hoc distribution "requires a paid Apple Developer account", because the build must be signed with a distribution certificate and an ad-hoc provisioning profile listing the device UDID — credentials that only a $99/yr membership can create. The free Personal Team used by local Xcode signing is a different mechanism: Xcode generates a 7-day, machine-local profile that cannot be exported to a CI service.
So the comparison is not "15 free cloud builds vs. a local build". It is:
| Local on the Mac mini | EAS Free | |
|---|---|---|
| Runs on your iPhone X | ✅ free, via Personal Team signing | ❌ needs the $99 Apple membership first |
| iOS Simulator build | ✅ unlimited | ✅ no Apple account needed |
| iOS builds/month | unlimited | 15 (not 25 — 15 iOS + 15 Android, counted separately) |
| Turnaround | ~10–20 min first build, then incremental + hot reload | full cloud build every time |
| Queue | none | low priority, 1 concurrency, 45-min timeout |
| Cost | $0 (hardware already owned) | $0 |
The decisive line is the first one. Everything below it only matters if the first one passes.
And even with unlimited free cloud builds, the dev loop would still be local. No one iterates against a 20-minute round trip when expo start hot-reloads JS changes in under a second. EAS is a distribution and CI tool, not a development tool — comparing it to the local loop is comparing two different jobs.
Where EAS Free is genuinely worth using
Not "never" — just not here:
- Android release builds. 15/month is ample for releases, EAS manages the keystore, and it sidesteps the documented Windows OOM pathology that
scripts/build-android.mjsexists to work around. This is a real win worth taking independently of iOS. - iOS, once the $99 is paid — which will be needed anyway for TestFlight and the store. At that point EAS handles ad-hoc credentials and TestFlight upload, which is genuinely tedious by hand.
- The macOS CI runner the parity plan calls for — EAS is one way to satisfy it without self-hosting.
eas build --local is a third path: it runs the EAS pipeline on your own machine and consumes no build quota, giving eas.json reproducibility without cloud waits. It does not change the Apple-account requirement for device builds.
The recommendation
Local Mac builds for the iOS dev loop; EAS Free for Android release builds; revisit iOS-on-EAS when the $99 is paid for distribution. Buy the Apple membership when you need TestFlight, push notifications, or Associated Domains — or when the 7-day re-signing becomes more annoying than $99/yr. Not before.
Step 1 — Get the code
bash
git clone https://github.com/digious-platforms/qrsetu.git
cd qrsetu
git checkout feat/mobile-nativewind-b2 # or the branch you are testing
nvm install && nvm use # honours .nvmrc (Node 22)
node -v # confirm before continuing
npm install # workspace install, from the ROOT — takes a few minutesAlways install from the repo root
This is an npm-workspaces monorepo. Running npm install inside apps/mobile creates a second, broken dependency tree and produces confusing native build errors later.
Step 2 — Verify the toolchain before building
Cheap to check, expensive to debug later.
bash
xcodebuild -version # must be 16.1 or newer, and must NOT say "CommandLineTools"
pod --version
node -v # 22.xIf xcodebuild reports the CommandLineTools path instead of Xcode:
bash
sudo xcode-select -s /Applications/Xcode.app/Contents/Developer
sudo xcodebuild -license acceptStep 3 — Generate the native iOS project
bash
cd apps/mobile
npx expo prebuild -p iosThis reads app.json and generates apps/mobile/ios/, then runs pod install. First run downloads the CocoaPods spec repo and compiles nothing yet — expect a few minutes.
ios/ is generated — never edit it by hand
apps/mobile/ios/ and apps/mobile/android/ are both gitignored. Anything you change inside them is destroyed by the next expo prebuild and never reaches another machine or CI.
All native configuration belongs in app.json (or a config plugin): ios.bundleIdentifier, ios.buildNumber, ios.deploymentTarget, plugins. This is the same lesson the Android gradle.properties incident taught — a native file edited by hand is a change that exists on exactly one machine.
Confirm the deployment target that was actually generated:
bash
grep "platform :ios" ios/Podfile
grep -m1 IPHONEOS_DEPLOYMENT_TARGET ios/*.xcodeproj/project.pbxproj(The .xcodeproj is named by prebuild from expo.name in app.json, so the glob avoids guessing it.)
If either shows a value below 16.4, CocoaPods will warn or fail on @expo/ui/expo-glass-effect. Pin it explicitly in app.json and prebuild again:
jsonc
"ios": {
"bundleIdentifier": "in.digious.qrsetu",
"buildNumber": "1",
"deploymentTarget": "16.4", // add this only if the generated value is lower
"supportsTablet": true
}bash
npx expo prebuild -p ios --cleanStep 4 — Run on the Simulator first
Always prove the build works on the Simulator before involving code signing. It removes an entire class of variables.
bash
npm run ios # = expo run:iosThe first compile is the slow one (10–20 minutes is normal; the M4 handles it well). Later runs are incremental and fast. Metro starts automatically; leave it running.
Once it launches, sanity-check the things this phase actually fixed:
- The splash background flows into
BrandSplashwith no colour jump, in light and dark mode. - Buttons scale on press.
- Toggle dark mode: Simulator → Features → Toggle Appearance (or
⌘⇧A).
Step 5 — Prepare the iPhone X
- Update to iOS 16.7.x if it is below 16.4 (see the warning at the top).
- Connect it to the Mac mini by cable, unlock it, and tap Trust This Computer (it asks for the passcode).
- Enable Developer Mode: Settings → Privacy & Security → Developer Mode → on, then restart the phone. iOS 16 requires this before it will run any self-signed app.
The Developer Mode menu item does not exist yet — do not go looking for it first
On iOS 16+, Developer Mode only appears in Settings once a Mac has attempted to install a development build. On a phone that has never had one, that row is simply absent, and hunting for it reads like something is broken. So the real order is: finish Step 6, run Step 7, let it fail, then enable Developer Mode and re-run Step 7. That is why this is listed third here and not as a prerequisite.
Step 6 — Set up free signing (no $99 needed)
One-time, in the Xcode GUI. Do all four sub-steps before running any build command — they are strictly ordered, each one produces what the next needs, and skipping ahead yields errors that name the wrong cause. expo run:ios can only reuse signing assets; it never creates a certificate, registers a device, or mints a profile.
bash
open ios/*.xcworkspace # the .xcworkspace, NOT the .xcodeprojIf Xcode has never been launched, open it first (open -a Xcode) and let it install its additional components. Its menu bar lives at the top of the screen and only shows Xcode's menus when Xcode is frontmost; Settings is under the Xcode menu (Cmd + ,), not File, and was called Preferences on older versions.
6a · Add your Apple ID. Cmd + , → Accounts → + → Apple ID → sign in. A team named <Your Name> (Personal Team) appears. No payment, no enrollment.
6b · Create the development certificate. Adding the Apple ID does not create one, and without it every build fails with CommandError: No code signing certificates are available to use. Still in Accounts: select your Apple ID → Manage Certificates… → + → Apple Development → wait for the row to appear → Done.
6c · Configure the target. In the project navigator click the topmost row — the app project (blue icon), not the Pods project below it and not a file inside it. The editor splits into PROJECT / TARGETS; under TARGETS pick the app target (not …Tests) → Signing & Capabilities tab → tick Automatically manage signing → Team = your Personal Team.
6d · Select the physical device as the destination — this is the step everyone misses. If Status shows:
⚠ Your team has no devices from which to generate a provisioning profile ⚠ No profiles for
in.digious.qrsetuwere found
…the cause is that the destination selector at the top of the window is still on a Simulator. Simulator builds need no signing, so Xcode has no device to register and nothing to build a profile for. Click the destination selector, choose your iPhone under the physical-device section, and both warnings clear together — they are one root cause stated twice. Click Try Again if they linger. Selecting a real device is what registers its UDID against your Personal Team and mints the iOS App Development profile.
Bundle identifier conflicts
If Xcode says the bundle identifier is unavailable, change it in app.json, not in Xcode — e.g. in.digious.qrsetu.dev — and re-run prebuild. ios/ is generated, so an Xcode edit is wiped by the next prebuild.
The keychain prompt during codesign
The first signed build raises "codesign wants to access key 'Apple Development: …' in your keychain." That asks for the Mac login password — not the Apple ID password. Click Always Allow, not Allow, or it reappears on every build.
If it seems to reject a password you know is right:
- Click inside the password field first. The dialog often appears without keyboard focus — especially when raised by
xcodebuildfrom a terminal — so keystrokes go nowhere. - Verify the password independently:
security unlock-keychain ~/Library/Keychains/login.keychain-db. Silence means correct;passphrase you entered is not correctmeans the login keychain is still on an older password, which happens when the account password was reset via Apple ID/iCloud recovery rather than System Settings. - Remove the prompt entirely: Keychain Access → login → My Certificates → expand
Apple Development: …→ select the private key →Cmd+I→ Access Control → Allow all applications to access this item → Save. - Check for duplicate identities:
security find-identity -v -p codesigning. Two or moreApple Developmententries means codesign may be picking one whose private key is missing — no password fixes that; remove the dead duplicate.
Do not use the widely-copied security set-key-partition-list -k <password> … one-liner: it puts your login password in plaintext into shell history.
Step 7 — Run on the iPhone X
bash
npx expo run:ios --device --configuration Release # for parity testing — see the warning belowPick your iPhone from the list. If the Xcode GUI build stalls on signing, the terminal command is worth trying anyway — the two take different paths through the keychain and one succeeding where the other fails is not unusual.
Release, not debug, for anything you intend to compare against Android
The Android side is tested as a release APK (npm run build:android), so an iOS debug build is not a like-for-like comparison: debug does not embed the JS bundle (it is fetched from Metro at every cold start, so the app needs the Mac running and the same Wi-Fi and re-bundles on each launch), keeps dev warnings on, and runs JS + reanimated unoptimized. Animation smoothness and startup in particular will differ for reasons that have nothing to do with iOS.
A Release install is also the only one that behaves like a normally-installed app: unplug the cable, no Mac, no Wi-Fi dependency. The cable is only required during the install itself. Use debug (npx expo start --dev-client, press i) for iterating on JS; re-install Release before signing off on parity.
On first launch the app will refuse to open and iOS will show "Untrusted Developer" — that is expected. Trust the certificate once:
Settings → General → VPN & Device Management → Apple Development: <your Apple ID> → Trust
The phone needs internet for this — iOS verifies the certificate with Apple, and on a dead connection it hangs on "Verifying…" with no useful message. Then launch the app from the home screen.
Step 8 — Verify
Work through the checklist in Parity Verification. The iPhone X is a particularly good device for this because it has a notch and a home indicator but no Dynamic Island, so it exercises the safe-area handling that was broken on the iOS PWA (the transitional surface iOS native has since replaced).
Pay special attention to:
- Safe areas — nothing under the notch; the tab bar and FAB sit above the home indicator.
- Haptics — buttons produce a light tap. This is new and iOS-only (the approved carve-out: Android gets a ripple, iOS gets haptics). If you feel nothing, check Settings → Sounds & Haptics → System Haptics is on — iOS suppresses them globally when it is off, which is correct behaviour, not a bug.
- Card shadow — the Setu Card preview in onboarding must have a visible shadow. It was previously clipped away on iOS by
overflow: 'hidden'. - Swipe-back from the screen edge, against our custom headers.
- Light and dark, both.
The iPhone disappeared from Xcode
A six-rung recovery ladder. The phone stops appearing in the destination selector, usually after a disk cleanup, a macOS update, or an Xcode upgrade. Nothing is wrong with the phone and the app does not need reinstalling — one of five pieces of local state is missing, and they have to be restored in order, because each rung's symptom masks the next one.
Diagnose first — this tells you which rung you are on:
bash
xcrun devicectl list devices # Xcode 15+ — what CoreDevice can see
xcrun xctrace list devices # works on older Xcode too
system_profiler SPUSBDataType | grep -A4 -i iphone # is it even on the USB bus?Work down until the phone reappears. Do not skip — a missing certificate looks identical to a missing profile.
Rung 0 · Rule out the boring cause. If system_profiler shows no iPhone, this is a cable, port or lock-screen problem, not a software one. Use a known-good data cable (charge-only cables are the classic trap), a different port, and make sure the phone is unlocked. 10 seconds, and it eliminates half the possibilities.
Rung 1 · Re-establish the trust pairing. Cleanup tools remove /var/db/lockdown, which is where the Mac stores its pairing records. The phone then charges normally and is simply invisible to Xcode.
- Unplug, lock and unlock the phone, plug back in.
- Tap Trust This Computer and enter the passcode.
- If that prompt never appears, the stale record is still there. Force it back with Settings → General → Transfer or Reset iPhone → Reset → Reset Location & Privacy. This is non-destructive — it clears permission grants, not data — and the trust prompt returns on the next connect.
Rung 2 · Re-enable Developer Mode. Re-pairing can switch it off. Settings → Privacy & Security → Developer Mode → on, then restart the phone. If the row is missing entirely, that is the Step 5 trap, not a new fault: iOS only shows it after a Mac has attempted a development install. Run npx expo run:ios --device once, let it fail, and the row appears.
Rung 3 · Let Xcode rebuild the device-support symbols. This is the most likely cause after a large cleanup. Open Xcode → Window → Devices and Simulators (Shift + Cmd + 2) and look at the phone's row. If it reads "Preparing debugger support for iPhone…" with a progress bar, ~/Library/Developer/Xcode/iOS DeviceSupport/ was deleted and Xcode is regenerating it.
The phone is not a selectable build destination until that finishes, which takes 5–20 minutes for iOS 16.7.16 on a first pass. Keep it plugged in and unlocked, and do not interrupt it. This is the rung that looks most like broken hardware and is purely a matter of waiting.
Rung 4 · Read the warning triangle. If the phone is listed with a yellow triangle, click the row — Xcode states the reason in plain text there rather than in the build log. On Xcode 26.6 against iOS 16.7 the support files are built in, so anything mentioning an unsupported OS version means Rung 3 has not completed.
Rung 5 · Restore the signing assets. Once the phone is visible but the build fails, two independent things may have been removed. Check both:
bash
security find-identity -v -p codesigning # expect an "Apple Development: <you>" line
ls ~/Library/MobileDevice/Provisioning\ Profiles/ # expect at least one .mobileprovision- No certificate → redo 6b (Xcode → Settings → Accounts → Manage Certificates →
+→ Apple Development). - No profile → redo 6d: select the physical iPhone as the destination. That is what re-registers its UDID and mints a fresh
iOS App Developmentprofile. Neitherexpo run:iosnor a rebuild will create these for you.
Rung 6 · Regenerate the native project. Safe at any time, because ios/ is generated output:
bash
cd apps/mobile && rm -rf ios && npx expo prebuild -p iosThen redo 6c and 6d — a fresh ios/ has no signing configuration, and this is the step that gets forgotten.
Installing a Release build on the phone
A Release-configuration build installs on your own device with free signing:
bash
cd apps/mobile
npx expo run:ios --device --configuration Release"Production build" means something different on a free Personal Team
This is a local Release build for your own registered device, and it still carries the 7-day expiry below. It is not a distributable build: TestFlight, ad-hoc distribution and App Store submission all require the $99 Apple Developer Program membership. Use this for performance and parity verification — which is what it is for — and do not read a successful Release build as "ready to ship to users".
The 7-day expiry
Free Personal Team provisioning profiles expire after 7 days. The app then refuses to launch — this is Apple's limit, not a bug and not something to debug.
bash
npx expo run:ios --device # re-signs and reinstallsOther free-tier limits: 3 apps per device, no TestFlight, and no push notifications or Associated Domains (which is why qrsetu:// is the iOS interim deep-link scheme). Buy the $99 membership when distribution is needed — development and parity verification do not need it.
Everyday loop, after the first build
bash
cd apps/mobile
npx expo start --dev-client # Metro; press "i" to open on the device/SimulatorYou only need to repeat expo run:ios when native code changes — a new native dependency, an app.json change, or a prebuild. Pure JS/TS changes hot-reload; do not rebuild for them.
Troubleshooting
pod install fails after a dependency change
bash
cd apps/mobile/ios && pod install --repo-updateIf it stays broken, delete and regenerate — ios/ is generated output, so discarding it is always safe:
bash
cd apps/mobile && rm -rf ios && npx expo prebuild -p iosPersonal development teams … do not support the Push Notifications capability (plus Provisioning Profile … does not support the Push Notifications capability and Entitlements file defines the value "aps-environment" — all three are one root cause). expo-notifications writes the aps-environment entitlement on every iOS build and a free Personal Team cannot sign it. Fixed in-repo by the withoutPushEntitlement config plugin (QRS-234); if you hit it, you are on a commit before that fix, or ios/ predates it:
bash
cd apps/mobile
npx expo config --type introspect | grep -c aps-environment # expect 0; if 1, pull latest
npx expo prebuild -p ios # regenerate the entitlements file
npx expo run:ios --device --configuration ReleaseWe use local notifications only, so the entitlement is not something we are giving up — it declares remote APNs push, which R1 does not have and which needs the $99 membership regardless.
The iPhone is not in the destination list at all — work the recovery ladder. Most common after a disk cleanup; usually Rung 1 (trust pairing) or Rung 3 (device-support symbols regenerating).
"Preparing debugger support for iPhone…" and the device is greyed out — Rung 3. iOS DeviceSupport was deleted; wait 5–20 minutes with the phone plugged in and unlocked.
No code signing certificates are available after a cleanup — Rung 5. The keychain certificate was removed; verify with security find-identity -v -p codesigning and redo Step 6b.
Your team has no devices… / No profiles for in.digious.qrsetu were found after a cleanup — Rung 5, profile half. Re-select the physical device as the destination (Step 6d) to re-mint it.
"Untrusted Developer" on launch — Step 7 covers it; trust the certificate in Settings.
"Unable to install… device is locked" — unlock the phone and keep it unlocked during install.
The app launched fine last week and now won't — the 7-day expiry. Re-run expo run:ios --device.
Build errors mentioning a deployment target or an unavailable API — the iOS 16.4 floor from Step 3. Check the phone's iOS version and the generated Podfile.
Metro serves a stale bundle
bash
npx expo start -cDisk full mid-build — clear DerivedData (see the disk-space section); it is pure cache.
Anything suggesting arch -x86_64, Rosetta, or excluded_architectures — ignore it. Those are Intel-era workarounds and they break Apple Silicon builds.