updates
This commit is contained in:
+4
-3
@@ -7,8 +7,8 @@ import UniformTypeIdentifiers
|
|||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
RootView().environmentObject(model).tint(.indigo)
|
RootView().environmentObject(model).tint(.indigo)
|
||||||
.onChange(of: phase) { _, value in
|
.onChange(of: phase, initial: true) { _, value in
|
||||||
if value == .background && model.biometric { model.locked = true }
|
Task { await model.updateScene(value) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,8 @@ struct RootView: View {
|
|||||||
Group {
|
Group {
|
||||||
if model.locked {
|
if model.locked {
|
||||||
ContentUnavailableView { Label("Tarnax is locked", systemImage: "lock.shield") } actions: {
|
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 {
|
} else if model.restoringSession {
|
||||||
TarnaxSplashView()
|
TarnaxSplashView()
|
||||||
|
|||||||
+44
-3
@@ -142,6 +142,14 @@ final class Transport: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
|||||||
@Published var lastRefresh: Date?
|
@Published var lastRefresh: Date?
|
||||||
@Published var locked = UserDefaults.standard.bool(forKey: "biometricLock")
|
@Published var locked = UserDefaults.standard.bool(forKey: "biometricLock")
|
||||||
@Published var biometric = 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 preferences: [String: Any] = [:]
|
||||||
@Published private(set) var preferencesLoading = false
|
@Published private(set) var preferencesLoading = false
|
||||||
private var preferencesTask: Task<Void, Never>?
|
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
|
let endpoint = action == "pause" ? (modern ? "stop" : "pause") : action == "resume" ? (modern ? "start" : "resume") : action
|
||||||
await perform("torrents/" + endpoint, ["hashes": hash])
|
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 {
|
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 {
|
do {
|
||||||
let context = LAContext()
|
guard try await authenticate(context), cycle == lockCycle else { return }
|
||||||
guard try await context.evaluatePolicy(.deviceOwnerAuthentication, localizedReason: "Unlock Tarnax") else { return }
|
|
||||||
locked = false
|
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 {
|
func enableLock(_ enabled: Bool) async {
|
||||||
if enabled {
|
if enabled {
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<key>NSFaceIDUsageDescription</key>
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import LocalAuthentication
|
||||||
|
|
||||||
final class MockProtocol: URLProtocol, @unchecked Sendable {
|
final class MockProtocol: URLProtocol, @unchecked Sendable {
|
||||||
static var handler: ((URLRequest) throws -> (Int, String))!
|
static var handler: ((URLRequest) throws -> (Int, String))!
|
||||||
@@ -152,6 +154,50 @@ final class MockProtocol: URLProtocol, @unchecked Sendable {
|
|||||||
disconnected.disconnect()
|
disconnected.disconnect()
|
||||||
await disconnected.restoreSessionIfNeeded()
|
await disconnected.restoreSessionIfNeeded()
|
||||||
check(disconnected.error == nil && !disconnected.connected, "Explicit disconnect suppresses automatic login for this launch")
|
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.")
|
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
|
set -eu
|
||||||
cd "$(dirname "$0")/.."
|
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
|
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"
|
xcrun swiftc -parse-as-library -module-cache-path "$BUILD_DIR/cache" Tarnax/Core.swift Tests/ContractTests.swift -o "$BUILD_DIR/ContractTests"
|
||||||
"$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:
|
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.
|
- 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`.
|
- 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.
|
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.
|
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
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT/scripts/common.sh"
|
||||||
CONFIGURATION=Debug
|
CONFIGURATION=Debug
|
||||||
SIGNED=NO
|
SIGNED=NO
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
@@ -14,8 +15,11 @@ while (($#)); do
|
|||||||
done
|
done
|
||||||
OUTPUT="${BUILD_OUTPUT_DIR:-$ROOT/build/$CONFIGURATION}"
|
OUTPUT="${BUILD_OUTPUT_DIR:-$ROOT/build/$CONFIGURATION}"
|
||||||
mkdir -p "$OUTPUT"
|
mkdir -p "$OUTPUT"
|
||||||
|
DERIVED_DATA="$(mktemp -d "$TARNAX_SCRATCH_ROOT/build-$CONFIGURATION.XXXXXX")"
|
||||||
xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \
|
xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \
|
||||||
-configuration "$CONFIGURATION" -destination 'generic/platform=iOS' \
|
-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"
|
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
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT/scripts/common.sh"
|
||||||
METHOD=release-testing
|
METHOD=release-testing
|
||||||
PROVISIONING=()
|
PROVISIONING=()
|
||||||
while (($#)); do
|
while (($#)); do
|
||||||
@@ -22,6 +23,7 @@ TEAM="${DEVELOPMENT_TEAM:-39R6U3437D}"
|
|||||||
BASE="${RELEASE_OUTPUT_DIR:-$ROOT/build/releases}"
|
BASE="${RELEASE_OUTPUT_DIR:-$ROOT/build/releases}"
|
||||||
mkdir -p "$BASE"
|
mkdir -p "$BASE"
|
||||||
OUTPUT="$(mktemp -d "$BASE/$(date +%Y%m%d-%H%M%S)-$METHOD.XXXXXX")"
|
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"
|
ARCHIVE="$OUTPUT/Tarnax.xcarchive"
|
||||||
EXPORT="$OUTPUT/export"
|
EXPORT="$OUTPUT/export"
|
||||||
echo "Release output: $OUTPUT"
|
echo "Release output: $OUTPUT"
|
||||||
@@ -34,7 +36,7 @@ with open(sys.argv[1], 'wb') as f:
|
|||||||
PY
|
PY
|
||||||
xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \
|
xcodebuild -project "$ROOT/Tarnax.xcodeproj" -scheme Tarnax \
|
||||||
-configuration Release -destination 'generic/platform=iOS' \
|
-configuration Release -destination 'generic/platform=iOS' \
|
||||||
-derivedDataPath "$OUTPUT/DerivedData" -archivePath "$ARCHIVE" \
|
-derivedDataPath "$DERIVED_DATA" -archivePath "$ARCHIVE" \
|
||||||
DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic \
|
DEVELOPMENT_TEAM="$TEAM" CODE_SIGN_STYLE=Automatic \
|
||||||
${PROVISIONING[@]+"${PROVISIONING[@]}"} archive 2>&1 | tee "$OUTPUT/archive.log"
|
${PROVISIONING[@]+"${PROVISIONING[@]}"} archive 2>&1 | tee "$OUTPUT/archive.log"
|
||||||
xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \
|
xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \
|
||||||
@@ -42,27 +44,17 @@ xcodebuild -exportArchive -archivePath "$ARCHIVE" -exportPath "$EXPORT" \
|
|||||||
${PROVISIONING[@]+"${PROVISIONING[@]}"} 2>&1 | tee "$OUTPUT/export.log"
|
${PROVISIONING[@]+"${PROVISIONING[@]}"} 2>&1 | tee "$OUTPUT/export.log"
|
||||||
IPA="$EXPORT/Tarnax.ipa"
|
IPA="$EXPORT/Tarnax.ipa"
|
||||||
[[ -f "$IPA" ]] || { echo 'Export finished without Tarnax.ipa' >&2; exit 1; }
|
[[ -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
|
trap 'rm -rf "$VERIFY"' EXIT
|
||||||
unzip -q "$IPA" -d "$VERIFY"
|
unzip -q "$IPA" -d "$VERIFY"
|
||||||
APP="$VERIFY/Payload/Tarnax.app"
|
APP="$VERIFY/Payload/Tarnax.app"
|
||||||
codesign --verify --deep --strict "$APP"
|
codesign --verify --deep --strict "$APP"
|
||||||
[[ -f "$APP/embedded.mobileprovision" ]] || { echo 'No embedded provisioning profile' >&2; exit 1; }
|
[[ -f "$APP/embedded.mobileprovision" ]] || { echo 'No embedded provisioning profile' >&2; exit 1; }
|
||||||
security cms -D -i "$APP/embedded.mobileprovision" > "$VERIFY/profile.plist"
|
security cms -D -i "$APP/embedded.mobileprovision" > "$VERIFY/profile.plist"
|
||||||
python3 - "$APP/Info.plist" "$VERIFY/profile.plist" "$METHOD" <<'PY'
|
PROFILE_FLAGS=()
|
||||||
import plistlib, sys, datetime
|
if [[ "$METHOD" == release-testing ]]; then PROFILE_FLAGS=(--distribution); fi
|
||||||
with open(sys.argv[1],'rb') as f: app=plistlib.load(f)
|
python3 "$ROOT/scripts/verify_profile.py" "$APP/Info.plist" "$VERIFY/profile.plist" ${PROFILE_FLAGS[@]+"${PROFILE_FLAGS[@]}"}
|
||||||
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
|
|
||||||
(cd "$EXPORT" && shasum -a 256 Tarnax.ipa > Tarnax.ipa.sha256)
|
(cd "$EXPORT" && shasum -a 256 Tarnax.ipa > Tarnax.ipa.sha256)
|
||||||
echo "Verified IPA: $IPA"
|
echo "Verified IPA: $IPA"
|
||||||
echo "SHA-256: $EXPORT/Tarnax.ipa.sha256"
|
echo "SHA-256: $EXPORT/Tarnax.ipa.sha256"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
source "$ROOT/scripts/common.sh"
|
||||||
CONTRACTS_ONLY=NO
|
CONTRACTS_ONLY=NO
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
--contracts-only) CONTRACTS_ONLY=YES ;;
|
--contracts-only) CONTRACTS_ONLY=YES ;;
|
||||||
@@ -12,6 +13,7 @@ if (($# > 1)); then echo 'Too many arguments' >&2; exit 2; fi
|
|||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
plutil -lint Tarnax/Info.plist Tarnax.xcodeproj/project.pbxproj
|
plutil -lint Tarnax/Info.plist Tarnax.xcodeproj/project.pbxproj
|
||||||
for script in scripts/*.sh Tests/run.sh; do bash -n "$script"; done
|
for script in scripts/*.sh Tests/run.sh; do bash -n "$script"; done
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 python3 Tests/ProfileTests.py
|
||||||
bash Tests/run.sh
|
bash Tests/run.sh
|
||||||
if [[ "$CONTRACTS_ONLY" == NO ]]; then bash scripts/build.sh; fi
|
if [[ "$CONTRACTS_ONLY" == NO ]]; then bash scripts/build.sh; fi
|
||||||
echo 'All requested checks passed.'
|
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