feat(ios): ad-hoc distribution scripts for pre-TestFlight testing

Add scripts/dist-ios-adhoc.sh and scripts/asc_api.py to build an ad-hoc
signed IPA and the OTA web-install files (manifest.plist + index.html)
for sharing the iOS client with registered devices before TestFlight.

- asc_api.py: App Store Connect API helper (ES256 JWT via cryptography,
  urllib) to register device UDIDs and list registered devices.
- dist-ios-adhoc.sh: registers UDIDs, builds the iOS device xcframework
  slice, archives + exports with method=release-testing using
  -allowProvisioningUpdates, and stages the install files into
  dist/ios-adhoc/.
- Document the workflow in clients/apple/README.md; ignore __pycache__.
This commit is contained in:
2026-06-21 15:28:26 +02:00
parent c1e6f4f7ff
commit 0c6b1a36cf
4 changed files with 489 additions and 0 deletions

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ clients/apple/Package.resolved
# Test artifacts: TOFU pin store written by vc_client during headless tests
# (core/src/core/client.cpp falls back to this relative path when tofu_store_path is unset).
voicecat_tofu_pins.txt
# Python bytecode cache (e.g. scripts/asc_api.py)
__pycache__/

View File

@@ -128,3 +128,29 @@ The `Package.swift` declares:
the fat static lib is C++20, so the final executable must link libc++ (the LLVM C++ standard
library on macOS). vcpkg's static deps are already in the `.a`; macOS system frameworks
(CoreAudio/CoreFoundation) are auto-discovered by the linker.
## Ad-hoc distribution (iOS, pre-TestFlight)
To hand the iOS app to a handful of friends before TestFlight, use
[`scripts/dist-ios-adhoc.sh`](../../scripts/dist-ios-adhoc.sh). It registers each device's
UDID, builds an ad-hoc-signed `VoiceCatiOS.ipa`, and generates the `manifest.plist` +
`index.html` for an over-the-air (`itms-services://`) web install. Ad-hoc builds only run on
devices whose UDID is registered *before* signing, and stock iOS won't install a bare `.ipa`
without a sideloading tool — so the web-install page is the friend-friendly path.
```bash
# One-time: create an App Store Connect API "Team Key" (.p8, Admin/App Manager access) at
# App Store Connect → Users and Access → Integrations → App Store Connect API
export ASC_KEY_ID=ABC123 ASC_ISSUER_ID=1111-... ASC_KEY_PATH=~/.appstoreconnect/AuthKey_ABC123.p8
# Register a device + build + stage everything into dist/ios-adhoc/
scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
--base-url https://example.com/voicecat
```
Then upload the three staged files (`VoiceCatiOS.ipa`, `manifest.plist`, `index.html`) to
that **HTTPS** folder and open `index.html` in Safari on a registered iPhone (iOS 18+).
Device UDID registration is automated via [`scripts/asc_api.py`](../../scripts/asc_api.py)
(`asc_api.py list` shows the registered devices against the 100-iOS-devices/year cap).
Requires a paid Apple Developer Program membership. Run `scripts/dist-ios-adhoc.sh --help`
for all flags.

