Grain Native iOS App Design
A full-featured Swift iOS app for Grain, targeting feature parity with the SvelteKit web app.
Constraints
- iOS 17+, SwiftUI + MVVM with
@Observable - SPM for dependencies
- Bundle ID:
social.grain.app - Target directory:
~/code/grain-native
Dependencies
- Nuke -- image loading, caching, prefetching
- JOSESwift or Swift-JWT -- ES256 signing for DPoP proofs
- KeychainAccess -- Keychain wrapper for key/token storage
Project Structure
Grain/
Grain.xcodeproj
Grain/
GrainApp.swift
Info.plist
Assets.xcassets
Models/
Records/ # Codable structs from lexicons
Views/ # API response view types
Common/ # AspectRatio, Location, Facet, BlobRef
API/
XRPCClient.swift
DPoP.swift
AuthManager.swift
TokenStorage.swift
Endpoints/
ViewModels/
Views/
Feed/
Gallery/
Profile/
Stories/
Search/
Notifications/
Create/
Settings/
Utilities/
GrainTests/
Authentication
OAuth with DPoP against the hatk server.
Client registration: Custom URL scheme (grain://oauth/callback). Client metadata served or configured on the hatk server to accept this redirect URI.
Flow:
- Generate ES256 P-256 key pair, store private key in Keychain.
POST /oauth/parwith DPoP proof, PKCE challenge (S256),client_id, redirect URIgrain://oauth/callback.- Open
ASWebAuthenticationSessionto/oauth/authorize?request_uri=<uri>&client_id=<id>. - Capture callback code, exchange at
POST /oauth/tokenwith DPoP proof + code verifier. - Store access token, refresh token, expiry in Keychain.
- All requests include
Authorization: DPoP <token>header and aDPoPproof header. - On 401 or token expiry, refresh with rotation. Handle
use_dpop_nonceretry.
DPoP proof requirements:
typ: dpop+jwt,alg: ES256- Public key (
jwk) in header with minimalkty,crv,x,y - Claims:
jti(UUID),htm(method),htu(URL without query),iat(timestamp) - For authenticated requests:
ath(base64url SHA256 of access token) - For nonce retries:
nonceclaim fromDPoP-Nonceresponse header
API Layer
Hand-written XRPCClient with generated Codable model types.
let gallery = try await client.query(
"social.grain.unspecced.getGallery",
params: ["uri": galleryUri],
as: GetGalleryResponse.self
)
Queries (GET):
social.grain.unspecced.getActorProfile-- profile with statssocial.grain.unspecced.getFollowers/getFollowing/getKnownFollowerssocial.grain.unspecced.getSuggestedFollowssocial.grain.unspecced.getGallery-- single gallery with photossocial.grain.unspecced.getGalleryThread-- paginated commentssocial.grain.unspecced.getStories/getStoryArchive/getStorysocial.grain.unspecced.getStoryAuthorssocial.grain.unspecced.searchGalleries/searchProfilessocial.grain.unspecced.getLocations/getCamerassocial.grain.unspecced.getNotificationsdev.hatk.getFeed-- feeds with cursor pagination (recent, following, actor, camera, location, hashtag)dev.hatk.getRecord/getRecordsdev.hatk.getPreferences
Procedures (POST):
dev.hatk.createRecord/putRecord/deleteRecorddev.hatk.uploadBlobdev.hatk.putPreferencesocial.grain.unspecced.deleteGallery
Data Model
Generated Codable structs from lexicon JSON files.
Records:
Gallery-- title, description, location, EXIF camera data, timestampsPhoto-- blob, aspect ratio, alt text, EXIF metadataPhotoExif-- ISO, aperture, focal length, camera make/model (integers scaled x1,000,000)GalleryItem-- links photo to gallery with positionComment-- text, facets, reply threadingStory-- ephemeral 24-hour media with location and aspect ratioFavorite-- points to gallery URIFollow-- follow relationshipActorProfile-- displayName, description, avatar blob
View types:
GalleryView-- gallery with photos, creator, stats, cameras, location, viewer statePhotoView-- photo with thumbnail/fullsize URLs, alt text, aspect ratio, EXIFProfileView/ProfileViewDetailed-- profile with optional stats and viewer relationshipStoryView-- story with creator, media URLs, timestampsNotificationItem-- notification with reason, author, gallery/comment context
Common types:
AspectRatio-- width/heightLocation-- H3 cell + address (country, region, locality, street)Facet-- rich text mentions, links, hashtagsBlobRef-- blob reference for uploads
Navigation
NavigationStack with Router observable managing path state.
Tab bar (5 tabs): Feed | Search | Create | Notifications | Profile
Screens
| Screen | ViewModel | Notes |
|---|---|---|
| Home feed | FeedViewModel | Paginated galleries, pull-to-refresh, cursor pagination |
| Following feed | FeedViewModel | Reused with different feed param |
| Gallery detail | GalleryViewModel | Photos, comment thread, favorite toggle |
| Profile | ProfileViewModel | User info, gallery grid, follow/unfollow, stories |
| Story viewer | StoryViewModel | Timed progression, swipe between authors |
| Create gallery | CreateGalleryViewModel | Photo picker, EXIF extraction, upload queue, title/description/location |
| Search | SearchViewModel | Text search galleries + profiles, camera/location/hashtag discovery |
| Notifications | NotificationsViewModel | Paginated list, 6 reason types |
| Edit profile | SettingsViewModel | Display name, description, avatar upload |
| Comments | CommentsViewModel | Threaded replies, post comment |
| Followers/Following | FollowListViewModel | Paginated user lists |
| Camera feed | FeedViewModel | Filtered by camera name |
| Location feed | FeedViewModel | Filtered by H3 cell |
| Hashtag feed | FeedViewModel | Filtered by tag |
Shared Patterns
- Cursor-based pagination via
loadMore() - Pull-to-refresh on all list views
- Nuke
LazyImagefor all photo rendering with prefetching on scroll - Optimistic UI updates for favorites, follows, comments