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):

  1. Select the Cairn target → Signing & Capabilities.
  2. Set the Team to your Apple Developer Team. XcodeGen leaves DEVELOPMENT_TEAM blank in project.yml deliberately so the YAML is repo-portable; Xcode writes the team ID into the project file once and make generate preserves it on subsequent regenerations.
  3. Confirm the bundle identifier (app.cairn.ios by default — change in project.yml if you want).
  4. 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:

  1. Connect your iPhone via USB (or pair over Wi-Fi in Finder).
  2. On the iPhone, tap Trust when prompted to trust this Mac.
  3. 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.
  4. 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 device
    

    project.yml deliberately leaves the team blank so the YAML stays repo-portable; make device injects it at build time via xcodebuild’s DEVELOPMENT_TEAM= setting, so neither the YAML nor the regenerated .xcodeproj ever holds it.

  5. 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. (After make generate wipes the team back out of the .xcodeproj on next run, step 4’s .team file keeps make device working.)
  6. First run only: after make device succeeds, the app installs but iOS won’t launch an untrusted developer build. Go to Settings → General → VPN & Device Management → (your developer cert) → Trust. Subsequent make device runs 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 (after CAIRN_ASSET_CAP).
  • resuming-cached — assets already in LocalHashStore (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 vs to-hash = assets that had no readable resources (skipped without error).
  • elapsed — wall-clock ms spent in hashAssets.
  • per-asset — average across the to-hash set. Useful for back-of-envelope: a 20k-photo library at per-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.00.2.0 → …).

Editing the app

  • UI changes: edit files in ../Sources/CairnIOSCore/UI/ and use SwiftUI Previews. The #Preview blocks render against CairnFixtures data, so you don’t need a server, simulator, or PhotoKit access.
  • Logic changes: edit ../Sources/CairnCore/. Run make test to verify.
  • iOS-specific impls (PhotoKit, Keychain, SwiftData wiring): edit ../Sources/CairnIOSCore/ (the non-UI/ files) or App/AppDependencies.swift.
  • Background refresh schedule, scene phases, app-lifecycle hooks: edit App/CairnApp.swift.
  • Bundle ID, version, Info.plist keys, capabilities: edit project.yml then make 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.