175
scripts/asc_api.py Executable file
View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""asc_api.py — minimal App Store Connect API helper for ad-hoc device registration.
Used by scripts/dist-ios-adhoc.sh to register friends' device UDIDs before building an
ad-hoc IPA. Talks to the App Store Connect API directly: signs an ES256 JWT with your
Team Key .p8 (via the `cryptography` package — no PyJWT/requests needed) and calls the
REST endpoints with urllib from the standard library.
Subcommands:
register register one device UDID (idempotent — an already-registered UDID is OK)
list list all registered devices (and the count, against the 100/year cap)
Auth is the same for both, supplied via flags (the wrapper script passes them from the
ASC_KEY_ID / ASC_ISSUER_ID / ASC_KEY_PATH env vars):
--key-id the Key ID of the App Store Connect API key
--issuer-id the Issuer ID (Users and Access -> Integrations)
--key path to the AuthKey_XXXXXXXXXX.p8 file
Examples:
python3 scripts/asc_api.py list \
--key-id ABC123 --issuer-id 11111111-2222-... --key ~/.appstoreconnect/AuthKey_ABC123.p8
python3 scripts/asc_api.py register --udid 00008110-0011... --name "My iPhone" \
--key-id ABC123 --issuer-id 11111111-2222-... --key ~/.appstoreconnect/AuthKey_ABC123.p8
The .p8 is created at App Store Connect -> Users and Access -> Integrations ->
App Store Connect API -> Team Keys, with Admin or App Manager access. Keep it out of the
repo.
"""
import argparse
import base64
import json
import sys
import time
import urllib.error
import urllib.request
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec, utils
API_BASE = "https://api.appstoreconnect.apple.com"
def _b64url(data: bytes) -> str:
"""base64url without padding, as JWT requires."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def make_jwt(key_id: str, issuer_id: str, key_path: str) -> str:
"""Build a short-lived ES256 JWT for the App Store Connect API."""
with open(key_path, "rb") as fh:
private_key = serialization.load_pem_private_key(fh.read(), password=None)
if not isinstance(private_key, ec.EllipticCurvePrivateKey):
sys.exit(f"error: {key_path} is not an EC private key (.p8 from App Store Connect)")
now = int(time.time())
header = {"alg": "ES256", "kid": key_id, "typ": "JWT"}
# exp must be <= 20 minutes out; 10 minutes is comfortable.
payload = {"iss": issuer_id, "iat": now, "exp": now + 600, "aud": "appstoreconnect-v1"}
signing_input = f"{_b64url(json.dumps(header).encode())}.{_b64url(json.dumps(payload).encode())}"
der_sig = private_key.sign(signing_input.encode("ascii"), ec.ECDSA(hashes.SHA256()))
# JWS wants raw r||s (two 32-byte big-endian ints), not the ASN.1/DER openssl emits.
r, s = utils.decode_dss_signature(der_sig)
raw_sig = r.to_bytes(32, "big") + s.to_bytes(32, "big")
return f"{signing_input}.{_b64url(raw_sig)}"
def _request(method: str, path: str, token: str, body: dict | None = None):
"""Perform an authenticated API call. Returns (status_code, parsed_json|None)."""
url = path if path.startswith("http") else f"{API_BASE}{path}"
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"Bearer {token}")
if data is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else None)
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
parsed = json.loads(raw) if raw else None
except json.JSONDecodeError:
parsed = {"_raw": raw.decode("utf-8", "replace")}
return exc.code, parsed
def _errors_text(payload) -> str:
if isinstance(payload, dict) and payload.get("errors"):
return "; ".join(
f"{e.get('title', '')}: {e.get('detail', '')}".strip(": ")
for e in payload["errors"]
)
return json.dumps(payload)
def cmd_register(args, token: str) -> int:
body = {
"data": {
"type": "devices",
"attributes": {
"name": args.name or args.udid,
"platform": "IOS",
"udid": args.udid,
},
}
}
status, payload = _request("POST", "/v1/devices", token, body)
if status in (200, 201):
print(f" registered: {args.udid} ({args.name or args.udid})")
return 0
# A UDID that already exists comes back as a 409 conflict, or a 422 with an error
# detail mentioning the device already exists. Either way it's fine — idempotent.
text = _errors_text(payload)
if status == 409 or "already exist" in text.lower() or "already been taken" in text.lower():
print(f" already registered: {args.udid}")
return 0
print(f"error: failed to register {args.udid} (HTTP {status}): {text}", file=sys.stderr)
return 1
def cmd_list(args, token: str) -> int:
path = "/v1/devices?limit=200&sort=name"
rows = []
while path:
status, payload = _request("GET", path, token)
if status != 200:
print(f"error: list failed (HTTP {status}): {_errors_text(payload)}", file=sys.stderr)
return 1
for d in payload.get("data", []):
a = d.get("attributes", {})
rows.append((a.get("platform", "?"), a.get("status", "?"),
a.get("udid", "?"), a.get("name", "")))
path = (payload.get("links") or {}).get("next")
ios = [r for r in rows if r[0] == "IOS"]
for platform, dev_status, udid, name in rows:
print(f" [{platform:7}] {dev_status:8} {udid} {name}")
print(f"\n {len(rows)} device(s) total, {len(ios)} iOS (cap is 100 iOS/membership year)")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="command", required=True)
def add_auth(p):
p.add_argument("--key-id", required=True)
p.add_argument("--issuer-id", required=True)
p.add_argument("--key", required=True, help="path to AuthKey_*.p8")
p_reg = sub.add_parser("register", help="register one device UDID (idempotent)")
add_auth(p_reg)
p_reg.add_argument("--udid", required=True)
p_reg.add_argument("--name", default=None)
p_list = sub.add_parser("list", help="list registered devices")
add_auth(p_list)
args = parser.parse_args()
token = make_jwt(args.key_id, args.issuer_id, args.key)
if args.command == "register":
return cmd_register(args, token)
if args.command == "list":
return cmd_list(args, token)
return 2
if __name__ == "__main__":
sys.exit(main())

