cairn iOS app
cairn iOS app
The iOS app target. Most of the actual code lives in the SPM packages one level up — CairnCore (multi-platform logic) and CairnIOSCore (SwiftUI screens + iOS-side store impls). This directory holds only what genuinely needs to be in an Xcode project: the @main App struct, dependency wiring, Info.plist + entitlements, code signing, and Fastlane distribution config.
Layout
iOS/
├── project.yml # XcodeGen config — single source of truth for project settings
├── App/
│ ├── CairnApp.swift # @main App — scene management + BGTaskScheduler.register
│ ├── AppDependencies.swift # Concrete wiring: Keychain, SwiftData, ImmichClient, etc.
│ └── Assets.xcassets/ # AppIcon catalog (placeholder — drop your icon in)
├── fastlane/
│ ├── Fastfile # Lanes: generate, test, build, beta, release
│ ├── Appfile # App ID, team ID
│ └── Matchfile # Cert/profile management
├── Gemfile # Fastlane gems
├── Makefile # Day-to-day commands — start here
└── README.md # You are here
Cairn.xcodeproj/ is generated by make generate and is gitignored. Never edit settings inside the .xcodeproj manually unless you’re going to manually replicate them in project.yml afterwards — the next make generate will overwrite your changes.
One-time setup (run once on each new Mac)
cd iOS
make install # installs xcodegen + fastlane gems
make generate # produces Cairn.xcodeproj
open Cairn.xcodeproj # configure signing once (see below)
Inside Xcode (one time, then never again):
- Select the
Cairntarget → Signing & Capabilities. - Set the Team to your Apple Developer Team. XcodeGen leaves
DEVELOPMENT_TEAMblank inproject.ymldeliberately so the YAML is repo-portable; Xcode writes the team ID into the project file once andmake generatepreserves it on subsequent regenerations. - Confirm the bundle identifier (
app.cairn.iosby default — change inproject.ymlif you want). - Build to a simulator to verify everything compiles.
That’s the entire manual Xcode dance. Everything else is make commands.
Day-to-day commands
make help # list available commands
make generate # regenerate Cairn.xcodeproj after editing project.yml
make open # regenerate + open in Xcode
make test # run all SPM tests (CairnCore + CairnIOSCore)
make build # produce a Release IPA via fastlane
make beta # build + upload to TestFlight
make release # build + upload to App Store
make screenshots # capture App Store screenshots (no Immich needed)
Screenshots
make screenshots drives CairnUITests/ScreenshotsUITests through
the app’s key screens and drops the PNGs under
fastlane/screenshots/en-US/. The app runs in a fixture mode
(-CAIRN_SCREENSHOT_MODE 1 launch arg) that short-circuits every real
dependency — Keychain, PhotoKit, SwiftData, ImmichClient — and
populates the model from CairnFixtures. Captures are deterministic;
no Immich server, Photos permission, or live data is needed.
Device list is hardcoded in scripts/capture-screenshots.sh; defaults
to iPhone 17 Pro Max + iPhone 17. Override with DEVICES="iPhone 17"
or a positional arg. Skipped devices print a warning.
The make screenshots path goes through a shell script rather than
fastlane snapshot because Apple’s xcodebuild and simctl disagree on
iOS simulator runtime version strings (simctl says “iOS 26.4”,
xcodebuild insists on “26.4.1” for the same runtime); fastlane’s
destination resolution reads simctl and hands xcodebuild a version it
rejects. The script resolves simulators by UDID instead, then
post-processes into the same fastlane/screenshots/ layout that
deliver / upload_to_app_store consume.
Running on a real device
One-time setup:
- Connect your iPhone via USB (or pair over Wi-Fi in Finder).
- On the iPhone, tap Trust when prompted to trust this Mac.
- Run
make device-list— the device should show as available (paired). If it shows unavailable, unlock the phone and try again; if it still fails, unplug + replug. - Record your Apple Developer Team ID. Find it at developer.apple.com → Account → Membership (a 10-character string like
ABC1234XYZ). Put it somewhere the Makefile can find it:echo 'ABC1234XYZ' > iOS/.team # per-repo, gitignored (recommended) echo 'ABC1234XYZ' > ~/.cairn-team # global, cross-repo # or prefix each invocation: DEVELOPMENT_TEAM=ABC1234XYZ make deviceproject.ymldeliberately leaves the team blank so the YAML stays repo-portable;make deviceinjects it at build time viaxcodebuild’sDEVELOPMENT_TEAM=setting, so neither the YAML nor the regenerated.xcodeprojever holds it. - In Xcode once, open
Cairn.xcodeproj→ Cairn target → Signing & Capabilities → make sure Automatic Signing is on and your Team is selected. This registers your device UDID with your Apple Developer profile. (Aftermake generatewipes the team back out of the .xcodeproj on next run, step 4’s.teamfile keepsmake deviceworking.) - First run only: after
make devicesucceeds, the app installs but iOS won’t launch an untrusted developer build. Go to Settings → General → VPN & Device Management → (your developer cert) → Trust. Subsequentmake deviceruns don’t need this.
Each deploy after that:
make device # build + install + launch on the first paired device
make device DEVICE=foo # explicit device name or UDID (get both from `make device-list`)
make device CAP=100 # cap first-sync hashing at 100 assets (testing on big libraries)
make device CAP=100 DEVICE=foo # combined
make device-logs # opens Console.app — filter by "cairn" in the sidebar
make device builds in Debug configuration for generic/platform=iOS, installs the .app via xcrun devicectl, and launches by bundle id. No IPA packaging, no TestFlight — it’s the iOS equivalent of flutter run once the signing setup is done.
CAP for testing. On a real phone with tens of thousands of photos, a full first-sync hash can take minutes. CAP=N sets the CAIRN_ASSET_CAP env var inside the app (via devicectl’s DEVICECTL_CHILD_* pass-through) and the reconciler truncates the full-enumeration fetch to the first N assets. Omit CAP (or pass CAP= / CAP=0) for no cap. Takes effect on any full-enumeration run — first sync, or after a token-expired fallback.
Credentials surviving reinstall. iOS wipes Keychain items on every reinstall where the provisioning profile regenerates (i.e., every make device), so normally you’d re-enter your Immich URL + API key each time. Skip that by creating iOS/.dev-secrets (gitignored):
url=https://your-immich.example.com
key=abc123xyz456
make device reads it, forwards the values via DEVICECTL_CHILD_CAIRN_DEV_SEED_* env vars, and AppDependencies.bootstrap writes them into an empty Keychain on launch. Onboarding is skipped — the app lands on Status and auto-syncs. Debug builds only; the seed block is #if DEBUG, so release builds ignore the env vars regardless.
Measuring hash performance on-device
The reconciler emits structured logs for every full-enumeration pass — useful for timing real-device hashing against your actual camera roll.
Two ways to see them:
make device-run # terminal-attached launch; stdout streams here, Ctrl-C detaches
make device-run CAP=250 # constrained run to iterate quickly
make device-run behaves exactly like make device (build → install → launch with dev-seed + cap) except it passes --console to devicectl. The app’s stdout/stderr — including [cairn.hash] lines — stream to your terminal until the app exits or you Ctrl-C.
Example output:
[cairn.hash] full-enum start: total=4218 resuming-cached=0 to-hash=4218
[cairn.hash] full-enum done: total=4218 resumed=0 hashed=4218 elapsed=47821ms per-asset=11.3ms
Fields:
total— assets in scope (afterCAIRN_ASSET_CAP).resuming-cached— assets already inLocalHashStore(from a prior cancelled or partial run); skipped.to-hash— assets this pass needs to actually hash.hashed— assets whose hash succeeded this pass. Mismatch vsto-hash= assets that had no readable resources (skipped without error).elapsed— wall-clock ms spent inhashAssets.per-asset— average across theto-hashset. Useful for back-of-envelope: a 20k-photo library atper-asset=15ms→ ~5 min total hash.
For persisted structured logs (filterable via Console.app / sysdiagnose), the same events go to os.Logger(subsystem: "app.cairn.ios", category: "hash") at .notice level. Run make device-logs to open Console.app.
Distribution
App Store Connect API key
cairn’s Fastlane config uses an App Store Connect API key (the modern auth path — no 2FA prompts, works in CI). Set these env vars before running make beta / make release:
export APP_STORE_CONNECT_API_KEY_KEY_ID="ABC1234XYZ"
export APP_STORE_CONNECT_API_KEY_ISSUER_ID="69a6de8f-..."
export APP_STORE_CONNECT_API_KEY_KEY_FILEPATH="$HOME/.appstoreconnect/AuthKey_ABC1234XYZ.p8"
Generate the key in App Store Connect → Users and Access → Keys → API Keys (Admin role minimum).
Certificates and profiles (match)
Edit iOS/fastlane/Matchfile to point git_url("https://github.com/your-org/apple-certificates") at a private repo (can be empty initially — match populates it). Then:
make setup-certs # first time on this Mac — creates certs in the match repo
make sync-certs # subsequent times / CI — pulls existing certs read-only
Build numbers
make beta and make release automatically bump CFBundleVersion to latest_TestFlight_build + 1 by editing project.yml directly (so the bump survives project regeneration). CFBundleShortVersionString is edited manually in project.yml when you want to ship a new marketing version (0.1.0 → 0.2.0 → …).
Editing the app
- UI changes: edit files in
../Sources/CairnIOSCore/UI/and use SwiftUI Previews. The#Previewblocks render againstCairnFixturesdata, so you don’t need a server, simulator, or PhotoKit access. - Logic changes: edit
../Sources/CairnCore/. Runmake testto verify. - iOS-specific impls (PhotoKit, Keychain, SwiftData wiring): edit
../Sources/CairnIOSCore/(the non-UI/files) orApp/AppDependencies.swift. - Background refresh schedule, scene phases, app-lifecycle hooks: edit
App/CairnApp.swift. - Bundle ID, version, Info.plist keys, capabilities: edit
project.ymlthenmake generate.
Known gotchas
Bundler version mismatch
Symptom: bundle install errors with
The running version of Bundler (4.0.6) does not match the version of the specification installed for it (4.0.8). This can be caused by reinstalling Ruby without removing previous installation, leaving around an upgraded default version of Bundler. Reinstalling Ruby from scratch should fix the problem.
This happens when the system Ruby has been reinstalled but old gem specs hang around with a newer Bundler dependency than what’s currently active. Non-destructive fix:
gem install bundler -v 4.0.8 # match the version the spec expects (use the version from the error)
# or
gem install bundler # update to whatever's latest
Then re-run make install. The Makefile’s install target now runs gem install bundler --conservative first to pre-empt this in fresh setups.
bundle install --path is gone
If you see The --path flag has been removed because it relied on being remembered across bundler invocations, the new pattern is:
bundle config set --local path 'vendor/bundle'
bundle install
The Makefile already does this.
Why XcodeGen?
Xcode .xcodeproj files are notoriously merge-unfriendly XML-ish blobs. XcodeGen reduces project state to a YAML file that’s diffable, code-reviewable, and survives regeneration cleanly. The cost is one extra step (make generate) when you change project settings; the benefit is that you never lose 15 minutes to a pbxproj merge conflict.