diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a53ba3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.DS_Store +build/ +*.xcresult +xcuserdata/ +*.ipa diff --git a/HelloWorld.xcodeproj/project.pbxproj b/HelloWorld.xcodeproj/project.pbxproj new file mode 100644 index 0000000..245becb --- /dev/null +++ b/HelloWorld.xcodeproj/project.pbxproj @@ -0,0 +1,194 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = {}; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + A10000000000000000000001 /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000002 /* App.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + A10000000000000000000002 /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + A10000000000000000000003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A10000000000000000000004 /* HelloWorld.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HelloWorld.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + A10000000000000000000005 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = (); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + A10000000000000000000006 = { + isa = PBXGroup; + children = ( + A10000000000000000000007 /* Sources */, + A10000000000000000000008 /* Resources */, + A10000000000000000000009 /* Products */, + ); + sourceTree = ""; + }; + A10000000000000000000007 /* Sources */ = { + isa = PBXGroup; + children = (A10000000000000000000002 /* App.swift */,); + path = Sources; + sourceTree = ""; + }; + A10000000000000000000008 /* Resources */ = { + isa = PBXGroup; + children = (A10000000000000000000003 /* Info.plist */,); + path = Resources; + sourceTree = ""; + }; + A10000000000000000000009 /* Products */ = { + isa = PBXGroup; + children = (A10000000000000000000004 /* HelloWorld.app */,); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + A1000000000000000000000A /* HelloWorld */ = { + isa = PBXNativeTarget; + buildConfigurationList = A1000000000000000000000B /* Build configuration list for PBXNativeTarget "HelloWorld" */; + buildPhases = ( + A1000000000000000000000C /* Sources */, + A10000000000000000000005 /* Frameworks */, + A1000000000000000000000D /* Resources */, + ); + buildRules = (); + dependencies = (); + name = HelloWorld; + productName = HelloWorld; + productReference = A10000000000000000000004 /* HelloWorld.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + A1000000000000000000000E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + A1000000000000000000000A = { + CreatedOnToolsVersion = 16.2; + DevelopmentTeam = HY3W4U256Z; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = A1000000000000000000000F /* Build configuration list for PBXProject "HelloWorld" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = (en, Base,); + mainGroup = A10000000000000000000006; + productRefGroup = A10000000000000000000009 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = (A1000000000000000000000A /* HelloWorld */,); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + A1000000000000000000000D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = (); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + A1000000000000000000000C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = (A10000000000000000000001 /* App.swift in Sources */,); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + A10000000000000000000010 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + SDKROOT = iphoneos; + }; + name = Debug; + }; + A10000000000000000000011 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + SDKROOT = iphoneos; + }; + name = Release; + }; + A10000000000000000000012 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HY3W4U256Z; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = local.proxmox.helloworld.e2e; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + A10000000000000000000013 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = HY3W4U256Z; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = local.proxmox.helloworld.e2e; + PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + A1000000000000000000000B /* Build configuration list for PBXNativeTarget "HelloWorld" */ = { + isa = XCConfigurationList; + buildConfigurations = (A10000000000000000000012 /* Debug */, A10000000000000000000013 /* Release */,); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + A1000000000000000000000F /* Build configuration list for PBXProject "HelloWorld" */ = { + isa = XCConfigurationList; + buildConfigurations = (A10000000000000000000010 /* Debug */, A10000000000000000000011 /* Release */,); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = A1000000000000000000000E /* Project object */; +} diff --git a/README.md b/README.md index 274bfb9..73ca4ca 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,94 @@ -# helloworld-ios-ota +# HelloWorld for iOS -Signed iOS OTA builds and installation manifests for HelloWorld \ No newline at end of file +A minimal UIKit application plus a command-line build and installation workflow. +The workflow was tested with: + +- Intel macOS Sonoma 14.8.7 running in Proxmox +- Xcode 16.2 and the iOS 18.2 SDK +- An iPhone 15 running iOS 26.5.2 +- `pymobiledevice3` 9.36.0 + +The host Xcode does not need to support the phone's installed iOS version. The +script builds the Xcode **target** directly for `iphoneos`, which avoids Xcode's +destination compatibility check, and uses `pymobiledevice3` to install the +result through Apple's installation service. + +## Prerequisites + +1. Select Xcode and finish its first-launch setup: + + ```bash + sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer + sudo xcodebuild -license accept + sudo xcodebuild -runFirstLaunch + ``` + +2. Install a current `pymobiledevice3`. Version 2.30.0 is too old for current + AltServer JIT support because it does not expose the `mounter` command. + + ```bash + brew install openssl@3 + python3 -m pip install -U pymobiledevice3 + ``` + +3. Sign in to the Apple account in Xcode, pair the iPhone, and enable + **Settings > Privacy & Security > Developer Mode** on the phone. + +4. Ensure the phone is registered with the Personal Team. AltServer can perform + the initial registration. A free Personal Team profile expires after seven + days, so rerun the script to rebuild and reinstall before expiration. + +## Build and install + +Connect and unlock one iPhone, then run: + +```bash +./scripts/build-install.sh +``` + +To select a device explicitly and launch the app after installation: + +```bash +./scripts/build-install.sh \ + --udid DEVICE_UDID \ + --launch +``` + +The launch step requires the phone to remain unlocked. The build and signed IPA +are written under `build/device/`. + +Useful overrides: + +```bash +TEAM_ID=HY3W4U256Z \ +BUNDLE_ID=local.proxmox.helloworld.e2e \ +CONFIGURATION=Debug \ +./scripts/build-install.sh +``` + +Use `--build-only` to compile, sign, package, and validate without installing. +When the phone is disconnected, pass its UDID explicitly so the script can +still verify the provisioning profile: + +```bash +./scripts/build-install.sh --build-only --udid DEVICE_UDID +``` + +## Why this works with an older Xcode + +An ordinary scheme build aimed at an iOS 26 phone causes Xcode 16.2 to reject +the destination before compilation. This project instead uses a target-level +device build: + +```bash +xcodebuild -project HelloWorld.xcodeproj \ + -target HelloWorld \ + -sdk iphoneos \ + -allowProvisioningUpdates \ + build +``` + +The resulting ARM64 app targets iOS 15 or later and therefore runs on iOS 26. +Installation and device queries are handled independently by +`pymobiledevice3`, which supports newer device protocols without requiring a +newer macOS release. diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..5b2bd97 --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,36 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Hello World + CFBundleExecutable + HelloWorld + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + HelloWorld + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + MinimumOSVersion + 15.0 + UILaunchScreen + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Sources/App.swift b/Sources/App.swift new file mode 100644 index 0000000..3a60a99 --- /dev/null +++ b/Sources/App.swift @@ -0,0 +1,31 @@ +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + var window: UIWindow? + + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil + ) -> Bool { + let label = UILabel() + label.text = "Hello, world!" + label.font = .systemFont(ofSize: 32, weight: .bold) + label.textAlignment = .center + + let viewController = UIViewController() + viewController.view.backgroundColor = .systemBackground + viewController.view.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.centerXAnchor.constraint(equalTo: viewController.view.centerXAnchor), + label.centerYAnchor.constraint(equalTo: viewController.view.centerYAnchor), + ]) + + let window = UIWindow(frame: UIScreen.main.bounds) + window.rootViewController = viewController + window.makeKeyAndVisible() + self.window = window + return true + } +} diff --git a/scripts/build-install.sh b/scripts/build-install.sh new file mode 100755 index 0000000..eca3a9c --- /dev/null +++ b/scripts/build-install.sh @@ -0,0 +1,192 @@ +#!/bin/bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PROJECT_PATH="$ROOT_DIR/HelloWorld.xcodeproj" +TARGET_NAME="HelloWorld" +TEAM_ID="${TEAM_ID:-HY3W4U256Z}" +BUNDLE_ID="${BUNDLE_ID:-local.proxmox.helloworld.e2e}" +CONFIGURATION="${CONFIGURATION:-Debug}" +DEVICE_UDID="${DEVICE_UDID:-}" +BUILD_ROOT="$ROOT_DIR/build/device" +BUILD_ONLY=0 +LAUNCH=0 + +usage() { + echo "Usage: $0 [--udid UDID] [--bundle-id ID] [--team-id ID] [--configuration NAME] [--build-only] [--launch]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --udid) + DEVICE_UDID="${2:?--udid requires a value}" + shift 2 + ;; + --bundle-id) + BUNDLE_ID="${2:?--bundle-id requires a value}" + shift 2 + ;; + --team-id) + TEAM_ID="${2:?--team-id requires a value}" + shift 2 + ;; + --configuration) + CONFIGURATION="${2:?--configuration requires a value}" + shift 2 + ;; + --build-only) + BUILD_ONLY=1 + shift + ;; + --launch) + LAUNCH=1 + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +for command_name in xcodebuild security codesign python3; do + if ! command -v "$command_name" >/dev/null 2>&1; then + echo "Required command not found: $command_name" >&2 + exit 1 + fi +done + +if command -v pymobiledevice3 >/dev/null 2>&1; then + PMD=(pymobiledevice3) +elif python3 -c 'import pymobiledevice3' >/dev/null 2>&1; then + PMD=(python3 -m pymobiledevice3) +else + echo "pymobiledevice3 is required. Install it with: python3 -m pip install -U pymobiledevice3" >&2 + exit 1 +fi + +run_pmd() { + PYTHONWARNINGS=ignore "${PMD[@]}" --no-color "$@" +} + +if [[ -z "$DEVICE_UDID" ]]; then + device_json="$(run_pmd usbmux list)" + DEVICE_UDID="$(printf '%s' "$device_json" | python3 -c ' +import json, sys +devices = json.load(sys.stdin) +print(devices[0].get("Identifier", "") if len(devices) == 1 else "") +')" + if [[ -z "$DEVICE_UDID" ]]; then + echo "Connect exactly one unlocked iPhone, or pass --udid explicitly." >&2 + exit 1 + fi +fi + +echo "Building $BUNDLE_ID for device $DEVICE_UDID" + +xcodebuild \ + -project "$PROJECT_PATH" \ + -target "$TARGET_NAME" \ + -configuration "$CONFIGURATION" \ + -sdk iphoneos \ + -allowProvisioningUpdates \ + -allowProvisioningDeviceRegistration \ + PRODUCT_BUNDLE_IDENTIFIER="$BUNDLE_ID" \ + DEVELOPMENT_TEAM="$TEAM_ID" \ + SYMROOT="$BUILD_ROOT" \ + OBJROOT="$BUILD_ROOT/Intermediates" \ + build + +APP_PATH="$BUILD_ROOT/$CONFIGURATION-iphoneos/$TARGET_NAME.app" +IPA_PATH="$BUILD_ROOT/$TARGET_NAME.ipa" + +if [[ ! -d "$APP_PATH" ]]; then + echo "Expected app was not produced: $APP_PATH" >&2 + exit 1 +fi + +actual_bundle_id="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIdentifier' "$APP_PATH/Info.plist")" +if [[ "$actual_bundle_id" != "$BUNDLE_ID" ]]; then + echo "Bundle ID mismatch: expected $BUNDLE_ID, found $actual_bundle_id" >&2 + exit 1 +fi + +codesign --verify --deep --strict "$APP_PATH" + +TEMP_DIR="$(mktemp -d /tmp/helloworld-install.XXXXXX)" +cleanup() { + if [[ -n "${TEMP_DIR:-}" && "$TEMP_DIR" == /tmp/helloworld-install.* ]]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +PROFILE_PLIST="$TEMP_DIR/profile.plist" +security cms -D -i "$APP_PATH/embedded.mobileprovision" > "$PROFILE_PLIST" + +if ! /usr/libexec/PlistBuddy -c 'Print :ProvisionedDevices' "$PROFILE_PLIST" 2>/dev/null | grep -Fq "$DEVICE_UDID"; then + echo "Provisioning profile does not contain device UDID $DEVICE_UDID." >&2 + echo "Register the phone with the Apple team, then rerun this script." >&2 + exit 1 +fi + +profile_name="$(/usr/libexec/PlistBuddy -c 'Print :Name' "$PROFILE_PLIST")" +profile_expiration="$(/usr/libexec/PlistBuddy -c 'Print :ExpirationDate' "$PROFILE_PLIST")" + +mkdir -p "$TEMP_DIR/Payload" +ditto "$APP_PATH" "$TEMP_DIR/Payload/$TARGET_NAME.app" +ditto -c -k --keepParent "$TEMP_DIR/Payload" "$IPA_PATH" + +echo "Signature and provisioning profile validated." +echo "Profile: $profile_name" +echo "Expires: $profile_expiration" +echo "IPA: $IPA_PATH" + +if [[ "$BUILD_ONLY" -eq 1 ]]; then + echo "Build-only verification complete." + exit 0 +fi + +run_pmd apps install --udid "$DEVICE_UDID" "$APP_PATH" + +query_output="$(run_pmd apps query --udid "$DEVICE_UDID" "$BUNDLE_ID")" +if ! printf '%s' "$query_output" | grep -Fq '"ProfileValidated": true'; then + echo "The app installed, but the device did not report a validated profile." >&2 + exit 1 +fi + +echo "Installed and profile-validated: $BUNDLE_ID" + +if [[ "$LAUNCH" -eq 1 ]]; then + if ! command -v xcrun >/dev/null 2>&1; then + echo "xcrun is required for --launch." >&2 + exit 1 + fi + + device_list_json="$TEMP_DIR/devicectl.json" + xcrun devicectl list devices --json-output "$device_list_json" >/dev/null + core_device_id="$(python3 -c ' +import json, sys +path, udid = sys.argv[1:] +with open(path, encoding="utf-8") as stream: + devices = json.load(stream).get("result", {}).get("devices", []) +for device in devices: + if device.get("hardwareProperties", {}).get("udid") == udid: + print(device.get("identifier", "")) + break +' "$device_list_json" "$DEVICE_UDID")" + + if [[ -z "$core_device_id" ]]; then + echo "Unable to map UDID to a CoreDevice identifier for launch." >&2 + exit 1 + fi + + xcrun devicectl device process launch --device "$core_device_id" "$BUNDLE_ID" + echo "Launched $BUNDLE_ID" +fi