285
scripts/dist-ios-adhoc.sh Executable file
View File

@@ -0,0 +1,285 @@
#!/usr/bin/env bash
#
# dist-ios-adhoc.sh — build an ad-hoc IPA of the iOS client and the files needed to
# install it on registered devices from an HTTPS web page (itms-services OTA install).
#
# This is for handing the app to a handful of friends BEFORE TestFlight. Ad-hoc builds
# only run on devices whose UDID is registered in your Apple Developer account, so the
# script registers UDIDs (via the App Store Connect API) before it signs.
#
# Pipeline:
# 1. Register each --udid with App Store Connect (idempotent; skip with --skip-register).
# 2. Build VoiceCatCore.xcframework (iOS device slice).
# 3. xcodebuild archive (Release, generic/platform=iOS), letting Xcode auto-create the
# ad-hoc Distribution cert + profile via -allowProvisioningUpdates + the API key.
# 4. xcodebuild -exportArchive with method=release-testing (Xcode's modern name for
# "ad-hoc") -> VoiceCatiOS.ipa.
# 5. Generate manifest.plist (OTA install manifest) + index.html (install page).
# 6. Stage VoiceCatiOS.ipa + manifest.plist + index.html into dist/ios-adhoc/.
#
# You then upload those three files to your HTTPS host and open index.html on the iPhone.
#
# Prerequisites (one-time, not scriptable):
# - Paid Apple Developer Program membership (Team ID is already set in the project).
# - An App Store Connect API "Team Key" (.p8) with Admin or App Manager access:
# App Store Connect -> Users and Access -> Integrations -> App Store Connect API.
# Note its Key ID and Issuer ID. Keep the .p8 out of the repo.
# - Each friend's device UDID (read it in Finder with the iPhone connected to a Mac).
#
# Auth — supply via env vars (or the matching flags):
# ASC_KEY_ID App Store Connect API Key ID (--key-id)
# ASC_ISSUER_ID App Store Connect API Issuer ID (--issuer-id)
# ASC_KEY_PATH path to AuthKey_XXXXXXXXXX.p8 (--key)
#
# Usage:
# scripts/dist-ios-adhoc.sh --udid <UDID> --name "Friend iPhone" \
# --base-url https://example.com/voicecat
# scripts/dist-ios-adhoc.sh --udids-file friends.csv --base-url https://example.com/vc
# scripts/dist-ios-adhoc.sh --skip-register --base-url https://example.com/vc # rebuild only
# scripts/dist-ios-adhoc.sh --dist /out --no-configure
# scripts/dist-ios-adhoc.sh -h|--help
#
# Flags:
# --udid <UDID> device to register (repeatable). Pair with an optional --name.
# --name <label> label for the immediately-preceding --udid (default: the UDID).
# --udids-file <file> CSV of "udid,name" lines (one device per line; blank/# lines skipped).
# --base-url <url> public HTTPS folder you'll upload the output to. Used to fill
# manifest.plist + index.html. If omitted, a __BASE_URL__ placeholder
# is written and you must edit both files before they work.
# --skip-register don't touch App Store Connect; just rebuild/export/stage.
# --key-id / --issuer-id / --key override the ASC_* env vars.
# --dist <path> output root (default <repo>/dist). Files land in <dist>/ios-adhoc/.
# --no-configure skip the cmake configure step in the xcframework build.
# -h, --help this help.
#
# macOS only. Requires Xcode, VCPKG_ROOT (or a configured build/apple-dev cache), and the
# Python `cryptography` package (already present in a standard macOS Python 3).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VC_SCRIPT_NAME="dist-ios-adhoc"
source "$SCRIPT_DIR/common.sh"
# ── Project constants (see clients/apple/iOS/VoiceCatiOS.xcodeproj) ───────────────
TEAM_ID="FJV8L966W4"
APP_BUNDLE_ID="cat.voice.VoiceCatiOS"
SCHEME="VoiceCatiOS"
APP_TITLE="VoiceCat"
XCODEPROJ="$VC_REPO_ROOT/clients/apple/iOS/VoiceCatiOS.xcodeproj"
# ── Args ──────────────────────────────────────────────────────────────────────────
KEY_ID="${ASC_KEY_ID:-}"
ISSUER_ID="${ASC_ISSUER_ID:-}"
KEY_PATH="${ASC_KEY_PATH:-}"
BASE_URL=""
SKIP_REGISTER=false
DO_CONFIGURE=true
UDIDS=() # parallel arrays: UDIDS[i] / UDID_NAMES[i]
UDID_NAMES=()
while [[ $# -gt 0 ]]; do
case "$1" in
--udid) UDIDS+=("$2"); UDID_NAMES+=("$2"); shift 2 ;;
--name) [[ ${#UDID_NAMES[@]} -gt 0 ]] || vc_die "--name must follow a --udid"
UDID_NAMES[${#UDID_NAMES[@]}-1]="$2"; shift 2 ;;
--udids-file) [[ -f "$2" ]] || vc_die "udids file not found: $2"
while IFS=',' read -r u n _; do
u="${u// /}"
[[ -z "$u" || "$u" == \#* ]] && continue
UDIDS+=("$u"); UDID_NAMES+=("${n:-$u}")
done < "$2"
shift 2 ;;
--base-url) BASE_URL="${2%/}"; shift 2 ;;
--skip-register) SKIP_REGISTER=true; shift ;;
--key-id) KEY_ID="$2"; shift 2 ;;
--issuer-id) ISSUER_ID="$2"; shift 2 ;;
--key) KEY_PATH="$2"; shift 2 ;;
--dist) vc_set_dist "$2"; shift 2 ;;
--no-configure) DO_CONFIGURE=false; shift ;;
-h|--help) vc_print_help "$0"; exit 0 ;;
*) vc_die "unknown arg: $1 (try --help)" ;;
esac
done
vc_require_macos
# Auth is needed for device registration AND for -allowProvisioningUpdates (Xcode uses the
# same key to mint the ad-hoc Distribution cert/profile).
[[ -n "$KEY_ID" ]] || vc_die "missing API Key ID (set ASC_KEY_ID or pass --key-id)"
[[ -n "$ISSUER_ID" ]] || vc_die "missing Issuer ID (set ASC_ISSUER_ID or pass --issuer-id)"
[[ -n "$KEY_PATH" ]] || vc_die "missing .p8 path (set ASC_KEY_PATH or pass --key)"
[[ -f "$KEY_PATH" ]] || vc_die "API key file not found: $KEY_PATH"
python3 -c "import cryptography" 2>/dev/null \
|| vc_die "Python 'cryptography' package not available: pip3 install cryptography"
if [[ -z "$BASE_URL" ]]; then
BASE_URL="__BASE_URL__"
vc_log "no --base-url given: manifest.plist + index.html will use the __BASE_URL__"
vc_log "placeholder — edit both files to your real HTTPS folder before uploading."
fi
OUT="$(vc_dist_subdir ios-adhoc)"
ARCHIVE="$OUT/VoiceCatiOS.xcarchive"
EXPORT_DIR="$OUT/export"
DERIVED="$OUT/DerivedData"
IPA="$OUT/VoiceCatiOS.ipa"
# ── Step 1: register devices ───────────────────────────────────────────────────────
if $SKIP_REGISTER; then
vc_step "skipping device registration (--skip-register)"
elif [[ ${#UDIDS[@]} -eq 0 ]]; then
vc_step "no --udid/--udids-file given — skipping device registration"
vc_log "(devices must already be registered, or this IPA won't install for them)"
else
vc_step "register ${#UDIDS[@]} device(s) with App Store Connect"
for i in "${!UDIDS[@]}"; do
python3 "$SCRIPT_DIR/asc_api.py" register \
--key-id "$KEY_ID" --issuer-id "$ISSUER_ID" --key "$KEY_PATH" \
--udid "${UDIDS[$i]}" --name "${UDID_NAMES[$i]}"
done
vc_ok "device registration complete"
fi
# ── Step 2: xcframework (iOS device slice) ──────────────────────────────────────────
vc_step "build VoiceCatCore.xcframework (iOS device slice)"
vc_resolve_vcpkg_root "apple-dev"
XCFW_ARGS=( --preset apple-ios )
$DO_CONFIGURE || XCFW_ARGS+=( --no-configure )
"$VC_REPO_ROOT/clients/apple/scripts/build-xcframework.sh" "${XCFW_ARGS[@]}"
XCFW="$VC_REPO_ROOT/clients/apple/VoiceCatCore.xcframework"
[[ -d "$XCFW" ]] || vc_die "xcframework not found at $XCFW"
vc_ok "xcframework ready -> $XCFW"
# ── Step 3: archive ─────────────────────────────────────────────────────────────────
vc_step "xcodebuild archive ($SCHEME / Release / generic iOS)"
xcodebuild archive \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath "$ARCHIVE" \
-derivedDataPath "$DERIVED" \
-allowProvisioningUpdates \
-authenticationKeyPath "$KEY_PATH" \
-authenticationKeyID "$KEY_ID" \
-authenticationKeyIssuerID "$ISSUER_ID"
[[ -d "$ARCHIVE" ]] || vc_die "archive not produced at $ARCHIVE"
vc_ok "archived -> $ARCHIVE"
# ── Step 4: export ad-hoc IPA ───────────────────────────────────────────────────────
# method "release-testing" is Xcode 15.3+/26's name for the old "ad-hoc" method.
# manageAppGroups lets automatic signing carry the group.cat.voice.VoiceCat capability
# (used by the ReplayKit broadcast extension) into the generated profiles.
EXPORT_PLIST="$OUT/ExportOptions.plist"
cat > "$EXPORT_PLIST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key> <string>release-testing</string>
<key>teamID</key> <string>$TEAM_ID</string>
<key>signingStyle</key> <string>automatic</string>
<key>manageAppGroups</key> <true/>
<key>stripSwiftSymbols</key> <true/>
</dict>
</plist>
PLIST
vc_step "xcodebuild -exportArchive (method=release-testing / ad-hoc)"
xcodebuild -exportArchive \
-archivePath "$ARCHIVE" \
-exportPath "$EXPORT_DIR" \
-exportOptionsPlist "$EXPORT_PLIST" \
-allowProvisioningUpdates \
-authenticationKeyPath "$KEY_PATH" \
-authenticationKeyID "$KEY_ID" \
-authenticationKeyIssuerID "$ISSUER_ID"
EXPORTED_IPA="$(find "$EXPORT_DIR" -maxdepth 1 -name '*.ipa' | head -n1)"
[[ -n "$EXPORTED_IPA" ]] || vc_die "no .ipa found in $EXPORT_DIR"
mv "$EXPORTED_IPA" "$IPA"
vc_ok "exported -> $IPA ($(du -h "$IPA" | cut -f1))"
# ── Step 5: read version + generate manifest.plist & index.html ─────────────────────
APP_IN_ARCHIVE="$(find "$ARCHIVE/Products/Applications" -maxdepth 1 -name '*.app' | head -n1)"
SHORT_VER="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' \
"$APP_IN_ARCHIVE/Info.plist" 2>/dev/null || echo "1.0")"
MANIFEST="$OUT/manifest.plist"
cat > "$MANIFEST" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>items</key>
<array>
<dict>
<key>assets</key>
<array>
<dict>
<key>kind</key> <string>software-package</string>
<key>url</key> <string>$BASE_URL/VoiceCatiOS.ipa</string>
</dict>
</array>
<key>metadata</key>
<dict>
<key>bundle-identifier</key> <string>$APP_BUNDLE_ID</string>
<key>bundle-version</key> <string>$SHORT_VER</string>
<key>kind</key> <string>software</string>
<key>title</key> <string>$APP_TITLE</string>
</dict>
</dict>
</array>
</dict>
</plist>
PLIST
INDEX="$OUT/index.html"
cat > "$INDEX" <<HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Install $APP_TITLE</title>
<style>
body { font: 17px -apple-system, system-ui, sans-serif; margin: 0; padding: 2.5rem 1.5rem;
color: #111; background: #f5f5f7; -webkit-text-size-adjust: 100%; }
main { max-width: 30rem; margin: 0 auto; }
h1 { font-size: 1.6rem; }
.btn { display: block; text-align: center; text-decoration: none; margin: 1.5rem 0;
padding: 0.9rem 1rem; border-radius: 0.8rem; background: #0a84ff; color: #fff;
font-weight: 600; }
.note { font-size: 0.9rem; color: #555; line-height: 1.5; }
code { background: #e5e5ea; padding: 0.1rem 0.3rem; border-radius: 0.3rem; }
</style>
</head>
<body>
<main>
<h1>$APP_TITLE — test build</h1>
<p>Tap below on your <strong>iPhone</strong> to install (version $SHORT_VER).</p>
<a class="btn" href="itms-services://?action=download-manifest&amp;url=$BASE_URL/manifest.plist">
Install $APP_TITLE
</a>
<p class="note">
Only works on devices whose UDID was registered for this build, on iOS 18 or later.
If nothing happens, make sure you opened this page in <strong>Safari</strong> and that
it is served over <strong>HTTPS</strong>. After installing, the app launches normally —
no "trust developer" step is needed.
</p>
</main>
</body>
</html>
HTML
# ── Done ────────────────────────────────────────────────────────────────────────────
vc_ok "staged install files in $OUT:"
printf ' %s\n' "VoiceCatiOS.ipa" "manifest.plist" "index.html"
echo
if [[ "$BASE_URL" == "__BASE_URL__" ]]; then
vc_log "NEXT: replace __BASE_URL__ in manifest.plist and index.html with your HTTPS"
vc_log " folder URL, then upload all three files there."
else
vc_log "NEXT: upload the three files above to $BASE_URL/"
vc_log " then open $BASE_URL/index.html in Safari on a registered iPhone."
fi