diff --git a/Tarnax/App.swift b/Tarnax/App.swift index 8e1c0e2..d0bee37 100644 --- a/Tarnax/App.swift +++ b/Tarnax/App.swift @@ -7,8 +7,8 @@ import UniformTypeIdentifiers var body: some Scene { WindowGroup { RootView().environmentObject(model).tint(.indigo) - .onChange(of: phase) { _, value in - if value == .background && model.biometric { model.locked = true } + .onChange(of: phase, initial: true) { _, value in + Task { await model.updateScene(value) } } } } @@ -25,7 +25,8 @@ struct RootView: View { Group { if model.locked { ContentUnavailableView { Label("Tarnax is locked", systemImage: "lock.shield") } actions: { - Button("Unlock") { Task { await model.unlock() } }.buttonStyle(.borderedProminent) + Button(model.authenticating ? "Authenticating…" : "Unlock") { Task { await model.unlock() } } + .buttonStyle(.borderedProminent).disabled(model.authenticating) } } else if model.restoringSession { TarnaxSplashView() diff --git a/Tarnax/Core.swift b/Tarnax/Core.swift index d49fccd..7c62c66 100644 --- a/Tarnax/Core.swift +++ b/Tarnax/Core.swift @@ -142,6 +142,14 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable { @Published var lastRefresh: Date? @Published var locked = UserDefaults.standard.bool(forKey: "biometricLock") @Published var biometric = UserDefaults.standard.bool(forKey: "biometricLock") + @Published private(set) var authenticating = false + private var automaticUnlockAttempted = false + private var foreground = false + private var lockCycle = 0 + private var unlockContext: LAContext? + var authenticate: (LAContext) async throws -> Bool = { context in + try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Tarnax") + } @Published private(set) var preferences: [String: Any] = [:] @Published private(set) var preferencesLoading = false private var preferencesTask: Task? @@ -237,12 +245,45 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable { let endpoint = action == "pause" ? (modern ? "stop" : "pause") : action == "resume" ? (modern ? "start" : "resume") : action await perform("torrents/" + endpoint, ["hashes": hash]) } + func updateScene(_ phase: ScenePhase) async { + foreground = phase == .active + if phase == .background && biometric { + lockCycle += 1 + automaticUnlockAttempted = false + locked = true + unlockContext?.invalidate() + } + if phase == .active { await unlockAutomaticallyIfNeeded() } + } + private func unlockAutomaticallyIfNeeded() async { + guard foreground, locked, biometric, !authenticating, !automaticUnlockAttempted else { return } + automaticUnlockAttempted = true + await unlock() + } func unlock() async { + guard locked, !authenticating else { return } + authenticating = true + let cycle = lockCycle + let context = LAContext() + unlockContext = context + defer { + authenticating = false + unlockContext = nil + // A previous prompt can finish after a new background/foreground cycle. + if cycle != lockCycle && foreground { + Task { await self.unlockAutomaticallyIfNeeded() } + } + } do { - let context = LAContext() - guard try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Tarnax") else { return } + guard try await authenticate(context), cycle == lockCycle else { return } locked = false - } catch { self.error = error.localizedDescription } + } catch { + guard cycle == lockCycle else { return } + let failure = error as NSError + let expected = failure.domain == LAError.errorDomain && + [LAError.userCancel.rawValue, LAError.appCancel.rawValue, LAError.systemCancel.rawValue].contains(failure.code) + if !expected && !(error is CancellationError) { self.error = error.localizedDescription } + } } func enableLock(_ enabled: Bool) async { if enabled { diff --git a/Tarnax/Info.plist b/Tarnax/Info.plist index 474b529..95fafb6 100644 --- a/Tarnax/Info.plist +++ b/Tarnax/Info.plist @@ -15,7 +15,7 @@ CFBundleShortVersionString 1.0 CFBundleVersion - 1 + 2 LSRequiresIPhoneOS NSFaceIDUsageDescription diff --git a/Tests/ContractTests.swift b/Tests/ContractTests.swift index f58be73..4f89803 100644 --- a/Tests/ContractTests.swift +++ b/Tests/ContractTests.swift @@ -1,4 +1,6 @@ import Foundation +import SwiftUI +import LocalAuthentication final class MockProtocol: URLProtocol, @unchecked Sendable { static var handler: ((URLRequest) throws -> (Int, String))! @@ -152,6 +154,50 @@ final class MockProtocol: URLProtocol, @unchecked Sendable { disconnected.disconnect() await disconnected.restoreSessionIfNeeded() check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch") + let protection = AppModel() + protection.locked = true; protection.biometric = true + var authCalls = 0 + protection.authenticate = { _ in authCalls += 1; return true } + await protection.updateScene(.active) + check(!protection.locked && authCalls == 1, "Face ID starts automatically on opening the locked app") + await protection.updateScene(.inactive) + await protection.updateScene(.active) + check(authCalls == 1, "Authentication inactive/active transitions do not prompt again") + await protection.updateScene(.background) + check(protection.locked, "Background locks protected app") + protection.authenticate = { _ in authCalls += 1; throw LAError(.userCancel) } + await protection.updateScene(.active) + check(protection.locked && protection.error == nil, "Cancelled Face ID stays locked without an alert") + await protection.updateScene(.inactive) + await protection.updateScene(.active) + check(authCalls == 2, "Cancelled authentication does not automatically loop") + protection.authenticate = { _ in authCalls += 1; return true } + await protection.unlock() + check(!protection.locked && authCalls == 3, "Manual unlock remains available after cancellation") + await protection.updateScene(.background) + await protection.updateScene(.active) + check(!protection.locked && authCalls == 4, "Returning from background automatically authenticates again") + await protection.updateScene(.background) + protection.authenticate = { _ in + authCalls += 1 + await protection.unlock() + return true + } + await protection.updateScene(.active) + check(authCalls == 5 && !protection.authenticating, "Overlapping unlock attempts share one authentication prompt") + await protection.updateScene(.background) + protection.authenticate = { _ in + await protection.updateScene(.background) + return true + } + await protection.updateScene(.active) + check(protection.locked, "An old authentication result cannot unlock after backgrounding") + protection.authenticate = { _ in + await protection.updateScene(.inactive) + return true + } + await protection.updateScene(.active) + check(!protection.locked, "Face ID success while system prompt is inactive unlocks the app") another.close(); transport.close(); print("\(checks) contract checks passed.") } } diff --git a/Tests/ProfileTests.py b/Tests/ProfileTests.py new file mode 100644 index 0000000..2a87f96 --- /dev/null +++ b/Tests/ProfileTests.py @@ -0,0 +1,46 @@ +import copy +import datetime +import importlib.util +import pathlib +import unittest + +spec = importlib.util.spec_from_file_location('verify_profile', pathlib.Path(__file__).resolve().parents[1] / 'scripts/verify_profile.py') +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + +class ProfileTests(unittest.TestCase): + def setUp(self): + self.app = {'CFBundleIdentifier': 'net.absdev.Tarnax'} + self.profile = { + 'ApplicationIdentifierPrefix': ['TESTTEAM'], + 'Entitlements': {'application-identifier': 'TESTTEAM.net.absdev.Tarnax'}, + 'ExpirationDate': datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=1), + 'ProvisionedDevices': ['test-phone'], + } + + def test_exact(self): + module.validate(self.app, self.profile, 'test-phone', True) + + def test_wildcard(self): + for identifier in ['TESTTEAM.*', 'TESTTEAM.net.absdev.*']: + with self.subTest(identifier=identifier): + self.profile['Entitlements']['application-identifier'] = identifier + module.validate(self.app, self.profile, 'test-phone') + + def test_rejections(self): + for mutation, options in [ + ({'Entitlements': {'application-identifier': 'OTHERTEAM.*'}}, {}), + ({'Entitlements': {'application-identifier': 'TESTTEAM.net.other.*'}}, {}), + ({'ProvisionedDevices': ['other-phone']}, {'device': 'test-phone'}), + ({'ExpirationDate': datetime.datetime(2000, 1, 1)}, {}), + ({'ProvisionedDevices': []}, {}), + ({'Entitlements': {'application-identifier': 'TESTTEAM.*', 'get-task-allow': True}}, {'distribution': True}), + ]: + with self.subTest(mutation=mutation): + profile = copy.deepcopy(self.profile) + profile.update(mutation) + with self.assertRaises(ValueError): + module.validate(self.app, profile, **options) + +if __name__ == '__main__': + unittest.main() diff --git a/Tests/run.sh b/Tests/run.sh index e48b74a..57e0ae4 100755 --- a/Tests/run.sh +++ b/Tests/run.sh @@ -1,7 +1,8 @@ -#!/bin/sh +#!/bin/bash set -eu cd "$(dirname "$0")/.." -BUILD_DIR="$(mktemp -d)" +source scripts/common.sh +BUILD_DIR="$(mktemp -d "$TARNAX_SCRATCH_ROOT/contracts.XXXXXX")" trap 'rm -rf "$BUILD_DIR"' EXIT xcrun swiftc -parse-as-library -module-cache-path "$BUILD_DIR/cache" Tarnax/Core.swift Tests/ContractTests.swift -o "$BUILD_DIR/ContractTests" "$BUILD_DIR/ContractTests" diff --git a/scripts/README.md b/scripts/README.md index d437423..dfa05d1 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -20,10 +20,28 @@ Provisioning updates are disabled by default. If you explicitly want Xcode to co Outputs: -- Builds: `build/Debug/` or `build/Release/`, including `build.log` and DerivedData. +- Builds: `build/Debug/` or `build/Release/`, including `build.log` and `Tarnax.app`. - Releases: a unique folder under `build/releases/`, containing `Tarnax.xcarchive`, `ExportOptions.plist`, logs, and `export/Tarnax.ipa` with a SHA-256 checksum. - Override roots with `BUILD_OUTPUT_DIR` or `RELEASE_OUTPUT_DIR`. Release export fails on any archive/export/signature/profile verification error. No unsigned ZIP is passed off as an installable IPA. Profiles include device identifiers: share only the intended IPA with testers and keep the remaining release diagnostics private. The current contract harness exercises mocked transport and model behavior. It does not sign in to a live server or exercise the physical-device UI. This project has no XCTest target; the script compiles and runs the existing executable contract harness. + +## Install on your iPhone + +Connect and unlock your iPhone, trust this Mac and enable Developer Mode when required. Pair the device in Xcode's Devices and Simulators first. + +```sh +./scripts/deploy.sh --list +./scripts/deploy.sh # Build signed Release, install and launch +./scripts/deploy.sh --device "iPhone name" # Select when multiple phones are paired +./scripts/deploy.sh --ipa /path/Tarnax.ipa # Install an already exported IPA +./scripts/deploy.sh --app /path/Tarnax.app --no-launch +``` + +The script selects only physical, paired iPhones; it never installs on every device. It checks the signature, app identifier, profile expiry and that the chosen phone is provisioned before installing. `TARNAX_DEVICE` can supply a default device identifier. It does not automatically modify Apple account provisioning. A locked or unreachable phone causes a clear device-tool failure; unlock/reconnect and rerun. Updating Tarnax keeps existing app data; launch replaces an already-running Tarnax process. + +## Temporary files + +All script-created temporary files and compiler intermediates use `~/Developer/Scratch/Tarnax/`, including DerivedData, test binaries, caches, IPA verification and deployment extraction. `TMPDIR` is also set there for child tools. Override with `TARNAX_SCRATCH_ROOT` if needed. You can delete this directory when no build, test or deploy script is running. Final apps, archives, IPAs and logs remain in the project `build/` directory. Apple services may maintain their own system caches outside this location. diff --git a/scripts/build.sh b/scripts/build.sh index 12ab391..515ac5c 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,6 +1,7 @@ #!/bin/bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/common.sh" CONFIGURATION=Debug SIGNED=NO while (($#)); do @@ -14,8 +15,11 @@ while (($#)); do done OUTPUT="${BUILD_OUTPUT_DIR:-$ROOT/build/$CONFIGURATION}" mkdir -p "$OUTPUT" +DERIVED_DATA="$(mktemp -d "$TARNAX_SCRATCH_ROOT/build-$CONFIGURATION.XXXXXX")" xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \ -configuration "$CONFIGURATION" -destination 'generic/platform=iOS' \ - -derivedDataPath "$OUTPUT/DerivedData" CODE_SIGNING_ALLOWED="$SIGNED" \ + -derivedDataPath "$DERIVED_DATA" CODE_SIGNING_ALLOWED="$SIGNED" \ build 2>&1 | tee "$OUTPUT/build.log" -echo "App: $OUTPUT/DerivedData/Build/Products/$CONFIGURATION-iphoneos/Tarnax.app" +ditto "$DERIVED_DATA/Build/Products/$CONFIGURATION-iphoneos/Tarnax.app" "$OUTPUT/Tarnax.app" +echo "App: $OUTPUT/Tarnax.app" +echo "Temporary build files: $DERIVED_DATA" diff --git a/scripts/common.sh b/scripts/common.sh new file mode 100755 index 0000000..2acb4db --- /dev/null +++ b/scripts/common.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# Shared scratch location; final products remain in the project build directory. +TARNAX_SCRATCH_ROOT="${TARNAX_SCRATCH_ROOT:-$HOME/Developer/Scratch/Tarnax}" +mkdir -p "$TARNAX_SCRATCH_ROOT/tmp" +export TMPDIR="$TARNAX_SCRATCH_ROOT/tmp/" +export TARNAX_SCRATCH_ROOT diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..0edb809 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,73 @@ +#!/bin/bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/common.sh" +DEVICE="${TARNAX_DEVICE:-}" +IPA='' +APP='' +LAUNCH=YES +LIST=NO +while (($#)); do + case "$1" in + --device|--ipa|--app) + [[ $# -ge 2 ]] || { echo "$1 needs a value" >&2; exit 2; } + case "$1" in --device) DEVICE="$2" ;; --ipa) IPA="$2" ;; --app) APP="$2" ;; esac + shift ;; + --no-launch) LAUNCH=NO ;; + --list) LIST=YES ;; + -h|--help) + echo 'Usage: scripts/deploy.sh [--device ID_OR_NAME] [--ipa FILE | --app BUNDLE] [--no-launch]' + echo ' scripts/deploy.sh --list' + echo 'Default: build signed Release, install on the single paired physical iPhone and launch.' + exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 2 ;; + esac + shift +done +[[ -z "$IPA" || -z "$APP" ]] || { echo 'Choose --ipa or --app, not both.' >&2; exit 2; } +if [[ "$LIST" == YES ]]; then exec xcrun devicectl list devices; fi +SCRATCH="$(mktemp -d "$TARNAX_SCRATCH_ROOT/deploy.XXXXXX")" +trap 'rm -rf "$SCRATCH"' EXIT +xcrun devicectl list devices --json-output "$SCRATCH/devices.json" >/dev/null +SELECTION="$(python3 - "$SCRATCH/devices.json" "$DEVICE" <<'PY' +import json,sys +with open(sys.argv[1]) as f:devices=json.load(f)['result']['devices'] +requested=sys.argv[2] +candidates=[] +for d in devices: + p=d.get('properties',{}) + h=p.get('hardware',d.get('hardwareProperties',{})) + c=p.get('connection',d.get('connectionProperties',{})) + state=p.get('state',d.get('deviceProperties',{})) + if h.get('reality')!='physical' or h.get('deviceType')!='iPhone':continue + if c.get('pairingState')!='paired':continue + identifiers=[d.get('identifier'),h.get('udid'),state.get('name')] + if requested and requested not in identifiers:continue + candidates.append((d['identifier'],h['udid'])) +if len(candidates)!=1: + sys.exit('Select one paired physical iPhone with --device. Run scripts/deploy.sh --list; connect, unlock and trust your iPhone in Xcode if needed.') +print('\t'.join(candidates[0])) +PY +)" +IFS=$'\t' read -r DEVICE_ID DEVICE_UDID <<< "$SELECTION" +if [[ -n "$IPA" ]]; then + [[ -f "$IPA" ]] || { echo "IPA not found: $IPA" >&2; exit 1; } + unzip -q "$IPA" -d "$SCRATCH/unpacked" + APP="$SCRATCH/unpacked/Payload/Tarnax.app" +elif [[ -z "$APP" ]]; then + BUILD_OUTPUT_DIR="$ROOT/build/Deploy" bash "$ROOT/scripts/build.sh" --release --signed + APP="$ROOT/build/Deploy/Tarnax.app" +fi +[[ -d "$APP" ]] || { echo "App bundle not found: $APP" >&2; exit 1; } +codesign --verify --deep --strict "$APP" +security cms -D -i "$APP/embedded.mobileprovision" > "$SCRATCH/profile.plist" +python3 "$ROOT/scripts/verify_profile.py" "$APP/Info.plist" "$SCRATCH/profile.plist" --device "$DEVICE_UDID" + +xcrun devicectl device install app --device "$DEVICE_ID" "$APP" --timeout 120 +echo 'Tarnax installed successfully.' +if [[ "$LAUNCH" == YES ]]; then + if ! xcrun devicectl device process launch --device "$DEVICE_ID" --terminate-existing net.absdev.Tarnax --timeout 60; then + echo 'Installation succeeded, but launch failed. Unlock your iPhone and open Tarnax, or rerun with --no-launch.' >&2 + exit 1 + fi +fi diff --git a/scripts/release.sh b/scripts/release.sh index ad8794f..8da4109 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,6 +1,7 @@ #!/bin/bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/common.sh" METHOD=release-testing PROVISIONING=() while (($#)); do @@ -22,6 +23,7 @@ TEAM="${DEVELOPMENT_TEAM:-39R6U3437D}" BASE="${RELEASE_OUTPUT_DIR:-$ROOT/build/releases}" mkdir -p "$BASE" OUTPUT="$(mktemp -d "$BASE/$(date +%Y%m%d-%H%M%S)-$METHOD.XXXXXX")" +DERIVED_DATA="$(mktemp -d "$TARNAX_SCRATCH_ROOT/release-derived.XXXXXX")" ARCHIVE="$OUTPUT/Tarnax.xcarchive" EXPORT="$OUTPUT/export" echo "Release output: $OUTPUT" @@ -34,7 +36,7 @@ with open(sys.argv[1], 'wb') as f: PY xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \ -configuration Release -destination 'generic/platform=iOS' \ - -derivedDataPath "$OUTPUT/DerivedData" -archivePath "$ARCHIVE" \ + -derivedDataPath "$DERIVED_DATA" -archivePath "$ARCHIVE" \ DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic \ ${PROVISIONING[@]+"${PROVISIONING[@]}"} archive 2>&1 | tee "$OUTPUT/archive.log" xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ @@ -42,27 +44,17 @@ xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \ ${PROVISIONING[@]+"${PROVISIONING[@]}"} 2>&1 | tee "$OUTPUT/export.log" IPA="$EXPORT/Tarnax.ipa" [[ -f "$IPA" ]] || { echo 'Export finished without Tarnax.ipa' >&2; exit 1; } -VERIFY="$(mktemp -d "$OUTPUT/verify.XXXXXX")" +VERIFY="$(mktemp -d "$TARNAX_SCRATCH_ROOT/verify.XXXXXX")" trap 'rm -rf "$VERIFY"' EXIT unzip -q "$IPA" -d "$VERIFY" APP="$VERIFY/Payload/Tarnax.app" codesign --verify --deep --strict "$APP" [[ -f "$APP/embedded.mobileprovision" ]] || { echo 'No embedded provisioning profile' >&2; exit 1; } security cms -D -i "$APP/embedded.mobileprovision" > "$VERIFY/profile.plist" -python3 - "$APP/Info.plist" "$VERIFY/profile.plist" "$METHOD" <<'PY' -import plistlib, sys, datetime -with open(sys.argv[1],'rb') as f: app=plistlib.load(f) -with open(sys.argv[2],'rb') as f: profile=plistlib.load(f) -assert app['CFBundleIdentifier']=='net.absdev.Tarnax', 'Unexpected app identifier' -assert profile.get('ProvisionedDevices'), 'Profile does not include registered devices' -assert profile['ExpirationDate']>datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None), 'Profile expired' -assert profile['Entitlements']['application-identifier'].endswith('.'+app['CFBundleIdentifier']), 'Profile/app mismatch' -if sys.argv[3]=='release-testing': - assert not profile['Entitlements'].get('get-task-allow',False), 'Distribution IPA has debugging enabled' -print('Verified:', app['CFBundleDisplayName'], app['CFBundleShortVersionString'], 'build', app['CFBundleVersion']) -print('Registered devices:',len(profile['ProvisionedDevices'])) -print('Profile expiry:',profile['ExpirationDate'].isoformat()+'Z') -PY +PROFILE_FLAGS=() +if [[ "$METHOD" == release-testing ]]; then PROFILE_FLAGS=(--distribution); fi +python3 "$ROOT/scripts/verify_profile.py" "$APP/Info.plist" "$VERIFY/profile.plist" ${PROFILE_FLAGS[@]+"${PROFILE_FLAGS[@]}"} + (cd "$EXPORT" && shasum -a 256 Tarnax.ipa > Tarnax.ipa.sha256) echo "Verified IPA: $IPA" echo "SHA-256: $EXPORT/Tarnax.ipa.sha256" diff --git a/scripts/test.sh b/scripts/test.sh index 6e1b963..dff4240 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -1,6 +1,7 @@ #!/bin/bash set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/common.sh" CONTRACTS_ONLY=NO case "${1:-}" in --contracts-only) CONTRACTS_ONLY=YES ;; @@ -12,6 +13,7 @@ if (($# > 1)); then echo 'Too many arguments' >&2; exit 2; fi cd "$ROOT" plutil -lint Tarnax/Info.plist Tarnax.xcodeproj/project.pbxproj for script in scripts/*.sh Tests/run.sh; do bash -n "$script"; done +PYTHONDONTWRITEBYTECODE=1 python3 Tests/ProfileTests.py bash Tests/run.sh if [[ "$CONTRACTS_ONLY" == NO ]]; then bash scripts/build.sh; fi echo 'All requested checks passed.' diff --git a/scripts/verify_profile.py b/scripts/verify_profile.py new file mode 100644 index 0000000..d8d01cb --- /dev/null +++ b/scripts/verify_profile.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Validate Tarnax's embedded profile, including Apple's wildcard App IDs.""" +import argparse +import datetime +import fnmatch +import plistlib +import sys + + +def validate(app, profile, device=None, distribution=False): + bundle = app.get('CFBundleIdentifier') + if bundle != 'net.absdev.Tarnax': + raise ValueError('This script installs only net.absdev.Tarnax.') + entitlements = profile.get('Entitlements', {}) + pattern = entitlements.get('application-identifier', '') + # The App ID prefix can differ from the Team ID on older Apple accounts. + prefixes = profile.get('ApplicationIdentifierPrefix', []) + if not pattern or not any(fnmatch.fnmatchcase(prefix + '.' + bundle, pattern) for prefix in prefixes): + raise ValueError('Profile does not match Tarnax.') + expiry = profile.get('ExpirationDate') + if expiry is None: + raise ValueError('Profile has no expiration date.') + if expiry.tzinfo is None: + expiry = expiry.replace(tzinfo=datetime.timezone.utc) + if expiry <= datetime.datetime.now(datetime.timezone.utc): + raise ValueError('The app provisioning profile has expired.') + devices = profile.get('ProvisionedDevices', []) + if not devices: + raise ValueError('Profile does not include registered devices.') + if device and device not in devices: + raise ValueError('This iPhone is not included in the app provisioning profile. Register it and export again.') + if distribution and entitlements.get('get-task-allow', False): + raise ValueError('Distribution IPA has debugging enabled.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('app_plist') + parser.add_argument('profile_plist') + parser.add_argument('--device') + parser.add_argument('--distribution', action='store_true') + args = parser.parse_args() + try: + with open(args.app_plist, 'rb') as f: + app = plistlib.load(f) + with open(args.profile_plist, 'rb') as f: + profile = plistlib.load(f) + validate(app, profile, args.device, args.distribution) + except (ValueError, OSError, plistlib.InvalidFileException) as error: + sys.exit(str(error)) + print('Verified Tarnax', app['CFBundleShortVersionString'], 'build', app['CFBundleVersion']) + print('Registered devices:', len(profile['ProvisionedDevices'])) + print('Profile expiry:', profile['ExpirationDate'].isoformat()) + + +if __name__ == '__main__': + main()