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

KeyValueWhat it does
Config.appIdentifier"shipThatApp"Internal app identifier string.
Config.imageMaxDimension700Max 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.baseURLhttps://api.shipthat.app/Base URL of the signed backend that proxies all OpenAI traffic. Point this at your own deployment.
Config.Api.authKeyfrom API_AUTH_KEYThe shared HMAC bootstrap secret — read from the gitignored xcconfig, not hardcoded.
Config.Api.requestTimeout180Request timeout in seconds (generous, because image generation can be slow).
Config.Api.retryDelay2Seconds to back off before a retry in ApiClient.
Config.Purchases.productIdstwo IDsRevenueCat / StoreKit product identifiers shown in the paywalls. See In-App Purchases.
Config.Purchases.subGroupId"21397077"App Store Connect subscription group ID.
Config.Purchases.minAppRunsBeforeReviewReq4How 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

  1. Copy the template to the real, gitignored file:

    cp ShipThatApp/Support/Config.Example.xcconfig \
       ShipThatApp/Support/Config.xcconfig
    
  2. Fill in each value with your own credentials (no quotes — xcconfig is picky):

    KeyWhere to get itUsed by
    TD_APP_IDTelemetryDeck dashboardAnalytics (Analytics)
    RC_API_KEYRevenueCat → Project settings → API keysIn-App Purchases
    ONESIGNAL_APP_IDOneSignal dashboardPush notifications
    SUPABASE_URLSupabase → Project Settings → APIAuthentication
    SUPABASE_KEYSupabase → Project Settings → API (anon key)Authentication
    API_AUTH_KEYYour backend's AUTH_SECRET_KEYThe signed AI proxy
  3. 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.

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 real Config.xcconfig (value, gitignored), wire it into Info.plist, then read it via Bundle.main.infoDictionary — exactly like API_AUTH_KEY does in Config.swift.
  • Adding a non-secret tunable? Put it directly in the relevant Config sub-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.

Previous
API Client & Security