# Development ## Requirements - Xcode 26+ - [XcodeGen](https://github.com/yonaskolb/XcodeGen) - [just](https://github.com/casey/just) - [xcbeautify](https://github.com/cpisciotta/xcbeautify) - [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) - [SwiftLint](https://github.com/realm/SwiftLint) ```bash brew install xcodegen just xcbeautify swiftformat swiftlint ``` ## Setup Generate the Xcode project and open it: ```bash just generate open Grain.xcodeproj ``` If using a non-Xcode editor with SourceKit-LSP (e.g. Zed), run once after generating: ```bash xcode-build-server config -scheme Grain -project Grain.xcodeproj ``` No local backend needed — `just sim` and `just device` both hit the production API at grain.social. ### Device builds Device builds require an Apple Developer account. Create a `.env` in the repo root: ``` APPLE_TEAM_ID=YOUR_TEAM_ID BUNDLE_ID=com.yourorg.grain BUNDLE_NAME=Grain ``` Then re-run `just generate` before building. Pass your device UDID directly or via an env var: ```bash just device 00000000-0000000000000000 # explicit UDID just device $iphonemax # via shell env var ``` Find your device UDID with `xcrun devicectl list devices`. ## Commands ```bash just sim # Build + install + launch on simulator (production API) just sim-local # Build + install + launch on simulator (local/dev API) just sim-next # Build + install + launch on simulator (next.grain.social) just sim-fresh # Same as `just sim`, but uninstalls first (wipes app sandbox) just sim-local-fresh # Same as `just sim-local`, but uninstalls first just device DEVICE_ID # Build + install to a plugged-in iOS device just test # Run tests (iPhone 17 Pro Max simulator) just generate # Regenerate Xcode project from project.yml just basemap-style # Regenerate the MapLibre style assets (needs node + npm) just format-fix # Fix formatting in-place just lint-fix # Fix lint violations just release # Bump build, archive, upload to App Store Connect ``` The `*-fresh` variants `xcrun simctl uninstall` your app's bundle id before installing, which clears URLCache, UserDefaults, on-disk caches (FeedCache, LabelDefinitionsCache), and Documents/Caches. Pass `SIM_UDID=` to target a specific simulator (otherwise uses the booted one). The location screens draw grain's own [Protomaps](https://protomaps.com) basemap through MapLibre — the same tiles as grain.social, served from `tiles.grain.social`, with no API key. The style ships as two bundle resources under `Grain/Resources/Basemap`, generated from the same theme package the web uses by `tools/basemap`; the output is committed, so a build needs neither node nor npm. Label fonts and sprite icons come from Protomaps' public asset host until the tile Worker serves them. > **Note:** The Xcode project is generated from `project.yml` — run `just generate` after adding or removing Swift files, or after pulling changes that touch `project.yml`. ## Releasing Builds ship to TestFlight from Xcode Cloud. Because `Grain.xcodeproj` is XcodeGen output and isn't committed, `ci_scripts/ci_post_clone.sh` runs on every Xcode Cloud build to pull git-lfs assets, generate the project, and copy the committed package pins into place. Build numbers are assigned by Xcode Cloud, which overwrites `CFBundleVersion` with its own run counter at archive time — `CURRENT_PROJECT_VERSION` in `project.yml` is ignored on CI. They only need to be unique and increasing within a marketing version, so a new train starting at a low number is fine. `MARKETING_VERSION` is still bumped by hand. Once a marketing version is approved on the App Store that train closes, and further uploads against it fail with `90186`/`90062` — so raise it in `project.yml` before releasing against an already-approved version. `just release` still archives and uploads from your Mac if you need it, but don't mix the two: it uploads `project.yml`'s `CURRENT_PROJECT_VERSION`, which is far ahead of Xcode Cloud's run counter, and once a higher build number exists on the current train Xcode Cloud's next run would be rejected as not increasing. When SPM dependencies change, refresh the committed pins — Xcode Cloud disables automatic package resolution, and the live file lives inside the gitignored `.xcodeproj`: ```bash just generate xcodebuild -resolvePackageDependencies -project Grain.xcodeproj -scheme Grain cp Grain.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved . ```