#!/usr/bin/env bash # # Clean-build the Release .app and install it on the cabled iPhone. # # scripts/build-ios.sh # build + install # DEVICE= scripts/build-ios.sh # pick a specific device # NO_INSTALL=1 scripts/build-ios.sh # build only; leave the phone alone # # NO_INSTALL exists because installing restarts the app, and a long-running pass on the phone dies # with it. The .app path is printed so it can be installed by hand: # xcrun devicectl device install app --device # # xcodebuild + devicectl rather than `expo run:ios`: the expo installer has hung on this setup, and # this way the build and the install fail separately and legibly. set -uo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LOGS="${TMPDIR:-/tmp}offscale-build" mkdir -p "$LOGS" log="$LOGS/ios.log" : >"$log" cd "$ROOT" || exit 1 # The vendored Tailscale core is built by hand, per platform, and forgetting it fails much later as a # missing symbol rather than a missing rebuild — iOS says "cannot find 'TailcoreListProfiles' in # scope", long after the Go compiled cleanly. Do it here instead of relying on anyone remembering. . "$ROOT/scripts/ensure-core.sh" ios # The iPhone to install on, unless one is named explicitly. Matched by UDID shape rather than column # position: the Model column has a variable number of words, so counting fields from either end picks # up part of the model name instead. # # Three things this has to get right, all learned the hard way: # # - devicectl says "connected" for a cabled device on some Xcode versions and "available (paired)" # on others. Both are installable. Matching only "connected" exits with "No connected iPhone" # while a cabled phone sits there working fine. # - It must be an iPhone. A paired iPad is "available" too and sorts first, so widening the state # match alone silently retargets the whole build at the iPad. # - "unavailable" CONTAINS "available", so a bare /available/ matches a phone that is paired but not # plugged in. Picking one does not fail here — it fails fifteen lines into xcodebuild with "Unable # to find a destination matching the provided destination specifier", which reads like a broken # project rather than an unplugged cable. # # With more than one iPhone paired there is no correct guess, so it refuses and lists them. if [[ -z "${DEVICE:-}" ]]; then cands="$(xcrun devicectl list devices 2>/dev/null | awk ' /\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/) { for (i = 1; i <= NF; i++) if ($i ~ /^[0-9A-F]{8}-[0-9A-F]{4}-/) { print $i; break } }')" n="$(printf '%s' "$cands" | grep -c . || true)" if [[ "$n" -eq 1 ]]; then DEVICE="$cands" elif [[ "$n" -gt 1 ]]; then echo "!! More than one iPhone is paired — refusing to guess. Pass DEVICE=:" >&2 xcrun devicectl list devices 2>/dev/null | awk '/\(iPhone[0-9]/ && (/connected/ || /(^|[^n])available/)' >&2 exit 1 fi fi # No device is not fatal: `generic/platform=iOS` builds an unsigned-to-no-particular-device arm64 # .app, which is what a build server wants. Passing `id=` instead fails deep inside # xcodebuild with a destination error that reads like a broken project. if [[ -z "${DEVICE:-}" ]]; then echo "device: none — building for generic iOS, no install" destination="generic/platform=iOS" NO_INSTALL=1 else echo "device: $DEVICE" destination="id=$DEVICE" fi echo "logs: $log" # `bun run prebuild`, NOT `bunx expo prebuild`: the package.json script runs gen-adaptive-icon.sh # first, and calling expo directly silently skips it — which ships an icon the Android launcher mask # crops. Trailing args append to that script, and `expo prebuild` is its last command. if ! bun run prebuild --platform ios --clean --no-install >>"$log" 2>&1; then echo "PREBUILD FAILED — $log"; exit 1 fi if ! (cd ios && pod install) >>"$log" 2>&1; then echo "POD INSTALL FAILED — $log"; exit 1 fi ws="$(find ios -maxdepth 1 -name '*.xcworkspace' | head -1)" scheme="$(basename "$ws" .xcworkspace)" derived="$LOGS/derived" # A SwiftPM binary artifact that was extracted by an interrupted build stays half-unpacked, and # xcodebuild then fails with "There is no Info.plist found at .../Some.xcframework". It reads like a # corrupt download and is not — the zip in ~/Library/Caches/org.swift.swiftpm is fine; only this # extraction of it is short. Any .xcframework missing its Info.plist means the whole SourcePackages # tree is suspect, so it goes and is re-extracted from the (good) cache. Cheap: no re-download. if [[ -d "$derived/SourcePackages/artifacts" ]]; then while IFS= read -r fw; do [[ -f "$fw/Info.plist" ]] && continue echo "half-extracted $(basename "$fw") — clearing SourcePackages" rm -rf "$derived/SourcePackages" break done < <(find "$derived/SourcePackages/artifacts" -maxdepth 4 -name '*.xcframework' -type d 2>/dev/null) fi # Wipe old products first. A rename leaves the PREVIOUS bundle behind in the same derivedDataPath, # and picking "a" .app then installs the stale one. rm -rf "$derived/Build/Products" # Release, always. Bare Debug needs Metro attached and behaves differently from what ships, so # testing it answers the wrong question. # # DEVELOPMENT_TEAM is also in app.json as ios.appleTeamId, so prebuild writes it into the pbxproj; # passed here too so the script still works against an ios/ dir that predates that. # # A Network Extension needs its own provisioning profile, and -allowProvisioningUpdates cannot create # one while Xcode has no account signed in — it falls back to the wildcard team profile and fails with # "Provisioning profile ... doesn't include the Network Extensions capability". Fix is one interactive # step: Xcode -> Settings -> Accounts -> sign in to the team, then rebuild. if ! xcodebuild -workspace "$ws" -scheme "$scheme" -configuration Release \ -destination "$destination" -derivedDataPath "$derived" \ DEVELOPMENT_TEAM="${DEVELOPMENT_TEAM:-RGHC4NJC77}" CODE_SIGN_STYLE=Automatic \ -allowProvisioningUpdates build >>"$log" 2>&1; then echo "XCODEBUILD FAILED — $log"; exit 1 fi # Newest by mtime, belt-and-braces with the wipe above. appPath="$(find "$derived/Build/Products" -maxdepth 2 -name '*.app' -print0 2>/dev/null \ | xargs -0 stat -f '%m %N' 2>/dev/null | sort -rn | head -1 | cut -d' ' -f2-)" [[ -z "$appPath" ]] && { echo "NO .app"; exit 1; } if [[ -n "${NO_INSTALL:-}" ]]; then echo "BUILT, not installed — $appPath"; exit 0 fi # Reinstalling invalidates the saved VPN configuration: iOS hands it back from loadAllFromPreferences, # reports "unchanged" on save, then refuses to start it. The app removes and rewrites it on failure, # which is self-healing — but it is why the first tunnel start after an install can take two attempts. if ! xcrun devicectl device install app --device "$DEVICE" "$appPath" >>"$log" 2>&1; then echo "INSTALL FAILED (device attached?) — app at $appPath"; exit 1 fi echo "OK ($scheme)"