Features
Configuration & Secrets
ShipThatApp keeps configuration in exactly two places, and the split is deliberate:
Config.swift— non-secret, compile-time constants: the API base URL, image limits, product IDs, review thresholds. Safe to commit, safe to read at a glance.Config.xcconfig— the secrets: API keys, your Supabase project, the backend HMAC secret. Gitignored. Never committed.
This is the security-first posture you want from day one: nothing sensitive is hardcoded in a Swift file where it can leak into a screenshot, a pasted snippet, or your git history. Keys are injected at build time into Info.plist and read back through Bundle.main.infoDictionary — so the same source compiles cleanly on your machine, a teammate's, and CI without anyone sharing secrets in code.
The one rule
ShipThatApp/Support/Config.xcconfig is in .gitignore and must stay there. If you ever see it show up in git status, stop and re-check before committing — that file holds your live keys and the shared backend secret.
Config.swift — the non-secret reference
Config.swift (in Utils/) is a plain enum namespace of compile-time constants. There is no CONSTANTS.swift global file — everything funnels through Config. Here is the whole thing:
enum Config {
static let appIdentifier = "shipThatApp"
static let imageMaxDimension: CGFloat = 700
enum Keychain {
// Key used to save the authentication token received from the backend
static let tokenKey = "token_key"
}
enum Api {
static let baseURL = "https://api.shipthat.app/"
// Read from the gitignored Config.xcconfig (via Info.plist) so the shared
// backend auth secret is never committed to source control.
static let authKey = Bundle.main.infoDictionary?["API_AUTH_KEY"] as? String ?? ""
static let requestTimeout: TimeInterval = 180
static let retryDelay: UInt32 = 2
}
enum Purchases {
static let productIds = ["sta_999_1m_1w0", "sta_499_1w"]
static let subGroupId = "21397077"
static let minAppRunsBeforeReviewReq = 4
}
}
What each value controls
| Key | Value | What it does |
|---|---|---|
Config.appIdentifier | "shipThatApp" | Internal app identifier string. |
Config.imageMaxDimension | 700 | Max pixel dimension images are downscaled to before upload (Vision, DALL·E, the Dex scanner). Keeps payloads small and fast. |
Config.Keychain.tokenKey | "token_key" | Keychain key for the backend session token (stored via KeychainSwift). |
Config.Api.baseURL | https://api.shipthat.app/ | Base URL of the signed backend that proxies all OpenAI traffic. Point this at your own deployment. |
Config.Api.authKey | from API_AUTH_KEY | The shared HMAC bootstrap secret — read from the gitignored xcconfig, not hardcoded. |
Config.Api.requestTimeout | 180 | Request timeout in seconds (generous, because image generation can be slow). |
Config.Api.retryDelay | 2 | Seconds to back off before a retry in ApiClient. |
Config.Purchases.productIds | two IDs | RevenueCat / StoreKit product identifiers shown in the paywalls. See In-App Purchases. |
Config.Purchases.subGroupId | "21397077" | App Store Connect subscription group ID. |
Config.Purchases.minAppRunsBeforeReviewReq | 4 | How many completed sessions before a review prompt fires. See Review Requests. |
Notice the pattern
Config.Api.authKey is the template for safe config: a constant lives in Config.swift, but its value is read from Config.xcconfig via Bundle.main.infoDictionary. Non-secret defaults stay in code; secrets stay in the gitignored file. Follow the same pattern whenever you add a key.
Config.xcconfig — your secrets
Every secret the app needs is declared in a single xcconfig that ships with a committed template you copy. The template, ShipThatApp/Support/Config.Example.xcconfig, lists every key:
// Do NOT use quotes around values in xcconfig files
TD_APP_ID = YOUR_TELEMETRY_DECK_APP_ID
RC_API_KEY = YOUR_REVENUECAT_API_KEY
ONESIGNAL_APP_ID = YOUR_ONESIGNAL_APP_ID
SUPABASE_URL = your-project.supabase.co
SUPABASE_KEY = YOUR_SUPABASE_ANON_KEY
// Shared HMAC bootstrap secret; must equal the backend AUTH_SECRET_KEY.
API_AUTH_KEY = YOUR_BACKEND_AUTH_SECRET_KEY
First-time setup
Copy the template to the real, gitignored file:
cp ShipThatApp/Support/Config.Example.xcconfig \ ShipThatApp/Support/Config.xcconfigFill in each value with your own credentials (no quotes — xcconfig is picky):
Key Where to get it Used by TD_APP_IDTelemetryDeck dashboard Analytics (Analytics) RC_API_KEYRevenueCat → Project settings → API keys In-App Purchases ONESIGNAL_APP_IDOneSignal dashboard Push notifications SUPABASE_URLSupabase → Project Settings → API Authentication SUPABASE_KEYSupabase → Project Settings → API (anon key) Authentication API_AUTH_KEYYour backend's AUTH_SECRET_KEYThe signed AI proxy Build. Xcode injects these into
Info.plist, and the app reads them at runtime.
API_AUTH_KEY must match the backend
API_AUTH_KEY is the shared HMAC bootstrap secret. It must equal the backend's AUTH_SECRET_KEY — that is what lets the app sign requests the proxy will accept. If they drift, every AI call (chat, image generation, vision, Dex) will be rejected at the gateway.
How the keys reach the app
Values declared in the xcconfig flow into Info.plist build settings, then are read back through Bundle.main.infoDictionary. Three concrete examples from the live code:
Analytics & purchases are configured in ShipThatAppApp.init():
private func configureTelemetry() {
guard let telemetryDeckAppID = Bundle.main.infoDictionary?["TD_APP_ID"] as? String else { return }
let configuration = TelemetryManagerConfiguration(appID: telemetryDeckAppID)
TelemetryManager.initialize(with: configuration)
}
private func configureRevenueCat() {
guard let revenueCatApiString = Bundle.main.infoDictionary?["RC_API_KEY"] as? String else { return }
Purchases.logLevel = .debug
Purchases.configure(withAPIKey: revenueCatApiString)
}
Supabase is configured lazily in AuthManager — and note that the URL and key are no longer hardcoded; they are read from the same injected Info.plist:
let client: SupabaseClient = {
guard let supabaseURLString = Bundle.main.infoDictionary?["SUPABASE_URL"] as? String,
let supabaseURL = URL(string: supabaseURLString),
let supabaseKey = Bundle.main.infoDictionary?["SUPABASE_KEY"] as? String else {
fatalError("Supabase configuration not found in Info.plist")
}
return SupabaseClient(supabaseURL: supabaseURL, supabaseKey: supabaseKey)
}()
That fatalError is intentional: if you forgot to fill in SUPABASE_URL / SUPABASE_KEY, the app fails loudly at launch instead of silently breaking auth deep in a flow.
Custom URL scheme (Magic Link)
Passwordless sign-in needs a custom URL scheme so iOS can route the magic-link callback back into the app. Register it in Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>shipthatapp</string>
</array>
</dict>
</array>
LandingView handles the callback in .onOpenURL, matching url.scheme == "shipthatapp" and url.host == "login-callback". If you change the scheme here, update that check too. Full walkthrough in Authentication.
Customize & extend
- Adding a new secret? Add the key to
Config.Example.xcconfig(template, committed), add it to your realConfig.xcconfig(value, gitignored), wire it intoInfo.plist, then read it viaBundle.main.infoDictionary— exactly likeAPI_AUTH_KEYdoes inConfig.swift. - Adding a non-secret tunable? Put it directly in the relevant
Configsub-enum (Api,Purchases, …) so it stays grouped and discoverable. - Renaming the app or bundle ID? That is a separate, broader change — see Rename the Xcode Project.
Keep the template honest
Whenever you add a key to Config.xcconfig, add the same key (with a placeholder value) to Config.Example.xcconfig. The example file is the contract a new teammate or your future self follows to get the app building — if it's missing a key, the build breaks for everyone but you.