From 3323f392e32e354733e7566aef61e388220be032 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 20 Jul 2026 12:07:10 +0200 Subject: [PATCH] Add signed iOS device debug workflow (#179) Add a protected development-signing workflow, isolated debug app identity, Linux USB device tooling, documentation, and focused validation coverage. --- .github/workflows/ios-device-debug.yml | 574 ++++++++++++ README.md | 4 +- docs/ios-device-debug.md | 382 ++++++++ ios/App/App.xcodeproj/project.pbxproj | 8 +- ios/App/App/Info.plist | 2 +- package.json | 1 + .../mobile/check-ios-debug-signing-env.mjs | 93 ++ scripts/mobile/ios-device.mjs | 856 ++++++++++++++++++ tests/unit/mobile-ios-device.spec.js | 491 ++++++++++ 9 files changed, 2406 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ios-device-debug.yml create mode 100644 docs/ios-device-debug.md create mode 100644 scripts/mobile/check-ios-debug-signing-env.mjs create mode 100644 scripts/mobile/ios-device.mjs create mode 100644 tests/unit/mobile-ios-device.spec.js diff --git a/.github/workflows/ios-device-debug.yml b/.github/workflows/ios-device-debug.yml new file mode 100644 index 00000000..41d695c2 --- /dev/null +++ b/.github/workflows/ios-device-debug.yml @@ -0,0 +1,574 @@ +name: iOS Device Debug IPA + +on: + workflow_dispatch: + inputs: + source_ref: + description: Same-repository branch, tag, or commit to build + required: true + default: master + type: string + expected_sha: + description: Full 40-character SHA that source_ref must resolve to + required: true + type: string + confirmation: + description: Type SIGN IOS DEBUG IPA + required: true + type: string + +permissions: + contents: read + +concurrency: + group: ios-device-debug-${{ github.run_id }} + cancel-in-progress: false + +jobs: + resolve: + name: Resolve and verify source + runs-on: ubuntu-24.04 + timeout-minutes: 10 + outputs: + source_sha: ${{ steps.resolve.outputs.source_sha }} + steps: + - name: Validate dispatch confirmation + shell: bash + env: + CONFIRMATION: ${{ inputs.confirmation }} + EXPECTED_SHA: ${{ inputs.expected_sha }} + SOURCE_REF: ${{ inputs.source_ref }} + WORKFLOW_REF: ${{ github.ref }} + run: | + set -euo pipefail + if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]; then + echo "The signing workflow must be dispatched from the master workflow ref" >&2 + exit 1 + fi + if [[ "$CONFIRMATION" != "SIGN IOS DEBUG IPA" ]]; then + echo "confirmation must exactly match SIGN IOS DEBUG IPA" >&2 + exit 1 + fi + if [[ ! "$EXPECTED_SHA" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "expected_sha must be a full 40-character commit SHA" >&2 + exit 1 + fi + if [[ -z "$SOURCE_REF" || "$SOURCE_REF" =~ [[:space:]] ]]; then + echo "source_ref must be non-empty and contain no whitespace" >&2 + exit 1 + fi + if [[ "$SOURCE_REF" == refs/pull/* || "$SOURCE_REF" == pull/* ]]; then + echo "Pull-request refs are not eligible for device-debug signing" >&2 + exit 1 + fi + + - name: Checkout same-repository history + uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Resolve immutable commit + id: resolve + shell: bash + env: + EXPECTED_SHA: ${{ inputs.expected_sha }} + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + expected_sha="${EXPECTED_SHA,,}" + + if [[ "$SOURCE_REF" =~ ^[0-9a-fA-F]{7,40}$ ]]; then + candidate="$SOURCE_REF" + elif [[ "$SOURCE_REF" == refs/heads/* ]]; then + candidate="refs/remotes/origin/${SOURCE_REF#refs/heads/}" + elif [[ "$SOURCE_REF" == refs/tags/* ]]; then + candidate="$SOURCE_REF" + elif git show-ref --verify --quiet "refs/remotes/origin/$SOURCE_REF"; then + candidate="refs/remotes/origin/$SOURCE_REF" + elif git show-ref --verify --quiet "refs/tags/$SOURCE_REF"; then + candidate="refs/tags/$SOURCE_REF" + else + echo "source_ref does not identify a same-repository branch, tag, or fetched commit" >&2 + exit 1 + fi + + source_sha="$(git rev-parse --verify "${candidate}^{commit}" 2>/dev/null || true)" + source_sha="${source_sha,,}" + if [[ ! "$source_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "source_ref could not be resolved to a commit" >&2 + exit 1 + fi + if [[ "$source_sha" != "$expected_sha" ]]; then + echo "source_ref resolved to a SHA different from expected_sha" >&2 + exit 1 + fi + + reachable=false + while IFS= read -r repository_ref; do + if git merge-base --is-ancestor "$source_sha" "$repository_ref" 2>/dev/null; then + reachable=true + break + fi + done < <(git for-each-ref --format='%(refname)' refs/remotes/origin refs/tags) + if [[ "$reachable" != true ]]; then + echo "The requested commit is not reachable from a same-repository branch or tag" >&2 + exit 1 + fi + + echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT" + echo "Resolved source_ref to $source_sha" >> "$GITHUB_STEP_SUMMARY" + + build: + name: Build development-signed IPA + needs: resolve + runs-on: macos-26 + timeout-minutes: 90 + environment: + name: mobile-device-debug + env: + IOS_PROJECT_PATH: ios/App/App.xcodeproj + IOS_SCHEME: App + IOS_DEBUG_BUNDLE_ID: ${{ vars.IOS_DEBUG_BUNDLE_ID || 'io.truckwash.app.debug' }} + IOS_DEBUG_API_URL: ${{ vars.IOS_DEBUG_API_URL || 'https://api-v2.truckwash.io/master/api' }} + APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MOBILE_VERSION_NAME: 0.0.${{ github.run_number }} + RESOLVED_SOURCE_SHA: ${{ needs.resolve.outputs.source_sha }} + steps: + - name: Checkout resolved source + uses: actions/checkout@v5 + with: + ref: ${{ needs.resolve.outputs.source_sha }} + fetch-depth: 1 + persist-credentials: false + + - name: Verify runner and resolve build number + shell: bash + run: | + set -euo pipefail + xcode_version="$(xcodebuild -version | head -n 1)" + xcode_major="$(awk '{split($2, version, "."); print version[1]}' <<< "$xcode_version")" + if [[ ! "$xcode_major" =~ ^[0-9]+$ ]] || (( xcode_major < 26 )); then + echo "Xcode 26 or newer is required; found $xcode_version" >&2 + exit 1 + fi + + build_number="$((10#$GITHUB_RUN_NUMBER * 100 + 10#$GITHUB_RUN_ATTEMPT))" + if [[ ! "$build_number" =~ ^[1-9][0-9]{0,17}$ ]]; then + echo "Derived build number is outside Apple's supported integer format" >&2 + exit 1 + fi + echo "MOBILE_VERSION_CODE=$build_number" >> "$GITHUB_ENV" + echo "XCODE_VERSION=$xcode_version" >> "$GITHUB_ENV" + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Build stable production web payload + env: + RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }} + VITE_API_URL: ${{ env.IOS_DEBUG_API_URL }} + VITE_RELEASE_MANAGER_CONTROL_API_URL: ${{ env.IOS_DEBUG_API_URL }} + VITE_RELEASE_PUBLIC_GATEWAY_API_URL: https://api-v2.truckwash.io + run: | + npm run build + node -e "const manifest = require('./dist/release-manifest.json'); if (manifest.commit_sha !== process.env.RESOLVED_SOURCE_SHA) { throw new Error('Web release manifest source SHA mismatch'); }" + + - name: Sync and validate iOS shell + shell: bash + run: | + set -euo pipefail + npx cap sync ios + npm run mobile:permissions:check + if grep -q 'isa = PBXShellScriptBuildPhase;' "$IOS_PROJECT_PATH/project.pbxproj"; then + echo "Unexpected Xcode shell-script build phase detected" >&2 + exit 1 + fi + xcodebuild -resolvePackageDependencies -project "$IOS_PROJECT_PATH" -scheme "$IOS_SCHEME" + + - name: Validate native Debug and Release settings + shell: bash + run: | + set -euo pipefail + debug_settings="$RUNNER_TEMP/ios-debug-build-settings.txt" + release_settings="$RUNNER_TEMP/ios-release-build-settings.txt" + xcodebuild -showBuildSettings \ + -project "$IOS_PROJECT_PATH" \ + -scheme "$IOS_SCHEME" \ + -configuration Debug \ + CODE_SIGNING_ALLOWED=NO > "$debug_settings" + xcodebuild -showBuildSettings \ + -project "$IOS_PROJECT_PATH" \ + -scheme "$IOS_SCHEME" \ + -configuration Release \ + CODE_SIGNING_ALLOWED=NO > "$release_settings" + + grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app\.debug$' "$debug_settings" + grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash Debug$' "$debug_settings" + grep -Eq '^[[:space:]]*PRODUCT_NAME = TruckWashDebug$' "$debug_settings" + grep -Eq '^[[:space:]]*CAPACITOR_DEBUG = true$' "$debug_settings" + grep -Eq '^[[:space:]]*DEBUG_INFORMATION_FORMAT = dwarf-with-dsym$' "$debug_settings" + grep -Eq '^[[:space:]]*IPHONEOS_DEPLOYMENT_TARGET = 15\.0$' "$debug_settings" + grep -Eq '^[[:space:]]*PRODUCT_BUNDLE_IDENTIFIER = io\.truckwash\.app$' "$release_settings" + grep -Eq '^[[:space:]]*APP_DISPLAY_NAME = Truck Wash$' "$release_settings" + grep -Eq '^[[:space:]]*PRODUCT_NAME = App$' "$release_settings" + + - name: Install and validate Apple development signing assets + shell: bash + env: + IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }} + IOS_DEBUG_CERTIFICATE_PASSWORD: ${{ secrets.IOS_DEBUG_CERTIFICATE_PASSWORD }} + IOS_DEBUG_PROVISION_PROFILE_BASE64: ${{ secrets.IOS_DEBUG_PROVISION_PROFILE_BASE64 }} + IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }} + run: | + set -euo pipefail + node scripts/mobile/check-ios-debug-signing-env.mjs + + certificate_path="$RUNNER_TEMP/ios-debug-development.p12" + profile_path="$RUNNER_TEMP/ios-debug-development.mobileprovision" + profile_plist="$RUNNER_TEMP/ios-debug-development-profile.plist" + keychain_path="$RUNNER_TEMP/ios-debug-signing.keychain-db" + keychain_password="$(openssl rand -base64 48 | tr -d '\n')" + echo "::add-mask::$keychain_password" + + node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_CERTIFICATE_BASE64.replace(/\\s/g, ''), 'base64'))" "$certificate_path" + node -e "const fs = require('fs'); fs.writeFileSync(process.argv[1], Buffer.from(process.env.IOS_DEBUG_PROVISION_PROFILE_BASE64.replace(/\\s/g, ''), 'base64'))" "$profile_path" + chmod 600 "$certificate_path" "$profile_path" + security cms -D -i "$profile_path" > "$profile_plist" + + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -P "$IOS_DEBUG_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$keychain_path" + security list-keychains -d user -s "$keychain_path" $(security list-keychains -d user | tr -d '"') + security set-key-partition-list \ + -S apple-tool:,apple: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + + signing_identity_sha="$(security find-identity -v -p codesigning "$keychain_path" | awk '/Apple Development/ {print $2; exit}')" + if [[ ! "$signing_identity_sha" =~ ^[0-9A-Fa-f]{40}$ ]]; then + echo "The PKCS#12 file does not contain a valid Apple Development signing identity" >&2 + exit 1 + fi + + IOS_SIGNING_IDENTITY_SHA="$signing_identity_sha" PROFILE_PLIST="$profile_plist" python3 <<'PY' + import datetime + import hashlib + import os + import plistlib + import re + import sys + + with open(os.environ["PROFILE_PLIST"], "rb") as handle: + profile = plistlib.load(handle) + + team_id = os.environ["APPLE_TEAM_ID"] + bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"] + entitlements = profile.get("Entitlements", {}) + allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()} + provisioned = set(profile.get("ProvisionedDevices", [])) + expiration = profile.get("ExpirationDate") + now = datetime.datetime.now(datetime.timezone.utc) + if expiration and expiration.tzinfo is None: + expiration = expiration.replace(tzinfo=datetime.timezone.utc) + + checks = { + "profile team identifier": team_id in profile.get("TeamIdentifier", []), + "application identifier": entitlements.get("application-identifier") == f"{team_id}.{bundle_id}", + "entitlement team identifier": entitlements.get("com.apple.developer.team-identifier") == team_id, + "development entitlement": entitlements.get("get-task-allow") is True, + "profile expiration": expiration is not None and expiration > now, + "registered devices": bool(allowed) and allowed <= provisioned, + "non-enterprise profile": profile.get("ProvisionsAllDevices") is not True, + "developer certificate": bool(profile.get("DeveloperCertificates")), + "profile UUID": isinstance(profile.get("UUID"), str) and re.fullmatch(r"[0-9A-Fa-f-]{36}", profile["UUID"]) is not None, + "safe profile name": isinstance(profile.get("Name"), str) and not any(char in profile["Name"] for char in "\r\n"), + } + identity_sha = os.environ["IOS_SIGNING_IDENTITY_SHA"].upper() + certificate_hashes = {hashlib.sha1(value).hexdigest().upper() for value in profile.get("DeveloperCertificates", [])} + checks["certificate belongs to profile"] = identity_sha in certificate_hashes + + failures = [label for label, passed in checks.items() if not passed] + if failures: + print("Development provisioning profile validation failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + sys.exit(1) + PY + + profile_uuid="$(/usr/libexec/PlistBuddy -c 'Print :UUID' "$profile_plist")" + profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$profile_plist")" + profile_expiration="$(PROFILE_PLIST="$profile_plist" python3 - <<'PY' + import datetime + import os + import plistlib + + with open(os.environ["PROFILE_PLIST"], "rb") as handle: + expiration = plistlib.load(handle)["ExpirationDate"] + if expiration.tzinfo is None: + expiration = expiration.replace(tzinfo=datetime.timezone.utc) + print(expiration.astimezone(datetime.timezone.utc).isoformat().replace("+00:00", "Z")) + PY + )" + profile_install_dir="$HOME/Library/MobileDevice/Provisioning Profiles" + profile_install_path="$profile_install_dir/$profile_uuid.mobileprovision" + mkdir -p "$profile_install_dir" + cp "$profile_path" "$profile_install_path" + + echo "IOS_KEYCHAIN_PATH=$keychain_path" >> "$GITHUB_ENV" + echo "IOS_PROFILE_INSTALL_PATH=$profile_install_path" >> "$GITHUB_ENV" + echo "IOS_PROFILE_NAME=$profile_name" >> "$GITHUB_ENV" + echo "IOS_PROFILE_UUID=$profile_uuid" >> "$GITHUB_ENV" + echo "IOS_PROFILE_EXPIRATION=$profile_expiration" >> "$GITHUB_ENV" + + - name: Archive Debug app with Apple Development signing + shell: bash + run: | + set -euo pipefail + archive_path="$RUNNER_TEMP/TruckWashDebug.xcarchive" + xcodebuild \ + -project "$IOS_PROJECT_PATH" \ + -scheme "$IOS_SCHEME" \ + -configuration Debug \ + -destination "generic/platform=iOS" \ + -archivePath "$archive_path" \ + archive \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Apple Development" \ + PROVISIONING_PROFILE_SPECIFIER="$IOS_PROFILE_NAME" \ + PRODUCT_BUNDLE_IDENTIFIER="$IOS_DEBUG_BUNDLE_ID" \ + MARKETING_VERSION="$MOBILE_VERSION_NAME" \ + CURRENT_PROJECT_VERSION="$MOBILE_VERSION_CODE" \ + DEBUG_INFORMATION_FORMAT="dwarf-with-dsym" \ + ONLY_ACTIVE_ARCH=NO + echo "IOS_ARCHIVE_PATH=$archive_path" >> "$GITHUB_ENV" + + - name: Export development IPA + shell: bash + run: | + set -euo pipefail + export_options="$RUNNER_TEMP/ios-debug-ExportOptions.plist" + export_path="$RUNNER_TEMP/ios-debug-export" + EXPORT_OPTIONS="$export_options" python3 <<'PY' + import os + import plistlib + + options = { + "method": "development", + "signingStyle": "manual", + "teamID": os.environ["APPLE_TEAM_ID"], + "provisioningProfiles": { + os.environ["IOS_DEBUG_BUNDLE_ID"]: os.environ["IOS_PROFILE_NAME"], + }, + "stripSwiftSymbols": True, + "manageAppVersionAndBuildNumber": False, + } + with open(os.environ["EXPORT_OPTIONS"], "wb") as handle: + plistlib.dump(options, handle) + PY + xcodebuild \ + -exportArchive \ + -archivePath "$IOS_ARCHIVE_PATH" \ + -exportPath "$export_path" \ + -exportOptionsPlist "$export_options" + + shopt -s nullglob + ipa_files=("$export_path"/*.ipa) + if [[ ${#ipa_files[@]} -ne 1 ]]; then + echo "Expected exactly one exported IPA; found ${#ipa_files[@]}" >&2 + exit 1 + fi + echo "IOS_EXPORTED_IPA=${ipa_files[0]}" >> "$GITHUB_ENV" + + - name: Validate exported IPA and embedded signature + shell: bash + env: + IOS_DEBUG_ALLOWED_UDIDS: ${{ secrets.IOS_DEBUG_ALLOWED_UDIDS }} + run: | + set -euo pipefail + inspect_dir="$RUNNER_TEMP/ios-debug-inspect" + mkdir -p "$inspect_dir" + unzip -q "$IOS_EXPORTED_IPA" -d "$inspect_dir" + shopt -s nullglob + app_bundles=("$inspect_dir"/Payload/*.app) + if [[ ${#app_bundles[@]} -ne 1 ]]; then + echo "Expected exactly one Payload app; found ${#app_bundles[@]}" >&2 + exit 1 + fi + + app_path="${app_bundles[0]}" + app_info="$app_path/Info.plist" + embedded_profile="$RUNNER_TEMP/ios-debug-embedded-profile.plist" + signature_entitlements="$RUNNER_TEMP/ios-debug-signature-entitlements.plist" + signature_details="$RUNNER_TEMP/ios-debug-signature-details.txt" + security cms -D -i "$app_path/embedded.mobileprovision" > "$embedded_profile" + codesign --verify --deep --strict "$app_path" + codesign -d --entitlements :- "$app_path" > "$signature_entitlements" + codesign -dvv "$app_path" > /dev/null 2> "$signature_details" + grep -Fq "TeamIdentifier=$APPLE_TEAM_ID" "$signature_details" + grep -Eq '^Authority=Apple Development:' "$signature_details" + + executable_name="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleExecutable' "$app_info")" + architectures="$(lipo -archs "$app_path/$executable_name")" + if [[ " $architectures " != *" arm64 "* ]]; then + echo "Exported executable does not contain arm64" >&2 + exit 1 + fi + + APP_INFO="$app_info" PROFILE_PLIST="$embedded_profile" SIGNATURE_ENTITLEMENTS="$signature_entitlements" python3 <<'PY' + import datetime + import os + import plistlib + import sys + + def load(path): + with open(path, "rb") as handle: + return plistlib.load(handle) + + info = load(os.environ["APP_INFO"]) + profile = load(os.environ["PROFILE_PLIST"]) + signature = load(os.environ["SIGNATURE_ENTITLEMENTS"]) + profile_entitlements = profile.get("Entitlements", {}) + team_id = os.environ["APPLE_TEAM_ID"] + bundle_id = os.environ["IOS_DEBUG_BUNDLE_ID"] + allowed = {line.strip() for line in os.environ["IOS_DEBUG_ALLOWED_UDIDS"].splitlines() if line.strip()} + provisioned = set(profile.get("ProvisionedDevices", [])) + expiration = profile.get("ExpirationDate") + now = datetime.datetime.now(datetime.timezone.utc) + if expiration and expiration.tzinfo is None: + expiration = expiration.replace(tzinfo=datetime.timezone.utc) + + checks = { + "bundle identifier": info.get("CFBundleIdentifier") == bundle_id, + "display name": info.get("CFBundleDisplayName") == "Truck Wash Debug", + "debug executable": info.get("CFBundleExecutable") == "TruckWashDebug", + "marketing version": info.get("CFBundleShortVersionString") == os.environ["MOBILE_VERSION_NAME"], + "build number": info.get("CFBundleVersion") == os.environ["MOBILE_VERSION_CODE"], + "minimum iOS": info.get("MinimumOSVersion") == "15.0", + "profile UUID": profile.get("UUID") == os.environ["IOS_PROFILE_UUID"], + "profile team": team_id in profile.get("TeamIdentifier", []), + "profile application identifier": profile_entitlements.get("application-identifier") == f"{team_id}.{bundle_id}", + "development profile": profile_entitlements.get("get-task-allow") is True, + "signature application identifier": signature.get("application-identifier") == f"{team_id}.{bundle_id}", + "signature team identifier": signature.get("com.apple.developer.team-identifier") == team_id, + "debuggable signature": signature.get("get-task-allow") is True, + "profile expiration": expiration is not None and expiration > now, + "registered devices": bool(allowed) and allowed <= provisioned, + "non-enterprise profile": profile.get("ProvisionsAllDevices") is not True, + } + failures = [label for label, passed in checks.items() if not passed] + if failures: + print("Exported development IPA validation failed:", file=sys.stderr) + for failure in failures: + print(f"- {failure}", file=sys.stderr) + sys.exit(1) + PY + + - name: Assemble debug artifact + shell: bash + env: + SOURCE_REF: ${{ inputs.source_ref }} + run: | + set -euo pipefail + short_sha="${RESOLVED_SOURCE_SHA:0:12}" + artifact_name="truck-wash-debug-${MOBILE_VERSION_NAME}-${short_sha}" + artifact_dir="$RUNNER_TEMP/device-debug-artifact" + ipa_filename="$artifact_name.ipa" + dsym_filename="$artifact_name.dSYM.zip" + mkdir -p "$artifact_dir" + cp "$IOS_EXPORTED_IPA" "$artifact_dir/$ipa_filename" + + shopt -s nullglob + dsym_bundles=("$IOS_ARCHIVE_PATH"/dSYMs/*.dSYM) + if [[ ${#dsym_bundles[@]} -eq 0 ]]; then + echo "The Debug archive did not contain any dSYM bundles" >&2 + exit 1 + fi + ditto -c -k --sequesterRsrc --keepParent "$IOS_ARCHIVE_PATH/dSYMs" "$artifact_dir/$dsym_filename" + + capacitor_version="$(node -p "require('./node_modules/@capacitor/core/package.json').version")" + BUILT_AT_UTC="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ + CAPACITOR_VERSION="$capacitor_version" \ + DSYM_FILENAME="$dsym_filename" \ + IPA_FILENAME="$ipa_filename" \ + MANIFEST_PATH="$artifact_dir/manifest.json" \ + python3 <<'PY' + import json + import os + + manifest = { + "schema_version": 1, + "repository": os.environ["GITHUB_REPOSITORY"], + "source_ref": os.environ["SOURCE_REF"], + "source_sha": os.environ["RESOLVED_SOURCE_SHA"], + "workflow_run": int(os.environ["GITHUB_RUN_NUMBER"]), + "workflow_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), + "built_at_utc": os.environ["BUILT_AT_UTC"], + "api_url": os.environ["IOS_DEBUG_API_URL"], + "release_manager_control_api_url": os.environ["IOS_DEBUG_API_URL"], + "bundle_id": os.environ["IOS_DEBUG_BUNDLE_ID"], + "display_name": "Truck Wash Debug", + "executable_name": "TruckWashDebug", + "version": os.environ["MOBILE_VERSION_NAME"], + "build": os.environ["MOBILE_VERSION_CODE"], + "minimum_ios": "15.0", + "capacitor_version": os.environ["CAPACITOR_VERSION"], + "xcode_version": os.environ["XCODE_VERSION"], + "signing_method": "development", + "profile_expiration_utc": os.environ["IOS_PROFILE_EXPIRATION"], + "ipa_filename": os.environ["IPA_FILENAME"], + "dsym_filename": os.environ["DSYM_FILENAME"], + } + with open(os.environ["MANIFEST_PATH"], "w", encoding="utf-8") as handle: + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") + PY + + ( + cd "$artifact_dir" + shasum -a 256 "$ipa_filename" "$dsym_filename" manifest.json > SHA256SUMS + ) + echo "IOS_DEBUG_ARTIFACT_DIR=$artifact_dir" >> "$GITHUB_ENV" + echo "IOS_DEBUG_ARTIFACT_NAME=$artifact_name" >> "$GITHUB_ENV" + + - name: Upload device-debug artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IOS_DEBUG_ARTIFACT_NAME }} + path: ${{ env.IOS_DEBUG_ARTIFACT_DIR }} + if-no-files-found: error + retention-days: 7 + + - name: Clean up Apple signing assets + if: always() + shell: bash + run: | + if [[ -n "${IOS_KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$IOS_KEYCHAIN_PATH" || true + else + security delete-keychain "$RUNNER_TEMP/ios-debug-signing.keychain-db" || true + fi + if [[ -n "${IOS_PROFILE_INSTALL_PATH:-}" ]]; then + rm -f "$IOS_PROFILE_INSTALL_PATH" + fi + rm -f \ + "$RUNNER_TEMP/ios-debug-development.p12" \ + "$RUNNER_TEMP/ios-debug-development.mobileprovision" \ + "$RUNNER_TEMP/ios-debug-development-profile.plist" \ + "$RUNNER_TEMP/ios-debug-embedded-profile.plist" \ + "$RUNNER_TEMP/ios-debug-signature-entitlements.plist" \ + "$RUNNER_TEMP/ios-debug-signature-details.txt" diff --git a/README.md b/README.md index 1b3f84a7..b673100e 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,9 @@ App Store Connect. See `docs/mobile-artifacts.md` for workflow triggers, required secrets, and local mobile checks. See `docs/app-store-release.md` for App Store Connect -release preparation and review notes. +release preparation and review notes. For a separate development-signed IPA +that can be installed on an approved iPhone from Ubuntu over USB, see +`docs/ios-device-debug.md`. ## Bubblewrap (TWA) Build and Install diff --git a/docs/ios-device-debug.md b/docs/ios-device-debug.md new file mode 100644 index 00000000..f12cf7c0 --- /dev/null +++ b/docs/ios-device-debug.md @@ -0,0 +1,382 @@ +# Cable-Connected iPhone Debug IPA Runbook + +This runbook covers development-signed iOS builds installed from an Ubuntu +workstation over USB. It is separate from the public App Store release path in +`docs/app-store-release.md`. + +The device build is deliberately a second app: + +- Debug bundle ID: `io.truckwash.app.debug` +- Debug display name: `Truck Wash Debug` +- Production bundle ID: `io.truckwash.app` +- Capacitor/Android app ID: `io.truckwash.twa` +- API: `https://api-v2.truckwash.io/master/api` + +Installing or uninstalling the debug app must not replace or remove the +production app. The debug workflow builds the Vue application in production +mode against the stable API; it does not use Vite's development `/api` default +or a live-reload server. + +## What Ubuntu Can And Cannot Do + +The current Ubuntu workstation already has `usbmuxd`, the libimobiledevice +utilities, and `ideviceinstaller`. The current iPhone has previously been +trusted and paired. Run the repository doctor before every install because the +phone can still be locked, trust can be reset, or Developer Mode can be off. + +This workflow supports: + +- Inspecting pairing, activation, lock, Developer Mode, and install-service + readiness. +- Installing and upgrading a valid development-signed IPA. +- Reading filtered device syslog and copying crash reports. + +Apple does not provide Xcode, LLDB device debugging, or Safari Web Inspector on +Linux. `CAPACITOR_DEBUG` and `get-task-allow` make the IPA suitable for a +development device, but they do not create an official Linux LLDB or WebKit +debugger. Use a physical Mac with Xcode/Safari for breakpoints or Web Inspector. +Use TestFlight or a physical Mac if a new or beta iOS release is incompatible +with libimobiledevice; never weaken device security or signing validation as a +workaround. + +## One-Time iPhone Preparation + +1. Connect the iPhone directly with a data-capable USB cable. +2. Unlock the phone and keep it awake. Tap **Trust** if iOS asks whether to + trust this computer, then enter the device passcode. +3. On iOS 16 or newer, open **Settings -> Privacy & Security -> Developer + Mode**, turn Developer Mode on, and accept the restart. iOS 15 does not have + this setting and the helper does not require it there. +4. After the restart, unlock the phone, confirm **Turn On** in the Developer + Mode prompt, and enter the passcode again. +5. Reconnect the cable and run the doctor described below. + +Developer Mode is an iOS security control and cannot be bypassed from Ubuntu. +If the Developer Mode setting is absent, connect the phone once to a physical +Mac and use Apple's supported Xcode or Apple Configurator device preparation, +then return to Ubuntu after the phone has restarted and Developer Mode is on. + +Trust, pairing, and Developer Mode can be cleared by device resets, iOS updates, +or privacy/location resets. Repeat these steps if the doctor reports that the +previously working device is no longer ready. + +## Apple Developer Setup + +This requires the paid Truck Wash ApS Apple Developer team and a user permitted +to manage certificates, identifiers, and devices. + +### Register the device and debug App ID + +1. Connect and unlock the iPhone, then get its UDID locally with + `idevice_id -l`. Treat the full UDID as sensitive operational data: do not + commit it or paste it into ordinary build logs. +2. In Apple Developer **Certificates, Identifiers & Profiles -> Devices**, add + the iPhone using that UDID. +3. Under **Identifiers**, create an explicit App ID for + `io.truckwash.app.debug`. +4. Enable only capabilities required by the current Xcode project. Do not copy + unrelated production entitlements into the debug App ID. + +### Create the certificate and development profile + +1. Create a dedicated **Apple Development** certificate for CI device-debug + signing. Keep its private key under the team's normal credential controls. +2. Export the certificate and private key together as a password-protected + `.p12` file. +3. Create an **iOS App Development** provisioning profile that selects: + - App ID `io.truckwash.app.debug` + - The dedicated Apple Development certificate + - Every approved physical test iPhone, including the cable-connected device +4. Download the `.mobileprovision` file. +5. Confirm the profile has not expired, includes the intended device UDIDs, and + grants `get-task-allow=true`. An App Store or ad-hoc profile is not valid for + this workflow. + +Base64-encode both files without line wrapping before adding them to GitHub. On +Ubuntu, for example: + +```sh +base64 -w 0 TruckWashDebug.p12 > TruckWashDebug.p12.base64 +base64 -w 0 TruckWashDebug.mobileprovision > TruckWashDebug.mobileprovision.base64 +``` + +Store the encoded values in GitHub immediately, verify one successful build, +then securely remove the local `.p12`, profile, encoded copies, CSR, and any +other private-key intermediates that are no longer required. Never commit +signing files or their encoded contents. + +## GitHub Environment And Dispatch Approval + +Create a repository environment named `mobile-device-debug`. Store the debug +signing configuration only in that environment. Required environment reviewers +are not available for this private repository's current GitHub plan, so the +manual `workflow_dispatch` inputs are the signing approval boundary. + +Restrict the environment's custom deployment branches to the exact `master` +branch. The checked-in workflow also refuses any other workflow ref. This keeps +signing secrets behind the reviewed workflow on `master`, while `source_ref` +can still select a separately inspected same-repository commit to build. + +Add these environment variables exactly: + +- `APPLE_TEAM_ID` +- `IOS_DEBUG_BUNDLE_ID=io.truckwash.app.debug` +- `IOS_DEBUG_API_URL=https://api-v2.truckwash.io/master/api` + +Add these environment secrets exactly: + +- `IOS_DEBUG_CERTIFICATE_BASE64`: base64 of the password-protected `.p12` +- `IOS_DEBUG_CERTIFICATE_PASSWORD`: password used to export the `.p12` +- `IOS_DEBUG_PROVISION_PROFILE_BASE64`: base64 of the development + `.mobileprovision` +- `IOS_DEBUG_ALLOWED_UDIDS`: newline-delimited UDIDs for every device that the + profile is expected to contain + +The workflow generates and masks a new random password for its temporary macOS +keychain on every run. Do not create or store an +`IOS_DEBUG_KEYCHAIN_PASSWORD` secret. + +Do not reuse the `mobile-store-production` distribution secrets. The debug job +must use an Apple Development certificate and iOS App Development profile; the +existing `io.truckwash.app` App Store workflow remains unchanged. + +The person dispatching a run must inspect the intended commit first. Do not +dispatch when: + +- The exact 40-character SHA is not the branch, tag, or commit intended. +- The source comes from a fork or another repository. +- The requested change is not appropriate to sign for a physical device. +- The signing profile is expired or no longer covers the intended device. + +The workflow independently resolves `source_ref` inside this repository and +requires it to equal `expected_sha`. It also requires the exact typed +confirmation `SIGN IOS DEBUG IPA`. A missing/mismatched SHA or confirmation +stops the unprivileged resolver before the environment signing secrets are used. + +## Build And Download A Debug IPA + +1. Open **Actions -> iOS Device Debug IPA -> Run workflow** and keep **Use + workflow from** set to `master`. +2. Inspect the intended commit and copy its complete 40-character SHA. +3. Enter `source_ref`. It may be a branch, tag, or commit in this repository and + defaults to `master`. +4. Enter the complete SHA as `expected_sha` and enter the exact confirmation + `SIGN IOS DEBUG IPA`. Submitting these inputs is approval to sign that source. +5. The resolver pins `source_ref` inside this repository and verifies it equals + `expected_sha`. A mismatch stops the run before signing. +6. Wait for the signed macOS job to finish. It builds a Debug archive against + `https://api-v2.truckwash.io/master/api`, exports it with method + `development`, validates the embedded profile and app identity, and never + uploads the result to App Store Connect. +7. Download the `truck-wash-debug--<12-character-SHA>` GitHub Actions + artifact for the run. Keep its same-prefix `.ipa`, `.dSYM.zip`, + `manifest.json`, and `SHA256SUMS` together in one directory. +8. From that directory, verify the download before connecting it to a device: + + ```sh + sha256sum --check SHA256SUMS + ``` + +Do not install an artifact after a checksum failure. The manifest records the +source ref and SHA, build/run numbers, bundle identity, stable API, minimum iOS, +Xcode/Capacitor versions, signing method, and provisioning-profile expiration. +It intentionally does not contain device UDIDs or secrets. + +Artifacts are retained for seven days. Keep the zipped dSYM with any crash +report from that build so a Mac/Xcode crash-symbolication path remains +available. + +## Ubuntu Device Commands + +Run commands from the repository root. The npm interface is: + +```sh +npm run mobile:ios:device -- +``` + +If the npm wrapper is unavailable, use the equivalent direct entrypoint: + +```sh +node scripts/mobile/ios-device.mjs +``` + +The helper uses USB devices only. With one connected iPhone, omit `--udid`. +With multiple devices connected, provide `--udid ID`; the command fails instead +of guessing. Normal output redacts full UDIDs. + +### Check readiness + +Unlock the phone and run: + +```sh +npm run mobile:ios:device -- doctor +``` + +The doctor verifies required host commands, USB discovery, pairing, activation, +unlocked state, Developer Mode, and installation-proxy access. Resolve every +reported failure before attempting an install. + +For a specific connected device: + +```sh +npm run mobile:ios:device -- doctor --udid DEVICE_UDID +``` + +### Install or upgrade + +Keep the downloaded artifact files together and run: + +```sh +npm run mobile:ios:device -- install ./truck-wash-debug-VERSION-SHA.ipa --manifest ./manifest.json +``` + +The helper requires and verifies `SHA256SUMS` and the complete workflow +manifest, then inspects the IPA and its embedded profile. It rejects missing or +mismatched artifact metadata, the wrong repository/source/API/bundle/executable, +an App Store/ad-hoc or expired profile, `get-task-allow=false`, a profile +missing the connected UDID, or an invalid app payload before calling +`ideviceinstaller`. + +If `io.truckwash.app.debug` is absent, the helper installs it. If it is already +present, the helper upgrades it and confirms the resulting version/build on the +phone. It never uninstalls or replaces `io.truckwash.app`. + +Launch **Truck Wash Debug** manually from the iPhone Home Screen. Keep the phone +online for the first launch so iOS can perform Apple's PPQ validation for the +provisioning profile. A firewall, DNS filter, VPN, or captive portal that +blocks Apple's validation service can prevent a correctly signed development +app from opening. + +### Collect filtered logs + +Start logging, then reproduce the issue on the phone: + +```sh +npm run mobile:ios:device -- logs +npm run mobile:ios:device -- logs --output ./truck-wash-debug.log +``` + +The debug executable is deliberately named `TruckWashDebug`, distinct from the +production executable. The helper verifies that exact name and filters +`idevicesyslog` output for it. It streams child-tool output through UDID +redaction; `--output` files are written by the helper with mode `0600` after +redaction. Logs should make it possible to correlate the app with its source SHA +and stable API target without exposing signing secrets or full device IDs. + +### Copy crash reports + +Create a destination directory and copy reports from the phone: + +```sh +mkdir -p ./ios-crashes +npm run mobile:ios:device -- crashes ./ios-crashes +``` + +Crash retrieval always keeps the original reports on the iPhone. Preserve the +matching IPA manifest and dSYM with each report. + +### Remove only the debug app + +Uninstall requires the exact debug bundle ID as typed confirmation: + +```sh +npm run mobile:ios:device -- uninstall --confirm io.truckwash.app.debug +``` + +The helper refuses to remove the production bundle or any other bundle ID. + +## Adding Devices And Renewing Signing + +A provisioning profile is a snapshot. Registering another iPhone in Apple +Developer does not update an already downloaded profile. + +When adding a device: + +1. Obtain its UDID locally and register it in the Apple Developer portal. +2. Regenerate the `io.truckwash.app.debug` iOS App Development profile with the + new and existing approved devices selected. +3. Replace `IOS_DEBUG_PROVISION_PROFILE_BASE64`. +4. Add the UDID to the newline-delimited `IOS_DEBUG_ALLOWED_UDIDS` secret. +5. Dispatch a new build; an existing IPA does not gain access to the new device. + +Monitor the profile expiration recorded in each artifact manifest and the Apple +Development certificate expiration in the portal. Before either expires, +create/renew the signing material, regenerate the profile, replace the affected +GitHub secrets, and prove the result with a new build and real-device install. +Revoked or expired signing material invalidates later installation and can stop +an already installed development build from launching. + +## Troubleshooting + +### No device, device locked, or installation proxy unavailable + +- Use a direct data-capable cable and avoid an unreliable hub. +- Unlock the iPhone, keep its screen awake, reconnect it, and rerun `doctor`. +- Close other tools that may be exclusively interacting with the device. +- Do not repeatedly retry installation while the doctor reports a lock/service + failure. + +### Pairing or trust failure + +- Unlock the phone and accept the Trust prompt. +- If no prompt appears and `doctor` reports invalid pairing, use the explicit + repair guidance printed by the helper, reconnect, and confirm trust again. +- Device privacy resets and some iOS updates require a new trust decision. + +### Developer Mode is disabled or absent on iOS 16 or newer + +- Enable it under **Settings -> Privacy & Security -> Developer Mode**, restart, + and confirm after the reboot. +- If the switch is absent, use a Mac with Xcode or Apple Configurator for + Apple's supported one-time preparation. There is no Ubuntu bypass. + +### IPA, profile, certificate, or UDID mismatch + +- Confirm the IPA is from **iOS Device Debug IPA**, not **Mobile Store Artifacts**. +- Check `manifest.json` for `io.truckwash.app.debug`, development signing, the + intended source SHA, and a future profile expiration. +- Regenerate the development profile when a device was added, a certificate was + replaced, or the profile expired; then replace the GitHub secret and rebuild. +- Never suppress the helper's profile, entitlement, bundle, or checksum checks. + +### App installs but will not launch + +- Keep the phone online for Apple's initial PPQ validation. +- Check whether VPN, DNS, firewall, captive-portal, or device-management policy + blocks Apple developer-app verification. +- Confirm Developer Mode is still on and the certificate/profile has not expired + or been revoked. +- Collect syslog and crash reports before reinstalling so evidence is preserved. + +### iOS beta or new major iOS version breaks device tools + +- Record the device model, exact iOS version, helper error, source SHA, and IPA + checksum. +- Update libimobiledevice only through a trusted package/source and rerun the + doctor. Do not install arbitrary device images or disable signing checks. +- If compatibility remains broken, distribute through TestFlight or install and + debug from a physical Mac with a compatible Xcode version. + +## Real-Device Acceptance Checklist + +For the first setup, after signing changes, and after major iOS upgrades: + +- `doctor` passes while the phone is unlocked. +- `SHA256SUMS` verifies and the manifest identifies the intended immutable SHA. +- **Truck Wash Debug** installs as `io.truckwash.app.debug` while the production + app and its data remain unchanged. +- Authentication, camera/QR permission, and foreground-location behavior work. +- Logs show the expected build/source context and stable API target. +- The debug app still launches and reaches the API after the cable is removed. +- A higher-numbered IPA upgrades the debug app without clearing its local state. +- Crash reports are copied without being deleted from the phone. +- The test record includes artifact checksum, source SHA, device model, iOS + version, outcome, and any residual iOS/libimobiledevice compatibility risk. + +## References + +- [Apple: enable Developer Mode on a device](https://developer.apple.com/documentation/xcode/enabling-developer-mode-on-a-device) +- [Apple: run an app on a physical device](https://developer.apple.com/documentation/Xcode/running-your-app-on-simulated-or-physical-devices) +- [Apple: register a single device](https://developer.apple.com/help/account/devices/register-a-single-device/) +- [Apple: create a development provisioning profile](https://developer.apple.com/help/account/provisioning-profiles/create-a-development-provisioning-profile/) +- [libimobiledevice project](https://github.com/libimobiledevice/libimobiledevice) diff --git a/ios/App/App.xcodeproj/project.pbxproj b/ios/App/App.xcodeproj/project.pbxproj index 317cc273..4679c923 100644 --- a/ios/App/App.xcodeproj/project.pbxproj +++ b/ios/App/App.xcodeproj/project.pbxproj @@ -217,7 +217,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer"; COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; @@ -298,6 +298,7 @@ isa = XCBuildConfiguration; baseConfigurationReference = 958DCC722DB07C7200EA8C5F /* debug.xcconfig */; buildSettings = { + APP_DISPLAY_NAME = "Truck Wash Debug"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -309,8 +310,8 @@ ); MARKETING_VERSION = 1.0; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; - PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app.debug; + PRODUCT_NAME = TruckWashDebug; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -320,6 +321,7 @@ 504EC3181FED79650016851F /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + APP_DISPLAY_NAME = "Truck Wash"; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/ios/App/App/Info.plist b/ios/App/App/Info.plist index d969b28d..4b1a03ec 100644 --- a/ios/App/App/Info.plist +++ b/ios/App/App/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion en CFBundleDisplayName - Truck Wash + $(APP_DISPLAY_NAME) CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/package.json b/package.json index 10654f24..40830c2b 100644 --- a/package.json +++ b/package.json @@ -68,6 +68,7 @@ "mobile:android:bundle:unsigned": "npm run mobile:android:sync && npm run mobile:permissions:check && cd android && ./gradlew bundleRelease", "mobile:android:play-upload": "node scripts/mobile/upload-google-play.mjs", "mobile:ios:sync": "npm run mobile:sync && npm run mobile:permissions:check", + "mobile:ios:device": "node scripts/mobile/ios-device.mjs", "playstore:graphics": "node scripts/playstore/generate-graphics.mjs" }, "dependencies": { diff --git a/scripts/mobile/check-ios-debug-signing-env.mjs b/scripts/mobile/check-ios-debug-signing-env.mjs new file mode 100644 index 00000000..bb1f454a --- /dev/null +++ b/scripts/mobile/check-ios-debug-signing-env.mjs @@ -0,0 +1,93 @@ +import { argv, env, exit } from "node:process"; + +const failures = []; +const expectedBundleId = "io.truckwash.app.debug"; +const expectedApiUrl = "https://api-v2.truckwash.io/master/api"; + +const requireVariable = (name) => { + if (!env[name]) { + failures.push(`Missing ${name}`); + } +}; + +const decodeBase64 = (name) => { + const encoded = String(env[name] ?? "").replace(/\s/g, ""); + if (!encoded) { + return null; + } + + if (encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + failures.push(`${name} is not valid base64`); + return null; + } + + const decoded = Buffer.from(encoded, "base64"); + if (decoded.length === 0) { + failures.push(`${name} is empty after base64 decoding`); + return null; + } + return decoded; +}; + +for (const name of [ + "APPLE_TEAM_ID", + "IOS_DEBUG_BUNDLE_ID", + "IOS_DEBUG_API_URL", + "IOS_DEBUG_CERTIFICATE_BASE64", + "IOS_DEBUG_CERTIFICATE_PASSWORD", + "IOS_DEBUG_PROVISION_PROFILE_BASE64", + "IOS_DEBUG_ALLOWED_UDIDS", + "MOBILE_VERSION_NAME", + "MOBILE_VERSION_CODE", +]) { + requireVariable(name); +} + +decodeBase64("IOS_DEBUG_CERTIFICATE_BASE64"); +decodeBase64("IOS_DEBUG_PROVISION_PROFILE_BASE64"); + +if (env.APPLE_TEAM_ID && !/^[A-Z0-9]{10}$/.test(env.APPLE_TEAM_ID)) { + failures.push("APPLE_TEAM_ID must be a 10-character Apple team identifier"); +} + +if (env.IOS_DEBUG_BUNDLE_ID && env.IOS_DEBUG_BUNDLE_ID !== expectedBundleId) { + failures.push(`IOS_DEBUG_BUNDLE_ID must be ${expectedBundleId}`); +} + +if (env.IOS_DEBUG_API_URL && env.IOS_DEBUG_API_URL !== expectedApiUrl) { + failures.push(`IOS_DEBUG_API_URL must be ${expectedApiUrl}`); +} + +if (env.MOBILE_VERSION_NAME && !/^0\.0\.[1-9]\d*$/.test(env.MOBILE_VERSION_NAME)) { + failures.push("MOBILE_VERSION_NAME must use the deterministic 0.0. format"); +} + +if (env.MOBILE_VERSION_CODE && !/^[1-9]\d{0,17}$/.test(env.MOBILE_VERSION_CODE)) { + failures.push("MOBILE_VERSION_CODE must be a positive integer of at most 18 digits"); +} + +if (env.IOS_DEBUG_ALLOWED_UDIDS) { + const lines = env.IOS_DEBUG_ALLOWED_UDIDS.split(/\r?\n/).map((value) => value.trim()); + const udids = lines.filter(Boolean); + if (udids.length === 0) { + failures.push("IOS_DEBUG_ALLOWED_UDIDS must contain at least one device UDID"); + } else if (udids.some((udid) => !/^[A-Za-z0-9-]{16,64}$/.test(udid))) { + failures.push("IOS_DEBUG_ALLOWED_UDIDS contains an invalid device UDID"); + } else if (new Set(udids).size !== udids.length) { + failures.push("IOS_DEBUG_ALLOWED_UDIDS contains duplicate device UDIDs"); + } +} + +if (argv.length > 2) { + failures.push("This check does not accept command-line arguments"); +} + +if (failures.length > 0) { + console.error("iOS device-debug signing environment is not configured:"); + for (const failure of failures) { + console.error(`- ${failure}`); + } + exit(1); +} + +console.log("iOS device-debug signing environment is configured."); diff --git a/scripts/mobile/ios-device.mjs b/scripts/mobile/ios-device.mjs new file mode 100644 index 00000000..1d629843 --- /dev/null +++ b/scripts/mobile/ios-device.mjs @@ -0,0 +1,856 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { constants as fsConstants, createWriteStream, fchmodSync, openSync } from "node:fs"; +import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, extname, join, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { finished } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +export const DEBUG_BUNDLE_ID = "io.truckwash.app.debug"; +export const DEBUG_DISPLAY_NAME = "Truck Wash Debug"; +export const DEBUG_EXECUTABLE_NAME = "TruckWashDebug"; +export const DEBUG_API_URL = "https://api-v2.truckwash.io/master/api"; + +const BASE_TOOLS = ["idevice_id", "idevicepair", "ideviceinfo"]; +const IPA_TOOLS = ["unzip", "openssl", "python3"]; +const DOCTOR_TOOLS = [...BASE_TOOLS, ...IPA_TOOLS, "ideviceinstaller", "idevicesyslog", "idevicecrashreport"]; + +const PLIST_TO_JSON = String.raw` +import datetime +import json +import plistlib +import sys + +def encode(value): + if isinstance(value, (datetime.datetime, datetime.date)): + encoded = value.isoformat() + return (encoded + "Z") if value.tzinfo is None else encoded.replace("+00:00", "Z") + if isinstance(value, bytes): + return {"type": "data", "length": len(value)} + raise TypeError(f"Unsupported plist value: {type(value).__name__}") + +with open(sys.argv[1], "rb") as source: + print(json.dumps(plistlib.load(source), default=encode)) +`; + +export class CliError extends Error { + constructor(message, { exitCode = 1, cause } = {}) { + super(message, { cause }); + this.name = "CliError"; + this.exitCode = exitCode; + } +} + +function usage() { + return `Usage: + node scripts/mobile/ios-device.mjs doctor [--udid ID] + node scripts/mobile/ios-device.mjs install IPA [--manifest FILE] [--udid ID] + node scripts/mobile/ios-device.mjs logs [--output FILE] [--udid ID] + node scripts/mobile/ios-device.mjs crashes DIRECTORY [--udid ID] + node scripts/mobile/ios-device.mjs uninstall --confirm ${DEBUG_BUNDLE_ID} [--udid ID]`; +} + +export function parseArgs(argv) { + const args = [...argv]; + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + return { help: true }; + } + + const command = args.shift(); + const supported = new Set(["doctor", "install", "logs", "crashes", "uninstall"]); + if (!supported.has(command)) { + throw new CliError(`Unknown command: ${command}\n\n${usage()}`); + } + + const options = { command, positionals: [] }; + const valueOptions = new Set(["--udid", "--manifest", "--output", "--confirm"]); + while (args.length > 0) { + const arg = args.shift(); + if (!arg.startsWith("--")) { + options.positionals.push(arg); + continue; + } + if (!valueOptions.has(arg)) { + throw new CliError(`Unknown option for ${command}: ${arg}`); + } + const key = arg.slice(2); + if (Object.hasOwn(options, key)) { + throw new CliError(`Option may only be supplied once: ${arg}`); + } + const value = args.shift(); + if (!value || value.startsWith("--")) { + throw new CliError(`Option requires a value: ${arg}`); + } + options[key] = value; + } + + const allowedOptions = { + doctor: new Set(["udid"]), + install: new Set(["udid", "manifest"]), + logs: new Set(["udid", "output"]), + crashes: new Set(["udid"]), + uninstall: new Set(["udid", "confirm"]), + }; + for (const key of ["udid", "manifest", "output", "confirm"]) { + if (Object.hasOwn(options, key) && !allowedOptions[command].has(key)) { + throw new CliError(`--${key} is not valid for ${command}`); + } + } + + const expectedPositionals = command === "install" || command === "crashes" ? 1 : 0; + if (options.positionals.length !== expectedPositionals) { + const expectation = expectedPositionals === 0 ? "no positional arguments" : "exactly one path"; + throw new CliError(`${command} requires ${expectation}.\n\n${usage()}`); + } + if (command === "uninstall" && options.confirm !== DEBUG_BUNDLE_ID) { + throw new CliError(`Refusing to uninstall. Supply --confirm ${DEBUG_BUNDLE_ID} exactly.`); + } + + return options; +} + +export function redactUdids(value, udids = []) { + let redacted = String(value ?? ""); + for (const udid of udids) { + if (udid) redacted = redacted.split(udid).join(""); + } + return redacted + .replace(/\b[0-9a-f]{40}\b/giu, "") + .replace(/\b[0-9a-f]{8}-[0-9a-f]{16}\b/giu, ""); +} + +export function selectUsbDevice(rawOutput, requestedUdid) { + const devices = [ + ...new Set( + String(rawOutput) + .split(/\r?\n/u) + .map((item) => item.trim()) + .filter(Boolean) + ), + ]; + if (devices.length === 0) { + throw new CliError("No cable-connected iPhone was found. Connect and unlock the phone, then retry."); + } + if (requestedUdid) { + if (!devices.includes(requestedUdid)) { + throw new CliError("The requested device is not connected over USB."); + } + return { udid: requestedUdid, allUdids: devices }; + } + if (devices.length !== 1) { + throw new CliError(`Found ${devices.length} USB devices. Select one explicitly with --udid ID.`); + } + return { udid: devices[0], allUdids: devices }; +} + +export function createRedactedLineWriter(output, udids) { + const decoder = new StringDecoder("utf8"); + let pending = ""; + let ended = false; + const flushCompleteLines = () => { + const newline = Math.max(pending.lastIndexOf("\n"), pending.lastIndexOf("\r")); + if (newline < 0) return; + output.write(redactUdids(pending.slice(0, newline + 1), udids)); + pending = pending.slice(newline + 1); + }; + return { + write(chunk) { + pending += decoder.write(chunk); + flushCompleteLines(); + }, + end() { + if (ended) return; + ended = true; + pending += decoder.end(); + if (pending) output.write(redactUdids(pending, udids)); + }, + }; +} + +export function spawnCommand(command, args, { inherit = false, streamRedactedUdids, redactedStdoutFile } = {}) { + return new Promise((resolvePromise, rejectPromise) => { + const streamRedacted = Array.isArray(streamRedactedUdids); + if (redactedStdoutFile && !streamRedacted) { + rejectPromise(new CliError("redactedStdoutFile requires streamed UDID redaction.")); + return; + } + const stdoutFileDescriptor = redactedStdoutFile ? openSync(redactedStdoutFile, "w", 0o600) : null; + if (stdoutFileDescriptor !== null) fchmodSync(stdoutFileDescriptor, 0o600); + const stdoutTarget = redactedStdoutFile + ? createWriteStream(redactedStdoutFile, { fd: stdoutFileDescriptor, autoClose: true }) + : process.stdout; + const stdoutFinished = redactedStdoutFile + ? finished(stdoutTarget).then( + () => null, + (error) => error + ) + : null; + const child = spawn(command, args, { + stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"], + }); + if (redactedStdoutFile) { + stdoutTarget.once("error", (error) => { + child.kill("SIGTERM"); + rejectPromise(error); + }); + } + const stdout = []; + const stderr = []; + let finishStreamedStdout = () => {}; + if (!inherit) { + if (streamRedacted) { + const stdoutWriter = createRedactedLineWriter(stdoutTarget, streamRedactedUdids); + const stderrWriter = createRedactedLineWriter(process.stderr, streamRedactedUdids); + child.stdout.on("data", (chunk) => stdoutWriter.write(chunk)); + child.stderr.on("data", (chunk) => stderrWriter.write(chunk)); + finishStreamedStdout = () => { + stdoutWriter.end(); + if (redactedStdoutFile && !stdoutTarget.writableEnded) stdoutTarget.end(); + }; + child.stdout.on("end", finishStreamedStdout); + child.stderr.on("end", () => stderrWriter.end()); + } else { + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + } + } + child.once("error", (error) => { + finishStreamedStdout(); + rejectPromise(error); + }); + child.once("close", async (code, signal) => { + finishStreamedStdout(); + const outputError = stdoutFinished ? await stdoutFinished : null; + if (outputError) { + rejectPromise(outputError); + return; + } + resolvePromise({ + code: code ?? (signal ? 1 : 0), + signal, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }); + }); + }); +} + +async function defaultCommandExists(command) { + const pathEntries = (process.env.PATH ?? "").split(":").filter(Boolean); + for (const entry of pathEntries) { + try { + await access(join(entry, command), fsConstants.X_OK); + return true; + } catch { + // Continue through PATH. + } + } + return false; +} + +function commandFailure(command, args, result, udids) { + const stderr = redactUdids( + Buffer.from(result.stderr ?? "") + .toString("utf8") + .trim(), + udids + ); + const detail = stderr ? `: ${stderr}` : ""; + return new CliError(`${command} ${args.join(" ")} failed${detail}`); +} + +async function runChecked(context, command, args, options = {}) { + let result; + try { + result = await context.run(command, args, options); + } catch (error) { + if (error?.code === "ENOENT") { + throw new CliError(`Required command is not installed: ${command}`, { cause: error }); + } + throw error; + } + if (result.code !== 0) { + throw commandFailure(command, args, result, context.knownUdids); + } + return result; +} + +async function requireTools(context, tools) { + const missing = []; + for (const tool of tools) { + if (!(await context.commandExists(tool))) missing.push(tool); + } + if (missing.length > 0) { + throw new CliError(`Missing required command${missing.length === 1 ? "" : "s"}: ${missing.join(", ")}`); + } +} + +async function discoverDevice(context, requestedUdid) { + const result = await runChecked(context, "idevice_id", ["-l"]); + const selection = selectUsbDevice(result.stdout, requestedUdid); + context.knownUdids = selection.allUdids; + return selection.udid; +} + +function lockedOrTrustHint(error) { + const message = String(error?.message ?? error).toLowerCase(); + if (message.includes("password protected") || message.includes("passcode") || message.includes("locked")) { + return "The iPhone is locked. Unlock it, keep the screen awake, and retry."; + } + if (message.includes("pair") || message.includes("trust") || message.includes("invalid host")) { + return "Pairing is not valid. Unlock the phone, accept Trust This Computer, then run doctor again."; + } + return null; +} + +async function validatePairing(context, udid) { + try { + await runChecked(context, "idevicepair", ["-u", udid, "validate"]); + } catch (error) { + throw new CliError(lockedOrTrustHint(error) ?? `Pairing validation failed: ${error.message}`, { + cause: error, + }); + } +} + +async function queryDeviceValue(context, udid, key) { + const result = await runChecked(context, "ideviceinfo", ["-u", udid, "-k", key]); + return Buffer.from(result.stdout).toString("utf8").trim(); +} + +async function assertInstallationProxy(context, udid) { + try { + await runChecked(context, "ideviceinstaller", [ + "-u", + udid, + "list", + "--user", + "-b", + DEBUG_BUNDLE_ID, + "-a", + "CFBundleIdentifier", + ]); + } catch (error) { + throw new CliError( + lockedOrTrustHint(error) ?? + "The installation service is unavailable. Unlock the phone, reconnect the cable, and retry.", + { cause: error } + ); + } +} + +function developerModeEnabled(output) { + return /\benabled\b/iu.test(String(output)) && !/\bdisabled\b/iu.test(String(output)); +} + +async function assertDeveloperMode(context, udid) { + const result = await runChecked(context, "idevicedevmodectl", ["-u", udid, "list"]); + if (!developerModeEnabled(Buffer.from(result.stdout).toString("utf8"))) { + throw new CliError( + "Developer Mode is disabled. Enable Settings > Privacy & Security > Developer Mode, restart the iPhone, and confirm Enable." + ); + } +} + +async function runDoctor(context, options) { + await requireTools(context, DOCTOR_TOOLS); + const udid = await discoverDevice(context, options.udid); + await validatePairing(context, udid); + + const activationState = await queryDeviceValue(context, udid, "ActivationState"); + if (activationState !== "Activated") { + throw new CliError(`The iPhone is not activated (state: ${activationState || "unknown"}).`); + } + await assertInstallationProxy(context, udid); + + const [model, iosVersion] = await Promise.all([ + queryDeviceValue(context, udid, "ProductType"), + queryDeviceValue(context, udid, "ProductVersion"), + ]); + if (requiresDeveloperMode(iosVersion)) { + await requireTools(context, ["idevicedevmodectl"]); + await assertDeveloperMode(context, udid); + } + context.out(`Ready: ${model || "iPhone"}, iOS ${iosVersion || "unknown"}`); + context.out( + `Pairing: valid; activation: active; installation service: available; Developer Mode: ${ + requiresDeveloperMode(iosVersion) ? "enabled" : "not required before iOS 16" + }.` + ); +} + +function bufferText(value) { + return Buffer.from(value ?? "").toString("utf8"); +} + +async function parsePlistFile(context, plistPath) { + const result = await runChecked(context, "python3", ["-c", PLIST_TO_JSON, plistPath]); + try { + return JSON.parse(bufferText(result.stdout)); + } catch (error) { + throw new CliError(`Could not parse plist ${basename(plistPath)}.`, { cause: error }); + } +} + +export function selectIpaPayload(entries) { + const normalized = entries.map((entry) => String(entry).trim()).filter(Boolean); + const infoPlists = normalized.filter((entry) => /^Payload\/[^/]+\.app\/Info\.plist$/u.test(entry)); + if (infoPlists.length !== 1) { + throw new CliError(`IPA must contain exactly one app payload; found ${infoPlists.length}.`); + } + const appRoot = dirname(infoPlists[0]); + const profilePath = `${appRoot}/embedded.mobileprovision`; + if (!normalized.includes(profilePath)) { + throw new CliError("IPA does not contain an embedded provisioning profile."); + } + return { appRoot, infoPlistPath: infoPlists[0], profilePath }; +} + +async function extractZipEntry(context, ipaPath, entry, destination) { + const result = await runChecked(context, "unzip", ["-p", ipaPath, entry]); + await writeFile(destination, Buffer.from(result.stdout)); +} + +async function fileExists(path) { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +export async function verifySha256File(targetPath, checksumPath) { + const content = await readFile(checksumPath, "utf8"); + const targetName = basename(targetPath); + const matches = content + .split(/\r?\n/u) + .map((line) => line.match(/^([0-9a-f]{64})\s+\*?(.+)$/iu)) + .filter(Boolean) + .filter((match) => basename(match[2].trim()) === targetName); + if (matches.length !== 1) { + throw new CliError(`${basename(checksumPath)} must contain exactly one checksum for ${targetName}.`); + } + const hash = createHash("sha256"); + hash.update(await readFile(targetPath)); + const actual = hash.digest("hex"); + if (actual.toLowerCase() !== matches[0][1].toLowerCase()) { + throw new CliError(`Checksum verification failed for ${targetName}.`); + } +} + +export function validateManifest(manifest, artifact, ipaPath) { + if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) { + throw new CliError("manifest.json must contain a JSON object."); + } + if (manifest.schema_version !== 1) { + throw new CliError("manifest.json must use schema_version 1."); + } + if (manifest.signing_method !== "development") { + throw new CliError("manifest.json must identify the signing method as development."); + } + if (manifest.repository !== "copenhagentruckwash/pleno-vue") { + throw new CliError("manifest.json repository must be copenhagentruckwash/pleno-vue."); + } + if (typeof manifest.source_ref !== "string" || !manifest.source_ref.trim()) { + throw new CliError("manifest.json must contain a non-empty source_ref."); + } + if (typeof manifest.source_sha !== "string" || !/^[0-9a-f]{40}$/iu.test(manifest.source_sha)) { + throw new CliError("manifest.json must contain a full 40-character source_sha."); + } + if (manifest.api_url !== DEBUG_API_URL || manifest.release_manager_control_api_url !== DEBUG_API_URL) { + throw new CliError(`manifest.json API URLs must both be ${DEBUG_API_URL}.`); + } + const expectations = [ + { + name: "bundle identifier", + value: manifest.bundle_id, + actual: artifact.info.CFBundleIdentifier, + }, + { + name: "display name", + value: manifest.display_name, + actual: artifact.info.CFBundleDisplayName ?? artifact.info.CFBundleName, + }, + { + name: "executable name", + value: manifest.executable_name, + actual: artifact.info.CFBundleExecutable, + }, + { + name: "version", + value: manifest.version, + actual: artifact.info.CFBundleShortVersionString, + }, + { + name: "build", + value: manifest.build, + actual: artifact.info.CFBundleVersion, + }, + { + name: "IPA filename", + value: manifest.ipa_filename, + actual: basename(ipaPath), + }, + { + name: "minimum iOS version", + value: manifest.minimum_ios, + actual: artifact.info.MinimumOSVersion, + }, + ]; + for (const expectation of expectations) { + if (expectation.value === undefined || expectation.value === null || expectation.value === "") { + throw new CliError(`manifest.json is missing ${expectation.name}.`); + } + if (String(expectation.value) !== String(expectation.actual ?? "")) { + throw new CliError( + `Manifest ${expectation.name} does not match the IPA (${expectation.value} != ${ + expectation.actual ?? "missing" + }).` + ); + } + } + const manifestExpiration = new Date(manifest.profile_expiration_utc).getTime(); + const profileExpiration = new Date(artifact.profile.ExpirationDate).getTime(); + if ( + !Number.isFinite(manifestExpiration) || + !Number.isFinite(profileExpiration) || + manifestExpiration !== profileExpiration + ) { + throw new CliError("Manifest profile expiration does not match the embedded profile."); + } +} + +function profileApplicationIdentifier(profile) { + return profile?.Entitlements?.["application-identifier"]; +} + +function parseNumericVersion(value, label) { + const normalized = String(value ?? "").trim(); + const match = normalized.match(/^(\d+(?:\.\d+)*)(?:[^.\d].*)?$/u); + if (!match) { + throw new CliError(`${label} is malformed: ${normalized || "missing"}.`); + } + const components = match[1].split(".").map((component) => Number(component)); + if (components.some((component) => !Number.isSafeInteger(component))) { + throw new CliError(`${label} contains an unsupported numeric component: ${normalized}.`); + } + return components; +} + +export function assertMinimumIosCompatible(minimumIos, deviceIos) { + const minimum = parseNumericVersion(minimumIos, "IPA minimum iOS version"); + const device = parseNumericVersion(deviceIos, "Connected iPhone iOS version"); + const componentCount = Math.max(minimum.length, device.length); + for (let index = 0; index < componentCount; index += 1) { + const minimumComponent = minimum[index] ?? 0; + const deviceComponent = device[index] ?? 0; + if (minimumComponent < deviceComponent) return; + if (minimumComponent > deviceComponent) { + throw new CliError(`IPA requires iOS ${minimumIos}, but the connected iPhone runs iOS ${deviceIos}.`); + } + } +} + +export function requiresDeveloperMode(deviceIos) { + return parseNumericVersion(deviceIos, "Connected iPhone iOS version")[0] >= 16; +} + +export function validateDevelopmentArtifact(artifact, connectedUdid, now = new Date()) { + const { info, profile } = artifact; + if (info.CFBundleIdentifier !== DEBUG_BUNDLE_ID) { + throw new CliError( + `Refusing IPA with bundle identifier ${info.CFBundleIdentifier ?? "missing"}; expected ${DEBUG_BUNDLE_ID}.` + ); + } + const displayName = info.CFBundleDisplayName ?? info.CFBundleName; + if (displayName !== DEBUG_DISPLAY_NAME) { + throw new CliError(`Refusing IPA with display name ${displayName ?? "missing"}; expected ${DEBUG_DISPLAY_NAME}.`); + } + if (!info.CFBundleExecutable || !info.CFBundleShortVersionString || !info.CFBundleVersion || !info.MinimumOSVersion) { + throw new CliError("IPA Info.plist is missing executable, version, build, or minimum iOS metadata."); + } + if (info.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) { + throw new CliError(`Refusing IPA with executable ${info.CFBundleExecutable}; expected ${DEBUG_EXECUTABLE_NAME}.`); + } + + const entitlements = profile?.Entitlements ?? {}; + if (entitlements["get-task-allow"] !== true) { + throw new CliError("IPA is not development-signed: get-task-allow is not true."); + } + const applicationIdentifier = profileApplicationIdentifier(profile); + if (typeof applicationIdentifier !== "string" || !applicationIdentifier.endsWith(`.${DEBUG_BUNDLE_ID}`)) { + throw new CliError("Provisioning profile application identifier does not match the debug bundle."); + } + const teamIdentifier = entitlements["com.apple.developer.team-identifier"]; + if (typeof teamIdentifier !== "string" || applicationIdentifier !== `${teamIdentifier}.${DEBUG_BUNDLE_ID}`) { + throw new CliError("Provisioning profile team identifier is inconsistent with its application identifier."); + } + if (!Array.isArray(profile.TeamIdentifier) || !profile.TeamIdentifier.includes(teamIdentifier)) { + throw new CliError("Provisioning profile does not include its entitlement team identifier."); + } + if (profile.ProvisionsAllDevices === true) { + throw new CliError("Enterprise provisioning profiles are not accepted for cable debug installation."); + } + if (!Array.isArray(profile.ProvisionedDevices) || profile.ProvisionedDevices.length === 0) { + throw new CliError("Provisioning profile contains no registered development devices."); + } + if (!profile.ProvisionedDevices.includes(connectedUdid)) { + throw new CliError("The connected iPhone is not included in the provisioning profile."); + } + const expiresAt = new Date(profile.ExpirationDate); + if (!Number.isFinite(expiresAt.getTime()) || expiresAt.getTime() <= now.getTime()) { + throw new CliError("The provisioning profile is expired or has an invalid expiration date."); + } + return { + bundleId: info.CFBundleIdentifier, + displayName, + executable: info.CFBundleExecutable, + version: String(info.CFBundleShortVersionString), + build: String(info.CFBundleVersion), + minimumIos: String(info.MinimumOSVersion), + expiresAt: expiresAt.toISOString(), + }; +} + +export async function inspectIpa(context, ipaInput, { manifestPath, connectedUdid }) { + const ipaPath = resolve(ipaInput); + if (extname(ipaPath).toLowerCase() !== ".ipa" || !(await fileExists(ipaPath))) { + throw new CliError(`IPA file not found: ${ipaInput}`); + } + await requireTools(context, IPA_TOOLS); + + const candidateManifest = manifestPath ? resolve(manifestPath) : join(dirname(ipaPath), "manifest.json"); + if (!(await fileExists(candidateManifest))) { + throw new CliError( + `Manifest file not found: ${manifestPath ?? candidateManifest}. Keep manifest.json with the workflow IPA.` + ); + } + const siblingChecksum = join(dirname(ipaPath), "SHA256SUMS"); + if (!(await fileExists(siblingChecksum))) { + throw new CliError(`Checksum file not found: ${siblingChecksum}. Keep SHA256SUMS with the workflow IPA.`); + } + await verifySha256File(ipaPath, siblingChecksum); + await verifySha256File(candidateManifest, siblingChecksum); + + const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-device-")); + try { + const listing = await runChecked(context, "unzip", ["-Z1", ipaPath]); + const payload = selectIpaPayload(bufferText(listing.stdout).split(/\r?\n/u)); + const infoPath = join(temporaryDirectory, "Info.plist"); + const profileCmsPath = join(temporaryDirectory, "embedded.mobileprovision"); + const profilePlistPath = join(temporaryDirectory, "profile.plist"); + await extractZipEntry(context, ipaPath, payload.infoPlistPath, infoPath); + await extractZipEntry(context, ipaPath, payload.profilePath, profileCmsPath); + + const decodedProfile = await runChecked(context, "openssl", [ + "smime", + "-inform", + "der", + "-verify", + "-noverify", + "-in", + profileCmsPath, + ]); + await writeFile(profilePlistPath, Buffer.from(decodedProfile.stdout)); + const artifact = { + info: await parsePlistFile(context, infoPath), + profile: await parsePlistFile(context, profilePlistPath), + }; + artifact.validated = validateDevelopmentArtifact(artifact, connectedUdid, context.now()); + + let manifest; + try { + manifest = JSON.parse(await readFile(candidateManifest, "utf8")); + } catch (error) { + throw new CliError(`Could not parse ${basename(candidateManifest)} as JSON.`, { cause: error }); + } + validateManifest(manifest, artifact, ipaPath); + return { ...artifact, ipaPath }; + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +async function parsePlistBuffer(context, buffer, label) { + const temporaryDirectory = await mkdtemp(join(tmpdir(), "truck-wash-ios-plist-")); + const plistPath = join(temporaryDirectory, "value.plist"); + try { + await writeFile(plistPath, Buffer.from(buffer)); + return await parsePlistFile(context, plistPath); + } catch (error) { + throw new CliError(`Could not parse ${label}.`, { cause: error }); + } finally { + await rm(temporaryDirectory, { recursive: true, force: true }); + } +} + +function findInstalledApp(plist) { + if (!plist || typeof plist !== "object") return null; + if (plist[DEBUG_BUNDLE_ID] && typeof plist[DEBUG_BUNDLE_ID] === "object") { + return plist[DEBUG_BUNDLE_ID]; + } + const candidates = Array.isArray(plist) ? plist : Object.values(plist); + return candidates.find((item) => item?.CFBundleIdentifier === DEBUG_BUNDLE_ID) ?? null; +} + +async function getInstalledDebugApp(context, udid) { + const result = await runChecked(context, "ideviceinstaller", [ + "-u", + udid, + "list", + "--user", + "--xml", + "-b", + DEBUG_BUNDLE_ID, + "-a", + "CFBundleIdentifier", + "-a", + "CFBundleExecutable", + "-a", + "CFBundleShortVersionString", + "-a", + "CFBundleVersion", + ]); + return findInstalledApp(await parsePlistBuffer(context, result.stdout, "installed-app list")); +} + +async function prepareDevice(context, requestedUdid, tools) { + await requireTools(context, [...BASE_TOOLS, ...tools]); + const udid = await discoverDevice(context, requestedUdid); + await validatePairing(context, udid); + return udid; +} + +async function runInstall(context, options) { + const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", ...IPA_TOOLS]); + const deviceIos = await queryDeviceValue(context, udid, "ProductVersion"); + if (requiresDeveloperMode(deviceIos)) { + await requireTools(context, ["idevicedevmodectl"]); + await assertDeveloperMode(context, udid); + } + await assertInstallationProxy(context, udid); + const artifact = await context.inspectIpa(context, options.positionals[0], { + manifestPath: options.manifest, + connectedUdid: udid, + }); + assertMinimumIosCompatible(artifact.validated.minimumIos, deviceIos); + const installed = await context.getInstalledDebugApp(context, udid); + const action = installed ? "upgrade" : "install"; + context.out(`${action === "upgrade" ? "Upgrading" : "Installing"} ${DEBUG_DISPLAY_NAME}...`); + await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", action, artifact.ipaPath], { + streamRedactedUdids: context.knownUdids, + }); + + const verified = await context.getInstalledDebugApp(context, udid); + if (!verified) { + throw new CliError("Installation command completed, but the debug app is not present on the iPhone."); + } + const actualVersion = String(verified.CFBundleShortVersionString ?? ""); + const actualBuild = String(verified.CFBundleVersion ?? ""); + if (actualVersion !== artifact.validated.version || actualBuild !== artifact.validated.build) { + throw new CliError( + `Installed version verification failed (expected ${artifact.validated.version} (${ + artifact.validated.build + }), found ${actualVersion || "missing"} (${actualBuild || "missing"})).` + ); + } + context.out(`Installed ${DEBUG_DISPLAY_NAME} ${actualVersion} (${actualBuild}).`); +} + +async function requireInstalledDebugApp(context, udid) { + const app = await context.getInstalledDebugApp(context, udid); + if (!app) { + throw new CliError(`${DEBUG_DISPLAY_NAME} is not installed.`); + } + if (app.CFBundleExecutable !== DEBUG_EXECUTABLE_NAME) { + throw new CliError( + `Installed debug app executable is ${ + app.CFBundleExecutable ?? "missing" + }; expected ${DEBUG_EXECUTABLE_NAME}. Reinstall the current device-debug IPA.` + ); + } + return app; +} + +async function runLogs(context, options) { + const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicesyslog", "python3"]); + const app = await requireInstalledDebugApp(context, udid); + const args = ["-u", udid, "--no-colors", "-p", String(app.CFBundleExecutable)]; + context.out(`Streaming logs for ${app.CFBundleExecutable}; press Ctrl-C to stop.`); + await runChecked(context, "idevicesyslog", args, { + streamRedactedUdids: context.knownUdids, + ...(options.output ? { redactedStdoutFile: resolve(options.output) } : {}), + }); +} + +async function runCrashes(context, options) { + const destination = resolve(options.positionals[0]); + await mkdir(destination, { recursive: true }); + const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "idevicecrashreport", "python3"]); + const app = await requireInstalledDebugApp(context, udid); + await runChecked( + context, + "idevicecrashreport", + ["-u", udid, "--keep", "--extract", "--filter", String(app.CFBundleExecutable), destination], + { streamRedactedUdids: context.knownUdids } + ); + context.out(`Copied crash reports to ${destination}; reports were kept on the iPhone.`); +} + +async function runUninstall(context, options) { + const udid = await prepareDevice(context, options.udid, ["ideviceinstaller", "python3"]); + const installed = await context.getInstalledDebugApp(context, udid); + if (!installed) { + context.out(`${DEBUG_DISPLAY_NAME} is not installed; nothing to remove.`); + return; + } + await runChecked(context, "ideviceinstaller", ["-u", udid, "-w", "uninstall", DEBUG_BUNDLE_ID], { + streamRedactedUdids: context.knownUdids, + }); + const remaining = await context.getInstalledDebugApp(context, udid); + if (remaining) throw new CliError(`Uninstall completed, but ${DEBUG_DISPLAY_NAME} is still present.`); + context.out(`Removed ${DEBUG_DISPLAY_NAME}. The production app was not touched.`); +} + +function createContext(overrides = {}) { + return { + run: overrides.run ?? spawnCommand, + commandExists: overrides.commandExists ?? defaultCommandExists, + inspectIpa: overrides.inspectIpa ?? inspectIpa, + getInstalledDebugApp: overrides.getInstalledDebugApp ?? getInstalledDebugApp, + now: overrides.now ?? (() => new Date()), + out: overrides.out ?? ((message) => process.stdout.write(`${message}\n`)), + err: overrides.err ?? ((message) => process.stderr.write(`${message}\n`)), + knownUdids: [], + }; +} + +export async function main(argv = process.argv.slice(2), overrides = {}) { + const context = createContext(overrides); + try { + const options = parseArgs(argv); + if (options.help) { + context.out(usage()); + return 0; + } + if (options.command === "doctor") await runDoctor(context, options); + if (options.command === "install") await runInstall(context, options); + if (options.command === "logs") await runLogs(context, options); + if (options.command === "crashes") await runCrashes(context, options); + if (options.command === "uninstall") await runUninstall(context, options); + return 0; + } catch (error) { + const message = redactUdids(error?.message ?? error, context.knownUdids); + context.err(`Error: ${message}`); + return error?.exitCode ?? 1; + } +} + +const isDirectInvocation = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectInvocation) { + process.exitCode = await main(); +} diff --git a/tests/unit/mobile-ios-device.spec.js b/tests/unit/mobile-ios-device.spec.js new file mode 100644 index 00000000..dd675670 --- /dev/null +++ b/tests/unit/mobile-ios-device.spec.js @@ -0,0 +1,491 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + DEBUG_API_URL, + DEBUG_BUNDLE_ID, + DEBUG_DISPLAY_NAME, + DEBUG_EXECUTABLE_NAME, + assertMinimumIosCompatible, + createRedactedLineWriter, + inspectIpa, + main, + parseArgs, + redactUdids, + requiresDeveloperMode, + selectIpaPayload, + selectUsbDevice, + spawnCommand, + validateDevelopmentArtifact, + validateManifest, + verifySha256File, +} from "../../scripts/mobile/ios-device.mjs"; + +const TEST_UDID = "00008110-0012345678901234"; +const temporaryDirectories = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +function validArtifact(overrides = {}) { + const info = { + CFBundleIdentifier: DEBUG_BUNDLE_ID, + CFBundleDisplayName: DEBUG_DISPLAY_NAME, + CFBundleExecutable: DEBUG_EXECUTABLE_NAME, + CFBundleShortVersionString: "0.0.42", + CFBundleVersion: "4201", + MinimumOSVersion: "15.0", + ...overrides.info, + }; + const profile = { + ExpirationDate: "2027-01-01T00:00:00Z", + ProvisionedDevices: [TEST_UDID], + TeamIdentifier: ["TEAM123456"], + Entitlements: { + "application-identifier": `TEAM123456.${DEBUG_BUNDLE_ID}`, + "com.apple.developer.team-identifier": "TEAM123456", + "get-task-allow": true, + ...overrides.entitlements, + }, + ...overrides.profile, + }; + return { info, profile }; +} + +function installedApp(overrides = {}) { + return { + CFBundleIdentifier: DEBUG_BUNDLE_ID, + CFBundleExecutable: DEBUG_EXECUTABLE_NAME, + CFBundleShortVersionString: "0.0.42", + CFBundleVersion: "4201", + ...overrides, + }; +} + +function commandResult(stdout = "", stderr = "", code = 0) { + return { code, stdout: Buffer.from(stdout), stderr: Buffer.from(stderr) }; +} + +function createHarness({ + developerMode = "enabled", + installedSequence = [], + installationProxyError, + pairingError, + productVersion = "27.0", + minimumIos = "15.0", +} = {}) { + const calls = []; + const output = []; + const errors = []; + const installed = [...installedSequence]; + const run = async (command, args, options = {}) => { + calls.push({ command, args, options }); + if (command === "idevice_id") return commandResult(`${TEST_UDID}\n`); + if (command === "idevicepair") { + return pairingError ? commandResult("", pairingError, 1) : commandResult("SUCCESS"); + } + if (command === "idevicedevmodectl") { + return commandResult(`Device DeveloperMode\n${TEST_UDID} ${developerMode}\n`); + } + if (command === "ideviceinfo") { + const key = args.at(-1); + const values = { + ActivationState: "Activated", + ProductType: "iPhone15,4", + ProductVersion: productVersion, + }; + return commandResult(values[key] ?? ""); + } + if (command === "ideviceinstaller") { + if (installationProxyError && args.includes("list")) { + return commandResult("", installationProxyError, 1); + } + return commandResult(); + } + if (command === "idevicesyslog" || command === "idevicecrashreport") { + return commandResult(); + } + throw new Error(`Unexpected command: ${command}`); + }; + return { + calls, + output, + errors, + overrides: { + run, + commandExists: async () => true, + inspectIpa: async (_context, ipaPath) => ({ + ipaPath, + validated: { + executable: DEBUG_EXECUTABLE_NAME, + version: "0.0.42", + build: "4201", + minimumIos, + }, + }), + getInstalledDebugApp: async () => installed.shift() ?? null, + now: () => new Date("2026-07-20T00:00:00Z"), + out: (message) => output.push(message), + err: (message) => errors.push(message), + }, + }; +} + +describe("argument and device selection safety", () => { + it("accepts the documented command shapes", () => { + expect(parseArgs(["doctor", "--udid", TEST_UDID])).toMatchObject({ + command: "doctor", + udid: TEST_UDID, + }); + expect(parseArgs(["install", "build.ipa", "--manifest", "manifest.json"])).toMatchObject({ + command: "install", + manifest: "manifest.json", + positionals: ["build.ipa"], + }); + }); + + it("requires the exact debug identifier for uninstall", () => { + expect(() => parseArgs(["uninstall", "--confirm", "io.truckwash.app"])).toThrow("Refusing to uninstall"); + expect(parseArgs(["uninstall", "--confirm", DEBUG_BUNDLE_ID]).confirm).toBe(DEBUG_BUNDLE_ID); + }); + + it("requires exactly one USB device by default", () => { + expect(() => selectUsbDevice("", undefined)).toThrow("No cable-connected iPhone"); + expect(() => selectUsbDevice("device-a\ndevice-b\n", undefined)).toThrow("Select one explicitly"); + expect(selectUsbDevice("device-a\ndevice-b\n", "device-b").udid).toBe("device-b"); + }); + + it("redacts both modern and legacy UDIDs", () => { + const legacy = "a".repeat(40); + expect(redactUdids(`device ${TEST_UDID} and ${legacy}`)).toBe("device and "); + }); + + it("redacts a UDID split across streamed child-process chunks", () => { + let output = ""; + const writer = createRedactedLineWriter({ write: (value) => (output += value) }, [TEST_UDID]); + writer.write(Buffer.from(`connected: ${TEST_UDID.slice(0, 10)}`)); + writer.write(Buffer.from(`${TEST_UDID.slice(10)}\nready\n`)); + writer.end(); + expect(output).toBe("connected: \nready\n"); + }); + + it("writes real child-process stdout to a file only after UDID redaction", async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-ios-redacted-log-test-")); + temporaryDirectories.push(directory); + const outputPath = join(directory, "device.log"); + const script = `process.stdout.write(${JSON.stringify(`connected:${TEST_UDID}\nmessage\n`)})`; + + await expect( + spawnCommand(process.execPath, ["-e", script], { + streamRedactedUdids: [TEST_UDID], + redactedStdoutFile: outputPath, + }) + ).resolves.toMatchObject({ code: 0 }); + expect(await readFile(outputPath, "utf8")).toBe("connected:\nmessage\n"); + expect((await stat(outputPath)).mode & 0o777).toBe(0o600); + }); + + it("rejects an invalid redacted output path before starting the child", async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-ios-invalid-log-test-")); + temporaryDirectories.push(directory); + const outputPath = join(directory, "missing", "device.log"); + + await expect( + spawnCommand(process.execPath, ["-e", "setInterval(() => {}, 1000)"], { + streamRedactedUdids: [TEST_UDID], + redactedStdoutFile: outputPath, + }) + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +describe("IPA validation", () => { + it.each([ + ["equal versions", "17.0", "17"], + ["newer connected iOS", "15.2.1", "17.0"], + ["prerelease suffixes", "17.0-beta.1", "17.0rc2"], + ])("accepts compatible minimum iOS for %s", (_name, minimumIos, deviceIos) => { + expect(() => assertMinimumIosCompatible(minimumIos, deviceIos)).not.toThrow(); + }); + + it("rejects an IPA requiring newer iOS", () => { + expect(() => assertMinimumIosCompatible("18.0", "17.6.1")).toThrow("IPA requires iOS 18.0"); + }); + + it.each([ + ["malformed minimum", "17..0", "17.0", "IPA minimum iOS version is malformed"], + ["malformed device version", "17.0", "version-17", "Connected iPhone iOS version is malformed"], + ["missing minimum", "", "17.0", "IPA minimum iOS version is malformed"], + ])("rejects %s", (_name, minimumIos, deviceIos, message) => { + expect(() => assertMinimumIosCompatible(minimumIos, deviceIos)).toThrow(message); + }); + + it("accepts a valid development artifact", () => { + expect(validateDevelopmentArtifact(validArtifact(), TEST_UDID, new Date("2026-07-20T00:00:00Z"))).toMatchObject({ + bundleId: DEBUG_BUNDLE_ID, + executable: DEBUG_EXECUTABLE_NAME, + version: "0.0.42", + build: "4201", + }); + }); + + it.each([ + ["production bundle", { info: { CFBundleIdentifier: "io.truckwash.app" } }, "expected io.truckwash.app.debug"], + ["production executable", { info: { CFBundleExecutable: "App" } }, `expected ${DEBUG_EXECUTABLE_NAME}`], + ["App Store profile", { entitlements: { "get-task-allow": false } }, "get-task-allow"], + [ + "ad-hoc profile", + { entitlements: { "get-task-allow": false }, profile: { ProvisionedDevices: [TEST_UDID] } }, + "get-task-allow", + ], + ["missing device", { profile: { ProvisionedDevices: ["another-device"] } }, "not included"], + ["expired profile", { profile: { ExpirationDate: "2026-01-01T00:00:00Z" } }, "expired"], + [ + "wrong application identifier", + { entitlements: { "application-identifier": "TEAM123456.io.other.app" } }, + "application identifier", + ], + ["wrong team identifier", { profile: { TeamIdentifier: ["OTHERTEAM"] } }, "entitlement team identifier"], + ])("rejects a %s", (_name, overrides, message) => { + expect(() => + validateDevelopmentArtifact(validArtifact(overrides), TEST_UDID, new Date("2026-07-20T00:00:00Z")) + ).toThrow(message); + }); + + it("requires exactly one top-level app payload and its profile", () => { + expect(selectIpaPayload(["Payload/App.app/Info.plist", "Payload/App.app/embedded.mobileprovision"])).toMatchObject({ + appRoot: "Payload/App.app", + }); + expect(() => + selectIpaPayload([ + "Payload/App.app/Info.plist", + "Payload/Other.app/Info.plist", + "Payload/App.app/embedded.mobileprovision", + ]) + ).toThrow("exactly one app payload"); + expect(() => selectIpaPayload(["Payload/App.app/Info.plist"])).toThrow("embedded provisioning profile"); + }); + + it("compares manifest identity and version fields with the IPA", () => { + const artifact = validArtifact(); + expect(() => + validateManifest( + { + schema_version: 1, + signing_method: "development", + repository: "copenhagentruckwash/pleno-vue", + source_ref: "agent/ios-device-debug", + source_sha: "a".repeat(40), + api_url: DEBUG_API_URL, + release_manager_control_api_url: DEBUG_API_URL, + bundle_id: DEBUG_BUNDLE_ID, + display_name: DEBUG_DISPLAY_NAME, + executable_name: DEBUG_EXECUTABLE_NAME, + version: "0.0.42", + build: "4201", + minimum_ios: "15.0", + profile_expiration_utc: "2027-01-01T00:00:00Z", + ipa_filename: "debug.ipa", + }, + artifact, + "/tmp/debug.ipa" + ) + ).not.toThrow(); + expect(() => + validateManifest({ schema_version: 1, signing_method: "development" }, artifact, "/tmp/debug.ipa") + ).toThrow("repository"); + expect(() => + validateManifest({ schema_version: 1, signing_method: "app-store" }, artifact, "/tmp/debug.ipa") + ).toThrow("signing method"); + }); + + it("requires Developer Mode only on iOS 16 and newer", () => { + expect(requiresDeveloperMode("15.8.4")).toBe(false); + expect(requiresDeveloperMode("16.0")).toBe(true); + expect(requiresDeveloperMode("27.0 beta")).toBe(true); + }); + + it("verifies SHA256SUMS and rejects a mismatch", async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-ios-test-")); + temporaryDirectories.push(directory); + const ipaPath = join(directory, "debug.ipa"); + const checksumPath = join(directory, "SHA256SUMS"); + const content = Buffer.from("fake IPA fixture"); + await writeFile(ipaPath, content); + const digest = createHash("sha256").update(content).digest("hex"); + await writeFile(checksumPath, `${digest} debug.ipa\n`); + await expect(verifySha256File(ipaPath, checksumPath)).resolves.toBeUndefined(); + await writeFile(checksumPath, `${"0".repeat(64)} debug.ipa\n`); + await expect(verifySha256File(ipaPath, checksumPath)).rejects.toThrow("Checksum verification failed"); + expect(await readFile(ipaPath, "utf8")).toBe("fake IPA fixture"); + }); + + it("requires the workflow manifest and checksums before inspecting an IPA", async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-ios-artifact-test-")); + temporaryDirectories.push(directory); + const ipaPath = join(directory, "debug.ipa"); + await writeFile(ipaPath, "fixture"); + const context = { commandExists: async () => true }; + + await expect(inspectIpa(context, ipaPath, { connectedUdid: TEST_UDID })).rejects.toThrow("Manifest file not found"); + await writeFile(join(directory, "manifest.json"), "{}"); + await expect(inspectIpa(context, ipaPath, { connectedUdid: TEST_UDID })).rejects.toThrow("Checksum file not found"); + }); +}); + +describe("device commands", () => { + it("doctor checks pairing, activation, installation access, and Developer Mode", async () => { + const harness = createHarness(); + await expect(main(["doctor"], harness.overrides)).resolves.toBe(0); + expect(harness.calls.map(({ command }) => command)).toEqual( + expect.arrayContaining(["idevice_id", "idevicepair", "ideviceinfo", "ideviceinstaller", "idevicedevmodectl"]) + ); + expect(harness.output.join("\n")).not.toContain(TEST_UDID); + expect(harness.output.join("\n")).toContain("Developer Mode: enabled"); + }); + + it("doctor gives targeted guidance when Developer Mode is disabled", async () => { + const harness = createHarness({ developerMode: "disabled" }); + await expect(main(["doctor"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain("Developer Mode is disabled"); + expect(harness.errors.join("\n")).not.toContain(TEST_UDID); + }); + + it("doctor accepts an iOS 15 device without invoking Developer Mode tooling", async () => { + const harness = createHarness({ developerMode: "disabled", productVersion: "15.8.4" }); + await expect(main(["doctor"], harness.overrides)).resolves.toBe(0); + expect(harness.calls.some(({ command }) => command === "idevicedevmodectl")).toBe(false); + expect(harness.output.join("\n")).toContain("not required before iOS 16"); + }); + + it("doctor gives targeted guidance when the phone is locked", async () => { + const harness = createHarness({ + installationProxyError: "Could not connect: device is password protected and locked", + }); + await expect(main(["doctor"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain("iPhone is locked"); + expect(harness.errors.join("\n")).not.toContain(TEST_UDID); + }); + + it("doctor gives targeted guidance when trust is invalid", async () => { + const harness = createHarness({ pairingError: "ERROR: Invalid HostID / pairing record" }); + await expect(main(["doctor"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain("Pairing is not valid"); + expect(harness.errors.join("\n")).not.toContain(TEST_UDID); + }); + + it.each([ + ["install", null], + ["upgrade", installedApp()], + ])("chooses %s and verifies the installed version", async (action, initialApp) => { + const harness = createHarness({ + installedSequence: [initialApp, installedApp()], + }); + await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(0); + expect( + harness.calls.some( + ({ command, args }) => + command === "ideviceinstaller" && args.includes(action) && args.includes("/tmp/debug.ipa") + ) + ).toBe(true); + expect(harness.output.at(-1)).toContain("0.0.42 (4201)"); + }); + + it("rejects an incompatible device before any install or upgrade mutation", async () => { + const harness = createHarness({ productVersion: "14.8.1", minimumIos: "15.0" }); + await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain("IPA requires iOS 15.0"); + const mutatingCalls = harness.calls.filter( + ({ command, args }) => + command === "ideviceinstaller" && + (args.includes("install") || args.includes("upgrade") || args.includes("uninstall")) + ); + expect(mutatingCalls).toHaveLength(0); + }); + + it("rejects a post-install version mismatch", async () => { + const harness = createHarness({ + installedSequence: [null, installedApp({ CFBundleVersion: "wrong" })], + }); + await expect(main(["install", "/tmp/debug.ipa"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain("Installed version verification failed"); + }); + + it("filters logs by the exact installed executable", async () => { + const harness = createHarness({ installedSequence: [installedApp()] }); + await expect(main(["logs", "--output", "device.log"], harness.overrides)).resolves.toBe(0); + const call = harness.calls.find(({ command }) => command === "idevicesyslog"); + expect(call.args).toEqual(expect.arrayContaining(["-p", DEBUG_EXECUTABLE_NAME])); + expect(call.args).not.toContain("--output"); + expect(call.options).toMatchObject({ + streamRedactedUdids: [TEST_UDID], + redactedStdoutFile: expect.stringMatching(/device\.log$/u), + }); + }); + + it("copies and extracts only debug-app crashes while keeping them on-device", async () => { + const directory = await mkdtemp(join(tmpdir(), "mobile-ios-crashes-test-")); + temporaryDirectories.push(directory); + const harness = createHarness({ installedSequence: [installedApp({ CFBundleExecutable: DEBUG_EXECUTABLE_NAME })] }); + await expect(main(["crashes", directory], harness.overrides)).resolves.toBe(0); + const call = harness.calls.find(({ command }) => command === "idevicecrashreport"); + expect(call.args).toEqual( + expect.arrayContaining(["--keep", "--extract", "--filter", DEBUG_EXECUTABLE_NAME, directory]) + ); + expect(call.args).not.toContain("--remove-all"); + }); + + it("refuses log collection from an older debug build with the production executable name", async () => { + const harness = createHarness({ installedSequence: [installedApp({ CFBundleExecutable: "App" })] }); + await expect(main(["logs"], harness.overrides)).resolves.toBe(1); + expect(harness.errors.join("\n")).toContain(`expected ${DEBUG_EXECUTABLE_NAME}`); + expect(harness.calls.some(({ command }) => command === "idevicesyslog")).toBe(false); + }); + + it("uninstalls only the debug bundle after exact confirmation", async () => { + const harness = createHarness({ installedSequence: [installedApp(), null] }); + await expect(main(["uninstall", "--confirm", DEBUG_BUNDLE_ID], harness.overrides)).resolves.toBe(0); + const call = harness.calls.find( + ({ command, args }) => command === "ideviceinstaller" && args.includes("uninstall") + ); + expect(call.args.at(-1)).toBe(DEBUG_BUNDLE_ID); + expect(call.args).not.toContain("io.truckwash.app"); + }); + + it("does not touch the device when uninstall confirmation is wrong", async () => { + const harness = createHarness(); + await expect(main(["uninstall", "--confirm", "io.truckwash.app"], harness.overrides)).resolves.toBe(1); + expect(harness.calls).toHaveLength(0); + }); +}); + +describe("repository device-debug configuration", () => { + it("keeps Debug and Release as distinct installable apps", async () => { + const project = await readFile("ios/App/App.xcodeproj/project.pbxproj", "utf8"); + const debugTarget = project.match(/504EC3171FED79650016851F \/\* Debug \*\/ = \{[\s\S]*?\n\t\t\};/u)?.[0]; + const releaseTarget = project.match(/504EC3181FED79650016851F \/\* Release \*\/ = \{[\s\S]*?\n\t\t\};/u)?.[0]; + const infoPlist = await readFile("ios/App/App/Info.plist", "utf8"); + + expect(debugTarget).toContain('APP_DISPLAY_NAME = "Truck Wash Debug";'); + expect(debugTarget).toContain(`PRODUCT_BUNDLE_IDENTIFIER = ${DEBUG_BUNDLE_ID};`); + expect(debugTarget).toContain(`PRODUCT_NAME = ${DEBUG_EXECUTABLE_NAME};`); + expect(releaseTarget).toContain('APP_DISPLAY_NAME = "Truck Wash";'); + expect(releaseTarget).toContain("PRODUCT_BUNDLE_IDENTIFIER = io.truckwash.app;"); + expect(infoPlist).toContain("$(APP_DISPLAY_NAME)"); + }); + + it("keeps signing secrets behind the master-only environment workflow", async () => { + const workflow = await readFile(".github/workflows/ios-device-debug.yml", "utf8"); + + expect(workflow).toContain('if [[ "$WORKFLOW_REF" != "refs/heads/master" ]]'); + expect(workflow).toContain("name: mobile-device-debug"); + expect(workflow).toContain("RELEASE_COMMIT_SHA: ${{ env.RESOLVED_SOURCE_SHA }}"); + expect(workflow).toContain("IOS_DEBUG_CERTIFICATE_BASE64: ${{ secrets.IOS_DEBUG_CERTIFICATE_BASE64 }}"); + expect(workflow).not.toContain("IOS_DEBUG_KEYCHAIN_PASSWORD"); + expect(workflow).not.toMatch(/upload-app|notarytool|transporter/iu); + }); +});