#!/bin/bash # PAILot iOS Deploy Script # # Installs a personal (Pro-unlocked) PAILot build onto one or more iOS devices. # # ── Two transports ───────────────────────────────────────────────────────── # 1. devicectl (default): `xcrun devicectl device install`. Reaches the device # over Apple's CoreDevice tunnel, but DISCOVERY is mDNS/Bonjour — so it only # works when the Mac and device share the local network (USB or same wifi). # It does NOT traverse Tailscale: a device on a remote network shows # "unavailable" and both its .ts.net name and tailnet IP are rejected. # # 2. --ota (fully remote): build + publish the IPA to the aibroker-ota hub, which # serves it over Tailscale (HTTPS via Tailscale Serve). Open the returned URL # in Safari on ANY device on the tailnet and tap Install. This is the path to # use when you are NOT on the local wifi. Requires `aibroker ota up` once. # (The device UDID must still be in the provisioning profile.) # # ── Usage ────────────────────────────────────────────────────────────────── # bash tools/deploy.sh # Matthias' iPhone (default, local network) # bash tools/deploy.sh -a # Amelie's iPhone # bash tools/deploy.sh -i # Matthias' iPad # bash tools/deploy.sh --all # every known device (continues on failure) # bash tools/deploy.sh --device ID # explicit CoreDevice id (local network) # bash tools/deploy.sh --ota # remote install over Tailscale (prints URL) # bash tools/deploy.sh --build # force a fresh Pro-unlocked build first # bash tools/deploy.sh --check # list devices devicectl can currently reach set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" APP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$APP_DIR" IPA_DIR="build/ios/ipa" IPA="" # resolved after build (flutter names it PAILot.ipa) resolve_ipa() { IPA="$(ls -t "$IPA_DIR"/*.ipa 2>/dev/null | head -1 || true)"; } # Known devices — CoreDevice identifiers from `xcrun devicectl list devices`. IPHONE_MATTHIAS="8708CADC-3330-50B6-AA0A-1655526A573A" IPHONE_AMELIE="E3F823F4-8DC8-5C88-948E-C7FA2B4C4C72" IPAD_MATTHIAS="8037E780-6BC0-5B9D-9ED4-D8F28DEEFD3F" TARGETS=() # entries: "identifier|label" FORCE_BUILD=false OTA=false while [[ $# -gt 0 ]]; do case "$1" in -a|--amelie) TARGETS+=("$IPHONE_AMELIE|Amelie's iPhone"); shift ;; -i|--ipad) TARGETS+=("$IPAD_MATTHIAS|Matthias' iPad"); shift ;; --iphone) TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone"); shift ;; --all) TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone" \ "$IPAD_MATTHIAS|Matthias' iPad" \ "$IPHONE_AMELIE|Amelie's iPhone") shift ;; --device) TARGETS+=("$2|device $2"); shift 2 ;; --ota) OTA=true; shift ;; --build) FORCE_BUILD=true; shift ;; --check) echo "=== Devices devicectl can reach ==="; xcrun devicectl list devices; exit 0 ;; -h|--help) sed -n '2,33p' "$0"; exit 0 ;; *) echo "Unknown option: $1" >&2; exit 2 ;; esac done # ── Build a Pro-unlocked personal IPA when needed ────────────────────────── # PAILOT_PRO unlocks Pro for personal / sideloaded installs ONLY. App Store # builds go through tools/build-appstore.sh / tools/release.sh, which do NOT # pass this define — paying users still see the paywall. resolve_ipa if [[ "$FORCE_BUILD" == true || -z "$IPA" ]]; then echo "=== Building Pro-unlocked IPA (PAILOT_PRO=true) ===" flutter build ipa --release --no-pub --export-method development \ --no-tree-shake-icons --dart-define=PAILOT_PRO=true ARCHIVE="build/ios/archive/Runner.xcarchive/Info.plist" if [ -f "$ARCHIVE" ]; then plutil -replace Name -string "PAILot" "$ARCHIVE" 2>/dev/null || true plutil -replace SchemeName -string "PAILot" "$ARCHIVE" 2>/dev/null || true fi resolve_ipa else echo "=== Using existing IPA: $IPA ===" echo " (Pro status depends on how it was built — use --build to force a fresh Pro build)" fi [[ -n "$IPA" && -f "$IPA" ]] || { echo "ERROR: no IPA found in $IPA_DIR" >&2; exit 1; } # ── OTA (remote over Tailscale) ──────────────────────────────────────────── if [[ "$OTA" == true ]]; then VERSION="$(grep -E '^version:' pubspec.yaml | sed -E 's/version:[[:space:]]*//; s/\+.*//' | head -1)" echo "" echo "=== Publishing to aibroker-ota hub (Tailscale) ===" resp="$(curl -sf -X POST http://127.0.0.1:8765/api/apps \ -F slug=pailot -F name=PAILot -F bundleId=com.tekmidian.pailot \ -F "version=${VERSION:-1.0.0}" -F platform=ios -F "file=@${IPA}" 2>&1)" || { echo "ERROR: publish failed — is the hub up? Run: aibroker ota up" >&2; exit 1; } # Derive the tailnet host for the HTTPS install URL. ts="tailscale"; command -v tailscale >/dev/null 2>&1 || ts="/Applications/Tailscale.app/Contents/MacOS/Tailscale" host="$("$ts" status --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).Self.DNSName.replace(/\.$/,""))}catch{}})' 2>/dev/null || true)" echo "" if [[ -n "$host" ]]; then echo " Install on any tailnet device — open in Safari:" echo " https://${host}/install/pailot/" else echo " Published. Open the install page in Safari on a tailnet device:" echo " https:///install/pailot/" fi echo "" echo "=== Done (OTA). Tap Install in Safari, then force-quit & reopen PAILot. ===" exit 0 fi # ── devicectl install (local network) ────────────────────────────────────── # Default target: Matthias' iPhone. if [[ ${#TARGETS[@]} -eq 0 ]]; then TARGETS+=("$IPHONE_MATTHIAS|Matthias' iPhone") fi install_one() { local id="$1" label="$2" echo "" echo "=== Installing on $label ($id) ===" if xcrun devicectl device install app --device "$id" "$IPA"; then echo " OK: $label" else echo " WARNING: install on $label failed — awake & on the same network?" echo " Off the local wifi? Use: bash tools/deploy.sh --ota" return 1 fi } fail=0 for t in "${TARGETS[@]}"; do install_one "${t%%|*}" "${t#*|}" || fail=1 done echo "" if [[ $fail -eq 0 ]]; then echo "=== Done — force-quit and reopen PAILot on the device(s). ===" else echo "=== Done, with failures (see WARNINGs above). ===" exit 1 fi