1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# 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=<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 .
```