updates
This commit is contained in:
+4
-3
@@ -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()
|
||||
|
||||
+44
-3
@@ -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<Void, Never>?
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<string>2</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSFaceIDUsageDescription</key>
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
+3
-2
@@ -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"
|
||||
|
||||
+19
-1
@@ -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.
|
||||
|
||||
+6
-2
@@ -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"
|
||||
|
||||
Executable
+6
@@ -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
|
||||
Executable
+73
@@ -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
|
||||
+8
-16
@@ -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"
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user