diff --git a/Tools/ExportOptions.plist b/Tools/ExportOptions.plist
new file mode 100644
index 0000000..19f10e6
--- /dev/null
+++ b/Tools/ExportOptions.plist
@@ -0,0 +1,24 @@
+
+
+
+
+
+ method
+ app-store-connect
+ teamID
+ 5HR8E5CWR7
+ signingStyle
+ automatic
+ uploadSymbols
+
+ manageAppVersionAndBuildNumber
+
+ destination
+ export
+
+
diff --git a/Tools/build-testflight.sh b/Tools/build-testflight.sh
new file mode 100755
index 0000000..de936ca
--- /dev/null
+++ b/Tools/build-testflight.sh
@@ -0,0 +1,66 @@
+#!/usr/bin/env bash
+# Build a signed archive and export a TestFlight-ready .ipa (T-020).
+#
+# Tools/build-testflight.sh # archive + export the .ipa
+# Tools/build-testflight.sh --upload # ...and upload to App Store Connect (needs API key, below)
+#
+# The first archive on a fresh machine mints the Apple Distribution cert + App Store provisioning
+# profile automatically (-allowProvisioningUpdates) -- as long as Xcode is signed into the Apple ID
+# that owns team 5HR8E5CWR7 (Xcode > Settings > Accounts). No cert wrangling by hand.
+#
+# BUILD NUMBER: derived from the git commit count, so every archive gets a unique, monotonically
+# increasing CFBundleVersion without hand-editing project.yml. TestFlight rejects a re-used build
+# number, so commit (or let CI bump) between uploads. The marketing version (0.1.0) still lives in
+# project.yml -- bump it there for a user-facing version change.
+#
+# UPLOAD (--upload) needs an App Store Connect API key (App Store Connect > Users and Access > Keys):
+# export ASC_KEY_ID=XXXXXXXXXX # the key's ID
+# export ASC_ISSUER_ID=xxxxxxxx-.... # the issuer UUID at the top of that page
+# place the AuthKey_.p8 in ~/.appstoreconnect/private_keys/ (altool finds it by ID)
+# Without a key, skip --upload and use Xcode's Organizer (Window > Organizer > Distribute App) once;
+# it does the same upload through a GUI and is the gentler first-timer path.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+SCHEME=KitchenSyncApp
+PROJECT=KitchenSyncApp.xcodeproj
+OUT=build/testflight
+ARCHIVE="$OUT/KitchenSyncApp.xcarchive"
+BUILDNUM=$(git rev-list --count HEAD)
+
+echo "==> regenerating the project (project.yml is the source of truth)"
+xcodegen generate
+
+echo "==> archiving $SCHEME (Release, build $BUILDNUM)"
+rm -rf "$OUT"
+mkdir -p "$OUT"
+xcodebuild archive \
+ -project "$PROJECT" \
+ -scheme "$SCHEME" \
+ -configuration Release \
+ -destination 'generic/platform=iOS' \
+ -archivePath "$ARCHIVE" \
+ CURRENT_PROJECT_VERSION="$BUILDNUM" \
+ -allowProvisioningUpdates
+
+echo "==> exporting the App Store .ipa"
+xcodebuild -exportArchive \
+ -archivePath "$ARCHIVE" \
+ -exportOptionsPlist Tools/ExportOptions.plist \
+ -exportPath "$OUT/export" \
+ -allowProvisioningUpdates
+
+IPA=$(ls "$OUT"/export/*.ipa 2>/dev/null | head -1)
+[ -n "$IPA" ] || { echo "no .ipa produced -- check the export log above" >&2; exit 1; }
+echo "==> built: $IPA (build $BUILDNUM)"
+
+if [ "${1:-}" = "--upload" ]; then
+ : "${ASC_KEY_ID:?set ASC_KEY_ID to upload}"
+ : "${ASC_ISSUER_ID:?set ASC_ISSUER_ID to upload}"
+ echo "==> uploading to App Store Connect (TestFlight)"
+ xcrun altool --upload-app -f "$IPA" -t ios \
+ --apiKey "$ASC_KEY_ID" --apiIssuer "$ASC_ISSUER_ID"
+ echo "==> uploaded. It appears under TestFlight after ~5-15 min of processing."
+else
+ echo "Next: run with --upload (API key set), or open $ARCHIVE in Xcode's Organizer and Distribute App."
+fi
diff --git a/docs/testflight.md b/docs/testflight.md
new file mode 100644
index 0000000..69f97f2
--- /dev/null
+++ b/docs/testflight.md
@@ -0,0 +1,60 @@
+# Shipping the app to a device owner (TestFlight)
+
+How a customer like Dan gets the KitchenSync app onto their phone once you ship them a unit.
+TestFlight is the right tool while the app is pre-App-Store; the App Store is the eventual home
+once you're shipping to more than a handful of people (a free companion app anyone can download,
+no per-person invites, no 90-day expiry).
+
+The bundle ID (`dev.ericdahl.kitchensync`) and team (`5HR8E5CWR7`) are already set in
+`project.yml`; automatic signing mints the distribution cert on the first archive. So the moving
+parts are: build the `.ipa`, upload it, invite the tester.
+
+## One-time setup (per app, per machine)
+
+1. **Xcode account.** Xcode > Settings > Accounts: sign in with the Apple ID that owns the
+ Developer Program membership for team `5HR8E5CWR7`. This is what lets `-allowProvisioningUpdates`
+ create the Apple Distribution cert + App Store profile without hand-wrangling.
+2. **Register the app in App Store Connect.** appstoreconnect.apple.com > Apps > **+** > New App:
+ iOS, the `dev.ericdahl.kitchensync` bundle ID (register it under Certificates, IDs & Profiles
+ first if it isn't there), an app name, primary language, SKU. This app record only has to be
+ created once; every build uploads into it.
+3. **(For scripted upload) an API key.** App Store Connect > Users and Access > **Keys** > **+**.
+ Give it "App Manager" access. Note the **Key ID** and the **Issuer ID** (top of the page), and
+ download the `AuthKey_.p8` **once** (you can't re-download it). Put it in
+ `~/.appstoreconnect/private_keys/`. This is optional — the first time, Xcode's Organizer GUI is
+ easier (see below).
+
+## Build a TestFlight build
+
+```sh
+Tools/build-testflight.sh # archive + export build/testflight/export/*.ipa
+Tools/build-testflight.sh --upload # ...and upload (needs ASC_KEY_ID / ASC_ISSUER_ID env + the .p8)
+```
+
+The build number is the git commit count, so it climbs on its own — but TestFlight rejects a
+**re-used** build number, so commit between uploads (or bump `CURRENT_PROJECT_VERSION`).
+
+**First-timer path (no API key):** run `Tools/build-testflight.sh` (no `--upload`), then open the
+`.xcarchive` in **Xcode > Window > Organizer > Distribute App > App Store Connect > Upload**. Xcode
+handles cert creation, upload, and shows any validation errors inline. Once you've done it once and
+have the API key, `--upload` makes it one command.
+
+## Invite Dan
+
+App Store Connect > your app > **TestFlight**:
+
+- Wait for the build to finish **processing** (~5–15 min after upload; you get an email).
+- Add Dan as a tester. **Internal** (needs him on your team, no review, instant) or **External**
+ (by email; the first external build needs a one-time **Beta App Review**, usually cleared within
+ a day). For shipping to customers, external is normal.
+- Dan installs Apple's **TestFlight** app from the App Store, opens your email invite (or a **public
+ link** you generate under External Testing — anyone with the link can join, no email needed), and
+ the build installs. Updates you push later just appear for him.
+
+## Gotchas
+
+- **Builds expire after 90 days.** Push a fresh build periodically or Dan's copy stops opening. This
+ is the main reason to move to the App Store once you're shipping in volume.
+- **Local Network permission.** On first launch iOS prompts for it; if Dan denies it, the app finds
+ no devices at all (Bonjour + `.local` both die silently). Worth a line in the ship note to him.
+- External testers cap at 10,000; internal at 100.
diff --git a/project.yml b/project.yml
index 5c54b15..c29f8fb 100644
--- a/project.yml
+++ b/project.yml
@@ -29,6 +29,23 @@ targets:
NSLocalNetworkUsageDescription: KitchenSync finds your devices on the local network to show their status and control their MIDI clock.
NSBonjourServices:
- _http._tcp
+ # Export compliance: the app talks plain HTTP to local devices and uses only the
+ # OS's standard TLS — both exempt — so it declares NO non-exempt encryption. This
+ # pre-answers the App Store Connect compliance question so each build processes
+ # straight through. Revisit if the app ever adds its own crypto.
+ ITSAppUsesNonExemptEncryption: false
+ # Required for App Store upload: an iPad-capable bundle (TARGETED_DEVICE_FAMILY 1,2)
+ # MUST declare orientations or ingestion rejects it (error 90474 — iPad multitasking).
+ # iPhone gets the usual three (no upside-down); iPad gets all four.
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ UISupportedInterfaceOrientations~ipad:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: dev.ericdahl.kitchensync