|
| 1 | +require 'json' |
| 2 | +require_relative 'xcode_simulator' |
| 3 | +require 'zip' |
| 4 | +require 'cfpropertylist' |
| 5 | + |
| 6 | +module EmergeCLI |
| 7 | + class XcodeDeviceManager |
| 8 | + class DeviceType |
| 9 | + VIRTUAL = :virtual |
| 10 | + PHYSICAL = :physical |
| 11 | + ANY = :any |
| 12 | + end |
| 13 | + |
| 14 | + def initialize(environment: Environment.new) |
| 15 | + @environment = environment |
| 16 | + end |
| 17 | + |
| 18 | + class << self |
| 19 | + def get_supported_platforms(ipa_path) |
| 20 | + return [] unless ipa_path&.end_with?('.ipa') |
| 21 | + |
| 22 | + Zip::File.open(ipa_path) do |zip_file| |
| 23 | + app_entry = zip_file.glob('**/*.app/').first || |
| 24 | + zip_file.glob('**/*.app').first || |
| 25 | + zip_file.find { |entry| entry.name.end_with?('.app/') || entry.name.end_with?('.app') } |
| 26 | + |
| 27 | + raise 'No .app found in .ipa file' unless app_entry |
| 28 | + |
| 29 | + app_dir = app_entry.name.end_with?('/') ? app_entry.name.chomp('/') : app_entry.name |
| 30 | + info_plist_path = "#{app_dir}/Info.plist" |
| 31 | + info_plist_entry = zip_file.find_entry(info_plist_path) |
| 32 | + raise 'Info.plist not found in app bundle' unless info_plist_entry |
| 33 | + |
| 34 | + info_plist_content = info_plist_entry.get_input_stream.read |
| 35 | + plist = CFPropertyList::List.new(data: info_plist_content) |
| 36 | + info_plist = CFPropertyList.native_types(plist.value) |
| 37 | + |
| 38 | + info_plist['CFBundleSupportedPlatforms'] || [] |
| 39 | + end |
| 40 | + end |
| 41 | + end |
| 42 | + |
| 43 | + def find_device_by_id(device_id) |
| 44 | + Logger.debug "Looking for device with ID: #{device_id}" |
| 45 | + devices_json = execute_command('xcrun xcdevice list') |
| 46 | + devices_data = JSON.parse(devices_json) |
| 47 | + |
| 48 | + found_device = devices_data.find { |device| device['identifier'] == device_id } |
| 49 | + raise "No device found with ID: #{device_id}" unless found_device |
| 50 | + |
| 51 | + device_type = found_device['simulator'] ? 'simulator' : 'physical' |
| 52 | + Logger.info "✅ Found device: #{found_device['name']} " \ |
| 53 | + "(#{found_device['identifier']}, #{device_type})" |
| 54 | + if found_device['simulator'] |
| 55 | + XcodeSimulator.new(found_device['identifier']) |
| 56 | + else |
| 57 | + XcodePhysicalDevice.new(found_device['identifier']) |
| 58 | + end |
| 59 | + end |
| 60 | + |
| 61 | + def find_device_by_type(device_type, ipa_path) |
| 62 | + case device_type |
| 63 | + when DeviceType::VIRTUAL |
| 64 | + find_and_boot_most_recently_used_simulator |
| 65 | + when DeviceType::PHYSICAL |
| 66 | + find_connected_device |
| 67 | + when DeviceType::ANY |
| 68 | + # Check supported platforms in Info.plist to make intelligent choice |
| 69 | + supported_platforms = self.class.get_supported_platforms(ipa_path) |
| 70 | + Logger.debug "Build supports platforms: #{supported_platforms.join(', ')}" |
| 71 | + |
| 72 | + if supported_platforms.include?('iPhoneOS') |
| 73 | + device = find_connected_device |
| 74 | + return device if device |
| 75 | + |
| 76 | + # Only fall back to simulator if it's also supported |
| 77 | + unless supported_platforms.include?('iPhoneSimulator') |
| 78 | + raise 'Build only supports physical devices, but no device is connected' |
| 79 | + end |
| 80 | + Logger.info 'No physical device found, falling back to simulator since build supports both' |
| 81 | + find_and_boot_most_recently_used_simulator |
| 82 | + |
| 83 | + elsif supported_platforms.include?('iPhoneSimulator') |
| 84 | + find_and_boot_most_recently_used_simulator |
| 85 | + else |
| 86 | + raise "Build doesn't support either physical devices or simulators" |
| 87 | + end |
| 88 | + end |
| 89 | + end |
| 90 | + |
| 91 | + private |
| 92 | + |
| 93 | + def execute_command(command) |
| 94 | + @environment.execute_command(command) |
| 95 | + end |
| 96 | + |
| 97 | + def find_connected_device |
| 98 | + Logger.info 'Finding connected device...' |
| 99 | + devices_json = execute_command('xcrun xcdevice list') |
| 100 | + Logger.debug "Device list output: #{devices_json}" |
| 101 | + |
| 102 | + devices_data = JSON.parse(devices_json) |
| 103 | + physical_devices = devices_data |
| 104 | + .select do |device| |
| 105 | + device['simulator'] == false && |
| 106 | + device['ignored'] == false && |
| 107 | + device['available'] == true && |
| 108 | + device['platform'] == 'com.apple.platform.iphoneos' |
| 109 | + end |
| 110 | + |
| 111 | + Logger.debug "Found physical devices: #{physical_devices}" |
| 112 | + |
| 113 | + if physical_devices.empty? |
| 114 | + Logger.info 'No physical connected device found' |
| 115 | + return nil |
| 116 | + end |
| 117 | + |
| 118 | + device = physical_devices.first |
| 119 | + Logger.info "Found connected physical device: #{device['name']} (#{device['identifier']})" |
| 120 | + XcodePhysicalDevice.new(device['identifier']) |
| 121 | + end |
| 122 | + |
| 123 | + def find_and_boot_most_recently_used_simulator |
| 124 | + Logger.info 'Finding and booting most recently used simulator...' |
| 125 | + simulators_json = execute_command('xcrun simctl list devices --json') |
| 126 | + Logger.debug "Simulators JSON: #{simulators_json}" |
| 127 | + |
| 128 | + simulators_data = JSON.parse(simulators_json) |
| 129 | + |
| 130 | + simulators = simulators_data['devices'].flat_map do |runtime, devices| |
| 131 | + next [] unless runtime.include?('iOS') # Only include iOS devices |
| 132 | + |
| 133 | + devices.select do |device| |
| 134 | + (device['name'].start_with?('iPhone', 'iPad') && |
| 135 | + device['isAvailable'] && |
| 136 | + !device['isDeleted']) |
| 137 | + end.map do |device| |
| 138 | + version = runtime.match(/iOS-(\d+)-(\d+)/)&.captures&.join('.').to_f |
| 139 | + last_booted = device['lastBootedAt'] ? Time.parse(device['lastBootedAt']) : Time.at(0) |
| 140 | + [device['udid'], device['state'], version, last_booted] |
| 141 | + end |
| 142 | + end.sort_by { |_, _, _, last_booted| last_booted }.reverse |
| 143 | + |
| 144 | + Logger.debug "Simulators: #{simulators}" |
| 145 | + |
| 146 | + raise 'No available simulator found' unless simulators.any? |
| 147 | + |
| 148 | + simulator_id, simulator_state, version, last_booted = simulators.first |
| 149 | + version_str = version.zero? ? '' : " (#{version})" |
| 150 | + last_booted_str = last_booted == Time.at(0) ? 'never' : last_booted.strftime('%Y-%m-%d %H:%M:%S') |
| 151 | + Logger.info "Found simulator #{simulator_id}#{version_str} (#{simulator_state}, last booted: #{last_booted_str})" |
| 152 | + |
| 153 | + simulator = XcodeSimulator.new(simulator_id, environment: @environment) |
| 154 | + simulator.boot unless simulator_state == 'Booted' |
| 155 | + simulator |
| 156 | + end |
| 157 | + end |
| 158 | +end |
0 commit comments