Files
voice-cat/scripts/asc_api.py
Talon 0c6b1a36cf 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__.
2026-06-21 15:28:26 +02:00

176 lines
6.8 KiB
Python
Executable File

#!/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())