iOS / iPhone app
Reference + checklist for building and shipping iPhone apps. Read the stack, copy the example's patterns, and run the publishing checklist BEFORE the first App Store upload — it prevents the rejections below that each cost a re-archive.
Stack (solopreneur templates)
- Native Swift:
~/startups/solopreneur/solo-factory/templates/stacks/ios-swift.yaml— Swift 6 + SwiftUI, SPM, xcodegen (project.yml→ generated.xcodeproj, do not commit the.xcodeproj), SwiftData (not Core Data), StoreKit 2,.xcstringsstring catalogs, SwiftLint + swift-format, lefthook, App Store Connect CLIasc(brew install asc). - KMP hybrid (shared logic, cross-platform):
~/startups/solopreneur/solo-factory/templates/stacks/kotlin-multiplatform.yaml— Kotlin Multiplatform + Compose Multiplatform, Gradle version catalog, expect/actual for platform code.
Pick native Swift for iOS-only; KMP when Android shares the domain/UI. The ios-swift.yaml carries the
full detail (house patterns, asc CLI commands, Xcode MCP tools, on-device-AI packages) — read it when
scaffolding; the sections below promote the highest-leverage bits.
House conventions (native Swift, Swift 6)
Match these so generated code fits the codebase (from ios-swift.yaml patterns, learned via SoloGraph):
@Observable @MainActor final classview models — NOTObservableObject.@State private var vm = MyVM()in the view; inject services via init (DI).actorfor heavy/ML/data services (own isolation);@Observable @MainActor final classfor UI-facing services (recording, audio). A protocol for every service (Services/Protocols/FooServiceProtocol.swift) → mocking + swapping.- SwiftData
@Modelfor persistence (NOT Core Data, NOT Firebase). Plain structs for transient data/config/API responses. Local-first. - async/await everywhere, no Combine for new code. Timer callbacks →
Task { @MainActor in … }. - Permissions:
requestPermission() async -> Boolin the protocol; request before use; "Open Settings" alert on denied;#if os(iOS)aroundAVAudioSession/UIApplicationfor macOS compat. - Dir layout:
App/Models/Views/ViewModels/Services/Services/Protocols/Extensions/Resources/. MVVM. - i18n: String Catalog (
.xcstrings, Xcode 16). Lint: SwiftLint + swift-format; hooks via lefthook. Tests: Swift Testing (@Test) new, XCTest legacy. IAP: StoreKit 2. Analytics: PostHog (EU).
Claude Code ↔ Xcode workflow
- Xcode MCP bridge (Xcode 26.3+,
xcrun mcpbridge; enable in Xcode → Settings → Intelligence → MCP Server): Claude Code can build/test/render natively —BuildProject,RunAllTests/RunSomeTests,RenderPreview(SwiftUI preview → image, visual verification without a full run),DocumentationSearch(Apple docs + WWDC),ExecuteSnippet(Swift REPL),GetBuildLog. Prefer these when available. - Simulator visual smoke test (no device needed for non-AR/non-camera screens):
xcrun simctl boot 'iPhone 16' 2>/dev/null || true xcrun simctl install booted <App.app>; xcrun simctl launch booted <bundle.id> xcrun simctl io booted screenshot /tmp/sim.png xcrun simctl spawn booted log stream --style compact --timeout 10 - ARKit/camera/real-GPS features can't be tested in the Simulator — build to verify compile, test on device.
The feedback loop: XcodeBuildMCP (install this first)
Without a loop the agent writes blind. In the browser it opens a page; on iOS it needs the simulator. Two MCP servers cover it, and they complement rather than overlap.
XcodeBuildMCP (npx -y xcodebuildmcp@latest mcp) — headless build, run,
test and UI automation. The part that matters most: snapshot_ui returns a
semantic tree with element references, and tap takes a reference rather
than a coordinate:
e244|tap|button|Make a montage from the selected period
e189|tap|button|Days e247|tap|button|Audio
That removes the whole class of failure a coordinate-driven walker suffers —
describe-ui-style dumps include views belonging to sheets underneath, off
to the side of the screen, and a tap aimed there dismisses whatever is on top.
Also worth having: wait_for_ui with a predicate instead of polling loops,
record_sim_video, coverage straight out of xcresult, and launch_app_sim
capturing runtime + os_log to files on its own.
Enable the workflows you need — the default is only 24 tools. UI automation and the device workflow are off unless you ask:
"xcodebuild": {
"command": "npx",
"args": ["-y", "xcodebuildmcp@latest", "mcp"],
"env": {
"XCODEBUILDMCP_ENABLED_WORKFLOWS":
"session-management,simulator,simulator-management,ui-automation,device,utilities,project-discovery"
}
}
With that it registers 44 tools instead of 24. The variable is not in --help;
it is XCODEBUILDMCP_ENABLED_WORKFLOWS, found by grepping the package.
Call session_set_defaults once (project, scheme, simulator id, bundle id,
persist: true) — it writes .xcodebuildmcp/config.yaml in the repo, and
every later call can go with empty arguments. Call session_show_defaults
before the first build of a session; the server asks for this explicitly.
Xcode's own bridge (xcrun mcpbridge, Xcode 26.3+) — advertised as 20
tools over XPC, including rendering a SwiftUI Preview without building and
running the whole app, plus a Swift REPL. It needs Xcode running with the
project open, so it is no use in a background or CI run.
Verify it answers before planning around it. On Xcode 26.6 with the project
open, initialize replies (serverInfo: {name: xcode-tools}) and then
tools/list never answers and the pipe closes — nothing usable. Probe it in
ten seconds rather than assuming either way:
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' | xcrun mcpbridge
Keep one MCP config, not two. A project .mcp.json and ~/.mcp.json that
both define XcodeBuildMCP start two servers, and the project copy usually
lacks the workflow filter — so the session carries a second, differently
configured set of the same tools. Pick one file, or keep them byte-identical
and say so in a comment.
Apple RAG MCP (official Swift docs and HIG over RAG) is a nice-to-have — context7 already answers most API questions, and HIG comes up once a day, not once a minute.
Driving the Simulator, and trusting the numbers
Learned the hard way on a video app; the traps are not app-specific.
Pick the reader by where the code runs, not by benchmark. Measured on one screen (Xcode 26.6, iOS 26 simulator, Aug 2026):
| Reader | Returned | Use it |
|---|---|---|
MCP snapshot_ui (XcodeBuildMCP) |
442 nodes, each with elementRef, role, label, available action |
inside an agent session — tap by elementRef, no coordinates, and batch several taps on one screen |
axe describe-ui --udid |
48 nodes, 21 labelled, with frames | shell scripts, which cannot reach MCP |
idb ui describe-all |
1 empty element; every tap answers Mach port invalid, device disconnected |
nothing, currently |
idb was the fast one and is now broken against current simulators — idb kill && idb connect does not revive it. Its "0.27s tap" is the speed of the error,
not of a tap; it does at least exit non-zero, so a idb … || axe … fallback
still works and merely wastes a call. Re-measure before trusting any of these
three, including this table — the tool that was right last release is the one
most likely to be quietly wrong now.
axe requires --udid. Without it, it prints usage to stderr and returns
nothing, so a script that does not check will read every screen as empty and
report the app as broken. Find it yourself rather than requiring an argument:
xcrun simctl list devices booted -j # → udid of the booted simulator
axe describe-ui --udid <UDID>
axe tap -x 200 -y 60 --udid <UDID> # points, not pixels
Calibrate the harness or your timings are fiction. A walk-the-path script reported a 108s user journey; the real figure was ~10s and the rest was the automation tool's own latency — seven taps at 5–12s each. Measure one probe at startup, subtract it from every step, and print "user waits" and "harness overhead" as two separate numbers. A stopwatch heavier than the thing it times will send you hunting regressions that do not exist.
Wait on facts, not on accessibility. Modal screens report nothing to describe-ui, so a
finished job looks like a timeout. Wait on the artefact instead: the file on disk, the row in the
database, the count from a debug endpoint. And a stalled encode stops growing just like a finished
one — check the file is playable (ffprobe), not merely still.
CPU traps worth checking in any SwiftUI app
- A dead
@EnvironmentObjectstill subscribes. One unused declaration in the root view invalidates the whole tree on every@Publishedchange.grepeach injected object for a second mention; if there is none, delete the declaration. - Publishing progress per item redraws per item. A field containing a changing path always compares unequal, so a tight loop invalidates ~20×/s for as long as it runs. Throttle to ~4/s — no screen shows more.
- Nothing asks about heat or battery by default.
grep -rn "thermalState\|isLowPowerMode" Sources/returning zero in an app that decodes video or runs Vision means it runs flat out on a hot phone. Back off at.serious, stop in Low Power Mode. - Background work restarting on every launch is a development tax. A debug build is launched dozens of times an hour; gate the auto-start behind an env var and turn it on deliberately.
- The Simulator window losing focus is not backgrounding. An app keeps decoding while you work in another macOS app — measured 51–89% CPU. Pressing Home inside iOS drops it to 0%. When the machine feels hot, background the app in iOS or terminate it; do not go looking for a leak.
Concurrency traps in export/render paths
- A one-second timestamp is not a unique filename. Two exports started in the same second
resolve to the same path and
AVAssetExportSessionfails the second with "Cannot Save" / "Cannot create file". CheckingfileExistsfirst does not fix it — both find the name free. Use an atomic counter. Reproduce with two concurrent exports before and after.
A Rust core linked into the app (uniffi)
Three traps, each of which reads as "my change did nothing" or "the bridge is broken", and none of which is either.
The app may be linking yesterday's core. CARGO_TARGET_DIR (commonly set
to a shared directory in a shell profile) means cargo does not write to
./target — while the Xcode project points its linker at $(SRCROOT)/../target/….
Everything builds, everything runs, and none of the day's work is in the app.
Add a pre-build script phase that copies the newer .a across, and leave
ENABLE_USER_SCRIPT_SANDBOXING off (it denies that phase access to the
directory). If a change appears to have no effect, check the .a timestamp
before re-reading the code.
Pin the deployment target in .cargo/config.toml, not in a build command.
[env]
IPHONEOS_DEPLOYMENT_TARGET = "17.0"
Left unset, every object is stamped with the SDK's own version and the linker
emits one warning per object — hundreds of them, all identical, hiding whatever
real warning arrives next. Pinning it in the Makefile fixes only that one door;
build-ios-sim.sh, make deploy and a bare cargo build keep the problem. And
a link-arg does not work: a staticlib is an archive and is never linked.
Changing this env var does not enter cargo's hash either, so the dependency
objects keep their old stamp — delete the target dir once when you set it.
Build for one concrete simulator. -destination 'generic/platform=iOS Simulator' asks for a universal build, which includes x86_64; an arm64-only
Rust core then fails to link with a page of missing uniffi symbols that reads
exactly like a broken bridge. Use -destination "id=$UDID".
Logging levels are a performance decision, not a style one. A tracing::info!
on a per-frame path wrote 11,752 records of one montage into a JSONL file on the
device — 30,577 lines and 10 MB in a day, inside the loop the user is waiting
on. Anything per-frame or per-item belongs at debug!/trace!, and whatever
you do write needs a retention sweep at startup; nothing else will ever delete
those files.
The first HTTP request a fresh process makes can hang. Not slow — no answer
at all until something cuts it off, with the retry succeeding in ~2s. Any client
built with reqwest::Client::new() has no timeout of any kind, and library
defaults can be worse than none (openai-oxide defaults to 600s). Set
connect_timeout, timeout and a pool_idle_timeout shorter than the minute a
mobile network takes to forget an idle connection, and let retry treat a timeout
as transient. Measured: 120s of nothing → 40s with an answer.
Debugging a stall on a real device (no screenshots there)
idevicescreenshot needs the developer disk image, and on a modern iOS it
often refuses to mount — the same reason xcodebuild -destination 'id=…'
fails with "developer disk image could not be mounted". So the simulator
playbook (tap, screenshot, look) does not transfer. Make the app report
instead:
A status endpoint. If the app already runs a local HTTP server (MCP, debug bridge), add
GET /statusreturning what it is doing: stage, percent, items done/total, which file is in hand, seconds elapsed, and a short list of completed steps with per-step timings. Twocurls a few seconds apart separate "slow" from "stuck" and name the culprit. This turned a multi-hour guessing game into a two-minute diagnosis.A step trace. One
os_logline per step with the gap since the previous one (▶︎ collected 50 clips +0.1s). Feed the same marks into the status payload so the log and the endpoint cannot disagree.Three things make the difference between a trace that answers and one that looks like it does — each cost a session to learn:
- Log at
.default, not.info..infois held in memory and never written down, solog show --predicate 'category == "trace"'returns nothing after the fact and the walk can only be watched live, if you thought to attach a stream first. On a phone, after the fact is usually the only chance there is. Twenty lines per run costs nothing. - Keep the finished walk. "Why was that slow" is asked once the result is on screen, and clearing the marks when the next run starts throws away the answer. Hand the account over on the next start, not on finish — a mark delivered via a hop to the main actor lands just after the code that ends the run, so freezing at finish drops the last step, which is the one people ask about.
- Do not bill the app for the user's thinking. A step recorded at a tap
carries everything since the previous mark, including however long somebody
stared at the screen. Mark taps separately (
👆 preview requested), reset the clock there, and charge them nothing. Before this a walk read 13.0s of app time with one step at 7.4s that looked like the thing to fix; with taps marked it read 5.6s and that step was 0.0s. The 7.4s was the test harness looking at the screen.
- Log at
Console when you need everything:
xcrun devicectl device process launch --device <udid> --console --terminate-existing <bundle>. It restarts the app, so it cannot observe a run already in progress — start it first.
Install without Xcode's device destination (works while the DDI does not):
xcodebuild -destination 'generic/platform=iOS' -configuration Debug \
-allowProvisioningUpdates -derivedDataPath /tmp/dd-device build
xcrun devicectl device install app --device <udid> \
/tmp/dd-device/Build/Products/Debug-iphoneos/App.app
xcrun devicectl device process launch --device <udid> --terminate-existing <bundle>
The phone must be unlocked for the install, and the developer certificate trusted once under Settings → General → VPN & Device Management (it needs network to verify). And note: install does not relaunch the app — a new build with new logging looks like it changed nothing until you launch it.
Timeouts: the ones that do not work
- A semaphore with no deadline is a hang.
sema.wait()around a PhotoKit or network callback parks that thread forever when the callback never comes. Usesema.wait(timeout:), log which item you gave up on, and carry on — losing one item beats losing the job. - Racing inside a task group does not time anything out. A group awaits its children on the way out, and cancellation is cooperative: a synchronous call ignores it, so the group waits for exactly the task you were escaping. Measured: 1 of 50 items after 226s with a "75s deadline" in place. Race through a continuation instead, resumed once under a lock by whichever side finishes first; the stuck work keeps running on its own thread and is dropped from the result.
- Bound
URLSession. The shared session waits 60s per request by default; an agent loop of three turns is three minutes of silence. SettimeoutInterval, and log the status and first bytes of any non-200 — otherwise "the provider refused" is indistinguishable from "the feature is broken".
PhotoKit costs, and where they hide
deliveryModedecides whether you download..highQualityFormatmeans the original, and for anything in iCloud that is a full download before your code runs. If the work is analysis on small frames, ask for.fastFormatand let Photos hand over whatever it has closest. Measured on a real library: 19.5s → 3.1s per clip, six times faster.Split any asset cache by that intent, or the cheap path and the original path shadow each other. Getting this wrong made previews re-fetch every original three times over.
A synchronous
requestImagecan return nil where the async one works. 40 of 40 thumbnails came back empty synchronously and all 40 arrived throughrequestImagewith a continuation. If thumbnails are mysteriously missing, this is the first thing to check.progressHandleris the only way to see an iCloud download. Without it the UI claims to be analysing while it is really waiting on the network — and add it to every path that resolves assets, not just the obvious one.Ask where a clip is before queueing it.
isNetworkAccessAllowed = falseturns a request into a cheap probe: a local asset comes back almost at once, one in iCloud comes back empty withPHImageResultIsInCloudKeyset and starts no download. Measured at 14ms per clip — 0.7s for 50.It matters because worker slots are few. With four slots and no ordering, all four filled with cloud clips while clips already on the device — a second's work each — queued behind them: 2% for a long time and nothing on screen. Read what is here first, the cloud after, and put a deadline on the probe so the worst case is the order you had anyway.
Where the reference comes from
Do not invent a screen from scratch when a convention already exists for it — people arrive already knowing how a length picker, a paywall or an onboarding step behaves, and a fresh idea in that slot costs them the knowledge.
Mobbin publishes https://mobbin.com/llms.txt — 66 KB, no key, ~272
mobile links organised four ways: by app category, by flow
(/explore/mobile/flows/… — onboarding, adding-to-cart, editing-profile), by
screen pattern, and by UI element. Fetch it, pick the two or three links that
match the screen being built, and look at those rather than describing a
layout from memory.
curl -s https://mobbin.com/llms.txt | grep -i 'flows/' # 60+ named flows
curl -s https://mobbin.com/llms.txt | grep -i 'ui-elements' # by component
Also worth having in the same slot: Apple's HIG for the platform rule, and a
design the user already made — a Claude Design project can be read with the
DesignSync MCP (list_files then get_file), which is how a .dc.html
mockup becomes tokens (Brand.swift) and components rather than a screenshot
someone eyeballs. Pull the palette, the radii and the blur values out of the
markup instead of guessing them:
grep -o 'linear-gradient([^)]*)' mock.html | sort | uniq -c | sort -rn | head
grep -oE '#[0-9A-Fa-f]{6}' mock.html | sort | uniq -c | sort -rn | head
grep -o 'border-radius:[^;]*' mock.html | sort | uniq -c | sort -rn | head
SwiftUI layout traps that cost a screenshot to find
GeometryReaderinside a stack takes the whole height and leaves its siblings at zero, drawing them under whatever comes next. Use a plainProgressViewor a fixed frame instead of measuring.- A bare
LazyVGridlays out every item and overflows its frame. Put it in aScrollViewwith a fixed height when the count is unbounded. ^[\(n) item](inflect: true)only expands in a localised string. Built as a plainStringit reaches the screen as markup — a user reported seeing "1 conflict true and some brackets"..sheetmodifiers do not stack. Several on one view and only the last works; use one.sheet(item:)with an enum.- Dismissing a sheet destroys its
@State. If work continues behind it, keep the run's state in an@Observableoutside the view, or coming back offers to start over while the first run is still going.
Accessibility-tree automation on the simulator
describe-ui returns the whole hierarchy, including views of sheets
underneath. Those sit off to the side — x=478 on a 402pt screen — so a tap
aimed at the topmost match by y can land on nothing and dismiss what is on
top. Filter candidates to the visible width, and clamp a centre that falls
past the edge (a long label has a frame wider than the phone). Chips inside a
horizontal ScrollView are not exposed at all — assert on their container
instead.
onTapGesture is invisible to the tree — and to VoiceOver. A tile built
as a ZStack with .contentShape(Rectangle()).onTapGesture offers no action at
all: the snapshot listed 17 actionable elements on a gallery screen and not
one of them was a clip. Same cause, two consequences — the automation cannot
drive it and a person using VoiceOver cannot use the feature. Fix once:
.accessibilityElement(children: .ignore)
.accessibilityLabel(label) // "Video, 30 seconds, analysed"
.accessibilityValue(isSelected ? "Selected" : "")
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton)
.accessibilityAction(.default, onTap)
The reverse failure is a row that exposes too much: artwork, title, source
and duration as four separate elements, none of which is the one that selects.
Collapse it with children: .ignore and one label. Afterwards the tree is
also a usable test surface — you can tap a specific clip by name, which is
impossible when everything is called "Song, Play".
A check with no time budget is not a check. A smoke script that only asks "did it answer" reported PASS on a 124-second reply. Give each step what it should cost warm, report over-budget, fail on wildly over — slow is a regression, and it is the one that silently arrives.
A checker must never blame the app for its own failure. Count "could not reach the screen" separately from "the screen has unnamed controls" and print both. An audit whose reader was misconfigured reported 7 screens with unnamed controls in an app that had none — same exit code as a real defect, and a day of work aimed at nothing. Same rule for a walker: if the library count does not move after Save, ask the app whether it attempted the save, so "the tap missed the button" and "the system refused the write" stop looking alike.
Never grep away a category of output to make a run look clean. 375 linker warnings stayed invisible for a day because the check filtered the line they were on, and the filter was written by the same person who then reported "no warnings".
A measurement from a busy or sleeping machine is not a measurement. Three
separate "regressions" — 909s, 1139s, 120s — were the laptop, not the code.
Re-run before believing a number, and caffeinate -dimsu anything long.
Ask a repeated question and you may be timing a cache. An LLM turn behind a gateway answered a fresh question in 9s and a repeat of an earlier one in 1.0s. Vary the prompt when timing, or the check passes with the model unplugged.
A permanently red test hides the real ones. Three PhotoKit tests had
asserted the opposite of what was actually true for long enough that four
genuine failures went unnoticed in the same run. If an environment cannot
answer the question (a test runner is regularly refused library resources the
app itself reads fine), XCTSkip with the reason — and note wait(for:)
records a failure the moment it times out, so use XCTWaiter().wait(...) when
the timeout is a decision rather than a verdict.
Example project — Caretta Friends (KMP + hybrid iOS)
~/startups/active/caretta-friends — real shipped app. Study it for the hybrid pattern:
- iOS = native SwiftUI shell hosting shared Compose screens.
iosApp/iosApp/ContentView.swiftis a SwiftUITabView+ per-tabNavigationStack; each content screen is a shared ComposeUIViewControllerfromIosEntry.kt(ComposeUIViewController { ... }). Native Swift owns the chrome (tab bar, nav), camera (Camera/CameraCaptureView.swift), map (Map/MapLibreView.swift, MapLibre + OSM), and AR (AR/ARNestView.swift, ARKit). - Bridge:
composeApp/src/iosMain/.../IosEntry.ktexposes VC factories + plain functions (mapPoints(),takeMapFocus(),currentStrings()). Swift callsIosEntryKt.*. - Gotchas file: the project's
docs/plan.md+CLAUDE.mdlist KMP-specific traps (Map-backed i18n to dodge an ART VerifyError;topmostViewController()becausekeyWindowis nil on iOS 15+; the Kotlin-2.3 iosArm64 ABI trap — don't add libs built with a newer Kotlin to commonMain). - ARKit geo-AR:
ARGeoAnchor(Apple location anchors) only works in select cities — for anywhere else useARWorldTrackingConfiguration+.gravityAndHeading, take one GPS fix as origin, place each point at its East/North (ENU) offset (world = (east, 0, -north)), and project world→screen each frame for SwiftUI overlays. LiDAR (sceneReconstruction = .mesh+.occlusion) is a free upgrade on Pro devices, degrades silently elsewhere. ARKit does NOT run in the Simulator — build to verify compile, test on a device. If markers land in the mirrored direction, flip the north/east sign.
Example projects — native SwiftUI (xcodegen stack)
These follow the ios-swift.yaml stack: SwiftUI + SPM + xcodegen (project.yml is the source of
truth; the .xcodeproj is generated and NOT committed). Copy their project.yml, folder layout, and
lefthook/SwiftLint setup for a new native app. All under ~/startups/active/:
- FaceAlarm (
FaceAlarm/ios-app, also akotlin-app) — alarm app; the "FaceAlarm pattern" for baked-in localized Markdown content (per-language.mdfiles in resources) is reused elsewhere. - life2film (
life2film/app) — video/photo → film. - photo-cleaner, photosweep — Photos-library cleanup (PhotoKit).
- reelcam — camera/reels capture. receiptbrain — receipt OCR (Vision). currencypal — currency. thinkoud — notes/AI.
Each has project.yml + a generated .xcodeproj (regenerate with xcodegen). Contrast with Caretta,
which is hand-maintained (no project.yml) → its .xcodeproj IS tracked.
App Store publishing checklist (run before first upload)
Each item below was a real upload rejection or a recurring prompt. Fix in the project up front.
- Product name ≠ "iosApp". The template default
PRODUCT_NAME = $(TARGET_NAME) = iosAppis a globally-taken App Store name → "App Record Creation failed… name already in use." Set a uniquePRODUCT_NAME(e.g.CarettaFriends, no spaces) in the app target's Debug+Release configs; keep the pretty home-screen name inInfo.plistCFBundleDisplayName("Caretta Friends"). - Orientations vs device family (error 90474). A portrait-only app that targets iPad
(
TARGETED_DEVICE_FAMILY = "1,2") must declare all four orientations for iPad multitasking. For a portrait phone app, set iPhone-onlyTARGETED_DEVICE_FAMILY = "1"instead. - Encryption prompt every submission. HTTPS/OS-only crypto is exempt → add
ITSAppUsesNonExemptEncryption = false(Boolean) toInfo.plist. App Store Connect stops asking. - "Upload Symbols Failed — dSYM for X.framework". Prebuilt SPM/binary frameworks (e.g. MapLibre
maplibre-gl-native-distribution) ship no dSYM — this is a harmless warning, the upload succeeds. Only that framework's internal crash frames won't symbolicate. Ignore it. - xcodeproj tracking. If the project uses xcodegen (has
project.yml) → commitproject.yml, gitignore the.xcodeproj. If it's hand-maintained (no generator) → trackproject.pbxproj(ignore only**/xcuserdata/); otherwise name/orientation/source-file changes vanish on a fresh clone. Adding a Swift file to a hand-maintained project means editing the pbxproj: aPBXFileReference, aPBXBuildFile, a group child, and thePBXSourcesBuildPhaseentry (use fresh 24-hex IDs).
Device build / install / connection
# Build for a physical device (signs with automatic provisioning)
xcodebuild -project iosApp/iosApp.xcodeproj -scheme iosApp -configuration Debug \
-destination generic/platform=iOS -derivedDataPath iosApp/build-device -allowProvisioningUpdates build
# → product is <PRODUCT_NAME>.app under build-device/Build/Products/Debug-iphoneos/
# Install + launch (modern CoreDevice)
xcrun devicectl device info details --device <CoreDeviceUUID> # raises the tunnel / mounts DDI
xcrun devicectl device install app --device <CoreDeviceUUID> <path>/<PRODUCT_NAME>.app
xcrun devicectl device process launch --device <CoreDeviceUUID> <bundle.id>
Connection troubleshooting (CoreDeviceError 1011 "unable to locate device" / ddiServicesAvailable: false):
the device shows unavailable when it's locked/asleep — the wireless dev link drops. Wi-Fi is fine
if the phone stays awake and unlocked; USB is the reliable fallback. xcrun devicectl list devices
shows state; xcrun xctrace list devices shows it by hardware UDID. If devicectl still can't reach it,
open Xcode → Window → Devices and Simulators once to re-mount the DDI. timeout is not on macOS zsh.
- KMP: the framework is embedded by a Run Script phase (
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode). - Fast Kotlin checks:
./gradlew :composeApp:compileKotlinIosSimulatorArm64. - Simulator build (validates Swift without a device):
-destination 'generic/platform=iOS Simulator'.
Shipping it
Everything past the build — TestFlight, App Store submission, screenshots, store metadata, signing
certificates, notarising a macOS build — lives in solo-ios-release. Use that skill; it carries
verified asc commands and the gotchas behind them.
Two things worth knowing here, because they are decided in the project, not at upload time:
TARGETED_DEVICE_FAMILYdecides which screenshot slots Apple demands. A universal build ("1,2") cannot be submitted without an iPad set, no matter what pre-submission validation reports.ITSAppUsesNonExemptEncryption = falsein Info.plist is not just about skipping a prompt: without it Apple expires the uploaded build roughly 24 h later, while it still reads as VALID.
Shared building blocks
- SharedAuth — reusable auth Swift Package (Supabase Auth + Google OAuth):
~/startups/shared/superduperai-auth/(see itspackages/+CLAUDE.md). Use instead of re-rolling auth. - On-device / private AI (add only when needed): FoundationModelsKit (Apple Foundation Models, iOS 26+,
@Generable), VecturaKit (on-device vector DB), LumoKit (local RAG over PDF/Markdown), MLX-Outil (tool calling via MLX). Privacy-first local search/AI — matches the offline-first ethos. - Reference: rudrank.com — iOS / MLX / Foundation Models / Xcode-MCP guides (the stack's upstream source).
Marketing screenshots
Capture clean app screens (xcrun simctl io booted screenshot, or adb exec-out screencap on
Android), then compose with PIL/ImageMagick: caption above, screenshot with rounded corners below.
caretta-friends/docs/store/compose.py is a working composer.
⚠️ Do not hardcode a slot size. Which ones Apple accepts depends on the app, not on the device you
shot with — ask asc screenshots sizes. A listing may take only 1284×2778 even though modern iPhones
render 1290×2796. Framing, captions and upload: solo-ios-release.
Living skill — add new App Store rejections / device-connection tricks / stack changes as you hit them.