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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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__/
|
||||
|
||||
@@ -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
175
scripts/asc_api.py
Executable 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
285
scripts/dist-ios-adhoc.sh
Executable 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&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
|
||||
Reference in New Issue
Block a user